From 891eac7c1b8b6a440706ad45c30cc51945b79c78 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 18 Sep 2026 14:16:23 +0200 Subject: [PATCH 1/4] Add JSON Schema for subscription migration listing Co-Authored-By: Claude Fable 5 --- .../subscription-migrations/commands.test.ts | 20 +++-- .../app/subscription-migrations/list.ts | 10 ++- .../list-output.test.ts | 81 +++++++++++++++++++ .../subscription-migrations/list-output.ts | 3 +- .../services/subscription-migrations/types.ts | 43 +++++++++- packages/cli/oclif.manifest.json | 4 +- .../rules/json-output-command-exceptions.js | 1 - 7 files changed, 148 insertions(+), 14 deletions(-) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts index fb36db9dc57..a6ea13ac25e 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts @@ -8,7 +8,10 @@ import {commands} from '../../../index.js' import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' import {linkedAppContext} from '../../../services/app-context.js' import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js' -import {migrationCancellationJsonOutputSchema} from '../../../services/subscription-migrations/types.js' +import { + migrationCancellationJsonOutputSchema, + migrationListJsonOutputSchema, +} from '../../../services/subscription-migrations/types.js' import {outputOperations} from '../../../services/subscription-migrations/command-output.js' import {getMigrationOperations} from '../../../services/subscription-migrations/get-operations.js' import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js' @@ -453,6 +456,12 @@ describe('subscription migration command metadata', () => { expect(Command.flags.json).toBe(jsonFlag.json) }) + test('list exposes and documents its JSON output schema', () => { + expect(List.jsonOutputSchema).toBe(migrationListJsonOutputSchema) + expect(List.description).toContain('`MigrationListResult` schema') + expect(List.description).toContain('```json') + }) + test('cancel exposes its JSON output schema', () => { expect(Cancel.jsonOutputSchema).toBe(migrationCancellationJsonOutputSchema) }) @@ -576,12 +585,9 @@ describe('subscription migration command metadata', () => { }, ) - test.each([Schedule, Unschedule, Status, List])( - '$name has no fenced-code markers in its plain description', - (Command) => { - expect(Command.description).not.toContain('```') - }, - ) + test.each([Schedule, Unschedule, Status])('$name has no fenced-code markers in its plain description', (Command) => { + expect(Command.description).not.toContain('```') + }) test('cancel documents its JSON output schema', () => { expect(Cancel.description).toContain('`MigrationCancellationResult` schema') diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.ts index a1a650e46e5..315e98baa3a 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.ts @@ -1,4 +1,5 @@ import {listFlags} from './flags.js' +import {migrationListJsonOutputSchema} from '../../../services/subscription-migrations/types.js' import {linkedAppContext} from '../../../services/app-context.js' import { iterateMigratableSubscriptionPages, @@ -6,6 +7,7 @@ import { } from '../../../services/subscription-migrations/list-migratable-subscriptions.js' import {outputMigrationList} from '../../../services/subscription-migrations/list-output.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' +import {jsonFlag} from '@shopify/cli-kit/node/cli' import {AbortError} from '@shopify/cli-kit/node/error' export default class List extends AppLinkedCommand { @@ -19,7 +21,7 @@ Use \`--status\` to filter subscriptions by migration status. Supported values a Run the command from an app project. By default, it uses the Client ID from the active app configuration. Use \`--path\` to select an app directory or \`--config\` to select a configuration. Pass \`--client-id\` to select a different app within the project. Use \`--reset\` to relink the app.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %>', @@ -29,7 +31,11 @@ Run the command from an app project. By default, it uses the Client ID from the '<%= config.bin %> <%= command.id %> --client-id > subscriptions.csv', ] - static flags = {...listFlags} + static flags = {...listFlags, ...jsonFlag} + + static get jsonOutputSchema() { + return migrationListJsonOutputSchema + } async run(): Promise { const {flags} = await this.parse(List) diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts index 70f4b7a9ee6..c8c71d73ee1 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts @@ -1,3 +1,4 @@ +import {migrationListJsonOutputSchema} from './types.js' import {outputMigrationList, serializeMigrationListCsv, serializeMigrationListJson} from './list-output.js' import {outputResult} from '@shopify/cli-kit/node/output' import {describe, expect, test, vi} from 'vitest' @@ -8,6 +9,15 @@ vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { return {...actual, outputResult: vi.fn()} }) +// Defaults to true, matching the real isUnitTest behavior under vitest. The stream boundary test overrides it to +// false so outputResult writes to process.stdout instead of collecting logs. +const isUnitTest = vi.hoisted(() => vi.fn(() => true)) + +vi.mock('@shopify/cli-kit/node/context/local', async (importOriginal) => ({ + ...(await importOriginal()), + isUnitTest, +})) + const CSV_HEADER = 'shop_id,status,manual_subscription_name,manual_subscription_price_amount,manual_subscription_price_currency_code,manual_subscription_interval,target_plan_handle,notification_kind,notification_opt_out_deadline,notification_sent_at,price_behavior,effective_date,last_failure_reason' @@ -256,3 +266,74 @@ describe('outputMigrationList JSON', () => { expect(outputResult).not.toHaveBeenCalled() }) }) + +describe('migration list JSON contract', () => { + test('preserves nullable fields and nested nulls', () => { + const value = subscription({ + manualSubscriptionName: null, + manualSubscriptionPrice: null, + targetPlanHandle: null, + notification: {kind: 'NONE', optOutDeadline: null, sentAt: null}, + priceBehavior: null, + effectiveDate: null, + lastFailureReason: null, + }) + + expect(serializeMigrationListJson([value])).toBe( + JSON.stringify({subscriptions: [value]}, null, 2), + ) + }) + + test.each([ + {manualSubscriptionPrice: {amount: 19.99, currencyCode: 'USD'}}, + {notification: {kind: 'NONE', optOutDeadline: null}}, + ])('rejects invalid subscription fields: %j', (fields) => { + expect(() => + migrationListJsonOutputSchema.validate({ + subscriptions: [{...subscription(), ...fields}], + }), + ).toThrow() + }) + + test.each([ + {status: 'UNKNOWN'}, + {priceBehavior: 'UNKNOWN'}, + {manualSubscriptionInterval: 'MONTHLY'}, + {lastFailureReason: 'UNKNOWN'}, + ])('accepts unknown server-provided values for pass-through fields: %j', (fields) => { + const value = {...subscription(), ...fields} + + const encoded = migrationListJsonOutputSchema.encode({subscriptions: [value]}) + + expect(JSON.parse(encoded)).toEqual({subscriptions: [value]}) + }) +}) + +describe('outputMigrationList stream boundary', () => { + test('writes all pages as one JSON document to stdout', async () => { + // Restore the real outputResult and report a non-test context so the document reaches process.stdout. + const actualOutput = + await vi.importActual('@shopify/cli-kit/node/output') + vi.mocked(outputResult).mockImplementation(actualOutput.outputResult) + isUnitTest.mockReturnValue(false) + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const value = subscription() + async function* pages() { + yield [value] + expect(stdout).not.toHaveBeenCalled() + yield [] + } + + try { + await outputMigrationList({pages: pages(), json: true}) + + expect(stdout).toHaveBeenCalledOnce() + expect(stdout.mock.calls[0]?.[0]).toBe(`${JSON.stringify({subscriptions: [value]}, null, 2)}\n`) + expect(stderr).not.toHaveBeenCalled() + } finally { + stdout.mockRestore() + stderr.mockRestore() + } + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.ts b/packages/app/src/cli/services/subscription-migrations/list-output.ts index 700c6c2efd7..d52b42435b3 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-output.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-output.ts @@ -1,3 +1,4 @@ +import {migrationListJsonOutputSchema} from './types.js' import {outputResult} from '@shopify/cli-kit/node/output' import type {MigratableSubscription} from '../../models/subscription-migrations.js' @@ -10,7 +11,7 @@ interface MigrationListOutputOptions { } export function serializeMigrationListJson(subscriptions: MigratableSubscription[]): string { - return JSON.stringify({subscriptions}, null, 2) + return migrationListJsonOutputSchema.encode({subscriptions}) } export function serializeMigrationListCsv(subscriptions: MigratableSubscription[]): string { diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index adaead18a68..1bff8fa0e3b 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -1,5 +1,5 @@ import {JsonAbortErrorSchema, JsonErrorSchema} from '@shopify/cli-kit/node/error/schema' -import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' import {zod} from '@shopify/cli-kit/node/schema' import type {MigrationOperation} from '../../models/subscription-migrations.js' import type {MigrationUserError} from './partners-api.js' @@ -80,3 +80,44 @@ export type MigrationCancellationOutcome = | {status: 'success'; operationId: string; operation: MigrationOperation} | {status: 'failed'; operationId: string; operation: MigrationOperation | null; userErrors: MigrationUserError[]} | {status: 'failed'; operationId: string; operation: null; error: unknown} + +const MigratableSubscriptionPriceSchema = zod.object({ + amount: zod.string(), + currencyCode: zod.string(), +}) + +const MigratableSubscriptionNotificationSchema = zod.object({ + kind: zod.string().describe('Known values: NONE, OPT_OUT, WHEN_REQUIRED.'), + optOutDeadline: zod.string().nullable(), + sentAt: zod.string().nullable(), +}) + +// Server-provided values are typed as strings (with the known values documented) instead of enums, so a new +// server-side value never makes `--json` output fail validation. Compatibility with the `MigratableSubscription` +// model is enforced where `serializeMigrationListJson` passes the model into this schema's `encode`. +const MigratableSubscriptionSchema = zod.object({ + shopId: zod.string(), + status: zod.string().describe('Known values: UNSCHEDULED, SCHEDULED, MIGRATED.'), + manualSubscriptionName: zod.string().nullable(), + manualSubscriptionPrice: MigratableSubscriptionPriceSchema.nullable(), + manualSubscriptionInterval: zod.string().describe('Known values: EVERY_30_DAYS, ANNUAL.'), + targetPlanHandle: zod.string().nullable(), + notification: MigratableSubscriptionNotificationSchema.nullable(), + priceBehavior: zod.string().nullable().describe('Known values: HONOR_BILLING_PRICE, PLAN_PRICE.'), + effectiveDate: zod.string().nullable(), + lastFailureReason: zod.string().nullable().describe('Known values: SUPERSEDED, SCHEDULING_FAILED.'), +}) + +export const migrationListJsonOutputSchema = defineJsonOutputSchema({ + name: 'MigrationListResult', + schema: zod.object({ + subscriptions: zod.array(MigratableSubscriptionSchema), + }), + definitions: { + MigratableSubscription: MigratableSubscriptionSchema, + MigratableSubscriptionPrice: MigratableSubscriptionPriceSchema, + MigratableSubscriptionNotification: MigratableSubscriptionNotificationSchema, + }, +}) + +export type MigrationListResult = InferJsonOutputSchema diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index efd0423ae9c..8dc4ec23294 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4746,8 +4746,8 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", - "descriptionWithMarkdown": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", + "description": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.\n\nOutput from `--json` conforms to the `MigrationListResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"subscriptions\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigratableSubscription\"\n }\n }\n },\n \"required\": [\n \"subscriptions\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationListResult\",\n \"definitions\": {\n \"MigratableSubscription\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopId\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"description\": \"Known values: UNSCHEDULED, SCHEDULED, MIGRATED.\"\n },\n \"manualSubscriptionName\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"manualSubscriptionPrice\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigratableSubscriptionPrice\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"manualSubscriptionInterval\": {\n \"type\": \"string\",\n \"description\": \"Known values: EVERY_30_DAYS, ANNUAL.\"\n },\n \"targetPlanHandle\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"notification\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigratableSubscriptionNotification\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"priceBehavior\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"Known values: HONOR_BILLING_PRICE, PLAN_PRICE.\"\n },\n \"effectiveDate\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"lastFailureReason\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"Known values: SUPERSEDED, SCHEDULING_FAILED.\"\n }\n },\n \"required\": [\n \"shopId\",\n \"status\",\n \"manualSubscriptionName\",\n \"manualSubscriptionPrice\",\n \"manualSubscriptionInterval\",\n \"targetPlanHandle\",\n \"notification\",\n \"priceBehavior\",\n \"effectiveDate\",\n \"lastFailureReason\"\n ],\n \"additionalProperties\": false\n },\n \"MigratableSubscriptionPrice\": {\n \"type\": \"object\",\n \"properties\": {\n \"amount\": {\n \"type\": \"string\"\n },\n \"currencyCode\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"amount\",\n \"currencyCode\"\n ],\n \"additionalProperties\": false\n },\n \"MigratableSubscriptionNotification\": {\n \"type\": \"object\",\n \"properties\": {\n \"kind\": {\n \"type\": \"string\",\n \"description\": \"Known values: NONE, OPT_OUT, WHEN_REQUIRED.\"\n },\n \"optOutDeadline\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"sentAt\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n }\n },\n \"required\": [\n \"kind\",\n \"optOutDeadline\",\n \"sentAt\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", "examples": [ "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> --status SCHEDULED > scheduled-subscriptions.csv", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index 0bbd504807f..9b100d78b9b 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -22,7 +22,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/import/dashboard-extensions.ts', 'packages/app/src/cli/commands/app/init.ts', 'packages/app/src/cli/commands/app/release.ts', - 'packages/app/src/cli/commands/app/subscription-migrations/list.ts', 'packages/app/src/cli/commands/app/subscription-migrations/schedule.ts', 'packages/app/src/cli/commands/app/subscription-migrations/status.ts', 'packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts', From 3d2026cb6a21400ef20e48cbd7dd60a49852d8d4 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 22 Sep 2026 14:23:42 +0200 Subject: [PATCH 2/4] Remove unused migration list result type --- .../cli/services/subscription-migrations/list-output.test.ts | 4 +--- .../app/src/cli/services/subscription-migrations/types.ts | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts index c8c71d73ee1..2ce2b20d5aa 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts @@ -279,9 +279,7 @@ describe('migration list JSON contract', () => { lastFailureReason: null, }) - expect(serializeMigrationListJson([value])).toBe( - JSON.stringify({subscriptions: [value]}, null, 2), - ) + expect(serializeMigrationListJson([value])).toBe(JSON.stringify({subscriptions: [value]}, null, 2)) }) test.each([ diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index 1bff8fa0e3b..eb180c299da 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -119,5 +119,3 @@ export const migrationListJsonOutputSchema = defineJsonOutputSchema({ MigratableSubscriptionNotification: MigratableSubscriptionNotificationSchema, }, }) - -export type MigrationListResult = InferJsonOutputSchema From 56e663d5ab996503f2f9a77b6ba8ce125e10657c Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 13:50:59 +0200 Subject: [PATCH 3/4] Normalize migration subscription list JSON contract --- ...malize-migration-subscription-list-json.md | 5 + .../list.integration.test.ts | 7 +- .../list-output.test.ts | 40 +++- .../subscription-migrations/list-output.ts | 3 +- .../subscription-migrations/result-codec.ts | 38 +++- .../services/subscription-migrations/types.ts | 71 ++++--- packages/cli/README.md | 179 ++++++++++++++++++ packages/cli/oclif.manifest.json | 4 +- 8 files changed, 305 insertions(+), 42 deletions(-) create mode 100644 .changeset/normalize-migration-subscription-list-json.md diff --git a/.changeset/normalize-migration-subscription-list-json.md b/.changeset/normalize-migration-subscription-list-json.md new file mode 100644 index 00000000000..cadfa7ac421 --- /dev/null +++ b/.changeset/normalize-migration-subscription-list-json.md @@ -0,0 +1,5 @@ +--- +"@shopify/cli": major +--- + +Normalize migration subscription JSON with shopGid identifiers, whole-second UTC instants, and calendar dates. diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts index 7e6365ad760..ca04468cb52 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts @@ -1,4 +1,5 @@ import List from './list.js' +import {projectMigratableSubscription} from '../../../services/subscription-migrations/result-codec.js' import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' import {linkedAppContext} from '../../../services/app-context.js' import {MigrationListProtocolError} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' @@ -273,8 +274,10 @@ describe('subscription migration list command output integration', () => { expect(jsonWrite).toBeGreaterThan(lastPageRequest) const output = stdoutWrites()[0]! - expect(output).toBe(JSON.stringify({subscriptions: [...pageOne, ...pageTwo]}, null, 2)) - expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo]}) + expect(output).toBe( + JSON.stringify({subscriptions: [...pageOne, ...pageTwo].map(projectMigratableSubscription)}, null, 2), + ) + expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo].map(projectMigratableSubscription)}) }) test('writes an empty JSON document when there are no subscriptions', async () => { diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts index 2ce2b20d5aa..40a83e92816 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts @@ -1,3 +1,4 @@ +import {projectMigratableSubscription} from './result-codec.js' import {migrationListJsonOutputSchema} from './types.js' import {outputMigrationList, serializeMigrationListCsv, serializeMigrationListJson} from './list-output.js' import {outputResult} from '@shopify/cli-kit/node/output' @@ -22,7 +23,7 @@ const CSV_HEADER = 'shop_id,status,manual_subscription_name,manual_subscription_price_amount,manual_subscription_price_currency_code,manual_subscription_interval,target_plan_handle,notification_kind,notification_opt_out_deadline,notification_sent_at,price_behavior,effective_date,last_failure_reason' const CSV_ROW = - 'gid://shopify/Shop/1,SCHEDULED,Legacy plan,19.99,USD,EVERY_30_DAYS,standard,NONE,2026-04-01T00:00:00Z,2026-03-01T00:00:00Z,HONOR_BILLING_PRICE,2026-05-01T00:00:00Z,SCHEDULING_FAILED' + 'gid://shopify/Shop/1,SCHEDULED,Legacy plan,19.99,USD,EVERY_30_DAYS,standard,NONE,2026-04-01T00:00:00Z,2026-03-01T00:00:00Z,HONOR_BILLING_PRICE,2026-05-01,SCHEDULING_FAILED' function subscription(overrides: Partial = {}): MigratableSubscription { return { @@ -38,7 +39,7 @@ function subscription(overrides: Partial = {}): Migratab sentAt: '2026-03-01T00:00:00Z', }, priceBehavior: 'HONOR_BILLING_PRICE', - effectiveDate: '2026-05-01T00:00:00Z', + effectiveDate: '2026-05-01', lastFailureReason: 'SCHEDULING_FAILED', ...overrides, } @@ -84,7 +85,7 @@ describe('migration list serialization', () => { const expected = `{ "subscriptions": [ { - "shopId": "gid://shopify/Shop/1", + "shopGid": "gid://shopify/Shop/1", "status": "SCHEDULED", "manualSubscriptionName": "Legacy plan", "manualSubscriptionPrice": { @@ -99,7 +100,7 @@ describe('migration list serialization', () => { "sentAt": "2026-03-01T00:00:00Z" }, "priceBehavior": "HONOR_BILLING_PRICE", - "effectiveDate": "2026-05-01T00:00:00Z", + "effectiveDate": "2026-05-01", "lastFailureReason": "SCHEDULING_FAILED" } ] @@ -109,6 +110,27 @@ describe('migration list serialization', () => { expect(serializeMigrationListJson(subscriptions)).not.toMatch(/\n$/) }) + test('normalizes fractional instants and offsets while preserving calendar dates', () => { + const value = subscription({ + notification: {kind: 'NONE', sentAt: '2026-03-01T01:00:00.999+01:00', optOutDeadline: null}, + effectiveDate: '2026-05-01', + }) + expect(JSON.parse(serializeMigrationListJson([value])).subscriptions[0]).toMatchObject({ + effectiveDate: '2026-05-01', + notification: {sentAt: '2026-03-01T00:00:00Z', optOutDeadline: null}, + }) + expect(() => + migrationListJsonOutputSchema.validate({ + subscriptions: [ + { + ...projectMigratableSubscription(value), + notification: {kind: 'NONE', sentAt: '2026-03-01T00:00:00.999Z', optOutDeadline: null}, + }, + ], + }), + ).toThrow() + }) + test('serializes CSV fields in the fixed header order without a trailing newline', () => { expect(serializeMigrationListCsv([subscription()])).toBe(`${CSV_HEADER}\n${CSV_ROW}`) }) @@ -247,7 +269,7 @@ describe('outputMigrationList JSON', () => { expect(outputResult).toHaveBeenCalledOnce() const output = vi.mocked(outputResult).mock.calls[0]![0] as string expect(output).toBe(serializeMigrationListJson([...pageOne, ...pageTwo])) - expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo]}) + expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo].map(projectMigratableSubscription)}) }) test('writes an empty JSON document for an empty result', async () => { @@ -279,7 +301,9 @@ describe('migration list JSON contract', () => { lastFailureReason: null, }) - expect(serializeMigrationListJson([value])).toBe(JSON.stringify({subscriptions: [value]}, null, 2)) + expect(serializeMigrationListJson([value])).toBe( + JSON.stringify({subscriptions: [value].map(projectMigratableSubscription)}, null, 2), + ) }) test.each([ @@ -288,7 +312,7 @@ describe('migration list JSON contract', () => { ])('rejects invalid subscription fields: %j', (fields) => { expect(() => migrationListJsonOutputSchema.validate({ - subscriptions: [{...subscription(), ...fields}], + subscriptions: [{...projectMigratableSubscription(subscription()), ...fields}], }), ).toThrow() }) @@ -299,7 +323,7 @@ describe('migration list JSON contract', () => { {manualSubscriptionInterval: 'MONTHLY'}, {lastFailureReason: 'UNKNOWN'}, ])('accepts unknown server-provided values for pass-through fields: %j', (fields) => { - const value = {...subscription(), ...fields} + const value = {...projectMigratableSubscription(subscription()), ...fields} const encoded = migrationListJsonOutputSchema.encode({subscriptions: [value]}) diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.ts b/packages/app/src/cli/services/subscription-migrations/list-output.ts index d52b42435b3..f4514dc0852 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-output.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-output.ts @@ -1,3 +1,4 @@ +import {projectMigratableSubscription} from './result-codec.js' import {migrationListJsonOutputSchema} from './types.js' import {outputResult} from '@shopify/cli-kit/node/output' import type {MigratableSubscription} from '../../models/subscription-migrations.js' @@ -11,7 +12,7 @@ interface MigrationListOutputOptions { } export function serializeMigrationListJson(subscriptions: MigratableSubscription[]): string { - return migrationListJsonOutputSchema.encode({subscriptions}) + return migrationListJsonOutputSchema.encode({subscriptions: subscriptions.map(projectMigratableSubscription)}) } export function serializeMigrationListCsv(subscriptions: MigratableSubscription[]): string { diff --git a/packages/app/src/cli/services/subscription-migrations/result-codec.ts b/packages/app/src/cli/services/subscription-migrations/result-codec.ts index 0f1a53cc777..e94ae3def15 100644 --- a/packages/app/src/cli/services/subscription-migrations/result-codec.ts +++ b/packages/app/src/cli/services/subscription-migrations/result-codec.ts @@ -1,4 +1,4 @@ -import type {MigrationOperation} from '../../models/subscription-migrations.js' +import type {MigratableSubscription, MigrationOperation} from '../../models/subscription-migrations.js' import type {MigrationUserError} from './partners-api.js' export function projectMigrationOperation(operation: MigrationOperation) { @@ -13,3 +13,39 @@ export function projectMigrationOperation(operation: MigrationOperation) { export function projectMigrationUserErrors(errors: MigrationUserError[]) { return errors.map(({message, field}) => ({message, fieldPath: field})) } + +export function projectMigratableSubscription(subscription: MigratableSubscription) { + return { + shopGid: subscription.shopId, + status: subscription.status, + manualSubscriptionName: subscription.manualSubscriptionName, + manualSubscriptionPrice: + subscription.manualSubscriptionPrice === null + ? null + : { + amount: subscription.manualSubscriptionPrice.amount, + currencyCode: subscription.manualSubscriptionPrice.currencyCode, + }, + manualSubscriptionInterval: subscription.manualSubscriptionInterval, + targetPlanHandle: subscription.targetPlanHandle, + notification: + subscription.notification === null + ? null + : { + kind: subscription.notification.kind, + optOutDeadline: normalizeInstant(subscription.notification.optOutDeadline), + sentAt: normalizeInstant(subscription.notification.sentAt), + }, + priceBehavior: subscription.priceBehavior, + effectiveDate: + subscription.effectiveDate === null || /^\d{4}-\d{2}-\d{2}$/.test(subscription.effectiveDate) + ? subscription.effectiveDate + : normalizeInstant(subscription.effectiveDate), + lastFailureReason: subscription.lastFailureReason, + } +} + +function normalizeInstant(value: string | null): string | null { + if (value === null) return null + return new Date(value).toISOString().replace(/\.\d{3}Z$/, 'Z') +} diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index eb180c299da..55b80c7a1a0 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -1,5 +1,5 @@ import {JsonAbortErrorSchema, JsonErrorSchema} from '@shopify/cli-kit/node/error/schema' -import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' import {zod} from '@shopify/cli-kit/node/schema' import type {MigrationOperation} from '../../models/subscription-migrations.js' import type {MigrationUserError} from './partners-api.js' @@ -81,38 +81,53 @@ export type MigrationCancellationOutcome = | {status: 'failed'; operationId: string; operation: MigrationOperation | null; userErrors: MigrationUserError[]} | {status: 'failed'; operationId: string; operation: null; error: unknown} -const MigratableSubscriptionPriceSchema = zod.object({ - amount: zod.string(), - currencyCode: zod.string(), -}) +const UtcInstantSchema = zod + .string() + .datetime() + .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/) +const CalendarDateSchema = zod.string().date() -const MigratableSubscriptionNotificationSchema = zod.object({ - kind: zod.string().describe('Known values: NONE, OPT_OUT, WHEN_REQUIRED.'), - optOutDeadline: zod.string().nullable(), - sentAt: zod.string().nullable(), -}) +const MigratableSubscriptionPriceSchema = zod + .object({ + amount: zod.string().regex(/^-?\d+(?:\.\d+)?$/), + currencyCode: zod.string().regex(/^[A-Z]{3}$/), + }) + .strict() -// Server-provided values are typed as strings (with the known values documented) instead of enums, so a new -// server-side value never makes `--json` output fail validation. Compatibility with the `MigratableSubscription` -// model is enforced where `serializeMigrationListJson` passes the model into this schema's `encode`. -const MigratableSubscriptionSchema = zod.object({ - shopId: zod.string(), - status: zod.string().describe('Known values: UNSCHEDULED, SCHEDULED, MIGRATED.'), - manualSubscriptionName: zod.string().nullable(), - manualSubscriptionPrice: MigratableSubscriptionPriceSchema.nullable(), - manualSubscriptionInterval: zod.string().describe('Known values: EVERY_30_DAYS, ANNUAL.'), - targetPlanHandle: zod.string().nullable(), - notification: MigratableSubscriptionNotificationSchema.nullable(), - priceBehavior: zod.string().nullable().describe('Known values: HONOR_BILLING_PRICE, PLAN_PRICE.'), - effectiveDate: zod.string().nullable(), - lastFailureReason: zod.string().nullable().describe('Known values: SUPERSEDED, SCHEDULING_FAILED.'), -}) +const MigratableSubscriptionNotificationSchema = zod + .object({ + kind: zod.string().describe('Known values: NONE, OPT_OUT, WHEN_REQUIRED.'), + optOutDeadline: UtcInstantSchema.nullable(), + sentAt: UtcInstantSchema.nullable(), + }) + .strict() + +const MigratableSubscriptionSchema = zod + .object({ + shopGid: ShopGidSchema, + status: zod.string().describe('Known values: UNSCHEDULED, SCHEDULED, MIGRATED.'), + manualSubscriptionName: zod.string().nullable(), + manualSubscriptionPrice: MigratableSubscriptionPriceSchema.nullable(), + manualSubscriptionInterval: zod.string().describe('Known values: EVERY_30_DAYS, ANNUAL.'), + targetPlanHandle: zod.string().nullable(), + notification: MigratableSubscriptionNotificationSchema.nullable(), + priceBehavior: zod.string().nullable().describe('Known values: HONOR_BILLING_PRICE, PLAN_PRICE.'), + effectiveDate: zod + .union([CalendarDateSchema, UtcInstantSchema]) + .nullable() + .describe('The upstream calendar date or whole-second UTC instant; date-only values retain their format.'), + lastFailureReason: zod.string().nullable().describe('Known values: SUPERSEDED, SCHEDULING_FAILED.'), + }) + .strict() + .describe('All subscription projection fields are present; unavailable values are null.') export const migrationListJsonOutputSchema = defineJsonOutputSchema({ name: 'MigrationListResult', - schema: zod.object({ - subscriptions: zod.array(MigratableSubscriptionSchema), - }), + schema: zod + .object({ + subscriptions: zod.array(MigratableSubscriptionSchema).describe('The complete list across every fetched page.'), + }) + .strict(), definitions: { MigratableSubscription: MigratableSubscriptionSchema, MigratableSubscriptionPrice: MigratableSubscriptionPriceSchema, diff --git a/packages/cli/README.md b/packages/cli/README.md index 59285135d43..6928fc32717 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4497,6 +4497,185 @@ DESCRIPTION to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app. + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `MigrationListResult` schema. + + ```json + { + "type": "object", + "properties": { + "subscriptions": { + "type": "array", + "items": { + "$ref": "#/definitions/MigratableSubscription" + }, + "description": "The complete list across every fetched page." + } + }, + "required": [ + "subscriptions" + ], + "additionalProperties": false, + "title": "MigrationListResult", + "definitions": { + "MigratableSubscription": { + "type": "object", + "properties": { + "shopGid": { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/Shop\\/\\d+$", + "description": "The Shopify Shop GID." + }, + "status": { + "type": "string", + "description": "Known values: UNSCHEDULED, SCHEDULED, MIGRATED." + }, + "manualSubscriptionName": { + "type": [ + "string", + "null" + ] + }, + "manualSubscriptionPrice": { + "anyOf": [ + { + "$ref": "#/definitions/MigratableSubscriptionPrice" + }, + { + "type": "null" + } + ] + }, + "manualSubscriptionInterval": { + "type": "string", + "description": "Known values: EVERY_30_DAYS, ANNUAL." + }, + "targetPlanHandle": { + "type": [ + "string", + "null" + ] + }, + "notification": { + "anyOf": [ + { + "$ref": "#/definitions/MigratableSubscriptionNotification" + }, + { + "type": "null" + } + ] + }, + "priceBehavior": { + "type": [ + "string", + "null" + ], + "description": "Known values: HONOR_BILLING_PRICE, PLAN_PRICE." + }, + "effectiveDate": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" + } + ] + }, + { + "type": "null" + } + ], + "description": "The upstream calendar date or whole-second UTC instant; date-only values retain their format." + }, + "lastFailureReason": { + "type": [ + "string", + "null" + ], + "description": "Known values: SUPERSEDED, SCHEDULING_FAILED." + } + }, + "required": [ + "shopGid", + "status", + "manualSubscriptionName", + "manualSubscriptionPrice", + "manualSubscriptionInterval", + "targetPlanHandle", + "notification", + "priceBehavior", + "effectiveDate", + "lastFailureReason" + ], + "additionalProperties": false, + "description": "All subscription projection fields are present; unavailable values are null." + }, + "MigratableSubscriptionPrice": { + "type": "object", + "properties": { + "amount": { + "type": "string", + "pattern": "^-?\\d+(?:\\.\\d+)?$" + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Z]{3}$" + } + }, + "required": [ + "amount", + "currencyCode" + ], + "additionalProperties": false + }, + "MigratableSubscriptionNotification": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "description": "Known values: NONE, OPT_OUT, WHEN_REQUIRED." + }, + "optOutDeadline": { + "anyOf": [ + { + "$ref": "#/definitions/MigratableSubscription/properties/effectiveDate/anyOf/0/anyOf/1" + }, + { + "type": "null" + } + ] + }, + "sentAt": { + "anyOf": [ + { + "$ref": "#/definitions/MigratableSubscription/properties/effectiveDate/anyOf/0/anyOf/1" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "optOutDeadline", + "sentAt" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` + EXAMPLES $ shopify app subscription-migrations list diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 8dc4ec23294..aa68ec4dcff 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4746,8 +4746,8 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.\n\nOutput from `--json` conforms to the `MigrationListResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"subscriptions\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigratableSubscription\"\n }\n }\n },\n \"required\": [\n \"subscriptions\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationListResult\",\n \"definitions\": {\n \"MigratableSubscription\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopId\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"description\": \"Known values: UNSCHEDULED, SCHEDULED, MIGRATED.\"\n },\n \"manualSubscriptionName\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"manualSubscriptionPrice\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigratableSubscriptionPrice\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"manualSubscriptionInterval\": {\n \"type\": \"string\",\n \"description\": \"Known values: EVERY_30_DAYS, ANNUAL.\"\n },\n \"targetPlanHandle\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"notification\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigratableSubscriptionNotification\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"priceBehavior\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"Known values: HONOR_BILLING_PRICE, PLAN_PRICE.\"\n },\n \"effectiveDate\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"lastFailureReason\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"Known values: SUPERSEDED, SCHEDULING_FAILED.\"\n }\n },\n \"required\": [\n \"shopId\",\n \"status\",\n \"manualSubscriptionName\",\n \"manualSubscriptionPrice\",\n \"manualSubscriptionInterval\",\n \"targetPlanHandle\",\n \"notification\",\n \"priceBehavior\",\n \"effectiveDate\",\n \"lastFailureReason\"\n ],\n \"additionalProperties\": false\n },\n \"MigratableSubscriptionPrice\": {\n \"type\": \"object\",\n \"properties\": {\n \"amount\": {\n \"type\": \"string\"\n },\n \"currencyCode\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"amount\",\n \"currencyCode\"\n ],\n \"additionalProperties\": false\n },\n \"MigratableSubscriptionNotification\": {\n \"type\": \"object\",\n \"properties\": {\n \"kind\": {\n \"type\": \"string\",\n \"description\": \"Known values: NONE, OPT_OUT, WHEN_REQUIRED.\"\n },\n \"optOutDeadline\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"sentAt\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n }\n },\n \"required\": [\n \"kind\",\n \"optOutDeadline\",\n \"sentAt\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", - "descriptionWithMarkdown": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", + "description": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `MigrationListResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"subscriptions\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigratableSubscription\"\n },\n \"description\": \"The complete list across every fetched page.\"\n }\n },\n \"required\": [\n \"subscriptions\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationListResult\",\n \"definitions\": {\n \"MigratableSubscription\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopGid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/Shop\\\\/\\\\d+$\",\n \"description\": \"The Shopify Shop GID.\"\n },\n \"status\": {\n \"type\": \"string\",\n \"description\": \"Known values: UNSCHEDULED, SCHEDULED, MIGRATED.\"\n },\n \"manualSubscriptionName\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"manualSubscriptionPrice\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigratableSubscriptionPrice\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"manualSubscriptionInterval\": {\n \"type\": \"string\",\n \"description\": \"Known values: EVERY_30_DAYS, ANNUAL.\"\n },\n \"targetPlanHandle\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"notification\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigratableSubscriptionNotification\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"priceBehavior\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"Known values: HONOR_BILLING_PRICE, PLAN_PRICE.\"\n },\n \"effectiveDate\": {\n \"anyOf\": [\n {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"date\"\n },\n {\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}Z$\"\n }\n ]\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The upstream calendar date or whole-second UTC instant; date-only values retain their format.\"\n },\n \"lastFailureReason\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"Known values: SUPERSEDED, SCHEDULING_FAILED.\"\n }\n },\n \"required\": [\n \"shopGid\",\n \"status\",\n \"manualSubscriptionName\",\n \"manualSubscriptionPrice\",\n \"manualSubscriptionInterval\",\n \"targetPlanHandle\",\n \"notification\",\n \"priceBehavior\",\n \"effectiveDate\",\n \"lastFailureReason\"\n ],\n \"additionalProperties\": false,\n \"description\": \"All subscription projection fields are present; unavailable values are null.\"\n },\n \"MigratableSubscriptionPrice\": {\n \"type\": \"object\",\n \"properties\": {\n \"amount\": {\n \"type\": \"string\",\n \"pattern\": \"^-?\\\\d+(?:\\\\.\\\\d+)?$\"\n },\n \"currencyCode\": {\n \"type\": \"string\",\n \"pattern\": \"^[A-Z]{3}$\"\n }\n },\n \"required\": [\n \"amount\",\n \"currencyCode\"\n ],\n \"additionalProperties\": false\n },\n \"MigratableSubscriptionNotification\": {\n \"type\": \"object\",\n \"properties\": {\n \"kind\": {\n \"type\": \"string\",\n \"description\": \"Known values: NONE, OPT_OUT, WHEN_REQUIRED.\"\n },\n \"optOutDeadline\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigratableSubscription/properties/effectiveDate/anyOf/0/anyOf/1\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"sentAt\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigratableSubscription/properties/effectiveDate/anyOf/0/anyOf/1\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"kind\",\n \"optOutDeadline\",\n \"sentAt\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", "examples": [ "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> --status SCHEDULED > scheduled-subscriptions.csv", From c988c4f8e2947a8696d8f0a9059c5eb3556d6e7f Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:01:16 +0200 Subject: [PATCH 4/4] Assert the public subscription projection on stdout --- .../cli/services/subscription-migrations/list-output.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts index 40a83e92816..86928b0ca16 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts @@ -351,7 +351,9 @@ describe('outputMigrationList stream boundary', () => { await outputMigrationList({pages: pages(), json: true}) expect(stdout).toHaveBeenCalledOnce() - expect(stdout.mock.calls[0]?.[0]).toBe(`${JSON.stringify({subscriptions: [value]}, null, 2)}\n`) + expect(stdout.mock.calls[0]?.[0]).toBe( + `${JSON.stringify({subscriptions: [projectMigratableSubscription(value)]}, null, 2)}\n`, + ) expect(stderr).not.toHaveBeenCalled() } finally { stdout.mockRestore()