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/commands.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts index 9b2cd0ffef5..9d99f3a8b0b 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 cde55350e35..9a8d98fd76f 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,8 +1,16 @@ import {presentMigrationCancellationResult, presentMigrationSubmissionResult} from './result-presenter.js' import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js' -import {projectMigrationSubmissionResult} from '../../../services/subscription-migrations/result-codec.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 {AbortError} from '@shopify/cli-kit/node/error' +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)) @@ -181,3 +189,63 @@ 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: 'gid://shopify/AppSubscriptionMigrationOperation/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: [projectMigrationOperation(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: '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'}), + ]) + } 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..8f271e5009b 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.' @@ -18,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 ', @@ -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..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,5 @@ +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' import {renderInfo} from '@shopify/cli-kit/node/ui' @@ -12,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'}}]}, @@ -29,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)', ) }) @@ -39,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() }) @@ -53,8 +57,37 @@ 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() }) }) + +describe('migration status JSON contract', () => { + test('encodes an empty operation list', () => { + outputOperations([], true) + 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([ + {...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 61a36433d89..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,5 @@ +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' import type {MigrationOperation} from '../../models/subscription-migrations.js' @@ -8,7 +10,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: operations.map(projectMigrationOperation)})) return } 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, diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index 05bc2637f01..daebbdbd7c3 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -210,3 +210,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/README.md b/packages/cli/README.md index 33b618ec7b3..84357043aaf 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -5159,6 +5159,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 2b45512588d..af0e5dc39e3 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5015,7 +5015,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\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 ", 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 37e5aabc044..974072c6df4 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/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/upgrade.ts',