Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions decisions/2026-06-25-001-country-flag-store-create-dev.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions packages/store/src/cli/commands/store/create/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('store create dev command', () => {
plan: 'plus',
featurePreview: undefined,
withDemoData: false,
country: undefined,
json: false,
})
})
Expand All @@ -54,6 +55,7 @@ describe('store create dev command', () => {
plan: 'plus',
featurePreview: undefined,
withDemoData: false,
country: undefined,
json: true,
})
})
Expand All @@ -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')

Expand Down Expand Up @@ -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()
})

Expand Down
8 changes: 7 additions & 1 deletion packages/store/src/cli/commands/store/create/dev.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<void> {
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())
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions packages/store/src/cli/commands/store/create/preview.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<CreatePreviewStoreResult>({
Expand Down
24 changes: 23 additions & 1 deletion packages/store/src/cli/flags.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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',
Expand Down
28 changes: 28 additions & 0 deletions packages/store/src/cli/services/store/create/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions packages/store/src/cli/services/store/create/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface CreateDevStoreOptions {
organization: Organization
featurePreview?: string
withDemoData?: boolean
country?: string
json: boolean
}

Expand Down Expand Up @@ -64,6 +65,10 @@ export async function createDevStore(options: CreateDevStoreOptions): Promise<vo
priceLookupKey: DEV_STORE_PLANS[plan],
prepopulateTestData: options.withDemoData ?? false,
developerPreviewHandle: options.featurePreview,
// NOTE: `country` is collected via --country and surfaced in output, but is not
// yet sent to BP because the published `createAppDevelopmentStore` schema does
// not expose the argument (see shop/world #22968). Wire it here once the backend
// lands and `pnpm graphql-codegen` regenerates the mutation variables.
},
unauthorizedHandler,
})
Expand Down Expand Up @@ -132,6 +137,7 @@ export async function createDevStore(options: CreateDevStoreOptions): Promise<vo
adminUrl: shopAdminUrl,
plan,
...(options.featurePreview ? {featurePreview: options.featurePreview} : {}),
...(options.country ? {country: options.country} : {}),
demoData: options.withDemoData ?? false,
},
organization: {
Expand All @@ -151,6 +157,7 @@ export async function createDevStore(options: CreateDevStoreOptions): Promise<vo
`Admin: ${shopAdminUrl ?? 'N/A'}`,
`Plan: ${plan}`,
...(options.featurePreview ? [`Feature preview: ${options.featurePreview}`] : []),
...(options.country ? [`Country: ${options.country}`] : []),
`Demo data: ${options.withDemoData ? 'enabled' : 'disabled'}`,
],
})
Expand Down
Loading