From 45ee3a478d922a55603d38b3099be1f694347629 Mon Sep 17 00:00:00 2001 From: freddie Date: Fri, 14 Aug 2026 14:20:29 +0100 Subject: [PATCH 1/9] Provision local HTTPS certificates automatically with a pinned mkcert Assisted-By: devx/07abca81-b031-4579-88c8-bc07dfd3c5aa --- .changeset/local-https-vite-plugin.md | 2 + AGENTS.md | 3 +- examples/README.md | 5 +- package.json | 2 +- .../skills/hydrogen-local-https/SKILL.md | 20 +- .../hydrogen/src/cli/__tests__/index.test.ts | 47 ++++ packages/hydrogen/src/cli/https.ts | 9 + packages/hydrogen/src/cli/index.ts | 4 + packages/hydrogen/src/vite/index.test.ts | 238 +++++++++++++++++- packages/hydrogen/src/vite/local-https.ts | 111 +++++++- packages/hydrogen/src/vite/mkcert.test.ts | 128 ++++++++++ packages/hydrogen/src/vite/mkcert.ts | 178 +++++++++++++ 12 files changed, 715 insertions(+), 32 deletions(-) create mode 100644 packages/hydrogen/src/cli/__tests__/index.test.ts create mode 100644 packages/hydrogen/src/cli/https.ts create mode 100644 packages/hydrogen/src/vite/mkcert.test.ts create mode 100644 packages/hydrogen/src/vite/mkcert.ts diff --git a/.changeset/local-https-vite-plugin.md b/.changeset/local-https-vite-plugin.md index 5747c9f156..4f9bb9bd7e 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 are provisioned automatically: the plugin (on `vite dev`), the `provisionLocalHttps()` helper, and the `hydrogen setup https` CLI command download a pinned, checksum-verified mkcert release for macOS, Linux, or Windows, install the local certificate authority, and generate the certificate files. The plugin skips automatic provisioning in CI environments; the explicit paths remain available there. diff --git a/AGENTS.md b/AGENTS.md index c01e78f221..b13f27444d 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. 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..6ea66ccc4d 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 setup https", "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..f7857e8111 100644 --- a/packages/hydrogen/skills/hydrogen-local-https/SKILL.md +++ b/packages/hydrogen/skills/hydrogen-local-https/SKILL.md @@ -9,21 +9,17 @@ 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 setup https ``` -Pass `certPath` and `keyPath` to use another location. Certificate generation is intentionally separate from the plugin. +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 setup https` explicitly when a CI job genuinely needs local HTTPS. ## Vite @@ -72,7 +68,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 setup https` (or restart once after the plugin provisions them): ```ts import { localHttps } from "@shopify/hydrogen/vite"; @@ -95,7 +91,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 setup https` (or restart once after the plugin provisions them): ```ts import { defineConfig } from "@solidjs/start/config"; 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..5e4f929f84 --- /dev/null +++ b/packages/hydrogen/src/cli/__tests__/index.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { runCli } from "../index"; + +const commandCalls = vi.hoisted(() => ({ + checkGraphQL: vi.fn(async () => {}), + setupHydrogen: vi.fn(async () => {}), + setupLocalHttps: vi.fn(async () => {}), +})); + +vi.mock("../gql", () => ({ checkGraphQL: commandCalls.checkGraphQL })); +vi.mock("../https", () => ({ setupLocalHttps: commandCalls.setupLocalHttps })); +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 setup https to the local HTTPS setup, not the skills setup", () => { + runWithArguments(["setup", "https"]); + + expect(commandCalls.setupLocalHttps).toHaveBeenCalledOnce(); + expect(commandCalls.setupHydrogen).not.toHaveBeenCalled(); + }); + + it("dispatches setup to the skills setup", () => { + runWithArguments(["setup"]); + + expect(commandCalls.setupHydrogen).toHaveBeenCalledOnce(); + expect(commandCalls.setupLocalHttps).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/https.ts b/packages/hydrogen/src/cli/https.ts new file mode 100644 index 0000000000..3573b0d243 --- /dev/null +++ b/packages/hydrogen/src/cli/https.ts @@ -0,0 +1,9 @@ +import { provisionLocalHttps } from "../vite"; + +export async function setupLocalHttps(): 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}`); +} diff --git a/packages/hydrogen/src/cli/index.ts b/packages/hydrogen/src/cli/index.ts index c35253533d..ef0254d607 100644 --- a/packages/hydrogen/src/cli/index.ts +++ b/packages/hydrogen/src/cli/index.ts @@ -4,12 +4,16 @@ import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { checkGraphQL } from "./gql"; +import { setupLocalHttps } from "./https"; import { setupHydrogen } from "./setup"; const CLI_ARGUMENTS_INDEX = 2; const FAILURE_EXIT_CODE = 1; +// Longer command paths must come before their prefixes: matching is +// first-match-wins on path prefixes, so `setup https` must precede `setup`. const COMMANDS = [ + { path: ["setup", "https"], run: async (_args: string[]) => setupLocalHttps() }, { 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/index.test.ts b/packages/hydrogen/src/vite/index.test.ts index 32e4f6b175..2f4fc79343 100644 --- a/packages/hydrogen/src/vite/index.test.ts +++ b/packages/hydrogen/src/vite/index.test.ts @@ -5,18 +5,31 @@ 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(), +})); + +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 +62,10 @@ describe("localHttps", () => { let keyPath: string; beforeEach(() => { + fsCalls.existsSync.mockClear(); fsCalls.readFileSync.mockClear(); + mkcertCalls.provisionCertificates.mockReset(); + 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 +73,27 @@ 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(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 +111,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 +131,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 +140,18 @@ describe("localHttps", () => { keyPath, }); const config = getHook(plugin.config, "config"); + const configureServer = getHook(plugin.configureServer, "configureServer"); + const use = vi.fn(); - const message = captureErrorMessage(() => config({} as any, {} as any)); + expect(await config({} as any, {} as any)).toBeUndefined(); + expect(configureServer({ middlewares: { use } } as any)).toBeUndefined(); + expect(use).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + + 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 setup https"); expect(message).toContain("brew install mkcert"); expect(message).toContain("mkcert -install"); expect(message).toContain(`mkdir -p '${directory}'`); @@ -142,13 +169,154 @@ 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(result).toMatchObject({ + server: { + https: { + cert: Buffer.from("certificate"), + key: Buffer.from("private-key"), + }, + }, + }); + expect(warn).not.toHaveBeenCalled(); + }); + + 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(); + }); + + 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 setup https"); + }); + + 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 setup https"); + }); + + 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"); @@ -417,6 +585,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..41a930aac5 100644 --- a/packages/hydrogen/src/vite/local-https.ts +++ b/packages/hydrogen/src/vite/local-https.ts @@ -1,12 +1,13 @@ -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 { provisionCertificates } from "./mkcert"; export const LOCAL_HTTPS_DEFAULTS = { host: "local.tryhydrogen.dev", @@ -24,6 +25,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. */ @@ -88,7 +90,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 +121,7 @@ export function localHttps(options: LocalHttpsOptions): LocalHttpsPlugin { }; }, configureServer(server) { - if (!settings) return; + if (!settings || !checkCertificateFiles(settings)) return; getCertificateFiles(); configureLocalHttpsServer(server, settings); @@ -120,6 +129,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 +184,78 @@ 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).", + ); + } + + 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 setup https", + "", + "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..2c151b6161 --- /dev/null +++ b/packages/hydrogen/src/vite/mkcert.test.ts @@ -0,0 +1,128 @@ +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("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..e716f98cfc --- /dev/null +++ b/packages/hydrogen/src/vite/mkcert.ts @@ -0,0 +1,178 @@ +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 { getLogger } from "../core/logging"; + +const log = getLogger("local-https"); + +// 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 { + 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}`); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + 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}`, { url: binary.url }); + await downloadVerified({ url: binary.url, sha256: binary.sha256, destination: binaryPath }); + + return binaryPath; +} + +function runMkcert(binaryPath: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + // Inherited stdio keeps trust-store password prompts visible and usable. + const child = spawn(binaryPath, args, { stdio: "inherit" }); + + child.on("error", reject); + child.on("close", (code, signal) => { + if (code === SUCCESS_EXIT_CODE) { + resolve(); + return; + } + + reject( + new Error(signal ? `mkcert was killed by ${signal}` : `mkcert exited with code ${code}`), + ); + }); + }); +} + +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", { + host: settings.host, + }); + await runMkcert(binaryPath, [ + "-install", + "-cert-file", + settings.certPath, + "-key-file", + settings.keyPath, + settings.host, + ]); + + 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(", ")}`); + } + + log.info("local https certificates ready; restart the dev server if it started without https", { + certPath: settings.certPath, + keyPath: settings.keyPath, + }); +} From 743b94528f839d96b4a319d8b0270e14b93d4ae0 Mon Sep 17 00:00:00 2001 From: freddie Date: Fri, 14 Aug 2026 14:40:53 +0100 Subject: [PATCH 2/9] Replace verbose mkcert output with minimal step confirmations Assisted-By: devx/07abca81-b031-4579-88c8-bc07dfd3c5aa --- packages/hydrogen/src/vite/mkcert.ts | 54 ++++++++++++++++++---------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/packages/hydrogen/src/vite/mkcert.ts b/packages/hydrogen/src/vite/mkcert.ts index e716f98cfc..27d7bd77f7 100644 --- a/packages/hydrogen/src/vite/mkcert.ts +++ b/packages/hydrogen/src/vite/mkcert.ts @@ -5,10 +5,16 @@ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" import { homedir } from "node:os"; import { dirname, join } from "node:path"; -import { getLogger } from "../core/logging"; +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) @@ -117,27 +123,36 @@ async function ensureMkcertBinary(): Promise { return binaryPath; } - log.info(`downloading mkcert ${MKCERT_VERSION}`, { url: binary.url }); + 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 { +function runMkcert(binaryPath: string, args: string[]): Promise { return new Promise((resolve, reject) => { - // Inherited stdio keeps trust-store password prompts visible and usable. - const child = spawn(binaryPath, args, { stdio: "inherit" }); + // 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(); + resolve(output); return; } - reject( - new Error(signal ? `mkcert was killed by ${signal}` : `mkcert exited with code ${code}`), - ); + const reason = signal ? `mkcert was killed by ${signal}` : `mkcert exited with code ${code}`; + reject(new Error(output.trim() === "" ? reason : `${reason}\n${output.trim()}`)); }); }); } @@ -154,10 +169,8 @@ export async function provisionCertificates(settings: ProvisionSettings): Promis 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", { - host: settings.host, - }); - await runMkcert(binaryPath, [ + log.info("generating a trusted local certificate; mkcert may prompt for your password."); + const mkcertOutput = await runMkcert(binaryPath, [ "-install", "-cert-file", settings.certPath, @@ -165,14 +178,17 @@ export async function provisionCertificates(settings: ProvisionSettings): Promis 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(", ")}`); } - - log.info("local https certificates ready; restart the dev server if it started without https", { - certPath: settings.certPath, - keyPath: settings.keyPath, - }); + confirm("local https certificates ready"); } From 9b1455432d9f5b0ff69183fed452daa568bf96ae Mon Sep 17 00:00:00 2001 From: freddie Date: Fri, 14 Aug 2026 14:47:32 +0100 Subject: [PATCH 3/9] Name the operation and URL when the mkcert download times out Assisted-By: devx/07abca81-b031-4579-88c8-bc07dfd3c5aa --- packages/hydrogen/src/vite/mkcert.test.ts | 15 ++++++++++++++ packages/hydrogen/src/vite/mkcert.ts | 25 ++++++++++++++++------- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/hydrogen/src/vite/mkcert.test.ts b/packages/hydrogen/src/vite/mkcert.test.ts index 2c151b6161..32a9dd9725 100644 --- a/packages/hydrogen/src/vite/mkcert.test.ts +++ b/packages/hydrogen/src/vite/mkcert.test.ts @@ -95,6 +95,21 @@ describe("downloadVerified", () => { 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 })); diff --git a/packages/hydrogen/src/vite/mkcert.ts b/packages/hydrogen/src/vite/mkcert.ts index 27d7bd77f7..7578bc1741 100644 --- a/packages/hydrogen/src/vite/mkcert.ts +++ b/packages/hydrogen/src/vite/mkcert.ts @@ -76,14 +76,25 @@ export async function downloadVerified(options: { sha256: string; destination: string; }): Promise { - 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}`); - } + 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}`); + } - const bytes = new Uint8Array(await response.arrayBuffer()); + 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( From b5b5d11e2ef5b26bcea5fa82ef657766c3ed606b Mon Sep 17 00:00:00 2001 From: freddie Date: Mon, 17 Aug 2026 19:08:17 +0100 Subject: [PATCH 4/9] Preserve local HTTPS fallback after API refactor Assisted-By: devx/05def498-8bdd-48d0-bd5e-35e4667a2f3d --- packages/hydrogen/src/vite/index.test.ts | 14 +++++++++++--- packages/hydrogen/src/vite/local-https.ts | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/hydrogen/src/vite/index.test.ts b/packages/hydrogen/src/vite/index.test.ts index 2f4fc79343..3f6249f9ff 100644 --- a/packages/hydrogen/src/vite/index.test.ts +++ b/packages/hydrogen/src/vite/index.test.ts @@ -521,6 +521,7 @@ describe("localHttps", () => { describe("localHttps plugin API", () => { beforeEach(() => { + fsCalls.existsSync.mockClear(); fsCalls.readFileSync.mockClear(); }); @@ -554,21 +555,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`)); }); diff --git a/packages/hydrogen/src/vite/local-https.ts b/packages/hydrogen/src/vite/local-https.ts index 41a930aac5..3e024c840f 100644 --- a/packages/hydrogen/src/vite/local-https.ts +++ b/packages/hydrogen/src/vite/local-https.ts @@ -73,7 +73,7 @@ export function localHttps(options: LocalHttpsOptions): LocalHttpsPlugin { }; const getDevServerConfig = () => { - if (!settings) return; + if (!settings || !checkCertificateFiles(settings)) return; getCertificateFiles(); From aefc4d496c54f47027511279583a821c639425f0 Mon Sep 17 00:00:00 2001 From: freddie Date: Thu, 20 Aug 2026 11:55:41 +0100 Subject: [PATCH 5/9] Confirm automatic local certificate installation --- .changeset/local-https-vite-plugin.md | 2 +- .../hydrogen/src/vite/certificate-prompt.ts | 16 +++++++++++ packages/hydrogen/src/vite/index.test.ts | 27 +++++++++++++++++++ packages/hydrogen/src/vite/local-https.ts | 8 ++++++ 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 packages/hydrogen/src/vite/certificate-prompt.ts diff --git a/.changeset/local-https-vite-plugin.md b/.changeset/local-https-vite-plugin.md index 4f9bb9bd7e..badcdd433a 100644 --- a/.changeset/local-https-vite-plugin.md +++ b/.changeset/local-https-vite-plugin.md @@ -4,4 +4,4 @@ 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 are provisioned automatically: the plugin (on `vite dev`), the `provisionLocalHttps()` helper, and the `hydrogen setup https` CLI command download a pinned, checksum-verified mkcert release for macOS, Linux, or Windows, install the local certificate authority, and generate the certificate files. The plugin skips automatic provisioning in CI environments; the explicit paths remain available there. +Certificates are provisioned automatically: after confirmation, the plugin (on `vite dev`), the `provisionLocalHttps()` helper, and the `hydrogen setup https` CLI command download a pinned, checksum-verified mkcert release for macOS, Linux, or Windows, install the local certificate authority, and generate the certificate files. The plugin skips automatic provisioning in CI environments; the explicit paths remain available there. diff --git a/packages/hydrogen/src/vite/certificate-prompt.ts b/packages/hydrogen/src/vite/certificate-prompt.ts new file mode 100644 index 0000000000..f73e2397a2 --- /dev/null +++ b/packages/hydrogen/src/vite/certificate-prompt.ts @@ -0,0 +1,16 @@ +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( + `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 answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes"; + } finally { + readline.close(); + } +} diff --git a/packages/hydrogen/src/vite/index.test.ts b/packages/hydrogen/src/vite/index.test.ts index 3f6249f9ff..2b3842cf57 100644 --- a/packages/hydrogen/src/vite/index.test.ts +++ b/packages/hydrogen/src/vite/index.test.ts @@ -17,6 +17,14 @@ 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, })); @@ -65,6 +73,7 @@ describe("localHttps", () => { 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"); @@ -89,6 +98,7 @@ describe("localHttps", () => { 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(); }); @@ -228,6 +238,7 @@ describe("localHttps", () => { expect(mkcertCalls.provisionCertificates).toHaveBeenCalledWith( expect.objectContaining({ host: "custom.test", certPath, keyPath }), ); + expect(promptCalls.confirmCertificateInstallation).toHaveBeenCalledWith("custom.test"); expect(result).toMatchObject({ server: { https: { @@ -239,6 +250,21 @@ describe("localHttps", () => { 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"); @@ -248,6 +274,7 @@ describe("localHttps", () => { 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 () => { diff --git a/packages/hydrogen/src/vite/local-https.ts b/packages/hydrogen/src/vite/local-https.ts index 3e024c840f..a43c67a526 100644 --- a/packages/hydrogen/src/vite/local-https.ts +++ b/packages/hydrogen/src/vite/local-https.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; 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 = { @@ -200,6 +201,13 @@ async function ensureCertificateFiles(settings: LocalHttpsSettings): Promise Date: Thu, 20 Aug 2026 11:56:46 +0100 Subject: [PATCH 6/9] Add local certificate install command --- .changeset/local-https-vite-plugin.md | 2 +- AGENTS.md | 2 +- package.json | 2 +- .../hydrogen/skills/hydrogen-local-https/SKILL.md | 8 ++++---- packages/hydrogen/src/cli/__tests__/index.test.ts | 14 ++++++++------ packages/hydrogen/src/cli/{https.ts => certs.ts} | 2 +- packages/hydrogen/src/cli/index.ts | 6 ++---- packages/hydrogen/src/vite/index.test.ts | 6 +++--- packages/hydrogen/src/vite/local-https.ts | 2 +- 9 files changed, 22 insertions(+), 22 deletions(-) rename packages/hydrogen/src/cli/{https.ts => certs.ts} (78%) diff --git a/.changeset/local-https-vite-plugin.md b/.changeset/local-https-vite-plugin.md index badcdd433a..5c7ce1567c 100644 --- a/.changeset/local-https-vite-plugin.md +++ b/.changeset/local-https-vite-plugin.md @@ -4,4 +4,4 @@ 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 are provisioned automatically: after confirmation, the plugin (on `vite dev`), the `provisionLocalHttps()` helper, and the `hydrogen setup https` CLI command download a pinned, checksum-verified mkcert release for macOS, Linux, or Windows, install the local certificate authority, and generate the certificate files. The plugin skips automatic provisioning in CI environments; the explicit paths remain available there. +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. diff --git a/AGENTS.md b/AGENTS.md index b13f27444d..55173d58b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +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. -- 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. Both download a pinned, checksum-verified mkcert release, trust the local certificate authority, and create the certificates 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/package.json b/package.json index 6ea66ccc4d..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": "turbo run build --filter=@shopify/hydrogen && node packages/hydrogen/bin/hydrogen.mjs setup https", + "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 f7857e8111..b076dc6b7a 100644 --- a/packages/hydrogen/skills/hydrogen-local-https/SKILL.md +++ b/packages/hydrogen/skills/hydrogen-local-https/SKILL.md @@ -14,12 +14,12 @@ Customer Account login, logout, and OAuth callbacks require an HTTPS, non-`local 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 -npx hydrogen setup https +npx hydrogen certs install ``` 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 setup https` explicitly when a CI job genuinely needs local HTTPS. +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 @@ -68,7 +68,7 @@ export default defineConfig({ ## Nuxt -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 setup https` (or restart once after the plugin provisions them): +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"; @@ -91,7 +91,7 @@ export default defineNuxtConfig({ ## SolidStart/Vinxi -Vinxi terminates TLS outside Vite and reads certificate paths when the config is evaluated, so provision certificates with `npx hydrogen setup https` (or restart once after the plugin provisions them): +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__/index.test.ts b/packages/hydrogen/src/cli/__tests__/index.test.ts index 5e4f929f84..f9053ef6a2 100644 --- a/packages/hydrogen/src/cli/__tests__/index.test.ts +++ b/packages/hydrogen/src/cli/__tests__/index.test.ts @@ -4,12 +4,14 @@ import { runCli } from "../index"; const commandCalls = vi.hoisted(() => ({ checkGraphQL: vi.fn(async () => {}), + installLocalHttpsCertificates: vi.fn(async () => {}), setupHydrogen: vi.fn(async () => {}), - setupLocalHttps: vi.fn(async () => {}), })); +vi.mock("../certs", () => ({ + installLocalHttpsCertificates: commandCalls.installLocalHttpsCertificates, +})); vi.mock("../gql", () => ({ checkGraphQL: commandCalls.checkGraphQL })); -vi.mock("../https", () => ({ setupLocalHttps: commandCalls.setupLocalHttps })); vi.mock("../setup", () => ({ setupHydrogen: commandCalls.setupHydrogen })); const originalArgv = process.argv; @@ -25,10 +27,10 @@ describe("runCli", () => { vi.clearAllMocks(); }); - it("dispatches setup https to the local HTTPS setup, not the skills setup", () => { - runWithArguments(["setup", "https"]); + it("dispatches certs install to local HTTPS installation", () => { + runWithArguments(["certs", "install"]); - expect(commandCalls.setupLocalHttps).toHaveBeenCalledOnce(); + expect(commandCalls.installLocalHttpsCertificates).toHaveBeenCalledOnce(); expect(commandCalls.setupHydrogen).not.toHaveBeenCalled(); }); @@ -36,7 +38,7 @@ describe("runCli", () => { runWithArguments(["setup"]); expect(commandCalls.setupHydrogen).toHaveBeenCalledOnce(); - expect(commandCalls.setupLocalHttps).not.toHaveBeenCalled(); + expect(commandCalls.installLocalHttpsCertificates).not.toHaveBeenCalled(); }); it("dispatches gql check with trailing arguments", () => { diff --git a/packages/hydrogen/src/cli/https.ts b/packages/hydrogen/src/cli/certs.ts similarity index 78% rename from packages/hydrogen/src/cli/https.ts rename to packages/hydrogen/src/cli/certs.ts index 3573b0d243..79aea97e36 100644 --- a/packages/hydrogen/src/cli/https.ts +++ b/packages/hydrogen/src/cli/certs.ts @@ -1,6 +1,6 @@ import { provisionLocalHttps } from "../vite"; -export async function setupLocalHttps(): Promise { +export async function installLocalHttpsCertificates(): Promise { const { host, certPath, keyPath } = await provisionLocalHttps(); console.log(`Local HTTPS is ready for https://${host}`); diff --git a/packages/hydrogen/src/cli/index.ts b/packages/hydrogen/src/cli/index.ts index ef0254d607..659df4cee0 100644 --- a/packages/hydrogen/src/cli/index.ts +++ b/packages/hydrogen/src/cli/index.ts @@ -3,17 +3,15 @@ import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { installLocalHttpsCertificates } from "./certs"; import { checkGraphQL } from "./gql"; -import { setupLocalHttps } from "./https"; import { setupHydrogen } from "./setup"; const CLI_ARGUMENTS_INDEX = 2; const FAILURE_EXIT_CODE = 1; -// Longer command paths must come before their prefixes: matching is -// first-match-wins on path prefixes, so `setup https` must precede `setup`. const COMMANDS = [ - { path: ["setup", "https"], run: async (_args: string[]) => setupLocalHttps() }, + { path: ["certs", "install"], run: async (_args: string[]) => installLocalHttpsCertificates() }, { 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/index.test.ts b/packages/hydrogen/src/vite/index.test.ts index 2b3842cf57..7e385629dc 100644 --- a/packages/hydrogen/src/vite/index.test.ts +++ b/packages/hydrogen/src/vite/index.test.ts @@ -161,7 +161,7 @@ describe("localHttps", () => { const message = String(warn.mock.calls[0]?.[0]); expect(message).toContain(certPath); expect(message).toContain(keyPath); - expect(message).toContain("npx hydrogen setup https"); + expect(message).toContain("npx hydrogen certs install"); expect(message).toContain("brew install mkcert"); expect(message).toContain("mkcert -install"); expect(message).toContain(`mkdir -p '${directory}'`); @@ -289,7 +289,7 @@ describe("localHttps", () => { 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 setup https"); + expect(message).toContain("npx hydrogen certs install"); }); it("does not provision during builds", async () => { @@ -315,7 +315,7 @@ describe("localHttps", () => { expect(warn).toHaveBeenCalledOnce(); const message = String(warn.mock.calls[0]?.[0]); expect(message).toContain("skipped in CI environments"); - expect(message).toContain("npx hydrogen setup https"); + expect(message).toContain("npx hydrogen certs install"); }); it("provisions when CI is explicitly disabled", async () => { diff --git a/packages/hydrogen/src/vite/local-https.ts b/packages/hydrogen/src/vite/local-https.ts index a43c67a526..064692547d 100644 --- a/packages/hydrogen/src/vite/local-https.ts +++ b/packages/hydrogen/src/vite/local-https.ts @@ -254,7 +254,7 @@ function formatMissingCertificateWarning( ` Private key: ${keyPath}`, "", "Run the automatic setup:", - " npx hydrogen setup https", + " npx hydrogen certs install", "", "Or install and configure mkcert, then generate the certificate:", " macOS: brew install mkcert", From e060d8b3042341dde275fa65b1f3ed7dd26a1391 Mon Sep 17 00:00:00 2001 From: freddie Date: Thu, 20 Aug 2026 12:00:15 +0100 Subject: [PATCH 7/9] Add local certificate uninstall command --- .changeset/local-https-vite-plugin.md | 2 +- .../skills/hydrogen-local-https/SKILL.md | 2 + .../hydrogen/src/cli/__tests__/certs.test.ts | 65 +++++++++++++++++++ .../hydrogen/src/cli/__tests__/index.test.ts | 8 +++ packages/hydrogen/src/cli/certs.ts | 38 ++++++++++- packages/hydrogen/src/cli/index.ts | 6 +- packages/hydrogen/src/vite/mkcert.ts | 8 +++ 7 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 packages/hydrogen/src/cli/__tests__/certs.test.ts diff --git a/.changeset/local-https-vite-plugin.md b/.changeset/local-https-vite-plugin.md index 5c7ce1567c..59a33e324e 100644 --- a/.changeset/local-https-vite-plugin.md +++ b/.changeset/local-https-vite-plugin.md @@ -4,4 +4,4 @@ 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. +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/packages/hydrogen/skills/hydrogen-local-https/SKILL.md b/packages/hydrogen/skills/hydrogen-local-https/SKILL.md index b076dc6b7a..b16eb3fb1b 100644 --- a/packages/hydrogen/skills/hydrogen-local-https/SKILL.md +++ b/packages/hydrogen/skills/hydrogen-local-https/SKILL.md @@ -17,6 +17,8 @@ The `localHttps` Vite plugin provisions missing certificates automatically when npx hydrogen certs install ``` +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. 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..85465fa85e --- /dev/null +++ b/packages/hydrogen/src/cli/__tests__/certs.test.ts @@ -0,0 +1,65 @@ +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(() => ({ uninstallCertificateAuthority: vi.fn(async () => {}) })); + +vi.mock("node:fs/promises", async (importOriginal) => ({ + ...(await importOriginal()), + rm: fsCalls.rm, +})); + +vi.mock("../../vite/mkcert", () => ({ + uninstallCertificateAuthority: mkcertCalls.uninstallCertificateAuthority, +})); + +describe("uninstallLocalHttpsCertificates", () => { + beforeEach(() => { + fsCalls.rm.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"), { recursive: true, force: true }], + ]); + 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("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 index f9053ef6a2..b2efe13c25 100644 --- a/packages/hydrogen/src/cli/__tests__/index.test.ts +++ b/packages/hydrogen/src/cli/__tests__/index.test.ts @@ -6,10 +6,12 @@ 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 })); @@ -34,6 +36,12 @@ describe("runCli", () => { 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"]); diff --git a/packages/hydrogen/src/cli/certs.ts b/packages/hydrogen/src/cli/certs.ts index 79aea97e36..dab68020cc 100644 --- a/packages/hydrogen/src/cli/certs.ts +++ b/packages/hydrogen/src/cli/certs.ts @@ -1,4 +1,11 @@ -import { provisionLocalHttps } from "../vite"; +import { rm } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { LOCAL_HTTPS_DEFAULTS, provisionLocalHttps } from "../vite"; +import { uninstallCertificateAuthority } from "../vite/mkcert"; + +const REMOVE_CA_FLAG = "--remove-ca"; export async function installLocalHttpsCertificates(): Promise { const { host, certPath, keyPath } = await provisionLocalHttps(); @@ -7,3 +14,32 @@ export async function installLocalHttpsCertificates(): Promise { 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; + await Promise.all([ + rm(join(certificateDirectory, `${host}.pem`), { force: true }), + rm(join(certificateDirectory, `${host}-key.pem`), { force: true }), + rm(join(hydrogenDirectory, "mkcert"), { recursive: true, force: true }), + ]); + + 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 659df4cee0..92674cac2f 100644 --- a/packages/hydrogen/src/cli/index.ts +++ b/packages/hydrogen/src/cli/index.ts @@ -3,7 +3,7 @@ import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { installLocalHttpsCertificates } from "./certs"; +import { installLocalHttpsCertificates, uninstallLocalHttpsCertificates } from "./certs"; import { checkGraphQL } from "./gql"; import { setupHydrogen } from "./setup"; @@ -12,6 +12,10 @@ 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/mkcert.ts b/packages/hydrogen/src/vite/mkcert.ts index 7578bc1741..1643d9e583 100644 --- a/packages/hydrogen/src/vite/mkcert.ts +++ b/packages/hydrogen/src/vite/mkcert.ts @@ -203,3 +203,11 @@ export async function provisionCertificates(settings: ProvisionSettings): Promis } 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"); +} From 9b22c8e1c5422a908e72c944996ab7bef665bad5 Mon Sep 17 00:00:00 2001 From: freddie Date: Thu, 20 Aug 2026 12:07:38 +0100 Subject: [PATCH 8/9] Only remove Hydrogen's pinned mkcert binary --- .../hydrogen/src/cli/__tests__/certs.test.ts | 25 +++++++++++++++++-- packages/hydrogen/src/cli/certs.ts | 12 ++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/hydrogen/src/cli/__tests__/certs.test.ts b/packages/hydrogen/src/cli/__tests__/certs.test.ts index 85465fa85e..3cd8b8619e 100644 --- a/packages/hydrogen/src/cli/__tests__/certs.test.ts +++ b/packages/hydrogen/src/cli/__tests__/certs.test.ts @@ -6,7 +6,12 @@ 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(() => ({ uninstallCertificateAuthority: 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()), @@ -14,12 +19,14 @@ vi.mock("node:fs/promises", async (importOriginal) => ({ })); vi.mock("../../vite/mkcert", () => ({ + resolveMkcertBinary: mkcertCalls.resolveMkcertBinary, uninstallCertificateAuthority: mkcertCalls.uninstallCertificateAuthority, })); describe("uninstallLocalHttpsCertificates", () => { beforeEach(() => { fsCalls.rm.mockClear(); + mkcertCalls.resolveMkcertBinary.mockClear(); mkcertCalls.uninstallCertificateAuthority.mockClear(); }); @@ -36,8 +43,9 @@ describe("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"), { recursive: true, 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.", @@ -54,6 +62,19 @@ describe("uninstallLocalHttpsCertificates", () => { 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", diff --git a/packages/hydrogen/src/cli/certs.ts b/packages/hydrogen/src/cli/certs.ts index dab68020cc..fb1ab0a3ea 100644 --- a/packages/hydrogen/src/cli/certs.ts +++ b/packages/hydrogen/src/cli/certs.ts @@ -3,7 +3,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { LOCAL_HTTPS_DEFAULTS, provisionLocalHttps } from "../vite"; -import { uninstallCertificateAuthority } from "../vite/mkcert"; +import { resolveMkcertBinary, uninstallCertificateAuthority } from "../vite/mkcert"; const REMOVE_CA_FLAG = "--remove-ca"; @@ -32,11 +32,15 @@ export async function uninstallLocalHttpsCertificates(args: string[] = []): Prom const hydrogenDirectory = join(homedir(), ".shopify", "hydrogen"); const certificateDirectory = join(hydrogenDirectory, "certs"); const host = LOCAL_HTTPS_DEFAULTS.host; - await Promise.all([ + const binary = resolveMkcertBinary(process.platform, process.arch); + const removals = [ rm(join(certificateDirectory, `${host}.pem`), { force: true }), rm(join(certificateDirectory, `${host}-key.pem`), { force: true }), - rm(join(hydrogenDirectory, "mkcert"), { recursive: true, 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) { From b6a50f757e3d5676e9d921d2c99fed751824b384 Mon Sep 17 00:00:00 2001 From: freddie Date: Thu, 20 Aug 2026 12:14:41 +0100 Subject: [PATCH 9/9] Default local HTTPS confirmation to yes --- .../hydrogen/src/vite/certificate-prompt.test.ts | 15 +++++++++++++++ packages/hydrogen/src/vite/certificate-prompt.ts | 10 ++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 packages/hydrogen/src/vite/certificate-prompt.test.ts 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 index f73e2397a2..03567367f1 100644 --- a/packages/hydrogen/src/vite/certificate-prompt.ts +++ b/packages/hydrogen/src/vite/certificate-prompt.ts @@ -6,11 +6,17 @@ export async function confirmCertificateInstallation(host: string): Promise