diff --git a/.changeset/local-https-vite-plugin.md b/.changeset/local-https-vite-plugin.md index 5747c9f156..59a33e324e 100644 --- a/.changeset/local-https-vite-plugin.md +++ b/.changeset/local-https-vite-plugin.md @@ -3,3 +3,5 @@ --- Add `localHttps()` under `@shopify/hydrogen/vite` for portable local HTTPS development with Customer Account API flows. Frameworks that terminate HTTPS outside Vite can use `localHttps(...).api.getDevServerConfig()`. + +Certificates can be provisioned by the plugin (after confirmation on `vite dev`), the `provisionLocalHttps()` helper, or the `hydrogen certs install` CLI command. Each path downloads a pinned, checksum-verified mkcert release for macOS, Linux, or Windows, installs the local certificate authority, and generates the certificate files. The plugin skips automatic provisioning in CI environments; the explicit paths remain available there. The paired `hydrogen certs uninstall` command removes Hydrogen's files and can remove the shared mkcert CA when passed `--remove-ca`. diff --git a/AGENTS.md b/AGENTS.md index c01e78f221..55173d58b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,6 @@ When designing or adjusting APIs for the `hydrogen` package, closely follow the ## Local HTTPS for Examples - Account-enabled framework examples use `https://local.tryhydrogen.dev:5173` for Customer Account OAuth callback testing. -- On macOS, install `mkcert` with Homebrew before running those examples: `brew install mkcert`. -- Vite-based examples consume Hydrogen's default certificates. From the repository root, run `pnpm https:setup` once to trust the local certificate authority and create them under `~/.shopify/hydrogen/certs/`. +- Vite-based examples consume Hydrogen's default certificates. Certificates are provisioned automatically on `https:dev` startup, or run `pnpm https:setup` once from the repository root (`npx hydrogen certs install` in a consumer project). Both download a pinned, checksum-verified mkcert release, trust the local certificate authority, and create the certificates under `~/.shopify/hydrogen/certs/`. - The Next.js example provisions its own certificate. The Hydrogen example uses the Shopify CLI tunnel flow. - After setup, run the relevant example with `pnpm --filter @shopify/hydrogen-example- https:dev` when that example provides the script. diff --git a/examples/README.md b/examples/README.md index a8acb9ef2b..1c62e7848e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -31,14 +31,13 @@ From the repository root: - `pnpm --filter @shopify/hydrogen-example- dev` — run one example. - `pnpm https:setup` then `pnpm --filter @shopify/hydrogen-example- https:dev` — run an account-enabled example on `https://local.tryhydrogen.dev:5173` when it provides an `https:dev` script. The Hydrogen example uses `--customer-account-push` instead of local certificates. -Local HTTPS requires `mkcert`. On macOS: +Local HTTPS certificates are provisioned automatically the first time an `https:dev` script starts, or explicitly with: ```sh -brew install mkcert pnpm https:setup ``` -This installs the local certificate authority and creates trusted `local.tryhydrogen.dev` certificates under `~/.shopify/hydrogen/certs/` so Customer Account OAuth can redirect to `https://local.tryhydrogen.dev:5173/account/authorize`. +Both download a pinned, checksum-verified [mkcert](https://github.com/FiloSottile/mkcert) release, install the local certificate authority (this may prompt for your password), and create trusted `local.tryhydrogen.dev` certificates under `~/.shopify/hydrogen/certs/` so Customer Account OAuth can redirect to `https://local.tryhydrogen.dev:5173/account/authorize`. The Next.js template provisions its own development certificate and does not use the Hydrogen certificates. diff --git a/package.json b/package.json index 59a8b1e97c..d7d9618c52 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "dev:svelte": "turbo run dev --filter=@shopify/hydrogen-example-sveltekit...", "dev:hydrogen": "pnpm --dir examples/hydrogen dev", "dev:hub": "node scripts/examples-dev.ts", - "https:setup": "mkcert -install && mkdir -p \"$HOME/.shopify/hydrogen/certs\" && mkcert -cert-file \"$HOME/.shopify/hydrogen/certs/local.tryhydrogen.dev.pem\" -key-file \"$HOME/.shopify/hydrogen/certs/local.tryhydrogen.dev-key.pem\" local.tryhydrogen.dev", + "https:setup": "turbo run build --filter=@shopify/hydrogen && node packages/hydrogen/bin/hydrogen.mjs certs install", "download:standard-types": "node scripts/download-standard-types.ts", "prepare:preview-dist": "node scripts/preview-template-dist.ts prepare", "validate:preview-dist": "node scripts/preview-template-dist.ts validate", diff --git a/packages/hydrogen/skills/hydrogen-local-https/SKILL.md b/packages/hydrogen/skills/hydrogen-local-https/SKILL.md index f998d57da8..b16eb3fb1b 100644 --- a/packages/hydrogen/skills/hydrogen-local-https/SKILL.md +++ b/packages/hydrogen/skills/hydrogen-local-https/SKILL.md @@ -9,21 +9,19 @@ description: > Customer Account login, logout, and OAuth callbacks require an HTTPS, non-`localhost` origin that exactly matches the URLs registered in Shopify admin. `local.tryhydrogen.dev` is a Shopify-owned domain that resolves publicly to `127.0.0.1`, so it provides a stable local hostname. -## Vite Certificate Setup +## Certificates -Vite-based frameworks need manual certificates until automatic provisioning is available. Install and trust [mkcert](https://github.com/FiloSottile/mkcert), then create the default certificate files: +The `localHttps` Vite plugin provisions missing certificates automatically when `vite dev` starts: it downloads a pinned, checksum-verified [mkcert](https://github.com/FiloSottile/mkcert) release, installs the local certificate authority (this may prompt for your password), and generates the certificate files under `~/.shopify/hydrogen/certs/`. To provision ahead of time — or for frameworks that read certificate paths before Vite starts (Nuxt, SolidStart) — run: ```sh -brew install mkcert -mkcert -install -mkdir -p ~/.shopify/hydrogen/certs -mkcert \ - -cert-file ~/.shopify/hydrogen/certs/local.tryhydrogen.dev.pem \ - -key-file ~/.shopify/hydrogen/certs/local.tryhydrogen.dev-key.pem \ - local.tryhydrogen.dev +npx hydrogen certs install ``` -Pass `certPath` and `keyPath` to use another location. Certificate generation is intentionally separate from the plugin. +Remove Hydrogen's generated certificate files and cached mkcert binary with `npx hydrogen certs uninstall`. This leaves mkcert's shared local certificate authority trusted because other projects may use it. Pass `--remove-ca` to remove that shared CA from the system trust stores too. + +Pass `certPath` and `keyPath` to use another location. When automatic download is unavailable for a platform, install mkcert manually and generate the files at the paths the warning prints. + +The plugin skips automatic provisioning when the `CI` environment variable is set, because installing the certificate authority needs an interactive trust prompt. Run `npx hydrogen certs install` explicitly when a CI job genuinely needs local HTTPS. ## Vite @@ -72,7 +70,7 @@ export default defineConfig({ ## Nuxt -Nitro terminates TLS, so provide certificate paths to both Nitro and Vite: +Nitro terminates TLS, so provide certificate paths to both Nitro and Vite. Nitro reads the paths when the config is evaluated, so provision certificates with `npx hydrogen certs install` (or restart once after the plugin provisions them): ```ts import { localHttps } from "@shopify/hydrogen/vite"; @@ -95,7 +93,7 @@ export default defineNuxtConfig({ ## SolidStart/Vinxi -Vinxi terminates TLS outside Vite: +Vinxi terminates TLS outside Vite and reads certificate paths when the config is evaluated, so provision certificates with `npx hydrogen certs install` (or restart once after the plugin provisions them): ```ts import { defineConfig } from "@solidjs/start/config"; diff --git a/packages/hydrogen/src/cli/__tests__/certs.test.ts b/packages/hydrogen/src/cli/__tests__/certs.test.ts new file mode 100644 index 0000000000..3cd8b8619e --- /dev/null +++ b/packages/hydrogen/src/cli/__tests__/certs.test.ts @@ -0,0 +1,86 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { uninstallLocalHttpsCertificates } from "../certs"; + +const fsCalls = vi.hoisted(() => ({ rm: vi.fn(async () => {}) })); +const mkcertCalls = vi.hoisted(() => ({ + resolveMkcertBinary: vi.fn((): { assetName: string } | undefined => ({ + assetName: "mkcert-pinned-platform", + })), + uninstallCertificateAuthority: vi.fn(async () => {}), +})); + +vi.mock("node:fs/promises", async (importOriginal) => ({ + ...(await importOriginal()), + rm: fsCalls.rm, +})); + +vi.mock("../../vite/mkcert", () => ({ + resolveMkcertBinary: mkcertCalls.resolveMkcertBinary, + uninstallCertificateAuthority: mkcertCalls.uninstallCertificateAuthority, +})); + +describe("uninstallLocalHttpsCertificates", () => { + beforeEach(() => { + fsCalls.rm.mockClear(); + mkcertCalls.resolveMkcertBinary.mockClear(); + mkcertCalls.uninstallCertificateAuthority.mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("removes Hydrogen files but keeps the shared certificate authority by default", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const hydrogenDirectory = join(homedir(), ".shopify", "hydrogen"); + + await uninstallLocalHttpsCertificates(); + + expect(fsCalls.rm.mock.calls).toEqual([ + [join(hydrogenDirectory, "certs", "local.tryhydrogen.dev.pem"), { force: true }], + [join(hydrogenDirectory, "certs", "local.tryhydrogen.dev-key.pem"), { force: true }], + [join(hydrogenDirectory, "mkcert", "mkcert-pinned-platform"), { force: true }], + ]); + expect(mkcertCalls.resolveMkcertBinary).toHaveBeenCalledWith(process.platform, process.arch); + expect(mkcertCalls.uninstallCertificateAuthority).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith( + "The shared mkcert CA remains trusted. Pass --remove-ca to remove it.", + ); + }); + + it("removes the shared certificate authority when requested", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await uninstallLocalHttpsCertificates(["--remove-ca"]); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("other projects")); + expect(mkcertCalls.uninstallCertificateAuthority).toHaveBeenCalledOnce(); + }); + + it("does not remove another mkcert binary on an unsupported platform", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + mkcertCalls.resolveMkcertBinary.mockReturnValueOnce(undefined); + const certificateDirectory = join(homedir(), ".shopify", "hydrogen", "certs"); + + await uninstallLocalHttpsCertificates(); + + expect(fsCalls.rm.mock.calls).toEqual([ + [join(certificateDirectory, "local.tryhydrogen.dev.pem"), { force: true }], + [join(certificateDirectory, "local.tryhydrogen.dev-key.pem"), { force: true }], + ]); + }); + + it("rejects unknown arguments without removing anything", async () => { + await expect(uninstallLocalHttpsCertificates(["--unknown"])).rejects.toThrow( + "Unknown argument: --unknown", + ); + + expect(fsCalls.rm).not.toHaveBeenCalled(); + expect(mkcertCalls.uninstallCertificateAuthority).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/hydrogen/src/cli/__tests__/index.test.ts b/packages/hydrogen/src/cli/__tests__/index.test.ts new file mode 100644 index 0000000000..b2efe13c25 --- /dev/null +++ b/packages/hydrogen/src/cli/__tests__/index.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { runCli } from "../index"; + +const commandCalls = vi.hoisted(() => ({ + checkGraphQL: vi.fn(async () => {}), + installLocalHttpsCertificates: vi.fn(async () => {}), + setupHydrogen: vi.fn(async () => {}), + uninstallLocalHttpsCertificates: vi.fn(async () => {}), +})); + +vi.mock("../certs", () => ({ + installLocalHttpsCertificates: commandCalls.installLocalHttpsCertificates, + uninstallLocalHttpsCertificates: commandCalls.uninstallLocalHttpsCertificates, +})); +vi.mock("../gql", () => ({ checkGraphQL: commandCalls.checkGraphQL })); +vi.mock("../setup", () => ({ setupHydrogen: commandCalls.setupHydrogen })); + +const originalArgv = process.argv; + +function runWithArguments(args: string[]): void { + process.argv = ["node", "hydrogen", ...args]; + runCli(); +} + +describe("runCli", () => { + afterEach(() => { + process.argv = originalArgv; + vi.clearAllMocks(); + }); + + it("dispatches certs install to local HTTPS installation", () => { + runWithArguments(["certs", "install"]); + + expect(commandCalls.installLocalHttpsCertificates).toHaveBeenCalledOnce(); + expect(commandCalls.setupHydrogen).not.toHaveBeenCalled(); + }); + + it("dispatches certs uninstall with trailing arguments", () => { + runWithArguments(["certs", "uninstall", "--remove-ca"]); + + expect(commandCalls.uninstallLocalHttpsCertificates).toHaveBeenCalledWith(["--remove-ca"]); + }); + + it("dispatches setup to the skills setup", () => { + runWithArguments(["setup"]); + + expect(commandCalls.setupHydrogen).toHaveBeenCalledOnce(); + expect(commandCalls.installLocalHttpsCertificates).not.toHaveBeenCalled(); + }); + + it("dispatches gql check with trailing arguments", () => { + runWithArguments(["gql", "check", "src/**/*.ts"]); + + expect(commandCalls.checkGraphQL).toHaveBeenCalledWith({ args: ["src/**/*.ts"] }); + }); +}); diff --git a/packages/hydrogen/src/cli/certs.ts b/packages/hydrogen/src/cli/certs.ts new file mode 100644 index 0000000000..fb1ab0a3ea --- /dev/null +++ b/packages/hydrogen/src/cli/certs.ts @@ -0,0 +1,49 @@ +import { rm } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { LOCAL_HTTPS_DEFAULTS, provisionLocalHttps } from "../vite"; +import { resolveMkcertBinary, uninstallCertificateAuthority } from "../vite/mkcert"; + +const REMOVE_CA_FLAG = "--remove-ca"; + +export async function installLocalHttpsCertificates(): Promise { + const { host, certPath, keyPath } = await provisionLocalHttps(); + + console.log(`Local HTTPS is ready for https://${host}`); + console.log(` Certificate: ${certPath}`); + console.log(` Private key: ${keyPath}`); +} + +export async function uninstallLocalHttpsCertificates(args: string[] = []): Promise { + const unknownArgument = args.find((argument) => argument !== REMOVE_CA_FLAG); + if (unknownArgument) { + throw new Error(`Unknown argument: ${unknownArgument}`); + } + + const removeCertificateAuthority = args.includes(REMOVE_CA_FLAG); + if (removeCertificateAuthority) { + console.warn( + "Warning: removing mkcert's shared local CA will make certificates created by other projects untrusted.", + ); + await uninstallCertificateAuthority(); + } + + const hydrogenDirectory = join(homedir(), ".shopify", "hydrogen"); + const certificateDirectory = join(hydrogenDirectory, "certs"); + const host = LOCAL_HTTPS_DEFAULTS.host; + const binary = resolveMkcertBinary(process.platform, process.arch); + const removals = [ + rm(join(certificateDirectory, `${host}.pem`), { force: true }), + rm(join(certificateDirectory, `${host}-key.pem`), { force: true }), + ]; + if (binary) { + removals.push(rm(join(hydrogenDirectory, "mkcert", binary.assetName), { force: true })); + } + await Promise.all(removals); + + console.log("Hydrogen's local HTTPS certificate files were removed."); + if (!removeCertificateAuthority) { + console.log(`The shared mkcert CA remains trusted. Pass ${REMOVE_CA_FLAG} to remove it.`); + } +} diff --git a/packages/hydrogen/src/cli/index.ts b/packages/hydrogen/src/cli/index.ts index c35253533d..92674cac2f 100644 --- a/packages/hydrogen/src/cli/index.ts +++ b/packages/hydrogen/src/cli/index.ts @@ -3,6 +3,7 @@ import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { installLocalHttpsCertificates, uninstallLocalHttpsCertificates } from "./certs"; import { checkGraphQL } from "./gql"; import { setupHydrogen } from "./setup"; @@ -10,6 +11,11 @@ const CLI_ARGUMENTS_INDEX = 2; const FAILURE_EXIT_CODE = 1; const COMMANDS = [ + { path: ["certs", "install"], run: async (_args: string[]) => installLocalHttpsCertificates() }, + { + path: ["certs", "uninstall"], + run: async (args: string[]) => uninstallLocalHttpsCertificates(args), + }, { path: ["setup"], run: async (_args: string[]) => setupHydrogen() }, { path: ["gql", "check"], run: async (args: string[]) => checkGraphQL({ args }) }, ] as const; diff --git a/packages/hydrogen/src/vite/certificate-prompt.test.ts b/packages/hydrogen/src/vite/certificate-prompt.test.ts new file mode 100644 index 0000000000..d72e45c7a4 --- /dev/null +++ b/packages/hydrogen/src/vite/certificate-prompt.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { acceptsCertificateInstallation } from "./certificate-prompt"; + +describe("acceptsCertificateInstallation", () => { + it.each([ + ["", true], + ["y", true], + ["YES", true], + ["n", false], + ["no", false], + ])("parses %j as %s", (answer, expected) => { + expect(acceptsCertificateInstallation(answer)).toBe(expected); + }); +}); diff --git a/packages/hydrogen/src/vite/certificate-prompt.ts b/packages/hydrogen/src/vite/certificate-prompt.ts new file mode 100644 index 0000000000..03567367f1 --- /dev/null +++ b/packages/hydrogen/src/vite/certificate-prompt.ts @@ -0,0 +1,22 @@ +import { createInterface } from "node:readline/promises"; + +export async function confirmCertificateInstallation(host: string): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) return false; + + const readline = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await readline.question( + `To run HTTPS in development, Hydrogen needs to download mkcert, add a local certificate authority to your system trust store, and create a certificate for "${host}". Continue? [Y/n] `, + ); + + return acceptsCertificateInstallation(answer); + } finally { + readline.close(); + } +} + +/** @internal Exported for tests. */ +export function acceptsCertificateInstallation(answer: string): boolean { + const response = answer.trim().toLowerCase(); + return response === "" || response === "y" || response === "yes"; +} diff --git a/packages/hydrogen/src/vite/index.test.ts b/packages/hydrogen/src/vite/index.test.ts index 32e4f6b175..7e385629dc 100644 --- a/packages/hydrogen/src/vite/index.test.ts +++ b/packages/hydrogen/src/vite/index.test.ts @@ -5,18 +5,39 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { localHttps } from "."; +import { localHttps, provisionLocalHttps } from "."; import { assert } from "../core/test-utils"; const fsCalls = vi.hoisted(() => ({ + existsSync: vi.fn(), readFileSync: vi.fn(), })); +const mkcertCalls = vi.hoisted(() => ({ + provisionCertificates: vi.fn(), +})); + +const promptCalls = vi.hoisted(() => ({ + confirmCertificateInstallation: vi.fn(), +})); + +vi.mock("./certificate-prompt", () => ({ + confirmCertificateInstallation: promptCalls.confirmCertificateInstallation, +})); + +vi.mock("./mkcert", () => ({ + provisionCertificates: mkcertCalls.provisionCertificates, +})); + vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + existsSync(...args: any[]) { + fsCalls.existsSync(...args); + return (actual.existsSync as any)(...args); + }, readFileSync(...args: any[]) { fsCalls.readFileSync(...args); return (actual.readFileSync as any)(...args); @@ -49,7 +70,11 @@ describe("localHttps", () => { let keyPath: string; beforeEach(() => { + fsCalls.existsSync.mockClear(); fsCalls.readFileSync.mockClear(); + mkcertCalls.provisionCertificates.mockReset(); + promptCalls.confirmCertificateInstallation.mockReset().mockResolvedValue(true); + vi.stubEnv("CI", ""); directory = fs.mkdtempSync(join(tmpdir(), "hydrogen-local-https-")); certPath = join(directory, "custom.test.pem"); keyPath = join(directory, "custom.test-key.pem"); @@ -57,24 +82,28 @@ describe("localHttps", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); fs.rmSync(directory, { recursive: true, force: true }); }); - it("has no side effects when disabled", () => { + it("has no side effects when disabled", async () => { const use = vi.fn(); const plugin = localHttps({ enabled: false }); const config = getHook(plugin.config, "config"); const configureServer = getHook(plugin.configureServer, "configureServer"); - expect(config({} as any, {} as any)).toBeUndefined(); + expect(await config({} as any, { command: "serve" } as any)).toBeUndefined(); expect(configureServer({ middlewares: { use } } as any)).toBeUndefined(); expect(plugin.api.getDevServerConfig()).toBeUndefined(); expect(use).not.toHaveBeenCalled(); + expect(mkcertCalls.provisionCertificates).not.toHaveBeenCalled(); + expect(promptCalls.confirmCertificateInstallation).not.toHaveBeenCalled(); + expect(fsCalls.existsSync).not.toHaveBeenCalled(); expect(fsCalls.readFileSync).not.toHaveBeenCalled(); }); - it("returns complete Vite server configuration from certificate files", () => { + it("returns complete Vite server configuration from certificate files", async () => { fs.writeFileSync(certPath, "certificate"); fs.writeFileSync(keyPath, "private-key"); const plugin = localHttps({ @@ -92,7 +121,7 @@ describe("localHttps", () => { port: 4_321, https: { cert: certPath, key: keyPath }, }); - expect(config({} as any, {} as any)).toEqual({ + expect(await config({} as any, {} as any)).toEqual({ server: { allowedHosts: ["custom.test"], host: "custom.test", @@ -112,7 +141,8 @@ describe("localHttps", () => { expect(fsCalls.readFileSync).toHaveBeenCalledTimes(2); }); - it("throws a setup error when certificate files are missing", () => { + it("warns and leaves Vite unconfigured when certificate files are missing", async () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); const plugin = localHttps({ enabled: true, host: "custom.test", @@ -120,11 +150,18 @@ describe("localHttps", () => { keyPath, }); const config = getHook(plugin.config, "config"); + const configureServer = getHook(plugin.configureServer, "configureServer"); + const use = vi.fn(); + + expect(await config({} as any, {} as any)).toBeUndefined(); + expect(configureServer({ middlewares: { use } } as any)).toBeUndefined(); + expect(use).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); - const message = captureErrorMessage(() => config({} as any, {} as any)); + const message = String(warn.mock.calls[0]?.[0]); expect(message).toContain(certPath); expect(message).toContain(keyPath); - expect(message).toContain("Local HTTPS requires a readable certificate file"); + expect(message).toContain("npx hydrogen certs install"); expect(message).toContain("brew install mkcert"); expect(message).toContain("mkcert -install"); expect(message).toContain(`mkdir -p '${directory}'`); @@ -142,13 +179,171 @@ describe("localHttps", () => { }); const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); - const config = getHook(plugin.config, "config"); - const message = captureErrorMessage(() => config({} as any, {} as any)); + const message = captureErrorMessage(() => plugin.api.getDevServerConfig()); expect(message).toContain("permission denied"); expect(message).toContain(certPath); }); + it("warns once when Vite creates multiple plugin instances", async () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + + for (let index = 0; index < 2; index += 1) { + const plugin = localHttps({ + enabled: true, + host: "custom.test", + certPath, + keyPath, + }); + const config = getHook(plugin.config, "config"); + expect(await config({} as any, {} as any)).toBeUndefined(); + } + + expect(warn).toHaveBeenCalledOnce(); + }); + + it("warns for distinct hosts that share missing certificate paths", async () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + + for (const host of ["first.test", "second.test"]) { + const plugin = localHttps({ enabled: true, host, certPath, keyPath }); + const config = getHook(plugin.config, "config"); + expect(await config({} as any, {} as any)).toBeUndefined(); + } + + expect(warn).toHaveBeenCalledTimes(2); + expect(String(warn.mock.calls[0]?.[0])).toContain("'first.test'"); + expect(String(warn.mock.calls[1]?.[0])).toContain("'second.test'"); + }); + + it("provisions missing certificates when Vite serves", async () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + mkcertCalls.provisionCertificates.mockImplementation( + async (settings: { certPath: string; keyPath: string }) => { + fs.writeFileSync(settings.certPath, "certificate"); + fs.writeFileSync(settings.keyPath, "private-key"); + }, + ); + const plugin = localHttps({ + enabled: true, + host: "custom.test", + port: 4_321, + certPath, + keyPath, + }); + const config = getHook(plugin.config, "config"); + + const result = await config({} as any, { command: "serve" } as any); + + expect(mkcertCalls.provisionCertificates).toHaveBeenCalledWith( + expect.objectContaining({ host: "custom.test", certPath, keyPath }), + ); + expect(promptCalls.confirmCertificateInstallation).toHaveBeenCalledWith("custom.test"); + expect(result).toMatchObject({ + server: { + https: { + cert: Buffer.from("certificate"), + key: Buffer.from("private-key"), + }, + }, + }); + expect(warn).not.toHaveBeenCalled(); + }); + + it("does not provision when certificate installation is declined", async () => { + promptCalls.confirmCertificateInstallation.mockResolvedValue(false); + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); + const config = getHook(plugin.config, "config"); + + expect(await config({} as any, { command: "serve" } as any)).toBeUndefined(); + + expect(mkcertCalls.provisionCertificates).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + expect(String(warn.mock.calls[0]?.[0])).toContain( + "requires confirmation in an interactive terminal", + ); + }); + + it("skips provisioning when certificate files already exist", async () => { + fs.writeFileSync(certPath, "certificate"); + fs.writeFileSync(keyPath, "private-key"); + const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); + const config = getHook(plugin.config, "config"); + + await config({} as any, { command: "serve" } as any); + + expect(mkcertCalls.provisionCertificates).not.toHaveBeenCalled(); + expect(promptCalls.confirmCertificateInstallation).not.toHaveBeenCalled(); + }); + + it("falls back to a warning when provisioning fails", async () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + mkcertCalls.provisionCertificates.mockRejectedValue(new Error("download blocked")); + const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); + const config = getHook(plugin.config, "config"); + + expect(await config({} as any, { command: "serve" } as any)).toBeUndefined(); + expect(warn).toHaveBeenCalledOnce(); + + const message = String(warn.mock.calls[0]?.[0]); + expect(message).toContain("Automatic certificate provisioning failed:"); + expect(message).toContain("download blocked"); + expect(message).toContain("npx hydrogen certs install"); + }); + + it("does not provision during builds", async () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); + const config = getHook(plugin.config, "config"); + + expect(await config({} as any, { command: "build" } as any)).toBeUndefined(); + + expect(mkcertCalls.provisionCertificates).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + }); + + it("does not provision in CI environments and says so in the warning", async () => { + vi.stubEnv("CI", "true"); + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); + const config = getHook(plugin.config, "config"); + + expect(await config({} as any, { command: "serve" } as any)).toBeUndefined(); + + expect(mkcertCalls.provisionCertificates).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + const message = String(warn.mock.calls[0]?.[0]); + expect(message).toContain("skipped in CI environments"); + expect(message).toContain("npx hydrogen certs install"); + }); + + it("provisions when CI is explicitly disabled", async () => { + vi.stubEnv("CI", "false"); + mkcertCalls.provisionCertificates.mockImplementation( + async (settings: { certPath: string; keyPath: string }) => { + fs.writeFileSync(settings.certPath, "certificate"); + fs.writeFileSync(settings.keyPath, "private-key"); + }, + ); + const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); + const config = getHook(plugin.config, "config"); + + await config({} as any, { command: "serve" } as any); + + expect(mkcertCalls.provisionCertificates).toHaveBeenCalledOnce(); + }); + + it("does not provision during vite preview", async () => { + vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); + const config = getHook(plugin.config, "config"); + + await config({} as any, { command: "serve", isPreview: true } as any); + + expect(mkcertCalls.provisionCertificates).not.toHaveBeenCalled(); + }); + it("sets forwarded headers without replacing existing values", () => { fs.writeFileSync(certPath, "certificate"); fs.writeFileSync(keyPath, "private-key"); @@ -353,6 +548,7 @@ describe("localHttps", () => { describe("localHttps plugin API", () => { beforeEach(() => { + fsCalls.existsSync.mockClear(); fsCalls.readFileSync.mockClear(); }); @@ -386,21 +582,28 @@ describe("localHttps plugin API", () => { } }); - it("throws through dev server config when a certificate is missing", () => { + it("warns and returns undefined when a certificate is missing", () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); const certPath = join(tmpdir(), "missing-cert.pem"); const keyPath = join(tmpdir(), "missing-key.pem"); const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); - const message = captureErrorMessage(() => plugin.api.getDevServerConfig()); + expect(plugin.api.getDevServerConfig()).toBeUndefined(); + expect(warn).toHaveBeenCalledOnce(); + + const message = String(warn.mock.calls[0]?.[0]); expect(message).toContain(certPath); expect(message).toContain(keyPath); }); it("looks for default certificates in the Hydrogen home directory", () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); const host = `missing-${process.pid}.local.tryhydrogen.dev`; const plugin = localHttps({ enabled: true, host }); - const message = captureErrorMessage(() => plugin.api.getDevServerConfig()); + expect(plugin.api.getDevServerConfig()).toBeUndefined(); + + const message = String(warn.mock.calls[0]?.[0]); expect(message).toContain(join(homedir(), ".shopify", "hydrogen", "certs", `${host}.pem`)); expect(message).toContain(join(homedir(), ".shopify", "hydrogen", "certs", `${host}-key.pem`)); }); @@ -417,6 +620,56 @@ function captureErrorMessage(run: () => unknown) { throw new Error("Expected function to throw"); } +describe("provisionLocalHttps", () => { + let directory: string; + let certPath: string; + let keyPath: string; + + beforeEach(() => { + mkcertCalls.provisionCertificates.mockReset(); + directory = fs.mkdtempSync(join(tmpdir(), "hydrogen-provision-local-https-")); + certPath = join(directory, "custom.test.pem"); + keyPath = join(directory, "custom.test-key.pem"); + }); + + afterEach(() => { + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it("provisions missing certificates and returns the resolved paths", async () => { + mkcertCalls.provisionCertificates.mockResolvedValue(undefined); + + const result = await provisionLocalHttps({ host: "custom.test", certPath, keyPath }); + + expect(mkcertCalls.provisionCertificates).toHaveBeenCalledWith( + expect.objectContaining({ host: "custom.test", certPath, keyPath }), + ); + expect(result).toEqual({ host: "custom.test", certPath, keyPath }); + }); + + it("skips provisioning when certificate files already exist", async () => { + fs.writeFileSync(certPath, "certificate"); + fs.writeFileSync(keyPath, "private-key"); + + const result = await provisionLocalHttps({ host: "custom.test", certPath, keyPath }); + + expect(mkcertCalls.provisionCertificates).not.toHaveBeenCalled(); + expect(result).toEqual({ host: "custom.test", certPath, keyPath }); + }); + + it("resolves default certificate paths from the host", async () => { + mkcertCalls.provisionCertificates.mockResolvedValue(undefined); + const host = `provision-${process.pid}.local.tryhydrogen.dev`; + + const result = await provisionLocalHttps({ host }); + + expect(result.certPath).toBe(join(homedir(), ".shopify", "hydrogen", "certs", `${host}.pem`)); + expect(result.keyPath).toBe( + join(homedir(), ".shopify", "hydrogen", "certs", `${host}-key.pem`), + ); + }); +}); + function createResponse() { const writeHead = vi.fn(function (this: ServerResponse) { return this; diff --git a/packages/hydrogen/src/vite/local-https.ts b/packages/hydrogen/src/vite/local-https.ts index 9396240060..064692547d 100644 --- a/packages/hydrogen/src/vite/local-https.ts +++ b/packages/hydrogen/src/vite/local-https.ts @@ -1,12 +1,14 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import type { OutgoingHttpHeader, OutgoingHttpHeaders, ServerResponse } from "node:http"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import type { Plugin, ViteDevServer } from "vite"; +import type { ConfigEnv, Plugin, ViteDevServer } from "vite"; import { CUSTOMER_ACCOUNT_PATHS } from "../core/url"; +import { confirmCertificateInstallation } from "./certificate-prompt"; +import { provisionCertificates } from "./mkcert"; export const LOCAL_HTTPS_DEFAULTS = { host: "local.tryhydrogen.dev", @@ -24,6 +26,7 @@ const HTTP1_ONLY_RESPONSE_HEADERS = new Set([ "transfer-encoding", "upgrade", ]); +const emittedMissingCertificateWarnings = new Set(); const loggedCustomerAccountSettings = new Set(); /** Options for Hydrogen's local HTTPS Vite plugin. */ @@ -71,7 +74,7 @@ export function localHttps(options: LocalHttpsOptions): LocalHttpsPlugin { }; const getDevServerConfig = () => { - if (!settings) return; + if (!settings || !checkCertificateFiles(settings)) return; getCertificateFiles(); @@ -88,7 +91,14 @@ export function localHttps(options: LocalHttpsOptions): LocalHttpsPlugin { return { name: "hydrogen-local-https", api: { getDevServerConfig }, - config() { + async config(_config: unknown, env: ConfigEnv) { + if (settings && env.command === "serve" && !env.isPreview) { + const certificatesAvailable = await ensureCertificateFiles(settings); + if (!certificatesAvailable) return; + } else if (settings && !checkCertificateFiles(settings)) { + return; + } + const certificates = getCertificateFiles(); if (!settings || !certificates) return; @@ -112,7 +122,7 @@ export function localHttps(options: LocalHttpsOptions): LocalHttpsPlugin { }; }, configureServer(server) { - if (!settings) return; + if (!settings || !checkCertificateFiles(settings)) return; getCertificateFiles(); configureLocalHttpsServer(server, settings); @@ -120,6 +130,28 @@ export function localHttps(options: LocalHttpsOptions): LocalHttpsPlugin { }; } +export type ProvisionLocalHttpsOptions = Omit; + +/** + * Downloads a pinned, checksum-verified mkcert release and generates the + * trusted local certificate files when they do not exist yet. The Vite plugin + * runs this automatically on `vite dev`; call it directly for frameworks that + * read certificate paths before Vite starts or from setup scripts. + */ +export async function provisionLocalHttps(options: ProvisionLocalHttpsOptions = {}) { + const settings = resolveLocalHttpsSettings({ enabled: true, ...options }); + + if (!certificateFilesExist(settings)) { + await provisionCertificates(settings); + } + + return { + host: settings.host, + certPath: settings.certPath, + keyPath: settings.keyPath, + }; +} + type LocalHttpsSettings = { host: string; port: number; @@ -153,6 +185,85 @@ function resolveCertificatePath(path: string | URL) { return isAbsolute(path) ? path : resolve(path); } +function certificateFilesExist(settings: LocalHttpsSettings) { + return existsSync(settings.certPath) && existsSync(settings.keyPath); +} + +async function ensureCertificateFiles(settings: LocalHttpsSettings): Promise { + if (certificateFilesExist(settings)) return true; + + // Installing the mkcert certificate authority needs an interactive trust + // prompt on first run, which hangs or fails on CI runners. + if (isContinuousIntegration()) { + return checkCertificateFiles( + settings, + "Automatic certificate provisioning is skipped in CI environments (the CI environment variable is set).", + ); + } + + if (!(await confirmCertificateInstallation(settings.host))) { + return checkCertificateFiles( + settings, + "Automatic certificate provisioning requires confirmation in an interactive terminal.", + ); + } + + try { + await provisionCertificates(settings); + return true; + } catch (error) { + return checkCertificateFiles( + settings, + `Automatic certificate provisioning failed:\n ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function isContinuousIntegration() { + const ci = process.env.CI; + return ci !== undefined && ci !== "" && ci !== "false" && ci !== "0"; +} + +function checkCertificateFiles(settings: LocalHttpsSettings, provisioningNote?: string) { + const missingPaths = [settings.certPath, settings.keyPath].filter((path) => !existsSync(path)); + if (missingPaths.length === 0) return true; + + const warning = formatMissingCertificateWarning(settings, missingPaths, provisioningNote); + if (!emittedMissingCertificateWarnings.has(warning)) { + emittedMissingCertificateWarnings.add(warning); + process.emitWarning(warning, { type: "HydrogenLocalHttpsWarning" }); + } + return false; +} + +function formatMissingCertificateWarning( + { certPath, host, keyPath }: LocalHttpsSettings, + missingPaths: string[], + provisioningNote?: string, +) { + const certificateDirectories = [...new Set([dirname(certPath), dirname(keyPath)])]; + const provisioningFailure = provisioningNote === undefined ? [] : ["", provisioningNote]; + + return [ + "Local HTTPS is disabled because certificate files are missing:", + ...missingPaths.map((path) => ` ${path}`), + ...provisioningFailure, + "", + "Expected certificate files:", + ` Certificate: ${certPath}`, + ` Private key: ${keyPath}`, + "", + "Run the automatic setup:", + " npx hydrogen certs install", + "", + "Or install and configure mkcert, then generate the certificate:", + " macOS: brew install mkcert", + " mkcert -install", + ` mkdir -p ${certificateDirectories.map(shellQuote).join(" ")}`, + ` mkcert -cert-file ${shellQuote(certPath)} -key-file ${shellQuote(keyPath)} ${shellQuote(host)}`, + ].join("\n"); +} + function readCertificateFiles(settings: LocalHttpsSettings): LocalHttpsCertificateFiles { return { cert: readCertificateFile(settings, "certificate", settings.certPath), diff --git a/packages/hydrogen/src/vite/mkcert.test.ts b/packages/hydrogen/src/vite/mkcert.test.ts new file mode 100644 index 0000000000..32a9dd9725 --- /dev/null +++ b/packages/hydrogen/src/vite/mkcert.test.ts @@ -0,0 +1,143 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { downloadVerified, provisionCertificates, resolveMkcertBinary } from "./mkcert"; + +const SUPPORTED_TARGETS = [ + ["darwin", "arm64", "mkcert-v1.4.4-darwin-arm64"], + ["darwin", "x64", "mkcert-v1.4.4-darwin-amd64"], + ["linux", "arm64", "mkcert-v1.4.4-linux-arm64"], + ["linux", "x64", "mkcert-v1.4.4-linux-amd64"], + ["win32", "arm64", "mkcert-v1.4.4-windows-arm64.exe"], + ["win32", "x64", "mkcert-v1.4.4-windows-amd64.exe"], +] as const; + +describe("resolveMkcertBinary", () => { + it.each(SUPPORTED_TARGETS)("resolves %s-%s to a pinned release", (platform, arch, assetName) => { + const binary = resolveMkcertBinary(platform, arch); + + expect(binary).toMatchObject({ + assetName, + url: `https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/${assetName}`, + }); + expect(binary?.sha256).toMatch(/^[a-f0-9]{64}$/); + }); + + it("pins a distinct checksum per target", () => { + const checksums = SUPPORTED_TARGETS.map( + ([platform, arch]) => resolveMkcertBinary(platform, arch)?.sha256, + ); + + expect(new Set(checksums).size).toBe(SUPPORTED_TARGETS.length); + }); + + it.each([ + ["sunos", "x64"], + ["linux", "arm"], + ["win32", "ia32"], + ])("returns undefined for unsupported %s-%s", (platform, arch) => { + expect(resolveMkcertBinary(platform, arch)).toBeUndefined(); + }); +}); + +describe("downloadVerified", () => { + let directory: string; + let destination: string; + + beforeEach(() => { + directory = fs.mkdtempSync(join(tmpdir(), "hydrogen-mkcert-")); + destination = join(directory, "bin", "mkcert"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + function stubFetch(response: Response) { + const fetchMock = vi.fn(async () => response); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + it("writes an executable file when the checksum matches", async () => { + const bytes = Buffer.from("mkcert binary contents"); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const fetchMock = stubFetch(new Response(bytes)); + + await downloadVerified({ url: "https://example.test/mkcert", sha256, destination }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://example.test/mkcert", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(fs.readFileSync(destination)).toEqual(bytes); + expect(fs.readdirSync(join(directory, "bin"))).toEqual(["mkcert"]); + if (process.platform !== "win32") { + expect(fs.statSync(destination).mode & 0o111).not.toBe(0); + } + }); + + it("refuses to write a download that does not match the pinned checksum", async () => { + stubFetch(new Response(Buffer.from("tampered contents"))); + + await expect( + downloadVerified({ + url: "https://example.test/mkcert", + sha256: "a".repeat(64), + destination, + }), + ).rejects.toThrow(/did not match the pinned SHA-256 checksum/); + expect(fs.existsSync(destination)).toBe(false); + }); + + it("names the operation and URL when the download times out", async () => { + const fetchMock = vi.fn(async () => { + throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + downloadVerified({ + url: "https://example.test/mkcert", + sha256: "a".repeat(64), + destination, + }), + ).rejects.toThrow("mkcert download timed out after 60s: https://example.test/mkcert"); + }); + + it("throws on a failed download response", async () => { + stubFetch(new Response(null, { status: 404 })); + + await expect( + downloadVerified({ + url: "https://example.test/mkcert", + sha256: "a".repeat(64), + destination, + }), + ).rejects.toThrow(/failed with status 404/); + expect(fs.existsSync(destination)).toBe(false); + }); +}); + +describe("provisionCertificates", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects unsupported platforms with manual instructions", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("sunos" as NodeJS.Platform); + + await expect( + provisionCertificates({ + host: "custom.test", + certPath: join(tmpdir(), "custom.test.pem"), + keyPath: join(tmpdir(), "custom.test-key.pem"), + }), + ).rejects.toThrow(/not available for sunos-.*Install mkcert manually/s); + }); +}); diff --git a/packages/hydrogen/src/vite/mkcert.ts b/packages/hydrogen/src/vite/mkcert.ts new file mode 100644 index 0000000000..1643d9e583 --- /dev/null +++ b/packages/hydrogen/src/vite/mkcert.ts @@ -0,0 +1,213 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +import { consoleLogger, getLogger } from "../core/logging"; + +const log = getLogger("local-https"); + +// Step confirmations print unprefixed on purpose: they are terminal output for +// an interactive provisioning flow, not subsystem log entries. +function confirm(message: string) { + consoleLogger.info(`☑️ ${message}`); +} + +// mkcert is pinned to an exact release and verified against SHA-256 checksums +// so that a compromised or replaced "latest" release can never execute. +// Checksums are sourced from the Hermit (cashapp/hermit-packages, mkcert.hcl) +// and Scoop (ScoopInstaller/Extras, bucket/mkcert.json) package registries. +const MKCERT_VERSION = "v1.4.4"; +const MKCERT_RELEASE_BASE_URL = `https://github.com/FiloSottile/mkcert/releases/download/${MKCERT_VERSION}`; +const EXECUTABLE_MODE = 0o755; +const SUCCESS_EXIT_CODE = 0; +const DOWNLOAD_TIMEOUT_MS = 60_000; + +const MKCERT_BINARIES: Record = { + "darwin-arm64": { + assetName: `mkcert-${MKCERT_VERSION}-darwin-arm64`, + sha256: "c8af0df44bce04359794dad8ea28d750437411d632748049d08644ffb66a60c6", + }, + "darwin-x64": { + assetName: `mkcert-${MKCERT_VERSION}-darwin-amd64`, + sha256: "a32dfab51f1845d51e810db8e47dcf0e6b51ae3422426514bf5a2b8302e97d4e", + }, + "linux-arm64": { + assetName: `mkcert-${MKCERT_VERSION}-linux-arm64`, + sha256: "b98f2cc69fd9147fe4d405d859c57504571adec0d3611c3eefd04107c7ac00d0", + }, + "linux-x64": { + assetName: `mkcert-${MKCERT_VERSION}-linux-amd64`, + sha256: "6d31c65b03972c6dc4a14ab429f2928300518b26503f58723e532d1b0a3bbb52", + }, + "win32-arm64": { + assetName: `mkcert-${MKCERT_VERSION}-windows-arm64.exe`, + sha256: "793747256c562622d40127c8080df26add2fb44c50906ce9db63b42a5280582e", + }, + "win32-x64": { + assetName: `mkcert-${MKCERT_VERSION}-windows-amd64.exe`, + sha256: "d2660b50a9ed59eada480750561c96abc2ed4c9a38c6a24d93e30e0977631398", + }, +}; + +export type MkcertBinary = { + assetName: string; + sha256: string; + url: string; +}; + +/** @internal Exported for tests. */ +export function resolveMkcertBinary(platform: string, arch: string): MkcertBinary | undefined { + const binary = MKCERT_BINARIES[`${platform}-${arch}`]; + if (!binary) return undefined; + + return { ...binary, url: `${MKCERT_RELEASE_BASE_URL}/${binary.assetName}` }; +} + +function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** @internal Exported for tests. */ +export async function downloadVerified(options: { + url: string; + sha256: string; + destination: string; +}): Promise { + let bytes: Uint8Array; + try { + const response = await fetch(options.url, { + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`mkcert download failed with status ${response.status}: ${options.url}`); + } + + bytes = new Uint8Array(await response.arrayBuffer()); + } catch (error) { + if (error instanceof Error && error.name === "TimeoutError") { + throw new Error( + `mkcert download timed out after ${DOWNLOAD_TIMEOUT_MS / 1_000}s: ${options.url}`, + { cause: error }, + ); + } + throw error; + } + const digest = sha256Hex(bytes); + if (digest !== options.sha256) { + throw new Error( + `mkcert download did not match the pinned SHA-256 checksum; refusing to run it.\n` + + ` URL: ${options.url}\n` + + ` Expected: ${options.sha256}\n` + + ` Received: ${digest}`, + ); + } + + await mkdir(dirname(options.destination), { recursive: true }); + + // Write-then-rename keeps concurrent dev servers from ever spawning a + // partially written binary: rename is atomic on the same filesystem. + const temporaryPath = `${options.destination}.tmp-${process.pid}`; + try { + await writeFile(temporaryPath, bytes, { mode: EXECUTABLE_MODE }); + await chmod(temporaryPath, EXECUTABLE_MODE); + await rename(temporaryPath, options.destination); + } catch (error) { + await rm(temporaryPath, { force: true }); + throw error; + } +} + +async function ensureMkcertBinary(): Promise { + const binary = resolveMkcertBinary(process.platform, process.arch); + if (!binary) { + throw new Error( + `Automatic mkcert download is not available for ${process.platform}-${process.arch}. ` + + "Install mkcert manually and generate the certificate.", + ); + } + + const binaryPath = join(homedir(), ".shopify", "hydrogen", "mkcert", binary.assetName); + if (existsSync(binaryPath) && sha256Hex(await readFile(binaryPath)) === binary.sha256) { + return binaryPath; + } + + log.info(`downloading mkcert ${MKCERT_VERSION}…`); + await downloadVerified({ url: binary.url, sha256: binary.sha256, destination: binaryPath }); + confirm(`mkcert ${MKCERT_VERSION} downloaded`); + + return binaryPath; +} + +function runMkcert(binaryPath: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + // stdin stays inherited so sudo can prompt on the terminal (sudo talks to + // the tty directly, not to the piped streams). mkcert's verbose output is + // captured instead of shown, and surfaced only when the command fails. + const child = spawn(binaryPath, args, { stdio: ["inherit", "pipe", "pipe"] }); + + let output = ""; + const collect = (chunk: Buffer) => { + output += chunk.toString(); + }; + child.stdout?.on("data", collect); + child.stderr?.on("data", collect); + + child.on("error", reject); + child.on("close", (code, signal) => { + if (code === SUCCESS_EXIT_CODE) { + resolve(output); + return; + } + + const reason = signal ? `mkcert was killed by ${signal}` : `mkcert exited with code ${code}`; + reject(new Error(output.trim() === "" ? reason : `${reason}\n${output.trim()}`)); + }); + }); +} + +export type ProvisionSettings = { + host: string; + certPath: string; + keyPath: string; +}; + +export async function provisionCertificates(settings: ProvisionSettings): Promise { + const binaryPath = await ensureMkcertBinary(); + + await mkdir(dirname(settings.certPath), { recursive: true }); + await mkdir(dirname(settings.keyPath), { recursive: true }); + + log.info("generating a trusted local certificate; mkcert may prompt for your password."); + const mkcertOutput = await runMkcert(binaryPath, [ + "-install", + "-cert-file", + settings.certPath, + "-key-file", + settings.keyPath, + settings.host, + ]); + confirm( + mkcertOutput.includes("The local CA is already installed") + ? "The local CA is already installed in the system trust store" + : "The local CA is now installed in the system trust store", + ); + confirm(`Created a new certificate for "${settings.host}"`); + + log.info("verifying certificates…"); + const missingPaths = [settings.certPath, settings.keyPath].filter((path) => !existsSync(path)); + if (missingPaths.length > 0) { + throw new Error(`mkcert did not create the expected files: ${missingPaths.join(", ")}`); + } + confirm("local https certificates ready"); +} + +export async function uninstallCertificateAuthority(): Promise { + const binaryPath = await ensureMkcertBinary(); + + log.info("removing the local certificate authority; mkcert may prompt for your password."); + await runMkcert(binaryPath, ["-uninstall"]); + confirm("The local CA was removed from the system trust stores"); +}