diff --git a/.changeset/local-https-vite-plugin.md b/.changeset/local-https-vite-plugin.md index 5747c9f156..bcbee47354 100644 --- a/.changeset/local-https-vite-plugin.md +++ b/.changeset/local-https-vite-plugin.md @@ -3,3 +3,9 @@ --- 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/.docs/dependencies.md b/.docs/dependencies.md index b37ed19ec0..61036227b4 100644 --- a/.docs/dependencies.md +++ b/.docs/dependencies.md @@ -9,6 +9,6 @@ When changing `@shopify/hydrogen/vite` local HTTPS behaviour, update these toget - `templates/react-router/README.md` - `templates/react-router/package.json` - `templates/react-router/vite.config.ts` -- framework example `https:dev` scripts and configs under `examples/*` +- framework example `dev:https` scripts and configs under `examples/*` `scripts/preview-template-dist.ts` copies `packages/hydrogen/skills` into template `.agents/skills` when preparing the dist branch, so template source directories should not duplicate those generated skill copies. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fa9eb1964..a99bd38570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,55 @@ 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 + 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 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 c01e78f221..480ff7d666 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,8 @@ 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/`. -- 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. +- Vite-based examples consume Hydrogen's default certificates. Certificates are provisioned automatically on `dev:https` startup, or run `pnpm https:setup` once from the repository root for frameworks that read certificate paths before Vite starts. Both download a pinned, checksum-verified mkcert release, trust the local certificate authority, and create the certificates under `~/.shopify/hydrogen/certs/`. +- 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 template 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. +- Reproduce the trusted local HTTPS CI check with `pnpm https:setup && pnpm run test:local-https`. diff --git a/examples/README.md b/examples/README.md index a8acb9ef2b..032907eddc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,16 +29,17 @@ From the repository root: - `pnpm dev` — run all workspace examples and templates in parallel. - `pnpm dev:hub` — run the examples with automatically allocated ports and open the browser hub with status, previews, and logs. - `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. +- `pnpm --filter @shopify/hydrogen-example- dev:https` — run an account-enabled example on `https://local.tryhydrogen.dev:5173` when it provides a `dev:https` script. Nuxt and SolidStart require the one-time `pnpm https:setup` prerequisite because they read certificate paths before Vite starts. 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 a `dev:https` 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`. + +Outside CI, the local HTTPS plugin also uses Shopify CLI to link an unlinked Hydrogen storefront and push the Customer Account callback, JavaScript origin, and logout URLs. It prints the values for manual configuration when automatic setup is unavailable. The Next.js template provisions its own development certificate and does not use the Hydrogen certificates. diff --git a/examples/astro/README.md b/examples/astro/README.md index 6829017b8c..f4c87d2216 100644 --- a/examples/astro/README.md +++ b/examples/astro/README.md @@ -37,13 +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 https:setup -pnpm --filter @shopify/hydrogen-example-astro https:dev +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/astro/astro.config.mjs b/examples/astro/astro.config.mjs index f20c79647d..9a02e4b8d6 100644 --- a/examples/astro/astro.config.mjs +++ b/examples/astro/astro.config.mjs @@ -4,8 +4,7 @@ import tailwindcss from "@tailwindcss/vite"; // @ts-check import { defineConfig } from "astro/config"; -const enabled = - process.env.VITE_LOCAL_HTTPS === "1" || process.env.npm_lifecycle_event === "https:dev"; +const enabled = process.env.npm_lifecycle_event === "dev:https"; const httpsOptions = { enabled }; export default defineConfig({ diff --git a/examples/astro/package.json b/examples/astro/package.json index 0dd37ef3f4..7b8815994e 100644 --- a/examples/astro/package.json +++ b/examples/astro/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "astro dev", - "https:dev": "astro dev", + "dev:https": "astro dev", "build": "astro build", "start": "node ./dist/server/entry.mjs", "typecheck": "astro check" diff --git a/examples/hydrogen/README.md b/examples/hydrogen/README.md index 9fabaa4ecc..564fb1b52d 100644 --- a/examples/hydrogen/README.md +++ b/examples/hydrogen/README.md @@ -49,5 +49,5 @@ Follow step 1 and 2 of ["plugins"]>[number]; -const enabled = - process.env.VITE_LOCAL_HTTPS === "1" || process.env.npm_lifecycle_event === "https:dev"; +const enabled = process.env.npm_lifecycle_event === "dev:https"; const httpsOptions = { enabled }; const httpsPlugin = localHttps(httpsOptions); diff --git a/examples/nuxt/package.json b/examples/nuxt/package.json index 1b2f5d9b21..25717d74f9 100644 --- a/examples/nuxt/package.json +++ b/examples/nuxt/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "nuxt dev", - "https:dev": "nuxt dev", + "dev:https": "nuxt dev", "build": "nuxt build", "start": "node .output/server/index.mjs", "typecheck": "nuxt typecheck" diff --git a/examples/solid-start/README.md b/examples/solid-start/README.md index cf3d73fd72..5924fd5f92 100644 --- a/examples/solid-start/README.md +++ b/examples/solid-start/README.md @@ -39,13 +39,15 @@ Port of the canonical `examples/core` design to [SolidStart](https://start.solid 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. SolidStart reads certificate paths before Vite starts, so provision them once before running local HTTPS: ```sh pnpm https:setup -pnpm --filter @shopify/hydrogen-example-solid-start https:dev +pnpm --filter @shopify/hydrogen-example-solid-start dev:https ``` +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/solid-start/app.config.ts b/examples/solid-start/app.config.ts index 44e2149e87..3b522b9ce3 100644 --- a/examples/solid-start/app.config.ts +++ b/examples/solid-start/app.config.ts @@ -2,8 +2,7 @@ import { localHttps } from "@shopify/hydrogen/vite"; import { defineConfig } from "@solidjs/start/config"; import tailwindcss from "@tailwindcss/vite"; -const enabled = - process.env.VITE_LOCAL_HTTPS === "1" || process.env.npm_lifecycle_event === "https:dev"; +const enabled = process.env.npm_lifecycle_event === "dev:https"; const httpsOptions = { enabled }; const httpsPlugin = localHttps(httpsOptions); const devServer = httpsPlugin.api.getDevServerConfig(); diff --git a/examples/solid-start/package.json b/examples/solid-start/package.json index 81ebf6541c..3dba2eff7e 100644 --- a/examples/solid-start/package.json +++ b/examples/solid-start/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "HOST=localhost vinxi dev", - "https:dev": "vinxi dev --host local.tryhydrogen.dev --port 5173", + "dev:https": "HOST=local.tryhydrogen.dev vinxi dev --port 5173", "build": "vinxi build", "start": "HOST=localhost vinxi start", "typecheck": "tsc" diff --git a/examples/sveltekit/README.md b/examples/sveltekit/README.md index b4d792e6d4..9a2d65a4a2 100644 --- a/examples/sveltekit/README.md +++ b/examples/sveltekit/README.md @@ -39,13 +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 https:setup -pnpm --filter @shopify/hydrogen-example-sveltekit https:dev +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/examples/sveltekit/package.json b/examples/sveltekit/package.json index 409ae4dd9c..4289aafad7 100644 --- a/examples/sveltekit/package.json +++ b/examples/sveltekit/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite dev", - "https:dev": "vite dev", + "dev:https": "vite dev", "build": "vite build", "start": "node --env-file-if-exists=.env build", "prepare": "svelte-kit sync || echo ''", diff --git a/examples/sveltekit/vite.config.ts b/examples/sveltekit/vite.config.ts index 162ce2dacb..22632fb17d 100644 --- a/examples/sveltekit/vite.config.ts +++ b/examples/sveltekit/vite.config.ts @@ -3,8 +3,7 @@ import { sveltekit } from "@sveltejs/kit/vite"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; -const enabled = - process.env.VITE_LOCAL_HTTPS === "1" || process.env.npm_lifecycle_event === "https:dev"; +const enabled = process.env.npm_lifecycle_event === "dev:https"; const httpsOptions = { enabled }; export default defineConfig({ diff --git a/package.json b/package.json index 59a8b1e97c..46567ae63e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "dev:svelte": "turbo run dev --filter=@shopify/hydrogen-example-sveltekit...", "dev:hydrogen": "pnpm --dir examples/hydrogen dev", "dev:hub": "node scripts/examples-dev.ts", - "https:setup": "mkcert -install && mkdir -p \"$HOME/.shopify/hydrogen/certs\" && mkcert -cert-file \"$HOME/.shopify/hydrogen/certs/local.tryhydrogen.dev.pem\" -key-file \"$HOME/.shopify/hydrogen/certs/local.tryhydrogen.dev-key.pem\" local.tryhydrogen.dev", + "https:setup": "turbo run build --filter=@shopify/hydrogen && node packages/hydrogen/bin/hydrogen.mjs certs install", "download:standard-types": "node scripts/download-standard-types.ts", "prepare:preview-dist": "node scripts/preview-template-dist.ts prepare", "validate:preview-dist": "node scripts/preview-template-dist.ts validate", @@ -26,14 +26,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", + "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 a6f026ff7c..6e39d15c23 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/skills/hydrogen-local-https/SKILL.md b/packages/hydrogen/skills/hydrogen-local-https/SKILL.md index f998d57da8..4573f1a894 100644 --- a/packages/hydrogen/skills/hydrogen-local-https/SKILL.md +++ b/packages/hydrogen/skills/hydrogen-local-https/SKILL.md @@ -9,21 +9,19 @@ description: > Customer Account login, logout, and OAuth callbacks require an HTTPS, non-`localhost` origin that exactly matches the URLs registered in Shopify admin. `local.tryhydrogen.dev` is a Shopify-owned domain that resolves publicly to `127.0.0.1`, so it provides a stable local hostname. -## Vite Certificate Setup +## Certificates -Vite-based frameworks need manual certificates until automatic provisioning is available. Install and trust [mkcert](https://github.com/FiloSottile/mkcert), then create the default certificate files: +The `localHttps` Vite plugin provisions missing certificates automatically when `vite dev` starts: it downloads a pinned, checksum-verified [mkcert](https://github.com/FiloSottile/mkcert) release, installs the local certificate authority (this may prompt for your password), and generates the certificate files under `~/.shopify/hydrogen/certs/`. To provision ahead of time — or for frameworks that read certificate paths before Vite starts (Nuxt, SolidStart) — run: ```sh -brew install mkcert -mkcert -install -mkdir -p ~/.shopify/hydrogen/certs -mkcert \ - -cert-file ~/.shopify/hydrogen/certs/local.tryhydrogen.dev.pem \ - -key-file ~/.shopify/hydrogen/certs/local.tryhydrogen.dev-key.pem \ - local.tryhydrogen.dev +npx hydrogen certs install ``` -Pass `certPath` and `keyPath` to use another location. Certificate generation is intentionally separate from the plugin. +Remove Hydrogen's generated certificate files and cached mkcert binary with `npx hydrogen certs uninstall`. This leaves mkcert's shared local certificate authority trusted because other projects may use it. Pass `--remove-ca` to remove that shared CA from the system trust stores too. + +Pass `certPath` and `keyPath` to use another location. When automatic download is unavailable for a platform, install mkcert manually and generate the files at the paths the warning prints. + +The plugin skips automatic provisioning when the `CI` environment variable is set, because installing the certificate authority needs an interactive trust prompt. Run `npx hydrogen certs install` explicitly when a CI job genuinely needs local HTTPS. ## Vite @@ -32,7 +30,7 @@ import { localHttps } from "@shopify/hydrogen/vite"; import { defineConfig } from "vite"; const httpsOptions = { - enabled: process.env.npm_lifecycle_event === "https:dev" || process.env.VITE_LOCAL_HTTPS === "1", + enabled: process.env.npm_lifecycle_event === "dev:https", }; export default defineConfig({ @@ -40,16 +38,7 @@ export default defineConfig({ }); ``` -Start Vite through an `https:dev` package script. A normal `vite dev` remains plain HTTP. - -```json -{ - "scripts": { - "dev": "vite dev", - "https:dev": "vite dev" - } -} -``` +Add a `"dev:https": "vite dev"` package script and run it through the project's package manager. The normal `dev` script remains plain HTTP. ## Astro @@ -59,7 +48,7 @@ Astro needs its own host and port in addition to the Vite plugin: import { LOCAL_HTTPS_DEFAULTS, localHttps } from "@shopify/hydrogen/vite"; import { defineConfig } from "astro/config"; -const enabled = process.env.npm_lifecycle_event === "https:dev" || process.env.VITE_LOCAL_HTTPS === "1"; +const enabled = process.env.npm_lifecycle_event === "dev:https"; const httpsOptions = { enabled }; export default defineConfig({ @@ -72,7 +61,7 @@ export default defineConfig({ ## Nuxt -Nitro terminates TLS, so provide certificate paths to both Nitro and Vite: +Nitro terminates TLS, so provide certificate paths to both Nitro and Vite. Nitro reads the paths when the config is evaluated, so provision certificates with `npx hydrogen certs install` (or restart once after the plugin provisions them): ```ts import { localHttps } from "@shopify/hydrogen/vite"; @@ -81,7 +70,7 @@ import type { NuxtConfig } from "nuxt/schema"; type VitePlugin = NonNullable["plugins"]>[number]; const httpsOptions = { - enabled: process.env.npm_lifecycle_event === "https:dev" || process.env.VITE_LOCAL_HTTPS === "1", + enabled: process.env.npm_lifecycle_event === "dev:https", }; const httpsPlugin = localHttps(httpsOptions); @@ -95,14 +84,14 @@ export default defineNuxtConfig({ ## SolidStart/Vinxi -Vinxi terminates TLS outside Vite: +Vinxi terminates TLS outside Vite and reads certificate paths when the config is evaluated, so provision certificates with `npx hydrogen certs install` (or restart once after the plugin provisions them): ```ts import { defineConfig } from "@solidjs/start/config"; import { localHttps } from "@shopify/hydrogen/vite"; const httpsOptions = { - enabled: process.env.npm_lifecycle_event === "https:dev" || process.env.VITE_LOCAL_HTTPS === "1", + enabled: process.env.npm_lifecycle_event === "dev:https", }; const httpsPlugin = localHttps(httpsOptions); const devServer = httpsPlugin.api.getDevServerConfig(); @@ -115,12 +104,8 @@ export default defineConfig({ Vinxi also needs its bind target and port on startup: -```json -{ - "scripts": { - "https:dev": "vinxi dev --host local.tryhydrogen.dev --port 5173" - } -} +```sh +npm run dev:https ``` ## Next.js @@ -133,6 +118,14 @@ next dev --experimental-https --hostname local.tryhydrogen.dev --port 5173 ## Shopify Admin +Outside CI, the `localHttps` Vite plugin uses the installed Shopify CLI to update Customer Account API settings when the server starts. Shopify CLI must include `@shopify/cli-hydrogen` 13.0.4 or later. If the project is not linked to a Hydrogen storefront, Shopify CLI starts the interactive linking flow before it pushes the callback, JavaScript origin, and logout URLs. + +The plugin skips Shopify CLI in CI. It also falls back without stopping the development server when Shopify CLI is missing or outdated, linking is cancelled, or the settings cannot be pushed. In these cases, configure the values printed in the terminal manually. + +Next.js does not use the Vite plugin, so configure its values manually too. + +### Manual Configuration + In the Hydrogen or Headless sales channel, open the storefront's **Customer Account API settings** and configure: ```text diff --git a/packages/hydrogen/src/cli/__tests__/certs.test.ts b/packages/hydrogen/src/cli/__tests__/certs.test.ts new file mode 100644 index 0000000000..3cd8b8619e --- /dev/null +++ b/packages/hydrogen/src/cli/__tests__/certs.test.ts @@ -0,0 +1,86 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { uninstallLocalHttpsCertificates } from "../certs"; + +const fsCalls = vi.hoisted(() => ({ rm: vi.fn(async () => {}) })); +const mkcertCalls = vi.hoisted(() => ({ + resolveMkcertBinary: vi.fn((): { assetName: string } | undefined => ({ + assetName: "mkcert-pinned-platform", + })), + uninstallCertificateAuthority: vi.fn(async () => {}), +})); + +vi.mock("node:fs/promises", async (importOriginal) => ({ + ...(await importOriginal()), + rm: fsCalls.rm, +})); + +vi.mock("../../vite/mkcert", () => ({ + resolveMkcertBinary: mkcertCalls.resolveMkcertBinary, + uninstallCertificateAuthority: mkcertCalls.uninstallCertificateAuthority, +})); + +describe("uninstallLocalHttpsCertificates", () => { + beforeEach(() => { + fsCalls.rm.mockClear(); + mkcertCalls.resolveMkcertBinary.mockClear(); + mkcertCalls.uninstallCertificateAuthority.mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("removes Hydrogen files but keeps the shared certificate authority by default", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const hydrogenDirectory = join(homedir(), ".shopify", "hydrogen"); + + await uninstallLocalHttpsCertificates(); + + expect(fsCalls.rm.mock.calls).toEqual([ + [join(hydrogenDirectory, "certs", "local.tryhydrogen.dev.pem"), { force: true }], + [join(hydrogenDirectory, "certs", "local.tryhydrogen.dev-key.pem"), { force: true }], + [join(hydrogenDirectory, "mkcert", "mkcert-pinned-platform"), { force: true }], + ]); + expect(mkcertCalls.resolveMkcertBinary).toHaveBeenCalledWith(process.platform, process.arch); + expect(mkcertCalls.uninstallCertificateAuthority).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith( + "The shared mkcert CA remains trusted. Pass --remove-ca to remove it.", + ); + }); + + it("removes the shared certificate authority when requested", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await uninstallLocalHttpsCertificates(["--remove-ca"]); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("other projects")); + expect(mkcertCalls.uninstallCertificateAuthority).toHaveBeenCalledOnce(); + }); + + it("does not remove another mkcert binary on an unsupported platform", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + mkcertCalls.resolveMkcertBinary.mockReturnValueOnce(undefined); + const certificateDirectory = join(homedir(), ".shopify", "hydrogen", "certs"); + + await uninstallLocalHttpsCertificates(); + + expect(fsCalls.rm.mock.calls).toEqual([ + [join(certificateDirectory, "local.tryhydrogen.dev.pem"), { force: true }], + [join(certificateDirectory, "local.tryhydrogen.dev-key.pem"), { force: true }], + ]); + }); + + it("rejects unknown arguments without removing anything", async () => { + await expect(uninstallLocalHttpsCertificates(["--unknown"])).rejects.toThrow( + "Unknown argument: --unknown", + ); + + expect(fsCalls.rm).not.toHaveBeenCalled(); + expect(mkcertCalls.uninstallCertificateAuthority).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/hydrogen/src/cli/__tests__/index.test.ts b/packages/hydrogen/src/cli/__tests__/index.test.ts new file mode 100644 index 0000000000..b2efe13c25 --- /dev/null +++ b/packages/hydrogen/src/cli/__tests__/index.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { runCli } from "../index"; + +const commandCalls = vi.hoisted(() => ({ + checkGraphQL: vi.fn(async () => {}), + installLocalHttpsCertificates: vi.fn(async () => {}), + setupHydrogen: vi.fn(async () => {}), + uninstallLocalHttpsCertificates: vi.fn(async () => {}), +})); + +vi.mock("../certs", () => ({ + installLocalHttpsCertificates: commandCalls.installLocalHttpsCertificates, + uninstallLocalHttpsCertificates: commandCalls.uninstallLocalHttpsCertificates, +})); +vi.mock("../gql", () => ({ checkGraphQL: commandCalls.checkGraphQL })); +vi.mock("../setup", () => ({ setupHydrogen: commandCalls.setupHydrogen })); + +const originalArgv = process.argv; + +function runWithArguments(args: string[]): void { + process.argv = ["node", "hydrogen", ...args]; + runCli(); +} + +describe("runCli", () => { + afterEach(() => { + process.argv = originalArgv; + vi.clearAllMocks(); + }); + + it("dispatches certs install to local HTTPS installation", () => { + runWithArguments(["certs", "install"]); + + expect(commandCalls.installLocalHttpsCertificates).toHaveBeenCalledOnce(); + expect(commandCalls.setupHydrogen).not.toHaveBeenCalled(); + }); + + it("dispatches certs uninstall with trailing arguments", () => { + runWithArguments(["certs", "uninstall", "--remove-ca"]); + + expect(commandCalls.uninstallLocalHttpsCertificates).toHaveBeenCalledWith(["--remove-ca"]); + }); + + it("dispatches setup to the skills setup", () => { + runWithArguments(["setup"]); + + expect(commandCalls.setupHydrogen).toHaveBeenCalledOnce(); + expect(commandCalls.installLocalHttpsCertificates).not.toHaveBeenCalled(); + }); + + it("dispatches gql check with trailing arguments", () => { + runWithArguments(["gql", "check", "src/**/*.ts"]); + + expect(commandCalls.checkGraphQL).toHaveBeenCalledWith({ args: ["src/**/*.ts"] }); + }); +}); diff --git a/packages/hydrogen/src/cli/certs.ts b/packages/hydrogen/src/cli/certs.ts new file mode 100644 index 0000000000..fb1ab0a3ea --- /dev/null +++ b/packages/hydrogen/src/cli/certs.ts @@ -0,0 +1,49 @@ +import { rm } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { LOCAL_HTTPS_DEFAULTS, provisionLocalHttps } from "../vite"; +import { resolveMkcertBinary, uninstallCertificateAuthority } from "../vite/mkcert"; + +const REMOVE_CA_FLAG = "--remove-ca"; + +export async function installLocalHttpsCertificates(): Promise { + const { host, certPath, keyPath } = await provisionLocalHttps(); + + console.log(`Local HTTPS is ready for https://${host}`); + console.log(` Certificate: ${certPath}`); + console.log(` Private key: ${keyPath}`); +} + +export async function uninstallLocalHttpsCertificates(args: string[] = []): Promise { + const unknownArgument = args.find((argument) => argument !== REMOVE_CA_FLAG); + if (unknownArgument) { + throw new Error(`Unknown argument: ${unknownArgument}`); + } + + const removeCertificateAuthority = args.includes(REMOVE_CA_FLAG); + if (removeCertificateAuthority) { + console.warn( + "Warning: removing mkcert's shared local CA will make certificates created by other projects untrusted.", + ); + await uninstallCertificateAuthority(); + } + + const hydrogenDirectory = join(homedir(), ".shopify", "hydrogen"); + const certificateDirectory = join(hydrogenDirectory, "certs"); + const host = LOCAL_HTTPS_DEFAULTS.host; + const binary = resolveMkcertBinary(process.platform, process.arch); + const removals = [ + rm(join(certificateDirectory, `${host}.pem`), { force: true }), + rm(join(certificateDirectory, `${host}-key.pem`), { force: true }), + ]; + if (binary) { + removals.push(rm(join(hydrogenDirectory, "mkcert", binary.assetName), { force: true })); + } + await Promise.all(removals); + + console.log("Hydrogen's local HTTPS certificate files were removed."); + if (!removeCertificateAuthority) { + console.log(`The shared mkcert CA remains trusted. Pass ${REMOVE_CA_FLAG} to remove it.`); + } +} diff --git a/packages/hydrogen/src/cli/index.ts b/packages/hydrogen/src/cli/index.ts index c35253533d..92674cac2f 100644 --- a/packages/hydrogen/src/cli/index.ts +++ b/packages/hydrogen/src/cli/index.ts @@ -3,6 +3,7 @@ import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { installLocalHttpsCertificates, uninstallLocalHttpsCertificates } from "./certs"; import { checkGraphQL } from "./gql"; import { setupHydrogen } from "./setup"; @@ -10,6 +11,11 @@ const CLI_ARGUMENTS_INDEX = 2; const FAILURE_EXIT_CODE = 1; const COMMANDS = [ + { path: ["certs", "install"], run: async (_args: string[]) => installLocalHttpsCertificates() }, + { + path: ["certs", "uninstall"], + run: async (args: string[]) => uninstallLocalHttpsCertificates(args), + }, { path: ["setup"], run: async (_args: string[]) => setupHydrogen() }, { path: ["gql", "check"], run: async (args: string[]) => checkGraphQL({ args }) }, ] as const; diff --git a/packages/hydrogen/src/vite/certificate-prompt.test.ts b/packages/hydrogen/src/vite/certificate-prompt.test.ts new file mode 100644 index 0000000000..d72e45c7a4 --- /dev/null +++ b/packages/hydrogen/src/vite/certificate-prompt.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { acceptsCertificateInstallation } from "./certificate-prompt"; + +describe("acceptsCertificateInstallation", () => { + it.each([ + ["", true], + ["y", true], + ["YES", true], + ["n", false], + ["no", false], + ])("parses %j as %s", (answer, expected) => { + expect(acceptsCertificateInstallation(answer)).toBe(expected); + }); +}); diff --git a/packages/hydrogen/src/vite/certificate-prompt.ts b/packages/hydrogen/src/vite/certificate-prompt.ts new file mode 100644 index 0000000000..03567367f1 --- /dev/null +++ b/packages/hydrogen/src/vite/certificate-prompt.ts @@ -0,0 +1,22 @@ +import { createInterface } from "node:readline/promises"; + +export async function confirmCertificateInstallation(host: string): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) return false; + + const readline = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await readline.question( + `To run HTTPS in development, Hydrogen needs to download mkcert, add a local certificate authority to your system trust store, and create a certificate for "${host}". Continue? [Y/n] `, + ); + + return acceptsCertificateInstallation(answer); + } finally { + readline.close(); + } +} + +/** @internal Exported for tests. */ +export function acceptsCertificateInstallation(answer: string): boolean { + const response = answer.trim().toLowerCase(); + return response === "" || response === "y" || response === "yes"; +} diff --git a/packages/hydrogen/src/vite/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 32e4f6b175..5d46f3c571 100644 --- a/packages/hydrogen/src/vite/index.test.ts +++ b/packages/hydrogen/src/vite/index.test.ts @@ -5,18 +5,39 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { localHttps } from "."; +import { localHttps, provisionLocalHttps } from "."; import { assert } from "../core/test-utils"; const fsCalls = vi.hoisted(() => ({ + existsSync: vi.fn(), readFileSync: vi.fn(), })); +const mkcertCalls = vi.hoisted(() => ({ + provisionCertificates: vi.fn(), +})); + +const promptCalls = vi.hoisted(() => ({ + confirmCertificateInstallation: vi.fn(), +})); + +vi.mock("./certificate-prompt", () => ({ + confirmCertificateInstallation: promptCalls.confirmCertificateInstallation, +})); + +vi.mock("./mkcert", () => ({ + provisionCertificates: mkcertCalls.provisionCertificates, +})); + vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + existsSync(...args: any[]) { + fsCalls.existsSync(...args); + return (actual.existsSync as any)(...args); + }, readFileSync(...args: any[]) { fsCalls.readFileSync(...args); return (actual.readFileSync as any)(...args); @@ -49,7 +70,11 @@ describe("localHttps", () => { let keyPath: string; beforeEach(() => { + fsCalls.existsSync.mockClear(); fsCalls.readFileSync.mockClear(); + mkcertCalls.provisionCertificates.mockReset(); + promptCalls.confirmCertificateInstallation.mockReset().mockResolvedValue(true); + vi.stubEnv("CI", ""); directory = fs.mkdtempSync(join(tmpdir(), "hydrogen-local-https-")); certPath = join(directory, "custom.test.pem"); keyPath = join(directory, "custom.test-key.pem"); @@ -57,24 +82,28 @@ describe("localHttps", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); fs.rmSync(directory, { recursive: true, force: true }); }); - it("has no side effects when disabled", () => { + it("has no side effects when disabled", async () => { const use = vi.fn(); const plugin = localHttps({ enabled: false }); const config = getHook(plugin.config, "config"); const configureServer = getHook(plugin.configureServer, "configureServer"); - expect(config({} as any, {} as any)).toBeUndefined(); + expect(await config({} as any, { command: "serve" } as any)).toBeUndefined(); expect(configureServer({ middlewares: { use } } as any)).toBeUndefined(); expect(plugin.api.getDevServerConfig()).toBeUndefined(); expect(use).not.toHaveBeenCalled(); + expect(mkcertCalls.provisionCertificates).not.toHaveBeenCalled(); + expect(promptCalls.confirmCertificateInstallation).not.toHaveBeenCalled(); + expect(fsCalls.existsSync).not.toHaveBeenCalled(); expect(fsCalls.readFileSync).not.toHaveBeenCalled(); }); - it("returns complete Vite server configuration from certificate files", () => { + it("returns complete Vite server configuration from certificate files", async () => { fs.writeFileSync(certPath, "certificate"); fs.writeFileSync(keyPath, "private-key"); const plugin = localHttps({ @@ -92,7 +121,7 @@ describe("localHttps", () => { port: 4_321, https: { cert: certPath, key: keyPath }, }); - expect(config({} as any, {} as any)).toEqual({ + expect(await config({} as any, {} as any)).toEqual({ server: { allowedHosts: ["custom.test"], host: "custom.test", @@ -112,7 +141,8 @@ describe("localHttps", () => { expect(fsCalls.readFileSync).toHaveBeenCalledTimes(2); }); - it("throws a setup error when certificate files are missing", () => { + it("warns and leaves Vite unconfigured when certificate files are missing", async () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); const plugin = localHttps({ enabled: true, host: "custom.test", @@ -120,11 +150,18 @@ describe("localHttps", () => { keyPath, }); const config = getHook(plugin.config, "config"); + const configureServer = getHook(plugin.configureServer, "configureServer"); + const use = vi.fn(); + + expect(await config({} as any, {} as any)).toBeUndefined(); + expect(configureServer({ middlewares: { use } } as any)).toBeUndefined(); + expect(use).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); - const message = captureErrorMessage(() => config({} as any, {} as any)); + const message = String(warn.mock.calls[0]?.[0]); expect(message).toContain(certPath); expect(message).toContain(keyPath); - expect(message).toContain("Local HTTPS requires a readable certificate file"); + expect(message).toContain("npx hydrogen certs install"); expect(message).toContain("brew install mkcert"); expect(message).toContain("mkcert -install"); expect(message).toContain(`mkdir -p '${directory}'`); @@ -142,13 +179,171 @@ describe("localHttps", () => { }); const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); - const config = getHook(plugin.config, "config"); - const message = captureErrorMessage(() => config({} as any, {} as any)); + const message = captureErrorMessage(() => plugin.api.getDevServerConfig()); expect(message).toContain("permission denied"); expect(message).toContain(certPath); }); + it("warns once when Vite creates multiple plugin instances", async () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + + for (let index = 0; index < 2; index += 1) { + const plugin = localHttps({ + enabled: true, + host: "custom.test", + certPath, + keyPath, + }); + const config = getHook(plugin.config, "config"); + expect(await config({} as any, {} as any)).toBeUndefined(); + } + + expect(warn).toHaveBeenCalledOnce(); + }); + + it("warns for distinct hosts that share missing certificate paths", async () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + + for (const host of ["first.test", "second.test"]) { + const plugin = localHttps({ enabled: true, host, certPath, keyPath }); + const config = getHook(plugin.config, "config"); + expect(await config({} as any, {} as any)).toBeUndefined(); + } + + expect(warn).toHaveBeenCalledTimes(2); + expect(String(warn.mock.calls[0]?.[0])).toContain("'first.test'"); + expect(String(warn.mock.calls[1]?.[0])).toContain("'second.test'"); + }); + + it("provisions missing certificates when Vite serves", async () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + mkcertCalls.provisionCertificates.mockImplementation( + async (settings: { certPath: string; keyPath: string }) => { + fs.writeFileSync(settings.certPath, "certificate"); + fs.writeFileSync(settings.keyPath, "private-key"); + }, + ); + const plugin = localHttps({ + enabled: true, + host: "custom.test", + port: 4_321, + certPath, + keyPath, + }); + const config = getHook(plugin.config, "config"); + + const result = await config({} as any, { command: "serve" } as any); + + expect(mkcertCalls.provisionCertificates).toHaveBeenCalledWith( + expect.objectContaining({ host: "custom.test", certPath, keyPath }), + ); + expect(promptCalls.confirmCertificateInstallation).toHaveBeenCalledWith("custom.test"); + expect(result).toMatchObject({ + server: { + https: { + cert: Buffer.from("certificate"), + key: Buffer.from("private-key"), + }, + }, + }); + expect(warn).not.toHaveBeenCalled(); + }); + + it("does not provision when certificate installation is declined", async () => { + promptCalls.confirmCertificateInstallation.mockResolvedValue(false); + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); + const config = getHook(plugin.config, "config"); + + expect(await config({} as any, { command: "serve" } as any)).toBeUndefined(); + + expect(mkcertCalls.provisionCertificates).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + expect(String(warn.mock.calls[0]?.[0])).toContain( + "requires confirmation in an interactive terminal", + ); + }); + + it("skips provisioning when certificate files already exist", async () => { + fs.writeFileSync(certPath, "certificate"); + fs.writeFileSync(keyPath, "private-key"); + const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); + const config = getHook(plugin.config, "config"); + + await config({} as any, { command: "serve" } as any); + + expect(mkcertCalls.provisionCertificates).not.toHaveBeenCalled(); + expect(promptCalls.confirmCertificateInstallation).not.toHaveBeenCalled(); + }); + + it("falls back to a warning when provisioning fails", async () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + mkcertCalls.provisionCertificates.mockRejectedValue(new Error("download blocked")); + const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); + const config = getHook(plugin.config, "config"); + + expect(await config({} as any, { command: "serve" } as any)).toBeUndefined(); + expect(warn).toHaveBeenCalledOnce(); + + const message = String(warn.mock.calls[0]?.[0]); + expect(message).toContain("Automatic certificate provisioning failed:"); + expect(message).toContain("download blocked"); + expect(message).toContain("npx hydrogen certs install"); + }); + + it("does not provision during builds", async () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); + const config = getHook(plugin.config, "config"); + + expect(await config({} as any, { command: "build" } as any)).toBeUndefined(); + + expect(mkcertCalls.provisionCertificates).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + }); + + it("does not provision in CI environments and says so in the warning", async () => { + vi.stubEnv("CI", "true"); + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); + const config = getHook(plugin.config, "config"); + + expect(await config({} as any, { command: "serve" } as any)).toBeUndefined(); + + expect(mkcertCalls.provisionCertificates).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + const message = String(warn.mock.calls[0]?.[0]); + expect(message).toContain("skipped in CI environments"); + expect(message).toContain("npx hydrogen certs install"); + }); + + it("provisions when CI is explicitly disabled", async () => { + vi.stubEnv("CI", "false"); + mkcertCalls.provisionCertificates.mockImplementation( + async (settings: { certPath: string; keyPath: string }) => { + fs.writeFileSync(settings.certPath, "certificate"); + fs.writeFileSync(settings.keyPath, "private-key"); + }, + ); + const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); + const config = getHook(plugin.config, "config"); + + await config({} as any, { command: "serve" } as any); + + expect(mkcertCalls.provisionCertificates).toHaveBeenCalledOnce(); + }); + + it("does not provision during vite preview", async () => { + vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); + const config = getHook(plugin.config, "config"); + + await config({} as any, { command: "serve", isPreview: true } as any); + + expect(mkcertCalls.provisionCertificates).not.toHaveBeenCalled(); + }); + it("sets forwarded headers without replacing existing values", () => { fs.writeFileSync(certPath, "certificate"); fs.writeFileSync(keyPath, "private-key"); @@ -250,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 }); @@ -265,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( @@ -284,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 }); @@ -297,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(); @@ -305,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); @@ -321,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", @@ -335,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) { @@ -353,6 +553,7 @@ describe("localHttps", () => { describe("localHttps plugin API", () => { beforeEach(() => { + fsCalls.existsSync.mockClear(); fsCalls.readFileSync.mockClear(); }); @@ -386,21 +587,28 @@ describe("localHttps plugin API", () => { } }); - it("throws through dev server config when a certificate is missing", () => { + it("warns and returns undefined when a certificate is missing", () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); const certPath = join(tmpdir(), "missing-cert.pem"); const keyPath = join(tmpdir(), "missing-key.pem"); const plugin = localHttps({ enabled: true, host: "custom.test", certPath, keyPath }); - const message = captureErrorMessage(() => plugin.api.getDevServerConfig()); + expect(plugin.api.getDevServerConfig()).toBeUndefined(); + expect(warn).toHaveBeenCalledOnce(); + + const message = String(warn.mock.calls[0]?.[0]); expect(message).toContain(certPath); expect(message).toContain(keyPath); }); it("looks for default certificates in the Hydrogen home directory", () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); const host = `missing-${process.pid}.local.tryhydrogen.dev`; const plugin = localHttps({ enabled: true, host }); - const message = captureErrorMessage(() => plugin.api.getDevServerConfig()); + expect(plugin.api.getDevServerConfig()).toBeUndefined(); + + const message = String(warn.mock.calls[0]?.[0]); expect(message).toContain(join(homedir(), ".shopify", "hydrogen", "certs", `${host}.pem`)); expect(message).toContain(join(homedir(), ".shopify", "hydrogen", "certs", `${host}-key.pem`)); }); @@ -417,6 +625,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..df57e907f8 100644 --- a/packages/hydrogen/src/vite/local-https.ts +++ b/packages/hydrogen/src/vite/local-https.ts @@ -1,12 +1,18 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import type { OutgoingHttpHeader, OutgoingHttpHeaders, ServerResponse } from "node:http"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import type { Plugin, ViteDevServer } from "vite"; +import type { ConfigEnv, Plugin, ViteDevServer } from "vite"; -import { CUSTOMER_ACCOUNT_PATHS } from "../core/url"; +import { confirmCertificateInstallation } from "./certificate-prompt"; +import { + configureCustomerAccountUrls, + isContinuousIntegration, + resolveCustomerAccountUrls, +} from "./customer-account"; +import { provisionCertificates } from "./mkcert"; export const LOCAL_HTTPS_DEFAULTS = { host: "local.tryhydrogen.dev", @@ -24,7 +30,8 @@ const HTTP1_ONLY_RESPONSE_HEADERS = new Set([ "transfer-encoding", "upgrade", ]); -const loggedCustomerAccountSettings = new Set(); +const emittedMissingCertificateWarnings = new Set(); +const startedCustomerAccountSetups = new Set(); /** Options for Hydrogen's local HTTPS Vite plugin. */ export type LocalHttpsOptions = { @@ -71,7 +78,7 @@ export function localHttps(options: LocalHttpsOptions): LocalHttpsPlugin { }; const getDevServerConfig = () => { - if (!settings) return; + if (!settings || !checkCertificateFiles(settings)) return; getCertificateFiles(); @@ -88,7 +95,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 +126,7 @@ export function localHttps(options: LocalHttpsOptions): LocalHttpsPlugin { }; }, configureServer(server) { - if (!settings) return; + if (!settings || !checkCertificateFiles(settings)) return; getCertificateFiles(); configureLocalHttpsServer(server, settings); @@ -120,6 +134,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 +189,80 @@ function resolveCertificatePath(path: string | URL) { return isAbsolute(path) ? path : resolve(path); } +function certificateFilesExist(settings: LocalHttpsSettings) { + return existsSync(settings.certPath) && existsSync(settings.keyPath); +} + +async function ensureCertificateFiles(settings: LocalHttpsSettings): Promise { + if (certificateFilesExist(settings)) return true; + + // Installing the mkcert certificate authority needs an interactive trust + // prompt on first run, which hangs or fails on CI runners. + if (isContinuousIntegration()) { + return checkCertificateFiles( + settings, + "Automatic certificate provisioning is skipped in CI environments (the CI environment variable is set).", + ); + } + + if (!(await confirmCertificateInstallation(settings.host))) { + return checkCertificateFiles( + settings, + "Automatic certificate provisioning requires confirmation in an interactive terminal.", + ); + } + + try { + await provisionCertificates(settings); + return true; + } catch (error) { + return checkCertificateFiles( + settings, + `Automatic certificate provisioning failed:\n ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function checkCertificateFiles(settings: LocalHttpsSettings, provisioningNote?: string) { + const missingPaths = [settings.certPath, settings.keyPath].filter((path) => !existsSync(path)); + if (missingPaths.length === 0) return true; + + const warning = formatMissingCertificateWarning(settings, missingPaths, provisioningNote); + if (!emittedMissingCertificateWarnings.has(warning)) { + emittedMissingCertificateWarnings.add(warning); + process.emitWarning(warning, { type: "HydrogenLocalHttpsWarning" }); + } + return false; +} + +function formatMissingCertificateWarning( + { certPath, host, keyPath }: LocalHttpsSettings, + missingPaths: string[], + provisioningNote?: string, +) { + const certificateDirectories = [...new Set([dirname(certPath), dirname(keyPath)])]; + const provisioningFailure = provisioningNote === undefined ? [] : ["", provisioningNote]; + + return [ + "Local HTTPS is disabled because certificate files are missing:", + ...missingPaths.map((path) => ` ${path}`), + ...provisioningFailure, + "", + "Expected certificate files:", + ` Certificate: ${certPath}`, + ` Private key: ${keyPath}`, + "", + "Run the automatic setup:", + " npx hydrogen certs install", + "", + "Or install and configure mkcert, then generate the certificate:", + " macOS: brew install mkcert", + " mkcert -install", + ` mkdir -p ${certificateDirectories.map(shellQuote).join(" ")}`, + ` mkcert -cert-file ${shellQuote(certPath)} -key-file ${shellQuote(keyPath)} ${shellQuote(host)}`, + ].join("\n"); +} + function readCertificateFiles(settings: LocalHttpsSettings): LocalHttpsCertificateFiles { return { cert: readCertificateFile(settings, "certificate", settings.certPath), @@ -215,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/packages/hydrogen/src/vite/mkcert.test.ts b/packages/hydrogen/src/vite/mkcert.test.ts new file mode 100644 index 0000000000..32a9dd9725 --- /dev/null +++ b/packages/hydrogen/src/vite/mkcert.test.ts @@ -0,0 +1,143 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { downloadVerified, provisionCertificates, resolveMkcertBinary } from "./mkcert"; + +const SUPPORTED_TARGETS = [ + ["darwin", "arm64", "mkcert-v1.4.4-darwin-arm64"], + ["darwin", "x64", "mkcert-v1.4.4-darwin-amd64"], + ["linux", "arm64", "mkcert-v1.4.4-linux-arm64"], + ["linux", "x64", "mkcert-v1.4.4-linux-amd64"], + ["win32", "arm64", "mkcert-v1.4.4-windows-arm64.exe"], + ["win32", "x64", "mkcert-v1.4.4-windows-amd64.exe"], +] as const; + +describe("resolveMkcertBinary", () => { + it.each(SUPPORTED_TARGETS)("resolves %s-%s to a pinned release", (platform, arch, assetName) => { + const binary = resolveMkcertBinary(platform, arch); + + expect(binary).toMatchObject({ + assetName, + url: `https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/${assetName}`, + }); + expect(binary?.sha256).toMatch(/^[a-f0-9]{64}$/); + }); + + it("pins a distinct checksum per target", () => { + const checksums = SUPPORTED_TARGETS.map( + ([platform, arch]) => resolveMkcertBinary(platform, arch)?.sha256, + ); + + expect(new Set(checksums).size).toBe(SUPPORTED_TARGETS.length); + }); + + it.each([ + ["sunos", "x64"], + ["linux", "arm"], + ["win32", "ia32"], + ])("returns undefined for unsupported %s-%s", (platform, arch) => { + expect(resolveMkcertBinary(platform, arch)).toBeUndefined(); + }); +}); + +describe("downloadVerified", () => { + let directory: string; + let destination: string; + + beforeEach(() => { + directory = fs.mkdtempSync(join(tmpdir(), "hydrogen-mkcert-")); + destination = join(directory, "bin", "mkcert"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + function stubFetch(response: Response) { + const fetchMock = vi.fn(async () => response); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + it("writes an executable file when the checksum matches", async () => { + const bytes = Buffer.from("mkcert binary contents"); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const fetchMock = stubFetch(new Response(bytes)); + + await downloadVerified({ url: "https://example.test/mkcert", sha256, destination }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://example.test/mkcert", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(fs.readFileSync(destination)).toEqual(bytes); + expect(fs.readdirSync(join(directory, "bin"))).toEqual(["mkcert"]); + if (process.platform !== "win32") { + expect(fs.statSync(destination).mode & 0o111).not.toBe(0); + } + }); + + it("refuses to write a download that does not match the pinned checksum", async () => { + stubFetch(new Response(Buffer.from("tampered contents"))); + + await expect( + downloadVerified({ + url: "https://example.test/mkcert", + sha256: "a".repeat(64), + destination, + }), + ).rejects.toThrow(/did not match the pinned SHA-256 checksum/); + expect(fs.existsSync(destination)).toBe(false); + }); + + it("names the operation and URL when the download times out", async () => { + const fetchMock = vi.fn(async () => { + throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + downloadVerified({ + url: "https://example.test/mkcert", + sha256: "a".repeat(64), + destination, + }), + ).rejects.toThrow("mkcert download timed out after 60s: https://example.test/mkcert"); + }); + + it("throws on a failed download response", async () => { + stubFetch(new Response(null, { status: 404 })); + + await expect( + downloadVerified({ + url: "https://example.test/mkcert", + sha256: "a".repeat(64), + destination, + }), + ).rejects.toThrow(/failed with status 404/); + expect(fs.existsSync(destination)).toBe(false); + }); +}); + +describe("provisionCertificates", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects unsupported platforms with manual instructions", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("sunos" as NodeJS.Platform); + + await expect( + provisionCertificates({ + host: "custom.test", + certPath: join(tmpdir(), "custom.test.pem"), + keyPath: join(tmpdir(), "custom.test-key.pem"), + }), + ).rejects.toThrow(/not available for sunos-.*Install mkcert manually/s); + }); +}); diff --git a/packages/hydrogen/src/vite/mkcert.ts b/packages/hydrogen/src/vite/mkcert.ts new file mode 100644 index 0000000000..1643d9e583 --- /dev/null +++ b/packages/hydrogen/src/vite/mkcert.ts @@ -0,0 +1,213 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +import { consoleLogger, getLogger } from "../core/logging"; + +const log = getLogger("local-https"); + +// Step confirmations print unprefixed on purpose: they are terminal output for +// an interactive provisioning flow, not subsystem log entries. +function confirm(message: string) { + consoleLogger.info(`☑️ ${message}`); +} + +// mkcert is pinned to an exact release and verified against SHA-256 checksums +// so that a compromised or replaced "latest" release can never execute. +// Checksums are sourced from the Hermit (cashapp/hermit-packages, mkcert.hcl) +// and Scoop (ScoopInstaller/Extras, bucket/mkcert.json) package registries. +const MKCERT_VERSION = "v1.4.4"; +const MKCERT_RELEASE_BASE_URL = `https://github.com/FiloSottile/mkcert/releases/download/${MKCERT_VERSION}`; +const EXECUTABLE_MODE = 0o755; +const SUCCESS_EXIT_CODE = 0; +const DOWNLOAD_TIMEOUT_MS = 60_000; + +const MKCERT_BINARIES: Record = { + "darwin-arm64": { + assetName: `mkcert-${MKCERT_VERSION}-darwin-arm64`, + sha256: "c8af0df44bce04359794dad8ea28d750437411d632748049d08644ffb66a60c6", + }, + "darwin-x64": { + assetName: `mkcert-${MKCERT_VERSION}-darwin-amd64`, + sha256: "a32dfab51f1845d51e810db8e47dcf0e6b51ae3422426514bf5a2b8302e97d4e", + }, + "linux-arm64": { + assetName: `mkcert-${MKCERT_VERSION}-linux-arm64`, + sha256: "b98f2cc69fd9147fe4d405d859c57504571adec0d3611c3eefd04107c7ac00d0", + }, + "linux-x64": { + assetName: `mkcert-${MKCERT_VERSION}-linux-amd64`, + sha256: "6d31c65b03972c6dc4a14ab429f2928300518b26503f58723e532d1b0a3bbb52", + }, + "win32-arm64": { + assetName: `mkcert-${MKCERT_VERSION}-windows-arm64.exe`, + sha256: "793747256c562622d40127c8080df26add2fb44c50906ce9db63b42a5280582e", + }, + "win32-x64": { + assetName: `mkcert-${MKCERT_VERSION}-windows-amd64.exe`, + sha256: "d2660b50a9ed59eada480750561c96abc2ed4c9a38c6a24d93e30e0977631398", + }, +}; + +export type MkcertBinary = { + assetName: string; + sha256: string; + url: string; +}; + +/** @internal Exported for tests. */ +export function resolveMkcertBinary(platform: string, arch: string): MkcertBinary | undefined { + const binary = MKCERT_BINARIES[`${platform}-${arch}`]; + if (!binary) return undefined; + + return { ...binary, url: `${MKCERT_RELEASE_BASE_URL}/${binary.assetName}` }; +} + +function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** @internal Exported for tests. */ +export async function downloadVerified(options: { + url: string; + sha256: string; + destination: string; +}): Promise { + let bytes: Uint8Array; + try { + const response = await fetch(options.url, { + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`mkcert download failed with status ${response.status}: ${options.url}`); + } + + bytes = new Uint8Array(await response.arrayBuffer()); + } catch (error) { + if (error instanceof Error && error.name === "TimeoutError") { + throw new Error( + `mkcert download timed out after ${DOWNLOAD_TIMEOUT_MS / 1_000}s: ${options.url}`, + { cause: error }, + ); + } + throw error; + } + const digest = sha256Hex(bytes); + if (digest !== options.sha256) { + throw new Error( + `mkcert download did not match the pinned SHA-256 checksum; refusing to run it.\n` + + ` URL: ${options.url}\n` + + ` Expected: ${options.sha256}\n` + + ` Received: ${digest}`, + ); + } + + await mkdir(dirname(options.destination), { recursive: true }); + + // Write-then-rename keeps concurrent dev servers from ever spawning a + // partially written binary: rename is atomic on the same filesystem. + const temporaryPath = `${options.destination}.tmp-${process.pid}`; + try { + await writeFile(temporaryPath, bytes, { mode: EXECUTABLE_MODE }); + await chmod(temporaryPath, EXECUTABLE_MODE); + await rename(temporaryPath, options.destination); + } catch (error) { + await rm(temporaryPath, { force: true }); + throw error; + } +} + +async function ensureMkcertBinary(): Promise { + const binary = resolveMkcertBinary(process.platform, process.arch); + if (!binary) { + throw new Error( + `Automatic mkcert download is not available for ${process.platform}-${process.arch}. ` + + "Install mkcert manually and generate the certificate.", + ); + } + + const binaryPath = join(homedir(), ".shopify", "hydrogen", "mkcert", binary.assetName); + if (existsSync(binaryPath) && sha256Hex(await readFile(binaryPath)) === binary.sha256) { + return binaryPath; + } + + log.info(`downloading mkcert ${MKCERT_VERSION}…`); + await downloadVerified({ url: binary.url, sha256: binary.sha256, destination: binaryPath }); + confirm(`mkcert ${MKCERT_VERSION} downloaded`); + + return binaryPath; +} + +function runMkcert(binaryPath: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + // stdin stays inherited so sudo can prompt on the terminal (sudo talks to + // the tty directly, not to the piped streams). mkcert's verbose output is + // captured instead of shown, and surfaced only when the command fails. + const child = spawn(binaryPath, args, { stdio: ["inherit", "pipe", "pipe"] }); + + let output = ""; + const collect = (chunk: Buffer) => { + output += chunk.toString(); + }; + child.stdout?.on("data", collect); + child.stderr?.on("data", collect); + + child.on("error", reject); + child.on("close", (code, signal) => { + if (code === SUCCESS_EXIT_CODE) { + resolve(output); + return; + } + + const reason = signal ? `mkcert was killed by ${signal}` : `mkcert exited with code ${code}`; + reject(new Error(output.trim() === "" ? reason : `${reason}\n${output.trim()}`)); + }); + }); +} + +export type ProvisionSettings = { + host: string; + certPath: string; + keyPath: string; +}; + +export async function provisionCertificates(settings: ProvisionSettings): Promise { + const binaryPath = await ensureMkcertBinary(); + + await mkdir(dirname(settings.certPath), { recursive: true }); + await mkdir(dirname(settings.keyPath), { recursive: true }); + + log.info("generating a trusted local certificate; mkcert may prompt for your password."); + const mkcertOutput = await runMkcert(binaryPath, [ + "-install", + "-cert-file", + settings.certPath, + "-key-file", + settings.keyPath, + settings.host, + ]); + confirm( + mkcertOutput.includes("The local CA is already installed") + ? "The local CA is already installed in the system trust store" + : "The local CA is now installed in the system trust store", + ); + confirm(`Created a new certificate for "${settings.host}"`); + + log.info("verifying certificates…"); + const missingPaths = [settings.certPath, settings.keyPath].filter((path) => !existsSync(path)); + if (missingPaths.length > 0) { + throw new Error(`mkcert did not create the expected files: ${missingPaths.join(", ")}`); + } + confirm("local https certificates ready"); +} + +export async function uninstallCertificateAuthority(): Promise { + const binaryPath = await ensureMkcertBinary(); + + log.info("removing the local certificate authority; mkcert may prompt for your password."); + await runMkcert(binaryPath, ["-uninstall"]); + confirm("The local CA was removed from the system trust stores"); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eed22fdebd..8752f3da3a 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 @@ -4307,6 +4313,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} @@ -13891,6 +13900,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-oxygen-template/SKILL.md b/skills/create-oxygen-template/SKILL.md index 186ed8936b..110313cbb9 100644 --- a/skills/create-oxygen-template/SKILL.md +++ b/skills/create-oxygen-template/SKILL.md @@ -27,7 +27,7 @@ Maintain `templates/react-router` as the canonical source for a professional sta - no `@shared/*` imports - no `examples/shared/*` runtime dependency - no `localCdnAssets` - - keep the Hydrogen local HTTPS Vite plugin and `https:dev` script, using its portable default certificate paths + - keep the Hydrogen local HTTPS Vite plugin and `dev:https` script, using its portable default certificate paths - no `@shopify/hydrogen-classic` - no Hydrogen Vite plugin from classic Hydrogen - no `@react-router/node`, `@react-router/serve`, or `react-router-serve` unless the template intentionally supports a Node server path diff --git a/skills/create-vercel-template/SKILL.md b/skills/create-vercel-template/SKILL.md index 2e30161c5c..87524abfda 100644 --- a/skills/create-vercel-template/SKILL.md +++ b/skills/create-vercel-template/SKILL.md @@ -28,7 +28,7 @@ Next.js on Vercel runs on the Node/serverless runtime, so `process.env` works an - no `@shared/*` imports - no `examples/shared/*` runtime dependency - no `localCdnAssets` (drop the turbopack rule from `next.config.ts`) - - keep the Next.js `https:dev` script for local Customer Account OAuth + - keep the Next.js `dev:https` script for local Customer Account OAuth - no `catalog:` dependency ranges in the final template package - use `@shopify/hydrogen: workspace:*` in this repository so template E2E exercises the package under development (see "Hydrogen dependency" below). Do not use repo-local `file:` dependencies or vendored package tarballs. @@ -70,7 +70,7 @@ must expose the template's required APIs, subpaths, TypeScript plugin, and schem - Keep `"packageManager": "pnpm@10.33.0"` so the eventual standalone distribution uses the intended manager. - Do not add `@vercel/functions` unless the app reintroduces an explicit Storefront cache adapter; the current Next template uses Next Cache Components (`"use cache"`, `cacheLife`, `cacheTag`). - Replace `typescript: catalog:` with a real npm range (e.g. `^5.9.3`). -- Keep the `https:dev` script alongside `dev`, `build`, `start`, `lint`, and `typecheck`. +- Keep the `dev:https` script alongside `dev`, `build`, `start`, `lint`, and `typecheck`. - Deploy uses the Vercel CLI, not a build dependency: document `npx vercel` / `npx vercel --prod` (optionally add a `"deploy": "vercel --prod"` script and tell the user to have the Vercel CLI available). @@ -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 e8586dd263..e4e3c8c108 100644 --- a/templates/nextjs/README.md +++ b/templates/nextjs/README.md @@ -23,11 +23,13 @@ Open . Customer Accounts require an HTTPS origin because Shopify OAuth rejects `http`. Run the HTTPS development server and open : ```sh -pnpm https:dev +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: @@ -63,7 +65,7 @@ If `PRIVATE_STOREFRONT_API_TOKEN` is unset, the app uses `mock.shop`. If you set | Script | Does | | --- | --- | | `pnpm dev` | Start the Next.js dev server. | -| `pnpm https:dev` | Start the Next.js dev server with trusted local HTTPS. | +| `pnpm dev:https` | Start the Next.js dev server with trusted local HTTPS. | | `pnpm build` | Build the production app. | | `pnpm start` | Start the production server after `pnpm build`. | | `pnpm lint` | Run ESLint. | diff --git a/templates/nextjs/package.json b/templates/nextjs/package.json index db522ad682..f4673f983b 100644 --- a/templates/nextjs/package.json +++ b/templates/nextjs/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "next dev", - "https:dev": "next dev --experimental-https --hostname local.tryhydrogen.dev --port 5173", + "dev:https": "next dev --experimental-https --hostname local.tryhydrogen.dev --port 5173", "build": "next build --debug-prerender", "start": "next start", "lint": "eslint", diff --git a/templates/react-router/README.md b/templates/react-router/README.md index bfb4fe23cb..acdc8335f1 100644 --- a/templates/react-router/README.md +++ b/templates/react-router/README.md @@ -50,25 +50,16 @@ cp .env.example .env # set PUBLIC_STORE_DOMAIN + PUBLIC_STOREFRONT_ID + PRIVAT npm run dev # Vite/Mini Oxygen loads .env into the worker environment ``` -Customer Account OAuth requires trusted local HTTPS. Install [mkcert](https://github.com/FiloSottile/mkcert), then create the default `local.tryhydrogen.dev` certificates: +Customer Account OAuth requires trusted local HTTPS. Run: ```bash -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 -``` - -Then run: - -```bash -npm run https:dev +npm run dev:https ``` Open . +The local HTTPS plugin provisions the certificate, links an unlinked Hydrogen storefront, and pushes the Customer Account callback, JavaScript origin, and logout URLs. See the `hydrogen-local-https` skill for CI and manual fallback behavior. + Mode is **auto-detected**: when a `PRIVATE_STOREFRONT_API_TOKEN` is present the app talks to the real store (`PUBLIC_STORE_DOMAIN`, falling back to the default in `app/lib/shop.ts`); with none it falls back to the `mock.shop` demo, so a fresh @@ -82,7 +73,7 @@ automatically** — the deployed site connects to your store with no extra confi | Script | Does | | --- | --- | | `npm run dev` | Start the Vite dev server with Mini Oxygen. | -| `npm run https:dev` | Start the Vite dev server with trusted local HTTPS. | +| `npm run dev:https` | Start the Vite dev server with trusted local HTTPS. | | `npm run build` | Production React Router build for Oxygen. | | `npm run preview` | Build and preview locally with Vite and Mini Oxygen. | | `npm run deploy` | Deploy to Oxygen with the Shopify CLI. | diff --git a/templates/react-router/package.json b/templates/react-router/package.json index efd2c99b2f..2224a477ab 100644 --- a/templates/react-router/package.json +++ b/templates/react-router/package.json @@ -7,7 +7,7 @@ "build": "react-router build", "deploy": "shopify hydrogen deploy --assets-dir dist/client --worker-dir dist/server", "dev": "vite dev", - "https:dev": "vite dev", + "dev:https": "vite dev", "preview": "react-router build && vite preview", "typecheck": "react-router typegen && tsc && hydrogen gql check --fail-on-warn" }, diff --git a/templates/react-router/vite.config.ts b/templates/react-router/vite.config.ts index 9974b6d94f..3ca10a750e 100644 --- a/templates/react-router/vite.config.ts +++ b/templates/react-router/vite.config.ts @@ -6,8 +6,7 @@ import { defineConfig } from "vite"; const oxygenPlugins = oxygen(); const oxygenPlugin = oxygenPlugins.find((plugin) => plugin.name === "oxygen:main"); -const enabled = - process.env.VITE_LOCAL_HTTPS === "1" || process.env.npm_lifecycle_event === "https:dev"; +const enabled = process.env.npm_lifecycle_event === "dev:https"; const httpsOptions = { enabled }; if (!oxygenPlugin?.api) {