From 9b8af7b585f34c5c1688c7113bc02de7cb4d6ed1 Mon Sep 17 00:00:00 2001 From: freddie Date: Fri, 14 Aug 2026 14:20:29 +0100 Subject: [PATCH 01/12] Provision local HTTPS certificates automatically with a pinned mkcert Assisted-By: devx/07abca81-b031-4579-88c8-bc07dfd3c5aa From 316a2086cfa4e94bf9684f72cc5cf3083b776e9f Mon Sep 17 00:00:00 2001 From: freddie Date: Fri, 14 Aug 2026 14:40:53 +0100 Subject: [PATCH 02/12] Replace verbose mkcert output with minimal step confirmations Assisted-By: devx/07abca81-b031-4579-88c8-bc07dfd3c5aa From 284282bbe81c4327930ad5b7aaf6f91bb34c7736 Mon Sep 17 00:00:00 2001 From: freddie Date: Fri, 14 Aug 2026 14:47:32 +0100 Subject: [PATCH 03/12] Name the operation and URL when the mkcert download times out Assisted-By: devx/07abca81-b031-4579-88c8-bc07dfd3c5aa From 611fb03f6570a6f992b4ba8c5765e88a5d18ebb0 Mon Sep 17 00:00:00 2001 From: freddie Date: Mon, 17 Aug 2026 19:08:17 +0100 Subject: [PATCH 04/12] Preserve local HTTPS fallback after API refactor Assisted-By: devx/05def498-8bdd-48d0-bd5e-35e4667a2f3d From baa3fdd61e024b1aa545b2972c304e51fc36da2b Mon Sep 17 00:00:00 2001 From: freddie Date: Thu, 20 Aug 2026 11:55:41 +0100 Subject: [PATCH 05/12] Confirm automatic local certificate installation From 152754cb97917b19c956bb6afd10c1b6c8244b5f Mon Sep 17 00:00:00 2001 From: freddie Date: Thu, 20 Aug 2026 11:56:46 +0100 Subject: [PATCH 06/12] Add local certificate install command From e00877304b188b6350b884bcb607743a0af234a8 Mon Sep 17 00:00:00 2001 From: freddie Date: Thu, 20 Aug 2026 12:00:15 +0100 Subject: [PATCH 07/12] Add local certificate uninstall command From 6a1d0a4c0c208b51b8dfdd96699fc6368ac018c2 Mon Sep 17 00:00:00 2001 From: freddie Date: Thu, 20 Aug 2026 12:07:38 +0100 Subject: [PATCH 08/12] Only remove Hydrogen's pinned mkcert binary From e96fcd551aa4234e12e00ec2e9a55a70a1ef5e1f Mon Sep 17 00:00:00 2001 From: freddie Date: Thu, 20 Aug 2026 12:14:41 +0100 Subject: [PATCH 09/12] Default local HTTPS confirmation to yes From f6f69c1aba053225970b91bd7f7d319795b00227 Mon Sep 17 00:00:00 2001 From: freddie Date: Mon, 17 Aug 2026 15:28:24 +0100 Subject: [PATCH 10/12] Configure local Customer Account URLs automatically Assisted-By: devx/05def498-8bdd-48d0-bd5e-35e4667a2f3d --- .changeset/pre/local-https-vite-plugin.md | 4 + .github/workflows/ci.yml | 36 +++ .oxlintrc.json | 1 + AGENTS.md | 1 + examples/astro/README.md | 4 +- examples/solid-start/README.md | 2 + examples/sveltekit/README.md | 4 +- package.json | 5 +- packages/hydrogen/README.md | 2 +- packages/hydrogen/package.json | 2 + .../skills/hydrogen-customer-account/SKILL.md | 2 +- .../src/vite/customer-account.test.ts | 171 +++++++++++ .../hydrogen/src/vite/customer-account.ts | 266 ++++++++++++++++++ packages/hydrogen/src/vite/index.test.ts | 9 +- packages/hydrogen/src/vite/local-https.ts | 48 ++-- pnpm-lock.yaml | 13 + scripts/test-local-https.ts | 166 +++++++++++ skills/create-vercel-template/SKILL.md | 2 +- templates/nextjs/README.md | 2 + 19 files changed, 701 insertions(+), 39 deletions(-) create mode 100644 packages/hydrogen/src/vite/customer-account.test.ts create mode 100644 packages/hydrogen/src/vite/customer-account.ts create mode 100644 scripts/test-local-https.ts diff --git a/.changeset/pre/local-https-vite-plugin.md b/.changeset/pre/local-https-vite-plugin.md index 59a33e324e..bcbee47354 100644 --- a/.changeset/pre/local-https-vite-plugin.md +++ b/.changeset/pre/local-https-vite-plugin.md @@ -5,3 +5,7 @@ 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`. + +When a local HTTPS server starts outside CI, the plugin uses Shopify CLI to link an unlinked project and push the callback, portless JavaScript origin, and logout URLs to the Customer Account API configuration. Shopify CLI must include `@shopify/cli-hydrogen` 13.0.4 or later. CI, missing CLI support, cancelled linking, and push failures fall back to printing the values for manual configuration without stopping the development server. + +Framework templates and examples expose local HTTPS through the `dev:https` package script, which the Vite configuration detects through `npm_lifecycle_event`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fa9eb1964..9557be4e50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,39 @@ jobs: - name: Test run: pnpm run test + + local-https: + name: Local HTTPS (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + env: + npm_config_registry: https://registry.npmjs.org/ + TURBO_TELEMETRY_DISABLED: "1" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 + with: + version: 10.33.0 + + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version-file: package.json + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Provision local HTTPS certificate + run: pnpm https:setup + + - name: Verify trusted local HTTPS + run: pnpm run test:local-https diff --git a/.oxlintrc.json b/.oxlintrc.json index 238d6805d1..95190e5b4e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -24,6 +24,7 @@ "scripts/**", "!scripts/copy-hydrogen-to-preview*.ts", "!scripts/preview-template-dist*.ts", + "!scripts/test-local-https.ts", "examples/**", "!examples/shared/local-cdn-assets-plugin/**/*.ts", "examples/hydrogen/**", diff --git a/AGENTS.md b/AGENTS.md index 1dbf7a8efb..e6bffb4b50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,5 +18,6 @@ When designing or adjusting APIs for the `hydrogen` package, closely follow the - 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 `dev:https` startup. This downloads a pinned, checksum-verified mkcert release, trusts the local certificate authority, and creates the certificates under `~/.shopify/hydrogen/certs/`. Nuxt and SolidStart may need one restart after first-run provisioning so their outer dev servers can load the certificate files. +- Outside CI, the local HTTPS plugin uses Shopify CLI to link an unlinked Hydrogen storefront and push the Customer Account callback, JavaScript origin, and logout URLs. Failures fall back to printing the values for manual configuration. - The Next.js example provisions its own certificate. The Hydrogen example uses the Shopify CLI tunnel flow. - Run the relevant example with `pnpm --filter @shopify/hydrogen-example- dev:https` when that example provides the script. diff --git a/examples/astro/README.md b/examples/astro/README.md index 322a3adb1d..f4c87d2216 100644 --- a/examples/astro/README.md +++ b/examples/astro/README.md @@ -37,12 +37,14 @@ Port of the canonical `examples/core` design to [Astro](https://astro.build/) ru The account flow uses `createCustomerSession` and `createCustomerAccountServerHandlers` from `@shopify/hydrogen/customer-account`, Customer Account values from `examples/shared/config.ts`, and an encrypted HttpOnly `__Host-` cookie adapter from `examples/shared/customer-session.ts`. -Customer Account OAuth requires a public HTTPS origin. To test locally without a tunnel, register `https://local.tryhydrogen.dev:5173/account/authorize` as the callback URI and run: +Customer Account OAuth requires a public HTTPS origin. To test locally without a tunnel, run: ```sh pnpm --filter @shopify/hydrogen-example-astro dev:https ``` +The local HTTPS plugin provisions the certificate, links an unlinked Hydrogen storefront, and pushes the Customer Account callback, JavaScript origin, and logout URLs. If automatic setup is unavailable, it prints the values for manual configuration. + ## Run ```sh diff --git a/examples/solid-start/README.md b/examples/solid-start/README.md index af2d335cf1..3e04396aed 100644 --- a/examples/solid-start/README.md +++ b/examples/solid-start/README.md @@ -47,6 +47,8 @@ pnpm --filter @shopify/hydrogen-example-solid-start dev:https On the first run, restart the command after the Vite plugin provisions the certificate so Vinxi can load it. +When the server starts, the local HTTPS plugin links an unlinked Hydrogen storefront and pushes the Customer Account callback, JavaScript origin, and logout URLs. If automatic setup is unavailable, it prints the values for manual configuration. + ## Run ```sh diff --git a/examples/sveltekit/README.md b/examples/sveltekit/README.md index 92e85f720c..9a2d65a4a2 100644 --- a/examples/sveltekit/README.md +++ b/examples/sveltekit/README.md @@ -39,12 +39,14 @@ Port of the canonical `examples/core` design to [SvelteKit 2](https://svelte.dev The account flow uses `createCustomerSession` and `createCustomerAccountServerHandlers` from `@shopify/hydrogen/customer-account`, Customer Account values from `examples/shared/config.ts`, and an encrypted HttpOnly `__Host-` cookie adapter from `examples/shared/customer-session.ts`. -Customer Account OAuth requires a public HTTPS origin. To test locally without a tunnel, register `https://local.tryhydrogen.dev:5173/account/authorize` as the callback URI and run: +Customer Account OAuth requires a public HTTPS origin. To test locally without a tunnel, run: ```sh pnpm --filter @shopify/hydrogen-example-sveltekit dev:https ``` +The local HTTPS plugin provisions the certificate, links an unlinked Hydrogen storefront, and pushes the Customer Account callback, JavaScript origin, and logout URLs. If automatic setup is unavailable, it prints the values for manual configuration. + ## Run ```sh diff --git a/package.json b/package.json index b9bcc08e66..80e6921bba 100644 --- a/package.json +++ b/package.json @@ -25,14 +25,15 @@ "benchmark:harness": "node scripts/storefront-benchmark-harness/run-opencode-docker.ts", "typecheck": "turbo run typecheck", "libcheck": "turbo run libcheck --filter='./packages/*'", - "lint": "oxlint --max-warnings=0 packages/ examples/ templates/ scripts/preview-template-dist*.ts", - "lint:ci": "oxlint --format github --max-warnings=0 packages/ examples/ templates/ scripts/preview-template-dist*.ts", + "lint": "oxlint --max-warnings=0 packages/ examples/ templates/ scripts/preview-template-dist*.ts scripts/test-local-https.ts", + "lint:ci": "oxlint --format github --max-warnings=0 packages/ examples/ templates/ scripts/preview-template-dist*.ts scripts/test-local-https.ts", "format": "oxfmt", "format:check": "oxfmt --check", "changeset": "changeset", "version-packages": "changeset version", "test": "pnpm run test:scripts && turbo run test", "test:scripts": "node --test scripts/*.test.ts templates/*/__test__/*.test.ts", + "test:local-https": "node --use-system-ca scripts/test-local-https.ts", "test:benchmark-harness": "tsc -p scripts/storefront-benchmark-harness/tsconfig.json && node --test scripts/storefront-benchmark-harness/*.test.ts", "check": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test", "test:e2e:storefront": "turbo run test:e2e --filter @shopify/storefront-e2e" diff --git a/packages/hydrogen/README.md b/packages/hydrogen/README.md index 7428223c50..8d29ba3a2b 100644 --- a/packages/hydrogen/README.md +++ b/packages/hydrogen/README.md @@ -69,7 +69,7 @@ const isLoggedIn = await customerSession.isLoggedIn( ); ``` -Customer Account OAuth methods require a public HTTPS origin. The writable session manager should expose the request origin; explicit `origin` options are only needed as overrides. For local development, use a tunnel or trusted local HTTPS through `localHttps` from `@shopify/hydrogen/vite`, and pass the framework's canonical request URL rather than an untrusted forwarded host. +Customer Account OAuth methods require a public HTTPS origin. The writable session manager should expose the request origin; explicit `origin` options are only needed as overrides. For local development, use a tunnel or trusted local HTTPS through `localHttps` from `@shopify/hydrogen/vite`, which provisions certificates and uses Shopify CLI to push Customer Account URLs outside CI. Pass the framework's canonical request URL rather than an untrusted forwarded host. Pass `customerSession` to `createCartServerHandlers({customerSession})` to associate newly created carts with the current customer when the session has a usable access token or successfully refreshed access token, and mark checkout URLs in authenticated cart GET responses with `logged_in=true`. diff --git a/packages/hydrogen/package.json b/packages/hydrogen/package.json index ebb41baf5c..5c18e5f743 100644 --- a/packages/hydrogen/package.json +++ b/packages/hydrogen/package.json @@ -93,6 +93,7 @@ "postcodegen": "node scripts/postprocess-tada-env.ts && oxfmt src/graphql/generated/*.d.ts src/graphql/generated/*.json" }, "dependencies": { + "cross-spawn": "7.0.6", "gql.tada": "1.9.2" }, "devDependencies": { @@ -100,6 +101,7 @@ "@graphql-codegen/introspection": "^6.0.0", "@graphql-codegen/typescript": "^6.0.0", "@testing-library/react": "^16.3.2", + "@types/cross-spawn": "6.0.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vue/test-utils": "^2.4.0", diff --git a/packages/hydrogen/skills/hydrogen-customer-account/SKILL.md b/packages/hydrogen/skills/hydrogen-customer-account/SKILL.md index a5cd93e3a2..3a12f55f0b 100644 --- a/packages/hydrogen/skills/hydrogen-customer-account/SKILL.md +++ b/packages/hydrogen/skills/hydrogen-customer-account/SKILL.md @@ -104,4 +104,4 @@ The same `@shopify/hydrogen/ts-plugin` and `hydrogen gql check` setup from the ` ## Local OAuth -Customer Account OAuth needs a public HTTPS callback origin. For local examples, use a trusted local HTTPS hostname and register the exact `/account/authorize` callback URL in the Customer Account app configuration. +Customer Account OAuth needs a public HTTPS origin. For local development, follow the `hydrogen-local-https` skill; its Vite plugin provisions a trusted certificate and pushes the callback, JavaScript origin, and logout URLs through Shopify CLI outside CI. diff --git a/packages/hydrogen/src/vite/customer-account.test.ts b/packages/hydrogen/src/vite/customer-account.test.ts new file mode 100644 index 0000000000..f7e9e8b745 --- /dev/null +++ b/packages/hydrogen/src/vite/customer-account.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + configureCustomerAccountUrls, + formatCustomerAccountSettings, + resolveCustomerAccountUrls, +} from "./customer-account"; + +const ROOT = "/project"; +const URLS = resolveCustomerAccountUrls("local.tryhydrogen.dev", 5_173); + +describe("resolveCustomerAccountUrls", () => { + it("keeps the JavaScript origin portless", () => { + expect(URLS).toEqual({ + callbackUri: "https://local.tryhydrogen.dev:5173/account/authorize", + devOrigin: "https://local.tryhydrogen.dev:5173", + javascriptOrigin: "https://local.tryhydrogen.dev", + logoutUri: "https://local.tryhydrogen.dev:5173", + }); + }); +}); + +describe("formatCustomerAccountSettings", () => { + it("prints all manual Customer Account API values", () => { + const output = formatCustomerAccountSettings(URLS); + + expect(output).toContain(URLS.callbackUri); + expect(output).toContain(`JavaScript origin(s): ${URLS.javascriptOrigin}\n`); + expect(output).toContain(`Logout URI: ${URLS.logoutUri}`); + }); +}); + +describe("configureCustomerAccountUrls", () => { + it("skips all Shopify CLI commands in CI and prints manual values", async () => { + const { logger, runShopifyCommand } = setup(); + + await configureCustomerAccountUrls( + { logger, root: ROOT, urls: URLS }, + { isCI: () => true, runShopifyCommand }, + ); + + expect(runShopifyCommand).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining(URLS.callbackUri)); + }); + + it("instructs users to update Shopify CLI when Hydrogen CLI is too old", async () => { + const { logger, runShopifyCommand } = setup({ version: "13.0.3" }); + + await configureCustomerAccountUrls( + { logger, root: ROOT, urls: URLS }, + { isCI: () => false, runShopifyCommand }, + ); + + expect(runShopifyCommand).toHaveBeenCalledOnce(); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("13.0.4 or later")); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("@shopify/cli@latest")); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining(URLS.callbackUri)); + }); + + it("links an unlinked project before pushing the derived origins", async () => { + let linked = false; + const hasLinkedStorefront = vi.fn(async () => linked); + const { logger, runShopifyCommand } = setup({ + onCommand(args) { + if (args[1] === "link") linked = true; + }, + }); + + await configureCustomerAccountUrls( + { logger, root: ROOT, urls: URLS }, + { hasLinkedStorefront, isCI: () => false, runShopifyCommand }, + ); + + expect(runShopifyCommand.mock.calls.map(([args]) => args)).toEqual([ + ["plugins", "--core", "--json"], + ["hydrogen", "link", "--path", ROOT], + [ + "hydrogen", + "customer-account-push", + "--path", + ROOT, + "--dev-origin", + URLS.devOrigin, + "--javascript-origin", + URLS.javascriptOrigin, + ], + ]); + expect(hasLinkedStorefront).toHaveBeenCalledTimes(2); + expect(logger.info).toHaveBeenCalledWith( + `Customer Account API settings updated for ${URLS.devOrigin}.`, + ); + }); + + it("pushes without linking when the project is already linked", async () => { + const { logger, runShopifyCommand } = setup(); + + await configureCustomerAccountUrls( + { logger, root: ROOT, urls: URLS }, + { + hasLinkedStorefront: async () => true, + isCI: () => false, + runShopifyCommand, + }, + ); + + expect(runShopifyCommand.mock.calls.map(([args]) => args)).toEqual([ + ["plugins", "--core", "--json"], + expect.arrayContaining(["customer-account-push"]), + ]); + }); + + it("warns with manual values and keeps going when linking is cancelled", async () => { + const { logger, runShopifyCommand } = setup(); + + await configureCustomerAccountUrls( + { logger, root: ROOT, urls: URLS }, + { + hasLinkedStorefront: async () => false, + isCI: () => false, + runShopifyCommand, + }, + ); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("finished without linking a Hydrogen storefront"), + ); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining(URLS.callbackUri)); + }); + + it("warns with manual values when the push fails", async () => { + const { logger, runShopifyCommand } = setup({ + onCommand(args) { + if (args.includes("customer-account-push")) throw new Error("access denied"); + }, + }); + + await configureCustomerAccountUrls( + { logger, root: ROOT, urls: URLS }, + { + hasLinkedStorefront: async () => true, + isCI: () => false, + runShopifyCommand, + }, + ); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("access denied")); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining(URLS.callbackUri)); + }); +}); + +function setup({ + version = "13.0.4", + onCommand, +}: { version?: string; onCommand?: (args: string[]) => void } = {}) { + const logger = { info: vi.fn(), warn: vi.fn() }; + const runShopifyCommand = vi.fn(async (args: string[]) => { + onCommand?.(args); + if (args[0] !== "plugins") return ""; + + return JSON.stringify([ + { + pjson: { + name: "@shopify/cli", + devDependencies: { "@shopify/cli-hydrogen": version }, + }, + }, + ]); + }); + + return { logger, runShopifyCommand }; +} diff --git a/packages/hydrogen/src/vite/customer-account.ts b/packages/hydrogen/src/vite/customer-account.ts new file mode 100644 index 0000000000..9fbbed232c --- /dev/null +++ b/packages/hydrogen/src/vite/customer-account.ts @@ -0,0 +1,266 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import spawn from "cross-spawn"; + +const CUSTOMER_ACCOUNT_AUTHORIZE_PATH = "/account/authorize"; +const MINIMUM_HYDROGEN_CLI_VERSION = "13.0.4"; +const SUCCESS_EXIT_CODE = 0; + +type Logger = { + info(message: string): void; + warn(message: string): void; +}; + +type RunShopifyCommand = ( + args: string[], + options: { captureOutput?: boolean; cwd: string }, +) => Promise; + +type CustomerAccountSetupDependencies = { + hasLinkedStorefront?: (root: string) => Promise; + isCI?: () => boolean; + runShopifyCommand?: RunShopifyCommand; +}; + +export type CustomerAccountUrls = { + callbackUri: string; + devOrigin: string; + javascriptOrigin: string; + logoutUri: string; +}; + +export function resolveCustomerAccountUrls(host: string, port: number): CustomerAccountUrls { + const javascriptOrigin = `https://${host}`; + const devOrigin = `${javascriptOrigin}:${port}`; + + return { + callbackUri: `${devOrigin}${CUSTOMER_ACCOUNT_AUTHORIZE_PATH}`, + devOrigin, + javascriptOrigin, + logoutUri: devOrigin, + }; +} + +export function formatCustomerAccountSettings(urls: CustomerAccountUrls) { + return [ + "", + "Customer Account API - configure these values for your storefront:", + "", + ` Callback URI(s) (required): ${urls.callbackUri}`, + ` JavaScript origin(s): ${urls.javascriptOrigin}`, + ` Logout URI: ${urls.logoutUri}`, + "", + ].join("\n"); +} + +export function isContinuousIntegration() { + const ci = process.env.CI; + return ci !== undefined && ci !== "" && ci !== "false" && ci !== "0"; +} + +export async function configureCustomerAccountUrls( + { + logger, + root, + urls, + }: { + logger: Logger; + root: string; + urls: CustomerAccountUrls; + }, + dependencies: CustomerAccountSetupDependencies = {}, +) { + const isCI = dependencies.isCI ?? isContinuousIntegration; + if (isCI()) { + logger.info(formatCustomerAccountSettings(urls)); + return; + } + + const runCommand = dependencies.runShopifyCommand ?? runShopifyCommand; + + try { + await requireCompatibleShopifyCli(root, runCommand); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.warn( + [ + "Automatic Customer Account API setup was skipped.", + reason, + "Install the latest Shopify CLI and restart the development server:", + " npm install -g @shopify/cli@latest", + formatCustomerAccountSettings(urls), + ].join("\n"), + ); + return; + } + + const hasLinkedStorefront = dependencies.hasLinkedStorefront ?? projectHasLinkedStorefront; + + try { + if (!(await hasLinkedStorefront(root))) { + logger.info("No linked Hydrogen storefront found. Starting Shopify CLI linking..."); + await runCommand(["hydrogen", "link", "--path", root], { cwd: root }); + + if (!(await hasLinkedStorefront(root))) { + throw new Error("Shopify CLI finished without linking a Hydrogen storefront."); + } + } + + logger.info("Updating Customer Account API settings with Shopify CLI..."); + await runCommand( + [ + "hydrogen", + "customer-account-push", + "--path", + root, + "--dev-origin", + urls.devOrigin, + "--javascript-origin", + urls.javascriptOrigin, + ], + { cwd: root }, + ); + logger.info(`Customer Account API settings updated for ${urls.devOrigin}.`); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.warn( + [ + "Local HTTPS is ready, but Customer Account API setup could not be completed:", + ` ${reason}`, + formatCustomerAccountSettings(urls), + ].join("\n"), + ); + } +} + +class ShopifyCliRequirementError extends Error {} + +async function requireCompatibleShopifyCli(root: string, runCommand: RunShopifyCommand) { + const hydrogenCliVersion = await getHydrogenCliVersion(root, runCommand); + if (!isVersionAtLeast(hydrogenCliVersion, MINIMUM_HYDROGEN_CLI_VERSION)) { + throw new ShopifyCliRequirementError( + `Found @shopify/cli-hydrogen ${hydrogenCliVersion}; ${MINIMUM_HYDROGEN_CLI_VERSION} or later is required.`, + ); + } +} + +async function getHydrogenCliVersion(root: string, runCommand: RunShopifyCommand) { + let output: string; + try { + output = await runCommand(["plugins", "--core", "--json"], { + captureOutput: true, + cwd: root, + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new ShopifyCliRequirementError( + `A compatible Shopify CLI installation was not found. ${reason}`, + ); + } + + let plugins: unknown; + try { + plugins = JSON.parse(output); + } catch { + throw new ShopifyCliRequirementError( + "Shopify CLI did not return valid plugin version information.", + ); + } + + if (!Array.isArray(plugins)) { + throw new ShopifyCliRequirementError("Shopify CLI did not return plugin version information."); + } + + for (const plugin of plugins) { + if (!isRecord(plugin)) continue; + const packageJson = plugin.pjson; + if (!isRecord(packageJson) || packageJson.name !== "@shopify/cli") continue; + + const devDependencies = packageJson.devDependencies; + if (!isRecord(devDependencies)) break; + + const version = devDependencies["@shopify/cli-hydrogen"]; + if (typeof version === "string") return version; + break; + } + + throw new ShopifyCliRequirementError( + "The installed Shopify CLI does not include @shopify/cli-hydrogen.", + ); +} + +function isVersionAtLeast(version: string, minimum: string) { + const parsedVersion = parseVersion(version); + const parsedMinimum = parseVersion(minimum); + if (!parsedVersion || !parsedMinimum) return false; + + for (let index = 0; index < parsedMinimum.numbers.length; index += 1) { + const difference = parsedVersion.numbers[index] - parsedMinimum.numbers[index]; + if (difference !== 0) return difference > 0; + } + + return parsedVersion.prerelease === undefined; +} + +function parseVersion(version: string) { + const match = /^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+.+)?$/.exec(version); + if (!match) return; + + return { + numbers: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4], + }; +} + +async function projectHasLinkedStorefront(root: string) { + try { + const project = JSON.parse(await readFile(join(root, ".shopify", "project.json"), "utf8")); + return ( + isRecord(project) && + isRecord(project.storefront) && + typeof project.storefront.id === "string" && + project.storefront.id !== "" + ); + } catch { + return false; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function runShopifyCommand( + args: string[], + { captureOutput = false, cwd }: { captureOutput?: boolean; cwd: string }, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn("shopify", args, { + cwd, + shell: false, + stdio: captureOutput ? ["ignore", "pipe", "pipe"] : "inherit", + }); + let output = ""; + let errorOutput = ""; + + const collect = (target: "output" | "errorOutput") => (chunk: Buffer) => { + if (target === "output") output += chunk.toString(); + else errorOutput += chunk.toString(); + }; + + child.stdout?.on("data", collect("output")); + child.stderr?.on("data", collect("errorOutput")); + child.on("error", reject); + child.on("close", (code, signal) => { + if (code === SUCCESS_EXIT_CODE) { + resolve(output); + return; + } + + const reason = signal ? `was killed by ${signal}` : `exited with code ${code}`; + const detail = errorOutput.trim(); + reject(new Error(`shopify ${args.join(" ")} ${reason}${detail ? `\n${detail}` : ""}`)); + }); + }); +} diff --git a/packages/hydrogen/src/vite/index.test.ts b/packages/hydrogen/src/vite/index.test.ts index 7e385629dc..5d46f3c571 100644 --- a/packages/hydrogen/src/vite/index.test.ts +++ b/packages/hydrogen/src/vite/index.test.ts @@ -445,6 +445,7 @@ describe("localHttps", () => { }); it("logs derived Customer Account settings when the server starts listening", () => { + vi.stubEnv("CI", "true"); fs.writeFileSync(certPath, "certificate"); fs.writeFileSync(keyPath, "private-key"); const { info, listening } = configurePlugin({ certPath, keyPath }); @@ -460,6 +461,7 @@ describe("localHttps", () => { }); it("logs the port the server actually bound instead of the configured port", () => { + vi.stubEnv("CI", "true"); fs.writeFileSync(certPath, "certificate"); fs.writeFileSync(keyPath, "private-key"); const { info, listening, middleware } = configurePlugin( @@ -479,6 +481,7 @@ describe("localHttps", () => { }); it("logs Customer Account settings once across Vite server instances", () => { + vi.stubEnv("CI", "true"); fs.writeFileSync(certPath, "certificate"); fs.writeFileSync(keyPath, "private-key"); const first = configurePlugin({ certPath, keyPath }, { port: 4_322 }); @@ -492,6 +495,7 @@ describe("localHttps", () => { }); it("logs settings immediately when Vite has no HTTP server", () => { + vi.stubEnv("CI", "true"); fs.writeFileSync(certPath, "certificate"); fs.writeFileSync(keyPath, "private-key"); const info = vi.fn(); @@ -500,7 +504,7 @@ describe("localHttps", () => { configureServer({ middlewares: { use: vi.fn() }, - config: { logger: { info } }, + config: { logger: { info, warn: vi.fn() }, root: directory }, httpServer: null, } as any); @@ -516,6 +520,7 @@ describe("localHttps", () => { | undefined; let listening: (() => void) | undefined; const info = vi.fn(); + const warn = vi.fn(); const plugin = localHttps({ enabled: true, host: "custom.test", @@ -530,7 +535,7 @@ describe("localHttps", () => { middleware = handler; }, }, - config: { logger: { info } }, + config: { logger: { info, warn }, root: directory }, httpServer: { address: () => (options.boundPort ? { port: options.boundPort } : null), once(event: string, listener: () => void) { diff --git a/packages/hydrogen/src/vite/local-https.ts b/packages/hydrogen/src/vite/local-https.ts index 064692547d..df57e907f8 100644 --- a/packages/hydrogen/src/vite/local-https.ts +++ b/packages/hydrogen/src/vite/local-https.ts @@ -6,8 +6,12 @@ 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 { + configureCustomerAccountUrls, + isContinuousIntegration, + resolveCustomerAccountUrls, +} from "./customer-account"; import { provisionCertificates } from "./mkcert"; export const LOCAL_HTTPS_DEFAULTS = { @@ -27,7 +31,7 @@ const HTTP1_ONLY_RESPONSE_HEADERS = new Set([ "upgrade", ]); const emittedMissingCertificateWarnings = new Set(); -const loggedCustomerAccountSettings = new Set(); +const startedCustomerAccountSetups = new Set(); /** Options for Hydrogen's local HTTPS Vite plugin. */ export type LocalHttpsOptions = { @@ -219,11 +223,6 @@ async function ensureCertificateFiles(settings: LocalHttpsSettings): Promise !existsSync(path)); if (missingPaths.length === 0) return true; @@ -326,37 +325,26 @@ function configureLocalHttpsServer(server: ViteDevServer, settings: LocalHttpsSe next(); }); - const logSettings = () => { + const configureCustomerAccounts = () => { const port = resolveBoundPort(); - const settingsKey = `${settings.host}:${port}`; - if (loggedCustomerAccountSettings.has(settingsKey)) return; - loggedCustomerAccountSettings.add(settingsKey); - server.config.logger.info(formatCustomerAccountSettings({ host: settings.host, port })); + const settingsKey = `${server.config.root}:${settings.host}:${port}`; + if (startedCustomerAccountSetups.has(settingsKey)) return; + startedCustomerAccountSetups.add(settingsKey); + + void configureCustomerAccountUrls({ + logger: server.config.logger, + root: server.config.root, + urls: resolveCustomerAccountUrls(settings.host, port), + }); }; if (server.httpServer) { - server.httpServer.once("listening", logSettings); + server.httpServer.once("listening", configureCustomerAccounts); } else { - logSettings(); + configureCustomerAccounts(); } } -function formatCustomerAccountSettings({ host, port }: Pick) { - const origin = `https://${host}`; - const portfulOrigin = `${origin}:${port}`; - - return [ - "", - "Customer Account API — make sure these values are configured for your storefront:", - "", - ` Callback URI(s) (required): ${portfulOrigin}${CUSTOMER_ACCOUNT_PATHS.authorize}`, - // Shopify's server-side validation rejects JavaScript origins containing a port. - ` JavaScript origin(s): ${origin}`, - ` Logout URI: ${portfulOrigin}`, - "", - ].join("\n"); -} - function stripHttp1OnlyResponseHeaders(response: ServerResponse) { const originalWriteHead = response.writeHead.bind(response); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 284faee019..b61a5f880c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,9 @@ importers: packages/hydrogen: dependencies: + cross-spawn: + specifier: 7.0.6 + version: 7.0.6 gql.tada: specifier: 1.9.2 version: 1.9.2(graphql@16.13.2)(typescript@5.9.3) @@ -281,6 +284,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@types/cross-spawn': + specifier: 6.0.6 + version: 6.0.6 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -4310,6 +4316,9 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==, tarball: https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz} + '@types/cross-spawn@6.0.6': + resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==, tarball: https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==, tarball: https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz} @@ -13894,6 +13903,10 @@ snapshots: '@types/cookie@0.6.0': {} + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 25.8.0 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 diff --git a/scripts/test-local-https.ts b/scripts/test-local-https.ts new file mode 100644 index 0000000000..9d466ea84b --- /dev/null +++ b/scripts/test-local-https.ts @@ -0,0 +1,166 @@ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { access } from "node:fs/promises"; +import { connect } from "node:net"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const HOST = "local.tryhydrogen.dev"; +const PORT = 5_173; +const REQUEST_TIMEOUT_MS = 5_000; +const READINESS_TIMEOUT_MS = 60_000; +const SHUTDOWN_TIMEOUT_MS = 5_000; +const SERVER_URL = `https://${HOST}:${PORT}/favicon.svg`; + +const packageManagerCli = process.env.npm_execpath; +if (!packageManagerCli) { + throw new Error("npm_execpath is missing. Run this test through the package manager script."); +} + +const certificateDirectory = join(homedir(), ".shopify", "hydrogen", "certs"); +await Promise.all([ + access(join(certificateDirectory, `${HOST}.pem`)), + access(join(certificateDirectory, `${HOST}-key.pem`)), +]); +await assertPortAvailable(); + +const server = spawn( + process.execPath, + [packageManagerCli, "--filter", "@shopify/hydrogen-template-react-router", "dev:https"], + { + cwd: process.cwd(), + detached: process.platform !== "win32", + env: { ...process.env, CI: "true" }, + stdio: ["ignore", "pipe", "pipe"], + }, +); +let stopping = false; +let serverClosed = false; +const serverClose = new Promise((resolve) => { + server.once("close", () => { + serverClosed = true; + resolve(); + }); +}); + +server.stdout?.on("data", (chunk: Buffer) => { + if (!stopping) process.stdout.write(chunk); +}); +server.stderr?.on("data", (chunk: Buffer) => { + if (!stopping) process.stderr.write(chunk); +}); + +try { + await Promise.race([waitForTrustedHttps(), rejectIfServerStops(server)]); + console.log(`Trusted local HTTPS verified at ${SERVER_URL}`); +} finally { + stopping = true; + await stopProcessTree(server, serverClose, () => serverClosed); +} + +async function waitForTrustedHttps() { + const deadline = Date.now() + READINESS_TIMEOUT_MS; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const response = await fetch(SERVER_URL, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) throw new Error(`Received HTTP ${response.status}`); + await response.body?.cancel(); + + return; + } catch (error) { + lastError = error; + await delay(500); + } + } + + throw new Error(`Trusted HTTPS server was not ready after ${READINESS_TIMEOUT_MS}ms`, { + cause: lastError, + }); +} + +async function assertPortAvailable() { + const portInUse = await new Promise((resolve) => { + const socket = connect({ host: HOST, port: PORT }); + let settled = false; + const finish = (result: boolean) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(result); + }; + + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.setTimeout(REQUEST_TIMEOUT_MS, () => finish(false)); + }); + + if (portInUse) throw new Error(`${HOST}:${PORT} is already in use`); +} + +function rejectIfServerStops(child: ChildProcess) { + return new Promise((_resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => { + reject( + new Error( + signal + ? `Local HTTPS server was killed by ${signal}` + : `Local HTTPS server exited with code ${code}`, + ), + ); + }); + }); +} + +async function stopProcessTree( + child: ChildProcess, + closePromise: Promise, + isServerClosed: () => boolean, +) { + if (!child.pid || isServerClosed()) return; + + if (process.platform === "win32") { + if (child.exitCode !== null || child.signalCode !== null) { + await requireServerClose(closePromise, isServerClosed); + return; + } + + const result = spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + stdio: "ignore", + }); + if (result.error) throw result.error; + if (result.status !== 0 && !isServerClosed()) { + throw new Error(`taskkill failed with exit code ${result.status}`); + } + await requireServerClose(closePromise, isServerClosed); + return; + } + + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + await requireServerClose(closePromise, isServerClosed); + return; + } + + await Promise.race([closePromise, delay(SHUTDOWN_TIMEOUT_MS)]); + + try { + process.kill(-child.pid, 0); + process.kill(-child.pid, "SIGKILL"); + } catch { + // The process group exited during the graceful shutdown period. + } + + await requireServerClose(closePromise, isServerClosed); +} + +async function requireServerClose(closePromise: Promise, isServerClosed: () => boolean) { + await Promise.race([closePromise, delay(SHUTDOWN_TIMEOUT_MS)]); + if (!isServerClosed()) throw new Error("Local HTTPS server did not close after termination"); +} diff --git a/skills/create-vercel-template/SKILL.md b/skills/create-vercel-template/SKILL.md index 337829c4e8..3f2b07f572 100644 --- a/skills/create-vercel-template/SKILL.md +++ b/skills/create-vercel-template/SKILL.md @@ -277,7 +277,7 @@ repository root, which is not a deployable Next.js project. Before finishing: 1. Install with `CI=true` from the repository root. -2. Run `rg -n "@shared/|examples/shared|localCdnAssets|localHttps|lru-cache|catalog:|file:" templates/ -g '!pnpm-lock.yaml' -g '!node_modules'` — expect no matches. (`process.env` and `workspace:*` are expected in the source Next.js template.) +2. Run `rg -n "@shared/|examples/shared|localCdnAssets|localHttps|lru-cache|catalog:|file:" templates/ -g '!pnpm-lock.yaml' -g '!node_modules' -g '!.agents/**'` — expect no matches. (`process.env` and `workspace:*` are expected in the source Next.js template.) 3. Run the template lint and typecheck (`eslint`, then `tsc --noEmit && hydrogen gql check --fail-on-warn`). Note: the GraphQL check passes without emitting the `*-graphql-env.d.ts` files on disk (they're gitignored, generated on demand) — that is expected. 4. Run `next build`. The source build can infer the repository workspace root; the standalone distribution should infer the template directory after installing its generated lockfile. diff --git a/templates/nextjs/README.md b/templates/nextjs/README.md index 1722294833..e4e3c8c108 100644 --- a/templates/nextjs/README.md +++ b/templates/nextjs/README.md @@ -28,6 +28,8 @@ pnpm dev:https Next.js provisions and reuses a trusted development certificate under `certificates/`. On first run, it may prompt to install the local certificate authority. +Next.js does not use Hydrogen's Vite plugin, so configure the Customer Account callback, JavaScript origin, and logout URLs manually. The `hydrogen-local-https` skill lists the exact values. + ## Environment Variables Copy `.env.example` to `.env` when you are ready to connect a real store: From bf8d61fa3801ffcc4b448e9dcbb872df09c1b2be Mon Sep 17 00:00:00 2001 From: freddie Date: Mon, 17 Aug 2026 18:18:56 +0100 Subject: [PATCH 11/12] Avoid Windows trust prompts in local HTTPS CI Assisted-By: devx/05def498-8bdd-48d0-bd5e-35e4667a2f3d --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9557be4e50..a99bd38570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,23 @@ jobs: run: pnpm install --frozen-lockfile - name: Provision local HTTPS certificate + if: runner.os != 'Windows' run: pnpm https:setup + - name: Provision local HTTPS certificate on Windows + if: runner.os == 'Windows' + shell: pwsh + env: + # GitHub-hosted runners cannot accept Windows' root-store prompt. + # Generate the same CA and provide it directly to Node's TLS verifier. + TRUST_STORES: none + run: | + pnpm https:setup + $rootCertificate = Join-Path $env:LOCALAPPDATA 'mkcert\rootCA.pem' + if (!(Test-Path $rootCertificate)) { + throw "mkcert root CA was not created at $rootCertificate" + } + Add-Content -Path $env:GITHUB_ENV -Value "NODE_EXTRA_CA_CERTS=$rootCertificate" + - name: Verify trusted local HTTPS run: pnpm run test:local-https From 8350957a01a038d5272ed115b95e88b100bd87dd Mon Sep 17 00:00:00 2001 From: freddie Date: Tue, 1 Sep 2026 16:54:17 +0100 Subject: [PATCH 12/12] Restore local HTTPS setup script after rebase --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 80e6921bba..be470dd466 100644 --- a/package.json +++ b/package.json @@ -16,6 +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 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",