From 5b96d8315ffc5cfee5a06405a6ab3bb9b3c0464f Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 18 Sep 2026 14:20:31 +0200 Subject: [PATCH 1/3] Add JSON Schema for subscription migration status Co-Authored-By: Claude Fable 5 --- .../subscription-migrations/commands.test.ts | 21 ++++++- .../result-presenter-output.test.ts | 56 ++++++++++++++++++- .../app/subscription-migrations/status.ts | 8 ++- .../command-output.test.ts | 17 ++++++ .../subscription-migrations/command-output.ts | 3 +- .../services/subscription-migrations/types.ts | 8 +++ packages/cli/oclif.manifest.json | 2 +- .../rules/json-output-command-exceptions.js | 1 - 8 files changed, 109 insertions(+), 7 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 163cd105477..5e853265edf 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 @@ -13,6 +13,7 @@ import { migrationCancellationJsonOutputSchema, migrationListJsonOutputSchema, migrationSubmissionJsonOutputSchema, + migrationStatusJsonOutputSchema, } from '../../../services/subscription-migrations/types.js' import {outputOperations} from '../../../services/subscription-migrations/command-output.js' import {getMigrationOperations} from '../../../services/subscription-migrations/get-operations.js' @@ -292,6 +293,16 @@ describe('subscription migration operation commands', () => { expect(watchMigrationOperations).not.toHaveBeenCalled() }) + test('status reports failed operations without changing the command exit code', async () => { + const failedOperation = {...completedOperation, status: 'FAILED' as const} + vi.mocked(getMigrationOperations).mockResolvedValue([failedOperation]) + + await expect(Status.run(['--id', failedOperation.id, '--json'])).resolves.toEqual({app}) + + expect(outputOperations).toHaveBeenCalledWith([failedOperation], true) + expect(process.exitCode).toBeUndefined() + }) + test('cancel presents every repeated ID in exactly one JSON document', async () => { const secondOperation = {...completedOperation, id: 'gid://shopify/AppSubscriptionMigrationOperation/2'} const result: MigrationCancellationResult = { @@ -471,6 +482,12 @@ describe('subscription migration command metadata', () => { expect(Schedule.description).toContain('```json') }) + test('status exposes and documents its JSON output schema', () => { + expect(Status.jsonOutputSchema).toBe(migrationStatusJsonOutputSchema) + expect(Status.description).toContain('`MigrationStatusResult` schema') + expect(Status.description).toContain('```json') + }) + test('cancel exposes its JSON output schema', () => { expect(Cancel.jsonOutputSchema).toBe(migrationCancellationJsonOutputSchema) }) @@ -594,8 +611,8 @@ describe('subscription migration command metadata', () => { }, ) - test.each([Unschedule, Status])('$name has no fenced-code markers in its plain description', (Command) => { - expect(Command.description).not.toContain('```') + test('unschedule has no fenced-code markers in its plain description', () => { + expect(Unschedule.description).not.toContain('```') }) test('cancel documents its JSON output schema', () => { diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts index 20dce54b97c..1aa1f040152 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts @@ -1,7 +1,12 @@ import {presentMigrationCancellationResult, presentMigrationSubmissionResult} from './result-presenter.js' import {projectMigrationSubmissionResult} from '../../../services/subscription-migrations/result-codec.js' -import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' +import {outputOperations} from '../../../services/subscription-migrations/command-output.js' +import {watchMigrationOperations} from '../../../services/subscription-migrations/watch-operations.js' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {beforeEach, describe, expect, test, vi} from 'vitest' +// eslint-disable-next-line n/prefer-global/console +import {Console} from 'node:console' const isUnitTest = vi.hoisted(() => vi.fn(() => false)) @@ -112,3 +117,52 @@ describe('migration submission JSON output', () => { } }) }) + +describe('migration status JSON output', () => { + test('writes the final result to stdout and typed watch progress to stderr', async () => { + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + // Use Node's console so Vitest's console capture does not bypass stderr. + const warn = vi.spyOn(console, 'warn').mockImplementation(new Console(process.stdout, process.stderr).warn) + const running = {id: 'operation-one', status: 'RUNNING' as const, total: 1, results: {edges: []}} + const completed = {...running, status: 'COMPLETED' as const} + + try { + await runWithCommandEventsForCommand(['--json'], async () => { + const operations = await watchMigrationOperations({ + clientId: 'client-id', + operationIds: [running.id], + waitForOperations: async ({onUpdate}) => { + await onUpdate?.([running]) + await onUpdate?.([completed]) + expect(stdout).not.toHaveBeenCalled() + return [completed] + }, + }) + outputOperations(operations, true) + }) + + expect(stdout).toHaveBeenCalledOnce() + expect(stdout.mock.calls[0]?.[0]).toBe(`${JSON.stringify({operations: [completed]}, null, 2)}\n`) + const events = stderr.mock.calls.map(([content]) => JSON.parse(content as string)) + expect(events).toEqual([ + expect.objectContaining({ + type: 'progress', + status: 'started', + message: 'Polling subscription migration operations', + }), + expect.objectContaining({type: 'progress', status: 'updated', message: 'operation-one: RUNNING (0/1 settled)'}), + expect.objectContaining({ + type: 'progress', + status: 'updated', + message: 'operation-one: COMPLETED (0/1 settled)', + }), + expect.objectContaining({type: 'progress', status: 'completed'}), + ]) + } finally { + warn.mockRestore() + stdout.mockRestore() + stderr.mockRestore() + } + }) +}) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/status.ts b/packages/app/src/cli/commands/app/subscription-migrations/status.ts index 08e0b3c0d7d..c7d7f91a778 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/status.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/status.ts @@ -1,9 +1,11 @@ import {statusFlags} from './flags.js' +import {migrationStatusJsonOutputSchema} from '../../../services/subscription-migrations/types.js' import {linkedAppContext} from '../../../services/app-context.js' import {outputOperations} from '../../../services/subscription-migrations/command-output.js' import {getMigrationOperations} from '../../../services/subscription-migrations/get-operations.js' import {watchMigrationOperations} from '../../../services/subscription-migrations/watch-operations.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' +import {jsonFlag} from '@shopify/cli-kit/node/cli' export default class Status extends AppLinkedCommand { static summary = 'Checks the status of app subscription migration operations.' @@ -26,7 +28,11 @@ Run the command from an app project. By default, it uses the Client ID from the '<%= config.bin %> <%= command.id %> --client-id --id --json', ] - static flags = {...statusFlags} + static flags = {...statusFlags, ...jsonFlag} + + static get jsonOutputSchema() { + return migrationStatusJsonOutputSchema + } async run(): Promise { const {flags} = await this.parse(Status) diff --git a/packages/app/src/cli/services/subscription-migrations/command-output.test.ts b/packages/app/src/cli/services/subscription-migrations/command-output.test.ts index 3360335bc43..f4513636ca3 100644 --- a/packages/app/src/cli/services/subscription-migrations/command-output.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/command-output.test.ts @@ -1,3 +1,4 @@ +import {migrationStatusJsonOutputSchema} from './types.js' import {formatMigrationOperationsStatus, outputOperations} from './command-output.js' import {outputResult} from '@shopify/cli-kit/node/output' import {renderInfo} from '@shopify/cli-kit/node/ui' @@ -58,3 +59,19 @@ describe('operation command output', () => { expect(outputResult).not.toHaveBeenCalled() }) }) + +describe('migration status JSON contract', () => { + test('encodes an empty operation list', () => { + outputOperations([], true) + expect(outputResult).toHaveBeenCalledWith(JSON.stringify({operations: []}, null, 2)) + }) + + test.each([ + {...operation('one'), status: 'UNKNOWN'}, + {...operation('one'), total: '2'}, + {...operation('one'), results: {edges: [{node: {shopId: 'shop-one', code: 'UNKNOWN'}}]}}, + {...operation('one'), results: null}, + ])('rejects invalid operations: %j', (value) => { + expect(() => migrationStatusJsonOutputSchema.validate({operations: [value]})).toThrow() + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/command-output.ts b/packages/app/src/cli/services/subscription-migrations/command-output.ts index 61a36433d89..766431a991e 100644 --- a/packages/app/src/cli/services/subscription-migrations/command-output.ts +++ b/packages/app/src/cli/services/subscription-migrations/command-output.ts @@ -1,3 +1,4 @@ +import {migrationStatusJsonOutputSchema} from './types.js' import {outputResult} from '@shopify/cli-kit/node/output' import {renderInfo} from '@shopify/cli-kit/node/ui' import type {MigrationOperation} from '../../models/subscription-migrations.js' @@ -8,7 +9,7 @@ export function formatMigrationOperationsStatus(operations: MigrationOperation[] export function outputOperations(operations: MigrationOperation[], json: boolean): void { if (json) { - outputResult(JSON.stringify({operations}, null, 2)) + outputResult(migrationStatusJsonOutputSchema.encode({operations})) return } diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index e7e03d93f7c..66d24e889e6 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -208,3 +208,11 @@ export type MigrationSubmissionResult = | {status: 'success'; submission: MigrationSubmission} | {status: 'failed'; submission: MigrationSubmission; failure: MigrationSubmissionFailure} | {status: 'cancelled'; changed: false; action: 'schedule' | 'unschedule'; reason: string} + +export const migrationStatusJsonOutputSchema = defineJsonOutputSchema({ + name: 'MigrationStatusResult', + schema: zod.object({operations: zod.array(MigrationOperationSchema)}).strict(), + definitions: {MigrationOperation: MigrationOperationSchema}, +}) + +export type MigrationStatusResult = InferJsonOutputSchema diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index bfb02e6ff51..03a29ad3f00 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5019,7 +5019,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Checks app subscription migration operation status.\n\nRepeat `--id` for every operation GID returned by a multi-batch submission. With `--watch`, the command displays the current state while polling and outputs the final state after all requested operations reach a terminal status.\n\n`RUNNING` means an operation is still processing. `COMPLETED` means processing finished, but you must inspect the per-shop results to confirm each outcome. `FAILED` means the operation failed, and `CANCELED` means cancellation stopped further processing.\n\nUse `--json` to output every operation and its per-shop results as structured JSON.\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": "Checks app subscription migration operation status.\n\nRepeat `--id` for every operation GID returned by a multi-batch submission. With `--watch`, the command displays the current state while polling and outputs the final state after all requested operations reach a terminal status.\n\n`RUNNING` means an operation is still processing. `COMPLETED` means processing finished, but you must inspect the per-shop results to confirm each outcome. `FAILED` means the operation failed, and `CANCELED` means cancellation stopped further processing.\n\nUse `--json` to output every operation and its per-shop results as structured JSON.\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 `MigrationStatusResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n }\n },\n \"required\": [\n \"operations\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationStatusResult\",\n \"definitions\": {\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"RUNNING\",\n \"COMPLETED\",\n \"FAILED\",\n \"CANCELED\"\n ]\n },\n \"total\": {\n \"type\": \"number\"\n },\n \"results\": {\n \"type\": \"object\",\n \"properties\": {\n \"edges\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperationResultEdge\"\n }\n }\n },\n \"required\": [\n \"edges\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"id\",\n \"status\",\n \"total\",\n \"results\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultEdge\": {\n \"type\": \"object\",\n \"properties\": {\n \"node\": {\n \"$ref\": \"#/definitions/MigrationOperationResultNode\"\n }\n },\n \"required\": [\n \"node\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultNode\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopId\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"enum\": [\n \"SCHEDULED\",\n \"CANCELED\",\n \"INVALID_PLAN\",\n \"INELIGIBLE\",\n \"BLOCKED\",\n \"ALREADY_SCHEDULED\",\n \"ALREADY_MIGRATED\",\n \"NOT_FOUND\",\n \"INTERNAL_ERROR\"\n ]\n }\n },\n \"required\": [\n \"shopId\",\n \"code\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Checks app subscription migration operation status.\n\nRepeat `--id` for every operation GID returned by a multi-batch submission. With `--watch`, the command displays the current state while polling and outputs the final state after all requested operations reach a terminal status.\n\n`RUNNING` means an operation is still processing. `COMPLETED` means processing finished, but you must inspect the per-shop results to confirm each outcome. `FAILED` means the operation failed, and `CANCELED` means cancellation stopped further processing.\n\nUse `--json` to output every operation and its per-shop results as structured JSON.\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 %> --id ", 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 2e0fe325a69..5e8f0c241c8 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -26,7 +26,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/status.ts', 'packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts', 'packages/app/src/cli/commands/app/webhook/trigger.ts', 'packages/cli/src/cli/commands/auth/login.ts', From bf61b086886187ab29e1700a012cd64c853e0f99 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 13:56:38 +0200 Subject: [PATCH 2/3] Normalize migration status JSON resources --- .changeset/normalize-migration-status-json.md | 5 ++ .../result-presenter-output.test.ts | 26 +++++-- .../app/subscription-migrations/status.ts | 2 +- .../command-output.test.ts | 34 ++++++--- .../subscription-migrations/command-output.ts | 3 +- packages/cli/README.md | 75 +++++++++++++++++++ packages/cli/oclif.manifest.json | 2 +- 7 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 .changeset/normalize-migration-status-json.md diff --git a/.changeset/normalize-migration-status-json.md b/.changeset/normalize-migration-status-json.md new file mode 100644 index 00000000000..8960b227897 --- /dev/null +++ b/.changeset/normalize-migration-status-json.md @@ -0,0 +1,5 @@ +--- +"@shopify/cli": major +--- + +Normalize migration status JSON with gid and shopGid identifiers and flat per-shop results. diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts index 1aa1f040152..b237653509b 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts @@ -1,12 +1,15 @@ import {presentMigrationCancellationResult, presentMigrationSubmissionResult} from './result-presenter.js' -import {projectMigrationSubmissionResult} from '../../../services/subscription-migrations/result-codec.js' -import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' +import { + projectMigrationOperation, + projectMigrationSubmissionResult, +} from '../../../services/subscription-migrations/result-codec.js' import {outputOperations} from '../../../services/subscription-migrations/command-output.js' import {watchMigrationOperations} from '../../../services/subscription-migrations/watch-operations.js' import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' import {beforeEach, describe, expect, test, vi} from 'vitest' // eslint-disable-next-line n/prefer-global/console import {Console} from 'node:console' +import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' const isUnitTest = vi.hoisted(() => vi.fn(() => false)) @@ -124,7 +127,12 @@ describe('migration status JSON output', () => { const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) // Use Node's console so Vitest's console capture does not bypass stderr. const warn = vi.spyOn(console, 'warn').mockImplementation(new Console(process.stdout, process.stderr).warn) - const running = {id: 'operation-one', status: 'RUNNING' as const, total: 1, results: {edges: []}} + const running = { + id: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one', + status: 'RUNNING' as const, + total: 1, + results: {edges: []}, + } const completed = {...running, status: 'COMPLETED' as const} try { @@ -143,7 +151,9 @@ describe('migration status JSON output', () => { }) expect(stdout).toHaveBeenCalledOnce() - expect(stdout.mock.calls[0]?.[0]).toBe(`${JSON.stringify({operations: [completed]}, null, 2)}\n`) + expect(stdout.mock.calls[0]?.[0]).toBe( + `${JSON.stringify({operations: [projectMigrationOperation(completed)]}, null, 2)}\n`, + ) const events = stderr.mock.calls.map(([content]) => JSON.parse(content as string)) expect(events).toEqual([ expect.objectContaining({ @@ -151,11 +161,15 @@ describe('migration status JSON output', () => { status: 'started', message: 'Polling subscription migration operations', }), - expect.objectContaining({type: 'progress', status: 'updated', message: 'operation-one: RUNNING (0/1 settled)'}), expect.objectContaining({ type: 'progress', status: 'updated', - message: 'operation-one: COMPLETED (0/1 settled)', + message: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one: RUNNING (0/1 settled)', + }), + expect.objectContaining({ + type: 'progress', + status: 'updated', + message: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one: COMPLETED (0/1 settled)', }), expect.objectContaining({type: 'progress', status: 'completed'}), ]) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/status.ts b/packages/app/src/cli/commands/app/subscription-migrations/status.ts index c7d7f91a778..8f271e5009b 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/status.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/status.ts @@ -20,7 +20,7 @@ Use \`--json\` to output every operation and its per-shop results as structured 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 %> --id ', diff --git a/packages/app/src/cli/services/subscription-migrations/command-output.test.ts b/packages/app/src/cli/services/subscription-migrations/command-output.test.ts index f4513636ca3..c6d607794ce 100644 --- a/packages/app/src/cli/services/subscription-migrations/command-output.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/command-output.test.ts @@ -1,3 +1,4 @@ +import {projectMigrationOperation} from './result-codec.js' import {migrationStatusJsonOutputSchema} from './types.js' import {formatMigrationOperationsStatus, outputOperations} from './command-output.js' import {outputResult} from '@shopify/cli-kit/node/output' @@ -13,7 +14,7 @@ vi.mock('@shopify/cli-kit/node/ui') function operation(id: string, status: MigrationOperation['status'] = 'RUNNING'): MigrationOperation { return { - id, + id: `gid://shopify/AppSubscriptionMigrationOperation/${id}`, status, total: 2, results: {edges: [{node: {shopId: 'gid://shopify/Shop/1', code: 'SCHEDULED'}}]}, @@ -30,7 +31,7 @@ describe('operation command output', () => { const operations = [operation('one', 'COMPLETED'), operation('two', 'RUNNING')] expect(formatMigrationOperationsStatus(operations)).toBe( - 'one: COMPLETED (1/2 settled) · two: RUNNING (1/2 settled)', + 'gid://shopify/AppSubscriptionMigrationOperation/one: COMPLETED (1/2 settled) · gid://shopify/AppSubscriptionMigrationOperation/two: RUNNING (1/2 settled)', ) }) @@ -40,10 +41,12 @@ describe('operation command output', () => { outputOperations(operations, true) expect(outputResult).toHaveBeenCalledOnce() - expect(outputResult).toHaveBeenCalledWith(JSON.stringify({operations}, null, 2)) + expect(outputResult).toHaveBeenCalledWith( + JSON.stringify({operations: operations.map(projectMigrationOperation)}, null, 2), + ) const jsonDocument = vi.mocked(outputResult).mock.calls[0]?.[0] if (typeof jsonDocument !== 'string') throw new Error('Expected operations output to be one JSON document') - expect(JSON.parse(jsonDocument)).toEqual({operations}) + expect(JSON.parse(jsonDocument)).toEqual({operations: operations.map(projectMigrationOperation)}) expect(renderInfo).not.toHaveBeenCalled() }) @@ -54,7 +57,10 @@ describe('operation command output', () => { expect(renderInfo).toHaveBeenCalledWith({ headline: 'Subscription migration operations.', - body: ['one: COMPLETED (1/2 settled)', 'two: RUNNING (1/2 settled)'], + body: [ + 'gid://shopify/AppSubscriptionMigrationOperation/one: COMPLETED (1/2 settled)', + 'gid://shopify/AppSubscriptionMigrationOperation/two: RUNNING (1/2 settled)', + ], }) expect(outputResult).not.toHaveBeenCalled() }) @@ -66,11 +72,21 @@ describe('migration status JSON contract', () => { expect(outputResult).toHaveBeenCalledWith(JSON.stringify({operations: []}, null, 2)) }) + test('accepts future upstream resource statuses and result codes', () => { + const value = { + ...projectMigrationOperation(operation('one')), + status: 'QUEUED', + results: [{shopGid: 'gid://shopify/Shop/1', code: 'FUTURE_CODE'}], + } + expect(migrationStatusJsonOutputSchema.validate({operations: [value]})).toEqual({operations: [value]}) + }) + test.each([ - {...operation('one'), status: 'UNKNOWN'}, - {...operation('one'), total: '2'}, - {...operation('one'), results: {edges: [{node: {shopId: 'shop-one', code: 'UNKNOWN'}}]}}, - {...operation('one'), results: null}, + {...projectMigrationOperation(operation('one')), total: '2'}, + {...projectMigrationOperation(operation('one')), total: -1}, + {...projectMigrationOperation(operation('one')), unexpected: true}, + {...projectMigrationOperation(operation('one')), results: [{shopGid: 'shop-one', code: 'SCHEDULED'}]}, + {...projectMigrationOperation(operation('one')), results: null}, ])('rejects invalid operations: %j', (value) => { expect(() => migrationStatusJsonOutputSchema.validate({operations: [value]})).toThrow() }) diff --git a/packages/app/src/cli/services/subscription-migrations/command-output.ts b/packages/app/src/cli/services/subscription-migrations/command-output.ts index 766431a991e..0d6b9c9fee6 100644 --- a/packages/app/src/cli/services/subscription-migrations/command-output.ts +++ b/packages/app/src/cli/services/subscription-migrations/command-output.ts @@ -1,3 +1,4 @@ +import {projectMigrationOperation} from './result-codec.js' import {migrationStatusJsonOutputSchema} from './types.js' import {outputResult} from '@shopify/cli-kit/node/output' import {renderInfo} from '@shopify/cli-kit/node/ui' @@ -9,7 +10,7 @@ export function formatMigrationOperationsStatus(operations: MigrationOperation[] export function outputOperations(operations: MigrationOperation[], json: boolean): void { if (json) { - outputResult(migrationStatusJsonOutputSchema.encode({operations})) + outputResult(migrationStatusJsonOutputSchema.encode({operations: operations.map(projectMigrationOperation)})) return } diff --git a/packages/cli/README.md b/packages/cli/README.md index bf61190b2dc..63f41b00cc6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4042,6 +4042,81 @@ 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 `MigrationStatusResult` schema. + + ```json + { + "type": "object", + "properties": { + "operations": { + "type": "array", + "items": { + "$ref": "#/definitions/MigrationOperation" + } + } + }, + "required": [ + "operations" + ], + "additionalProperties": false, + "title": "MigrationStatusResult", + "definitions": { + "MigrationOperation": { + "type": "object", + "properties": { + "gid": { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/AppSubscriptionMigrationOperation\\/[^/]+$", + "description": "The Shopify AppSubscriptionMigrationOperation GID." + }, + "status": { + "type": "string", + "minLength": 1, + "description": "Upstream status: RUNNING, COMPLETED, FAILED, or CANCELED." + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "shopGid": { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/Shop\\/\\d+$", + "description": "The Shopify Shop GID." + }, + "code": { + "type": "string", + "minLength": 1, + "description": "The upstream per-shop migration result code." + } + }, + "required": [ + "shopGid", + "code" + ], + "additionalProperties": false + } + } + }, + "required": [ + "gid", + "status", + "total", + "results" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` + EXAMPLES $ shopify app subscription-migrations status --id diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 03a29ad3f00..e5b43442dc9 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5019,7 +5019,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Checks app subscription migration operation status.\n\nRepeat `--id` for every operation GID returned by a multi-batch submission. With `--watch`, the command displays the current state while polling and outputs the final state after all requested operations reach a terminal status.\n\n`RUNNING` means an operation is still processing. `COMPLETED` means processing finished, but you must inspect the per-shop results to confirm each outcome. `FAILED` means the operation failed, and `CANCELED` means cancellation stopped further processing.\n\nUse `--json` to output every operation and its per-shop results as structured JSON.\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 `MigrationStatusResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n }\n },\n \"required\": [\n \"operations\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationStatusResult\",\n \"definitions\": {\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"RUNNING\",\n \"COMPLETED\",\n \"FAILED\",\n \"CANCELED\"\n ]\n },\n \"total\": {\n \"type\": \"number\"\n },\n \"results\": {\n \"type\": \"object\",\n \"properties\": {\n \"edges\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperationResultEdge\"\n }\n }\n },\n \"required\": [\n \"edges\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"id\",\n \"status\",\n \"total\",\n \"results\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultEdge\": {\n \"type\": \"object\",\n \"properties\": {\n \"node\": {\n \"$ref\": \"#/definitions/MigrationOperationResultNode\"\n }\n },\n \"required\": [\n \"node\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultNode\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopId\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"enum\": [\n \"SCHEDULED\",\n \"CANCELED\",\n \"INVALID_PLAN\",\n \"INELIGIBLE\",\n \"BLOCKED\",\n \"ALREADY_SCHEDULED\",\n \"ALREADY_MIGRATED\",\n \"NOT_FOUND\",\n \"INTERNAL_ERROR\"\n ]\n }\n },\n \"required\": [\n \"shopId\",\n \"code\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Checks app subscription migration operation status.\n\nRepeat `--id` for every operation GID returned by a multi-batch submission. With `--watch`, the command displays the current state while polling and outputs the final state after all requested operations reach a terminal status.\n\n`RUNNING` means an operation is still processing. `COMPLETED` means processing finished, but you must inspect the per-shop results to confirm each outcome. `FAILED` means the operation failed, and `CANCELED` means cancellation stopped further processing.\n\nUse `--json` to output every operation and its per-shop results as structured JSON.\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 `MigrationStatusResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n }\n },\n \"required\": [\n \"operations\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationStatusResult\",\n \"definitions\": {\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/AppSubscriptionMigrationOperation\\\\/[^/]+$\",\n \"description\": \"The Shopify AppSubscriptionMigrationOperation GID.\"\n },\n \"status\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"Upstream status: RUNNING, COMPLETED, FAILED, or CANCELED.\"\n },\n \"total\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"results\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopGid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/Shop\\\\/\\\\d+$\",\n \"description\": \"The Shopify Shop GID.\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The upstream per-shop migration result code.\"\n }\n },\n \"required\": [\n \"shopGid\",\n \"code\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"gid\",\n \"status\",\n \"total\",\n \"results\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Checks app subscription migration operation status.\n\nRepeat `--id` for every operation GID returned by a multi-batch submission. With `--watch`, the command displays the current state while polling and outputs the final state after all requested operations reach a terminal status.\n\n`RUNNING` means an operation is still processing. `COMPLETED` means processing finished, but you must inspect the per-shop results to confirm each outcome. `FAILED` means the operation failed, and `CANCELED` means cancellation stopped further processing.\n\nUse `--json` to output every operation and its per-shop results as structured JSON.\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 %> --id ", From d6e4ef8e9c342372076c55d01712f094b100f809 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 13:59:51 +0200 Subject: [PATCH 3/3] Type operation projections with the public status contract --- .../src/cli/services/subscription-migrations/result-codec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 737c1c28306..02ff1f45ade 100644 --- a/packages/app/src/cli/services/subscription-migrations/result-codec.ts +++ b/packages/app/src/cli/services/subscription-migrations/result-codec.ts @@ -1,8 +1,8 @@ -import type {MigrationSubmissionResult, MigrationSubmissionJsonOutput} from './types.js' +import type {MigrationSubmissionResult, MigrationSubmissionJsonOutput, MigrationStatusResult} from './types.js' import type {MigratableSubscription, MigrationOperation} from '../../models/subscription-migrations.js' import type {MigrationUserError} from './partners-api.js' -export function projectMigrationOperation(operation: MigrationOperation) { +export function projectMigrationOperation(operation: MigrationOperation): MigrationStatusResult['operations'][number] { return { gid: operation.id, status: operation.status,