From 6d5c228ff3470ec12bc7b42940b17ba25356f9a4 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Sun, 28 Jun 2026 19:34:23 +0300 Subject: [PATCH] Add --country flag to store create dev (validation/scaffolding only) Extracts and shares the country-code validation already used by store create preview: countryFlag, isCountryCode, and a shared invalidCountryCodeMessage in flags.ts, plus devStoreFlags.country. The flag is validated, plumbed into createDevStore options, and shown in JSON/success output, but is NOT yet sent to the BP createAppDevelopmentStore mutation -- the published schema does not expose a country argument until shop/world#671185 (part of shop/world#22968) merges. GraphQL wiring is a trivial follow-up. Command is hidden; no changeset (not functional end-to-end yet). Assisted-By: devx/bf777827-dc69-4993-9b11-c401dc19c4be --- ...06-25-001-country-flag-store-create-dev.md | 42 +++++++++++++++ ...02-country-flag-draft-pr-without-schema.md | 52 +++++++++++++++++++ packages/cli/oclif.manifest.json | 9 ++++ .../src/cli/commands/store/create/dev.test.ts | 41 +++++++++++++++ .../src/cli/commands/store/create/dev.ts | 8 ++- .../src/cli/commands/store/create/preview.ts | 4 +- packages/store/src/cli/flags.ts | 24 ++++++++- .../src/cli/services/store/create/dev.test.ts | 28 ++++++++++ .../src/cli/services/store/create/dev.ts | 7 +++ 9 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 decisions/2026-06-25-001-country-flag-store-create-dev.md create mode 100644 decisions/2026-06-28-002-country-flag-draft-pr-without-schema.md diff --git a/decisions/2026-06-25-001-country-flag-store-create-dev.md b/decisions/2026-06-25-001-country-flag-store-create-dev.md new file mode 100644 index 00000000000..ad905293773 --- /dev/null +++ b/decisions/2026-06-25-001-country-flag-store-create-dev.md @@ -0,0 +1,42 @@ +# 2026-06-25-001 — `--country` flag for `store create dev` (country piece of shop/world#22968) + +## Context +`shopify store create dev` needs a `--country` flag, mirroring `store create preview`, +which already accepts and validates a country code. The Business Platform +`createAppDevelopmentStore` mutation does not yet expose a `country` argument — that +backend change is an open (unmerged) PR in `shop/world`. The CLI fetches the published +BP schema from `shop/world` `main` via `bin/get-graphql-schemas`, and CI enforces a +graphql-codegen freshness check. + +## Decision +1. Extract and share the country validation logic in `packages/store/src/cli/flags.ts`: + - `countryFlag(env)` factory (already existed, now exported) builds a normalized + (trim + uppercase) `--country` flag. + - `isCountryCode(value)` (already existed) validates the two-letter shape. + - New `invalidCountryCodeMessage` constant so every store-creation command emits the + same error. + - New `devStoreFlags.country` (env `SHOPIFY_FLAG_STORE_COUNTRY`), alongside + `previewStoreFlags.country`. +2. Add `--country` to the `store create dev` command with shared validation, threaded + into `createDevStore` options and surfaced in JSON/success output. +3. Do **not** wire `country` into the `createAppDevelopmentStore` mutation variables or + the `.graphql` operation yet — left a code comment marking the single insertion point. +4. No changeset: the command is `hidden` and the flag is not yet functional end-to-end. + +## Rationale +- The shareable validation/flag logic is mergeable now and unblocks the CLI side ahead + of the backend. +- Adding `country` to the `.graphql` operation now would break the CI graphql-codegen + freshness check, because the published schema (`shop/world` main) lacks the argument + until the backend PR merges. Passing it in mutation `variables` would also be a + TypeScript error against the generated variables type. +- Referencing the flag via `devStoreFlags.country` (member expression) instead of a + direct `countryFlag(...)` call inside the command's `flags` block avoids a crash in the + `@shopify/cli/command-flags-with-env` lint rule, matching the existing + `previewStoreFlags.country` / `storeFlags['organization-id']` pattern. + +## Alternatives rejected +- Hand-editing the generated `create_app_development_store.ts` to add `country`: would be + reverted by `pnpm graphql-codegen` and fail the CI freshness check. +- Adding `country` to the `.graphql` operation now: breaks codegen against the live schema. +- Inlining the validation/error string per-command: duplicates logic the task asked to share. diff --git a/decisions/2026-06-28-002-country-flag-draft-pr-without-schema.md b/decisions/2026-06-28-002-country-flag-draft-pr-without-schema.md new file mode 100644 index 00000000000..ad99552d647 --- /dev/null +++ b/decisions/2026-06-28-002-country-flag-draft-pr-without-schema.md @@ -0,0 +1,52 @@ +# 2026-06-28-002 — Ship `--country` flag as a draft PR without the BP schema wiring + +## Context +The `--country` flag for `store create dev` (see 2026-06-25-001) was tophatted locally +against a local Business Platform (`~/world/trees/pool-2`) whose `createAppDevelopmentStore` +mutation was patched to accept an optional `country: String` argument (Ariel's PR +`shop/world#671185`). The tophat involved three `TOPHAT ONLY` manual overrides that wired +`country` onto the GraphQL operation and into the mutation variables: +- `packages/store/src/cli/api/graphql/business-platform-organizations/mutations/create_app_development_store.graphql` +- `packages/store/src/cli/api/graphql/business-platform-organizations/generated/create_app_development_store.ts` +- `packages/store/src/cli/services/store/create/dev.ts` + +The tophat was verified working end-to-end locally after restarting the BP web process +(`overmind restart web` in `core/shopify`) to reload the memoized GraphQL-Ruby schema, and +attaching an `ApiClient` to the `shopify-cli` ServiceApp in the local DB. + +`shop/world#671185` is NOT yet merged to `main`. The CLI's `pnpm graphql-codegen` fetches +the published schema from `shop/world` `main`, and CI enforces a graphql-codegen freshness +check. + +## Decision +Open the CLI change as a **draft PR without the GraphQL schema wiring**: +1. Reverted all three `TOPHAT ONLY` overrides so the `.graphql` operation and generated + `.ts` are byte-identical to `HEAD` (passes the codegen freshness check). +2. Kept the full flag scaffolding: `--country` flag, shared `countryFlag` / + `isCountryCode` / `invalidCountryCodeMessage`, `devStoreFlags.country`, plumbing into + `createDevStore` options, and `country` in JSON/success output. +3. The service no longer sends `country` to BP; replaced the tophat comment with a NOTE + marking the single insertion point for when the backend lands. +4. No changeset: the command is `hidden` and `country` is not functional end-to-end. + +## Rationale +- A single CI-green PR cannot send `country` on the wire until the published schema has + the argument; keeping the tophat edits would leave CI red until the BP PR merges. +- Shipping the flag scaffolding now (green) gets the shareable validation/flag logic + reviewed and landed, decoupled from the backend timeline. +- The wiring is a trivial follow-up once `shop/world#671185` merges to `main`. + +## Follow-up (once shop/world#671185 merges to main) +1. `pnpm graphql-codegen:get-graphql-schemas && pnpm graphql-codegen` +2. Add `$country: String` to `create_app_development_store.graphql` and the + `createAppDevelopmentStore(country: $country)` argument. +3. Replace the NOTE in `services/store/create/dev.ts` with `country: options.country`. +4. Add a changeset and unhide the command when the broader feature is ready. + +## Alternatives rejected +- **Single stacked/draft PR keeping the `TOPHAT ONLY` edits** (Option A): accurately + communicates the dependency but leaves CI red on the codegen freshness check until the + BP PR merges. Rejected in favor of a green draft per Ariel's preference. +- **Keeping `country: options.country` in the service while reverting the generated + variables type**: TypeScript error — the object literal would reference a property the + reverted `CreateAppDevelopmentStoreMutationVariables` type no longer declares. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index b1a03f7b2ec..04fd4091cf7 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5942,6 +5942,15 @@ "descriptionWithMarkdown": "Creates a new app development store in your organization.", "enableJsonFlag": false, "flags": { + "country": { + "description": "Two-letter country code for the store, such as US, CA, or GB.", + "env": "SHOPIFY_FLAG_STORE_COUNTRY", + "hasDynamicHelp": false, + "multiple": false, + "name": "country", + "required": false, + "type": "option" + }, "feature-preview": { "description": "The handle of a feature preview to enable on the new development store.", "env": "SHOPIFY_FLAG_STORE_FEATURE_PREVIEW", diff --git a/packages/store/src/cli/commands/store/create/dev.test.ts b/packages/store/src/cli/commands/store/create/dev.test.ts index 28916e4c998..5e77f815bcc 100644 --- a/packages/store/src/cli/commands/store/create/dev.test.ts +++ b/packages/store/src/cli/commands/store/create/dev.test.ts @@ -41,6 +41,7 @@ describe('store create dev command', () => { plan: 'plus', featurePreview: undefined, withDemoData: false, + country: undefined, json: false, }) }) @@ -54,6 +55,7 @@ describe('store create dev command', () => { plan: 'plus', featurePreview: undefined, withDemoData: false, + country: undefined, json: true, }) }) @@ -77,10 +79,48 @@ describe('store create dev command', () => { plan: 'basic', featurePreview: 'extended_variants', withDemoData: true, + country: undefined, json: false, }) }) + test('normalizes and passes the --country flag through to the service', async () => { + await StoreCreateDev.run([ + '--name', + 'my-test-store', + '--plan', + 'plus', + '--organization-id', + '12345', + '--country', + 'ca', + ]) + + expect(createDevStore).toHaveBeenCalledWith(expect.objectContaining({country: 'CA'})) + }) + + test('rejects an invalid --country value without calling the service', async () => { + const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + + await expect( + StoreCreateDev.run([ + '--name', + 'my-test-store', + '--plan', + 'plus', + '--organization-id', + '12345', + '--country', + 'USA', + ]), + ).rejects.toThrow() + expect(createDevStore).not.toHaveBeenCalled() + + mockExit.mockRestore() + }) + test('prompts for the name when --name is omitted in an interactive environment', async () => { vi.mocked(storeNamePrompt).mockResolvedValue('prompted-store') @@ -141,6 +181,7 @@ describe('store create dev command', () => { expect(StoreCreateDev.flags.plan).toBeDefined() expect(StoreCreateDev.flags['feature-preview']).toBeDefined() expect(StoreCreateDev.flags['with-demo-data']).toBeDefined() + expect(StoreCreateDev.flags.country).toBeDefined() expect(StoreCreateDev.flags.json).toBeDefined() }) diff --git a/packages/store/src/cli/commands/store/create/dev.ts b/packages/store/src/cli/commands/store/create/dev.ts index 3ba6dfbda3e..a2db8d104ba 100644 --- a/packages/store/src/cli/commands/store/create/dev.ts +++ b/packages/store/src/cli/commands/store/create/dev.ts @@ -1,7 +1,7 @@ import {createDevStore} from '../../../services/store/create/dev.js' import {devStorePlanHandles, DevStorePlan} from '../../../services/store/constants.js' import {storeNamePrompt, storePlanPrompt} from '../../../prompts/store.js' -import {storeFlags} from '../../../flags.js' +import {devStoreFlags, invalidCountryCodeMessage, isCountryCode, storeFlags} from '../../../flags.js' import {selectOrg} from '@shopify/organizations' import Command from '@shopify/cli-kit/node/base-command' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' @@ -40,12 +40,17 @@ export default class StoreCreateDev extends Command { default: false, env: 'SHOPIFY_FLAG_STORE_WITH_DEMO_DATA', }), + country: devStoreFlags.country, } async run(): Promise { const {flags} = await this.parse(StoreCreateDev) this.failMissingNonTTYFlags(flags, ['name', 'organization-id', 'plan']) + if (flags.country !== undefined && !isCountryCode(flags.country)) { + this.error(invalidCountryCodeMessage) + } + const organization = await selectOrg(flags['organization-id']?.toString()) const name = flags.name ?? (await storeNamePrompt()) const plan = (flags.plan as DevStorePlan | undefined) ?? (await storePlanPrompt()) @@ -57,6 +62,7 @@ export default class StoreCreateDev extends Command { plan, featurePreview: flags['feature-preview'], withDemoData: flags['with-demo-data'], + country: flags.country, json: flags.json, }) } catch (error) { diff --git a/packages/store/src/cli/commands/store/create/preview.ts b/packages/store/src/cli/commands/store/create/preview.ts index f49c1ae4e2f..125efdb122a 100644 --- a/packages/store/src/cli/commands/store/create/preview.ts +++ b/packages/store/src/cli/commands/store/create/preview.ts @@ -1,4 +1,4 @@ -import {isCountryCode, previewStoreFlags} from '../../../flags.js' +import {invalidCountryCodeMessage, isCountryCode, previewStoreFlags} from '../../../flags.js' import {type CreatePreviewStoreResult, createPreviewStoreCommand} from '../../../services/store/create/preview/index.js' import {writeCreatePreviewStoreResult} from '../../../services/store/create/preview/result.js' import StoreCommand from '../../../utilities/store-command.js' @@ -37,7 +37,7 @@ export default class StoreCreatePreview extends StoreCommand { const {flags} = await this.parse(StoreCreatePreview) if (flags.country !== undefined && !isCountryCode(flags.country)) { - this.error('Country must be a two-letter country code, for example: US.') + this.error(invalidCountryCodeMessage) } const result = await renderSingleTask({ diff --git a/packages/store/src/cli/flags.ts b/packages/store/src/cli/flags.ts index d0c7d52df71..43ca28ba1c8 100644 --- a/packages/store/src/cli/flags.ts +++ b/packages/store/src/cli/flags.ts @@ -1,7 +1,14 @@ import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {Flags} from '@oclif/core' -function countryFlag(env: string) { +/** + * Builds a reusable `--country` flag. The value is normalized to an uppercase, + * trimmed string so downstream validation and the backend receive a consistent + * two-letter country code. + * + * @param env - The environment variable that can supply the flag's value. + */ +export function countryFlag(env: string) { return Flags.string({ description: 'Two-letter country code for the store, such as US, CA, or GB.', env, @@ -10,14 +17,29 @@ function countryFlag(env: string) { }) } +/** + * Returns true when the value is a two-letter (ISO 3166-1 alpha-2 shaped) + * country code. Assumes the value has already been normalized to uppercase by + * `countryFlag`'s parser. + */ export function isCountryCode(value: string): boolean { return /^[A-Z]{2}$/.test(value) } +/** + * Error message shown when a `--country` flag value is not a two-letter code. + * Shared so every store-creation command reports the same guidance. + */ +export const invalidCountryCodeMessage = 'Country must be a two-letter country code, for example: US.' + export const previewStoreFlags = { country: countryFlag('SHOPIFY_FLAG_PREVIEW_STORE_COUNTRY'), } +export const devStoreFlags = { + country: countryFlag('SHOPIFY_FLAG_STORE_COUNTRY'), +} + export const storeFlags = { store: Flags.string({ char: 's', diff --git a/packages/store/src/cli/services/store/create/dev.test.ts b/packages/store/src/cli/services/store/create/dev.test.ts index c94971468dd..cf755cc7465 100644 --- a/packages/store/src/cli/services/store/create/dev.test.ts +++ b/packages/store/src/cli/services/store/create/dev.test.ts @@ -161,6 +161,34 @@ describe('createDevStore', () => { expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"demoData": true')) }) + test('includes the country in JSON output when provided', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, + }) + + await createDevStore({name: 'test-store', organization: defaultOrg, plan: 'basic', country: 'CA', json: true}) + + expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"country": "CA"')) + }) + + test('includes the country in the success output when provided', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({ + organization: {id: '123', storeCreation: {status: 'COMPLETE'}}, + }) + + await createDevStore({name: 'test-store', organization: defaultOrg, plan: 'plus', country: 'CA', json: false}) + + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.arrayContaining(['Country: CA']), + }), + ) + }) + test('outputs JSON when --json flag is set', async () => { vi.mocked(businessPlatformOrganizationsRequestDoc) .mockResolvedValueOnce(defaultMutationResult) diff --git a/packages/store/src/cli/services/store/create/dev.ts b/packages/store/src/cli/services/store/create/dev.ts index 9929f0fcd9e..8c515e431e5 100644 --- a/packages/store/src/cli/services/store/create/dev.ts +++ b/packages/store/src/cli/services/store/create/dev.ts @@ -22,6 +22,7 @@ interface CreateDevStoreOptions { organization: Organization featurePreview?: string withDemoData?: boolean + country?: string json: boolean } @@ -64,6 +65,10 @@ export async function createDevStore(options: CreateDevStoreOptions): Promise