diff --git a/.changeset/normalize-subscription-migration-json.md b/.changeset/normalize-subscription-migration-json.md new file mode 100644 index 00000000000..9e0982fb8f2 --- /dev/null +++ b/.changeset/normalize-subscription-migration-json.md @@ -0,0 +1,6 @@ +--- +"@shopify/cli": major +"@shopify/cli-kit": minor +--- + +Normalize migration cancellation JSON and share error serialization to preserve completed batch outcomes, error classifications, and recovery guidance. diff --git a/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts b/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts index 6279cce7c24..45fdbb47823 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts @@ -2,7 +2,9 @@ import {operationFlags} from './flags.js' import {presentMigrationCancellationResult} from './result-presenter.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 AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' +import {jsonFlag} from '@shopify/cli-kit/node/cli' export default class Cancel extends AppLinkedCommand { static summary = 'Cancels app subscription migration operations.' @@ -15,7 +17,7 @@ Repeat \`--id\` to cancel every operation GID returned by a multi-batch submissi 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 ', @@ -23,7 +25,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 = {...operationFlags} + static flags = {...operationFlags, ...jsonFlag} + + static get jsonOutputSchema() { + return migrationCancellationJsonOutputSchema + } async run(): Promise { const {flags} = await this.parse(Cancel) 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 93109ff7059..fb36db9dc57 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,6 +8,7 @@ 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 {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' @@ -18,11 +19,14 @@ import {outputResult} from '@shopify/cli-kit/node/output' import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import type {MigrationOperation} from '../../../models/subscription-migrations.js' -import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js' +import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js' vi.mock('../../../services/app-context.js') -vi.mock('../../../services/subscription-migrations/cancel-operations.js') +vi.mock('../../../services/subscription-migrations/cancel-operations.js', async (importOriginal) => ({ + ...(await importOriginal()), + cancelMigrationOperations: vi.fn(), +})) vi.mock('../../../services/subscription-migrations/command-output.js') vi.mock('../../../services/subscription-migrations/get-operations.js') vi.mock('../../../services/subscription-migrations/run-submission-command.js') @@ -109,10 +113,9 @@ describe('subscription migration submission commands', () => { watch: true, }) expect(outputResult).toHaveBeenCalledOnce() - expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ - schemaVersion: 1, - ...successfulSubmissionResult.submission, - }) + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual( + successfulSubmissionResult.submission, + ) expect(result).toEqual({app}) }) @@ -192,7 +195,6 @@ describe('subscription migration submission commands', () => { expect(process.exitCode).toBe(1) expect(outputResult).toHaveBeenCalledOnce() expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ - schemaVersion: 1, ...failedResult.submission, failure: failedResult.failure, }) @@ -326,8 +328,29 @@ describe('subscription migration operation commands', () => { }) expect(outputResult).toHaveBeenCalledOnce() expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ - schemaVersion: 1, - outcomes: result.outcomes, + status: 'partial', + operations: result.outcomes.map((outcome) => ({ + status: outcome.status, + operationGid: outcome.operationId, + operation: + outcome.operation === null + ? null + : { + gid: outcome.operation.id, + status: outcome.operation.status, + total: outcome.operation.total, + results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), + }, + ...(outcome.status === 'failed' && 'userErrors' in outcome + ? { + error: { + type: 'abort', + message: outcome.userErrors.map(({message}) => message).join('; '), + details: {userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field}))}, + }, + } + : {}), + })), }) expect(process.exitCode).toBe(1) }) @@ -430,6 +453,10 @@ describe('subscription migration command metadata', () => { expect(Command.flags.json).toBe(jsonFlag.json) }) + test('cancel exposes its JSON output schema', () => { + expect(Cancel.jsonOutputSchema).toBe(migrationCancellationJsonOutputSchema) + }) + test.each([Schedule, Unschedule, Status, Cancel, List])('$name has no legacy migration flags', (Command) => { expect(Object.keys(Command.flags)).not.toEqual( expect.arrayContaining(['yes', 'operation', 'operation-id', 'run', 'run-id', 'idempotency-key']), @@ -549,10 +576,15 @@ describe('subscription migration command metadata', () => { }, ) - test.each([Schedule, Unschedule, Status, Cancel, List])( + test.each([Schedule, Unschedule, Status, List])( '$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') + expect(Cancel.description).toContain('```json') + }) }) 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 379e099e8a4..7e6365ad760 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 @@ -249,7 +249,7 @@ describe('subscription migration list command output integration', () => { expect(outputResult).not.toHaveBeenCalled() }) - test('writes exactly one complete versioned JSON document after every page succeeds', async () => { + test('writes exactly one complete JSON document after every page succeeds', async () => { const pageOne = [subscription('gid://shopify/Shop/1'), subscription('gid://shopify/Shop/2')] const pageTwo = [subscription('gid://shopify/Shop/3')] vi.mocked(getMigratableSubscriptionPage) @@ -273,17 +273,17 @@ describe('subscription migration list command output integration', () => { expect(jsonWrite).toBeGreaterThan(lastPageRequest) const output = stdoutWrites()[0]! - expect(output).toBe(JSON.stringify({schemaVersion: 1, subscriptions: [...pageOne, ...pageTwo]}, null, 2)) - expect(JSON.parse(output)).toEqual({schemaVersion: 1, subscriptions: [...pageOne, ...pageTwo]}) + expect(output).toBe(JSON.stringify({subscriptions: [...pageOne, ...pageTwo]}, null, 2)) + expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo]}) }) - test('writes an empty versioned JSON document when there are no subscriptions', async () => { + test('writes an empty JSON document when there are no subscriptions', async () => { vi.mocked(getMigratableSubscriptionPage).mockResolvedValue(page([])) await List.run(['--json']) expect(outputResult).toHaveBeenCalledOnce() - expect(JSON.parse(stdoutWrites()[0]!)).toEqual({schemaVersion: 1, subscriptions: []}) + expect(JSON.parse(stdoutWrites()[0]!)).toEqual({subscriptions: []}) }) test('writes no JSON at all when a later page fails', async () => { 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 63cf6581c5e..a1a650e46e5 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.ts @@ -13,7 +13,7 @@ export default class List extends AppLinkedCommand { static descriptionWithMarkdown = `Lists every app subscription eligible for migration. -By 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\`. +By 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\`. Use \`--status\` to filter subscriptions by migration status. Supported values are \`UNSCHEDULED\`, \`SCHEDULED\`, and \`MIGRATED\`. diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts index 5dabf541ab9..930147aeb86 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts @@ -1,7 +1,8 @@ import {encodeMigrationCancellationResult, encodeMigrationSubmissionResult} from './result-codec.js' +import {migrationCancellationJsonOutputSchema} from '../../../services/subscription-migrations/types.js' import {describe, expect, test} from 'vitest' import type {MigrationOperation} from '../../../models/subscription-migrations.js' -import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js' +import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' import type { MigrationSubmission, MigrationSubmissionResult, @@ -21,7 +22,7 @@ function submission(): MigrationSubmission { { batchIndex: 0, batchPayloadDigest: 'batch-digest', - operation: operation('operation-one'), + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/operation-one'), }, ], } @@ -34,8 +35,8 @@ describe('subscription migration result codecs', () => { const document = encodeMigrationSubmissionResult(result) - expect(JSON.parse(document)).toEqual({schemaVersion: 1, ...value}) - expect(document).toBe(JSON.stringify({schemaVersion: 1, ...value}, null, 2)) + expect(JSON.parse(document)).toEqual(value) + expect(document).toBe(JSON.stringify(value, null, 2)) expect(document).not.toContain('idempotencyKey') }) @@ -54,7 +55,6 @@ describe('subscription migration result codecs', () => { const document = encodeMigrationSubmissionResult(result) expect(JSON.parse(document)).toEqual({ - schemaVersion: 1, ...value, failure: { type: 'submission', @@ -70,22 +70,23 @@ describe('subscription migration result codecs', () => { const result: MigrationSubmissionResult = { status: 'failed', submission: value, - failure: {type: 'operations', operationIds: ['operation-one']}, + failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, } const document = encodeMigrationSubmissionResult(result) expect(JSON.parse(document)).toEqual({ - schemaVersion: 1, ...value, - failure: {type: 'operations', operationIds: ['operation-one']}, + failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, }) expect(document).toBe( JSON.stringify( { - schemaVersion: 1, ...value, - failure: {type: 'operations', operationIds: ['operation-one']}, + failure: { + type: 'operations', + operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one'], + }, }, null, 2, @@ -93,19 +94,61 @@ describe('subscription migration result codecs', () => { ) }) + test('rejects cancellation documents with an invalid outcome', () => { + expect(() => + migrationCancellationJsonOutputSchema.validate({ + status: 'success', + operations: [ + {status: 'success', operationGid: 'gid://shopify/AppSubscriptionMigrationOperation/one', operation: null}, + ], + }), + ).toThrow() + }) + + test('rejects extra resource fields, invalid GIDs, and negative counts', () => { + const operationResult = { + gid: 'gid://shopify/AppSubscriptionMigrationOperation/1', + status: 'RUNNING', + total: 0, + results: [], + } + for (const invalid of [ + {...operationResult, accidental: true}, + {...operationResult, gid: '1'}, + {...operationResult, total: -1}, + {...operationResult, total: 0.5}, + {...operationResult, results: [{shopGid: '1', code: 'SCHEDULED'}]}, + ]) { + expect(() => + migrationCancellationJsonOutputSchema.validate({ + status: 'success', + operations: [{status: 'success', operationGid: operationResult.gid, operation: invalid}], + }), + ).toThrow() + } + }) + + test('encodes an empty cancellation collection as an object', () => { + expect(JSON.parse(encodeMigrationCancellationResult({outcomes: []}))).toEqual({status: 'success', operations: []}) + }) + test('encodes every cancellation outcome in one JSON document', () => { const result: MigrationCancellationResult = { outcomes: [ - {status: 'success', operationId: 'one', operation: operation('one')}, + { + status: 'success', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/one'), + }, { status: 'failed', - operationId: 'two', - operation: operation('two'), + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/two', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/two'), userErrors: [{message: 'Already completed', field: ['id']}], }, { status: 'failed', - operationId: 'three', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/three', operation: null, userErrors: [{message: 'Operation not found', field: null}], }, @@ -114,7 +157,61 @@ describe('subscription migration result codecs', () => { const document = encodeMigrationCancellationResult(result) - expect(JSON.parse(document)).toEqual({schemaVersion: 1, outcomes: result.outcomes}) - expect(document).toBe(JSON.stringify({schemaVersion: 1, outcomes: result.outcomes}, null, 2)) + expect(JSON.parse(document)).toEqual({ + status: 'partial', + operations: result.outcomes.map((outcome) => ({ + status: outcome.status, + operationGid: outcome.operationId, + operation: + outcome.operation === null + ? null + : { + gid: outcome.operation.id, + status: outcome.operation.status, + total: outcome.operation.total, + results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), + }, + ...(outcome.status === 'failed' && 'userErrors' in outcome + ? { + error: { + type: 'abort', + message: outcome.userErrors.map(({message}) => message).join('; '), + details: {userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field}))}, + }, + } + : {}), + })), + }) + expect(document).toBe( + JSON.stringify( + { + status: 'partial', + operations: result.outcomes.map((outcome) => ({ + status: outcome.status, + operationGid: outcome.operationId, + operation: + outcome.operation === null + ? null + : { + gid: outcome.operation.id, + status: outcome.operation.status, + total: outcome.operation.total, + results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), + }, + ...(outcome.status === 'failed' && 'userErrors' in outcome + ? { + error: { + type: 'abort', + message: outcome.userErrors.map(({message}) => message).join('; '), + details: {userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field}))}, + }, + } + : {}), + })), + }, + null, + 2, + ), + ) }) }) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts index bdcf740ee81..530539e7798 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts @@ -1,12 +1,19 @@ -import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js' +import { + migrationCancellationJsonOutputSchema, + type MigrationCancellationResult, +} from '../../../services/subscription-migrations/types.js' +import { + projectMigrationOperation, + projectMigrationUserErrors, +} from '../../../services/subscription-migrations/result-codec.js' +import {errorToJson} from '@shopify/cli-kit/node/error/serialization' import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js' export function encodeMigrationSubmissionResult(result: MigrationSubmissionResult): string { const document = result.status === 'success' - ? {schemaVersion: 1, ...result.submission} + ? result.submission : { - schemaVersion: 1, ...result.submission, failure: result.failure, } @@ -14,5 +21,28 @@ export function encodeMigrationSubmissionResult(result: MigrationSubmissionResul } export function encodeMigrationCancellationResult(result: MigrationCancellationResult): string { - return JSON.stringify({schemaVersion: 1, outcomes: result.outcomes}, null, 2) + return migrationCancellationJsonOutputSchema.encode({ + status: result.outcomes.some(({status}) => status === 'failed') ? 'partial' : 'success', + operations: result.outcomes.map((outcome) => + outcome.status === 'success' + ? { + status: outcome.status, + operationGid: outcome.operationId, + operation: projectMigrationOperation(outcome.operation), + } + : { + status: outcome.status, + operationGid: outcome.operationId, + operation: outcome.operation === null ? null : projectMigrationOperation(outcome.operation), + error: + 'error' in outcome + ? errorToJson(outcome.error) + : { + type: 'abort' as const, + message: outcome.userErrors.map(({message}) => message).join('; '), + details: {userErrors: projectMigrationUserErrors(outcome.userErrors)}, + }, + }, + ), + }) } 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 new file mode 100644 index 00000000000..e271bd26d3b --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts @@ -0,0 +1,143 @@ +import {presentMigrationCancellationResult} from './result-presenter.js' +import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' + +const isUnitTest = vi.hoisted(() => vi.fn(() => false)) + +vi.mock('@shopify/cli-kit/node/context/local', async (importOriginal) => ({ + ...(await importOriginal()), + isUnitTest, +})) + +beforeEach(() => { + isUnitTest.mockReturnValue(false) +}) + +describe('migration cancellation JSON output', () => { + test('writes one parseable JSON document to stdout without stderr output', () => { + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const result: MigrationCancellationResult = { + outcomes: [ + { + status: 'failed' as const, + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one', + operation: null, + userErrors: [{message: 'Already completed', field: ['id']}], + }, + { + status: 'failed', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/operation-two', + operation: null, + userErrors: [{message: 'Operation not found', field: null}], + }, + ], + } + + try { + expect(presentMigrationCancellationResult(result, {json: true})).toBe(1) + + expect(stdout).toHaveBeenCalledOnce() + const output = stdout.mock.calls[0]?.[0] + expect(typeof output).toBe('string') + expect(JSON.parse(output as string)).toEqual({ + status: 'partial', + operations: result.outcomes.map((outcome) => ({ + status: outcome.status, + operationGid: outcome.operationId, + operation: + outcome.operation === null + ? null + : { + gid: outcome.operation.id, + status: outcome.operation.status, + total: outcome.operation.total, + results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), + }, + ...(outcome.status === 'failed' && 'userErrors' in outcome + ? { + error: { + type: 'abort', + message: outcome.userErrors.map(({message}) => message).join('; '), + details: {userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field}))}, + }, + } + : {}), + })), + }) + expect(stderr).not.toHaveBeenCalled() + } finally { + stdout.mockRestore() + stderr.mockRestore() + } + }) + + test.each(['empty payload', 'HTTP 403'])( + 'retains every batch outcome and the shared error for %s', + async (failure) => { + const successfulId = 'gid://shopify/AppSubscriptionMigrationOperation/one' + const failedId = 'gid://shopify/AppSubscriptionMigrationOperation/two' + const forbiddenError = Object.assign( + new AbortError( + 'Forbidden', + 'Ensure you are using the correct account. You can switch with `shopify auth login`', + ), + { + request: {authorization: 'secret'}, + accessToken: 'secret', + }, + ) + const cancelOperation = vi.fn().mockResolvedValueOnce({ + operation: {id: successfulId, status: 'CANCELED', total: 1, results: {edges: []}}, + userErrors: [], + }) + if (failure === 'empty payload') { + cancelOperation.mockResolvedValueOnce({operation: null, userErrors: []}) + } else { + cancelOperation.mockRejectedValueOnce(forbiddenError) + } + const result = await cancelMigrationOperations({ + clientId: 'client-id', + operationIds: [successfulId, failedId], + cancelOperation, + }) + const expectedError = + failure === 'empty payload' + ? { + type: 'bug', + message: `Migration cancellation for ${failedId} returned neither an operation nor user errors`, + stack: expect.any(String), + } + : { + type: 'abort', + message: 'Forbidden', + tryMessage: 'Ensure you are using the correct account. You can switch with `shopify auth login`', + } + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + try { + expect(presentMigrationCancellationResult(result, {json: true})).toBe(1) + + expect(stdout).toHaveBeenCalledOnce() + expect(JSON.parse(stdout.mock.calls[0]![0] as string)).toEqual({ + status: 'partial', + operations: [ + { + status: 'success', + operationGid: successfulId, + operation: {gid: successfulId, status: 'CANCELED', total: 1, results: []}, + }, + {status: 'failed', operationGid: failedId, operation: null, error: expectedError}, + ], + }) + expect(stderr).not.toHaveBeenCalled() + } finally { + stdout.mockRestore() + stderr.mockRestore() + } + }, + ) +}) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts index 441835c623d..1822260464f 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts @@ -7,7 +7,7 @@ import {outputResult} from '@shopify/cli-kit/node/output' import {renderInfo, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigrationOperation} from '../../../models/subscription-migrations.js' -import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js' +import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' import type { MigrationSubmission, MigrationSubmissionResult, @@ -33,7 +33,7 @@ function submission(): MigrationSubmission { { batchIndex: 0, batchPayloadDigest: 'batch-digest', - operation: operation('operation-one'), + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/operation-one'), }, ], } @@ -55,18 +55,21 @@ describe('migration submission result presenter', () => { expect(exitCode).toBe(0) expect(outputResult).toHaveBeenCalledOnce() - expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({schemaVersion: 1, ...value}) + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual(value) expect(renderSuccess).not.toHaveBeenCalled() expect(renderWarning).not.toHaveBeenCalled() }) test('outputs exactly one operation failure JSON document without invoking a fatal renderer', () => { const value = submission() - value.operations[0]!.operation = operation('operation-one', 'FAILED') + value.operations[0]!.operation = operation( + 'gid://shopify/AppSubscriptionMigrationOperation/operation-one', + 'FAILED', + ) const result: MigrationSubmissionResult = { status: 'failed', submission: value, - failure: {type: 'operations', operationIds: ['operation-one']}, + failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, } const exitCode = presentMigrationSubmissionResult(result, {json: true, watch: true}) @@ -74,9 +77,8 @@ describe('migration submission result presenter', () => { expect(exitCode).toBe(1) expect(outputResult).toHaveBeenCalledOnce() expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ - schemaVersion: 1, ...value, - failure: {type: 'operations', operationIds: ['operation-one']}, + failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, }) expect(renderSuccess).not.toHaveBeenCalled() expect(renderWarning).not.toHaveBeenCalled() @@ -92,13 +94,16 @@ describe('migration submission result presenter', () => { const rendered = JSON.stringify(vi.mocked(renderSuccess).mock.calls[0]?.[0]) expect(rendered).toContain('Subscription migrations scheduled.') expect(rendered).not.toContain('idempotency') - expect(rendered).toContain('operation-one') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/operation-one') expect(outputResult).not.toHaveBeenCalled() }) test('renders terminal operations for watched human success', () => { const value = submission() - value.operations[0]!.operation = operation('operation-one', 'COMPLETED') + value.operations[0]!.operation = operation( + 'gid://shopify/AppSubscriptionMigrationOperation/operation-one', + 'COMPLETED', + ) const result: MigrationSubmissionResult = {status: 'success', submission: value} const exitCode = presentMigrationSubmissionResult(result, {json: false, watch: true}) @@ -106,7 +111,7 @@ describe('migration submission result presenter', () => { expect(exitCode).toBe(0) expect(renderInfo).toHaveBeenCalledWith({ headline: 'Subscription migration operations.', - body: ['operation-one: COMPLETED (0/2 settled)'], + body: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one: COMPLETED (0/2 settled)'], }) expect(renderSuccess).not.toHaveBeenCalled() }) @@ -116,7 +121,7 @@ describe('migration submission result presenter', () => { expect(renderSuccess).toHaveBeenCalledOnce() const rendered = JSON.stringify(vi.mocked(renderSuccess).mock.calls[0]?.[0]) - expect(rendered).toContain('operation-one') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/operation-one') expect(rendered).not.toContain('idempotency') }) @@ -125,13 +130,16 @@ describe('migration submission result presenter', () => { value.operations.push({ batchIndex: 1, batchPayloadDigest: 'batch-digest-two', - operation: operation('operation-two', 'COMPLETED'), + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/operation-two', 'COMPLETED'), }) - value.operations[0]!.operation = operation('operation-one', 'FAILED') + value.operations[0]!.operation = operation( + 'gid://shopify/AppSubscriptionMigrationOperation/operation-one', + 'FAILED', + ) const result: MigrationSubmissionResult = { status: 'failed', submission: value, - failure: {type: 'operations', operationIds: ['operation-one']}, + failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, } const exitCode = presentMigrationSubmissionResult(result, {json: false, watch: true}) @@ -141,9 +149,9 @@ describe('migration submission result presenter', () => { const rendered = JSON.stringify(vi.mocked(renderWarning).mock.calls[0]?.[0]) expect(rendered).not.toContain('idempotency') expect(rendered).toContain('Failed operation IDs') - expect(rendered).toContain('operation-one') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/operation-one') expect(rendered).toContain('FAILED') - expect(rendered).toContain('operation-two') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/operation-two') expect(rendered).toContain('COMPLETED') expect(renderSuccess).not.toHaveBeenCalled() expect(renderInfo).not.toHaveBeenCalled() @@ -155,7 +163,7 @@ describe('migration submission result presenter', () => { value.operations.push({ batchIndex: 1, batchPayloadDigest: 'batch-digest-two', - operation: operation('operation-two'), + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/operation-two'), }) const result: MigrationSubmissionResult = { status: 'failed', @@ -176,8 +184,8 @@ describe('migration submission result presenter', () => { expect(renderWarning).toHaveBeenCalledOnce() const rendered = JSON.stringify(vi.mocked(renderWarning).mock.calls[0]?.[0]) expect(rendered).not.toContain('idempotency') - expect(rendered).toContain('operation-one') - expect(rendered).toContain('operation-two') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/operation-one') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/operation-two') expect(rendered).toContain('Batch index: 2') expect(rendered).toContain('Rejected remaining shops') expect(rendered).toContain('Invalid plan') @@ -209,13 +217,33 @@ describe('migration submission result presenter', () => { }) describe('migration cancellation result presenter', () => { + test.each([false, true])('throws a fatal error for one failed cancellation with json=%s', (json) => { + const result: MigrationCancellationResult = { + outcomes: [ + { + status: 'failed', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + operation: null, + userErrors: [{message: 'Operation not found', field: ['id']}], + }, + ], + } + expect(() => presentMigrationCancellationResult(result, {json})).toThrow('Operation not found') + expect(outputResult).not.toHaveBeenCalled() + expect(renderWarning).not.toHaveBeenCalled() + }) + test('outputs exactly one JSON document and reports failure', () => { const result: MigrationCancellationResult = { outcomes: [ - {status: 'success', operationId: 'one', operation: operation('one', 'CANCELED')}, + { + status: 'success', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/one', 'CANCELED'), + }, { status: 'failed', - operationId: 'two', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/two', operation: null, userErrors: [{message: 'Already completed', field: ['id']}], }, @@ -227,8 +255,29 @@ describe('migration cancellation result presenter', () => { expect(exitCode).toBe(1) expect(outputResult).toHaveBeenCalledOnce() expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ - schemaVersion: 1, - outcomes: result.outcomes, + status: 'partial', + operations: result.outcomes.map((outcome) => ({ + status: outcome.status, + operationGid: outcome.operationId, + operation: + outcome.operation === null + ? null + : { + gid: outcome.operation.id, + status: outcome.operation.status, + total: outcome.operation.total, + results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), + }, + ...(outcome.status === 'failed' && 'userErrors' in outcome + ? { + error: { + type: 'abort', + message: outcome.userErrors.map(({message}) => message).join('; '), + details: {userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field}))}, + }, + } + : {}), + })), }) expect(renderSuccess).not.toHaveBeenCalled() expect(renderWarning).not.toHaveBeenCalled() @@ -237,11 +286,15 @@ describe('migration cancellation result presenter', () => { test('renders successful and failed cancellations together without discarding returned operations', () => { const result: MigrationCancellationResult = { outcomes: [ - {status: 'success', operationId: 'one', operation: operation('one', 'CANCELED')}, + { + status: 'success', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/one', 'CANCELED'), + }, { status: 'failed', - operationId: 'two', - operation: operation('two', 'COMPLETED'), + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/two', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/two', 'COMPLETED'), userErrors: [ {message: 'Already completed', field: ['id']}, {message: 'Cancellation denied', field: null}, @@ -255,9 +308,9 @@ describe('migration cancellation result presenter', () => { expect(exitCode).toBe(1) expect(renderWarning).toHaveBeenCalledOnce() const rendered = JSON.stringify(vi.mocked(renderWarning).mock.calls[0]?.[0]) - expect(rendered).toContain('one') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/one') expect(rendered).toContain('CANCELED') - expect(rendered).toContain('two') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/two') expect(rendered).toContain('COMPLETED') expect(rendered).toContain('Already completed') expect(rendered).toContain('Cancellation denied') @@ -266,14 +319,22 @@ describe('migration cancellation result presenter', () => { test('renders all successful cancellations and reports success', () => { const result: MigrationCancellationResult = { - outcomes: [{status: 'success', operationId: 'one', operation: operation('one', 'CANCELED')}], + outcomes: [ + { + status: 'success', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/one', 'CANCELED'), + }, + ], } const exitCode = presentMigrationCancellationResult(result, {json: false}) expect(exitCode).toBe(0) expect(renderSuccess).toHaveBeenCalledOnce() - expect(JSON.stringify(vi.mocked(renderSuccess).mock.calls[0]?.[0])).toContain('one') + expect(JSON.stringify(vi.mocked(renderSuccess).mock.calls[0]?.[0])).toContain( + 'gid://shopify/AppSubscriptionMigrationOperation/one', + ) expect(renderWarning).not.toHaveBeenCalled() }) }) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts index c0f2ea77534..d91df6614db 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts @@ -1,11 +1,14 @@ import {encodeMigrationCancellationResult, encodeMigrationSubmissionResult} from './result-codec.js' +import {projectMigrationOperation} from '../../../services/subscription-migrations/result-codec.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {errorToJson} from '@shopify/cli-kit/node/error/serialization' import {outputResult} from '@shopify/cli-kit/node/output' import {renderInfo, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import type {MigrationOperation} from '../../../models/subscription-migrations.js' import type { MigrationCancellationOutcome, MigrationCancellationResult, -} from '../../../services/subscription-migrations/cancel-operations.js' +} from '../../../services/subscription-migrations/types.js' import type { MigrationSubmission, MigrationSubmissionResult, @@ -48,6 +51,17 @@ export function presentMigrationCancellationResult( result: MigrationCancellationResult, options: CancellationPresentationOptions, ): 0 | 1 { + const outcome = result.outcomes[0] + if (result.outcomes.length === 1 && outcome?.status === 'failed') { + if ('error' in outcome) throw outcome.error + const error = new AbortError(outcome.userErrors.map(({message}) => message).join('; ')) + error.details = { + operationGid: outcome.operationId, + operation: outcome.operation === null ? null : projectMigrationOperation(outcome.operation), + userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field})), + } + throw error + } const hasFailures = result.outcomes.some(({status}) => status === 'failed') if (options.json) { @@ -129,9 +143,13 @@ function formatCancellationOutcomes(outcomes: MigrationCancellationOutcome[]): s ? [] : [ 'Failed operations:', - ...failures.map(({operationId, operation, userErrors}) => { - const returnedStatus = operation ? ` (returned status: ${operation.status})` : '' - return `${operationId}: ${userErrors.map(({message}) => message).join('; ')}${returnedStatus}` + ...failures.map((outcome) => { + const returnedStatus = outcome.operation ? ` (returned status: ${outcome.operation.status})` : '' + const message = + 'error' in outcome + ? errorToJson(outcome.error).message + : outcome.userErrors.map(({message}) => message).join('; ') + return `${outcome.operationId}: ${message}${returnedStatus}` }), ]), ] diff --git a/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts b/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts index 7e242de88a2..91cc846b865 100644 --- a/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts @@ -1,4 +1,5 @@ import {MigrationCancellationProtocolError, cancelMigrationOperations} from './cancel-operations.js' +import {AbortError} from '@shopify/cli-kit/node/error' import {describe, expect, test, vi} from 'vitest' import type {MigrationOperation} from '../../models/subscription-migrations.js' import type {MigrationOperationPayload} from './partners-api.js' @@ -12,9 +13,25 @@ function payload(id: string): MigrationOperationPayload { } describe('cancelMigrationOperations', () => { + test.each([ + ['gid://shopify/AppSubscriptionMigrationOperation/one', 'invalid-id'], + ['invalid-id', 'gid://shopify/AppSubscriptionMigrationOperation/one'], + ['gid://shopify/AppSubscriptionMigrationOperation/one', 'gid://shopify/Shop/123'], + ['gid://shopify/AppSubscriptionMigrationOperation/one', 'gid://shopify/AppSubscriptionMigrationOperation/'], + ])('rejects malformed IDs before canceling any operation: %s, %s', async (firstId, secondId) => { + const operationIds = [firstId, secondId] + const cancelOperation = vi.fn().mockResolvedValue(payload('gid://shopify/AppSubscriptionMigrationOperation/one')) + + const promise = cancelMigrationOperations({clientId: 'client-id', operationIds, cancelOperation}) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toThrow('Invalid subscription migration operation IDs:') + expect(cancelOperation).not.toHaveBeenCalled() + }) + test('returns mixed success and failure outcomes in input order', async () => { const cancelOperation = vi.fn(({operationId}: {operationId: string}) => { - if (operationId === 'failed') { + if (operationId === 'gid://shopify/AppSubscriptionMigrationOperation/failed') { return Promise.resolve({ operation: operation(operationId), userErrors: [{message: 'Already completed', field: ['id']}], @@ -25,23 +42,44 @@ describe('cancelMigrationOperations', () => { const result = await cancelMigrationOperations({ clientId: 'client-id', - operationIds: ['two', 'failed', 'one'], + operationIds: [ + 'gid://shopify/AppSubscriptionMigrationOperation/two', + 'gid://shopify/AppSubscriptionMigrationOperation/failed', + 'gid://shopify/AppSubscriptionMigrationOperation/one', + ], cancelOperation, }) - expect(cancelOperation).toHaveBeenNthCalledWith(1, {clientId: 'client-id', operationId: 'two'}) - expect(cancelOperation).toHaveBeenNthCalledWith(2, {clientId: 'client-id', operationId: 'failed'}) - expect(cancelOperation).toHaveBeenNthCalledWith(3, {clientId: 'client-id', operationId: 'one'}) + expect(cancelOperation).toHaveBeenNthCalledWith(1, { + clientId: 'client-id', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/two', + }) + expect(cancelOperation).toHaveBeenNthCalledWith(2, { + clientId: 'client-id', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/failed', + }) + expect(cancelOperation).toHaveBeenNthCalledWith(3, { + clientId: 'client-id', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + }) expect(result).toEqual({ outcomes: [ - {status: 'success', operationId: 'two', operation: operation('two')}, + { + status: 'success', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/two', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/two'), + }, { status: 'failed', - operationId: 'failed', - operation: operation('failed'), + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/failed', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/failed'), userErrors: [{message: 'Already completed', field: ['id']}], }, - {status: 'success', operationId: 'one', operation: operation('one')}, + { + status: 'success', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/one'), + }, ], }) }) @@ -55,13 +93,17 @@ describe('cancelMigrationOperations', () => { ], }) - const result = await cancelMigrationOperations({clientId: 'client-id', operationIds: ['one'], cancelOperation}) + const result = await cancelMigrationOperations({ + clientId: 'client-id', + operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/one'], + cancelOperation, + }) expect(result).toEqual({ outcomes: [ { status: 'failed', - operationId: 'one', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', operation: null, userErrors: [ {message: 'Already completed', field: ['id']}, @@ -75,20 +117,61 @@ describe('cancelMigrationOperations', () => { test('throws a protocol error for an unexplained empty payload', async () => { const cancelOperation = vi.fn().mockResolvedValue({operation: null, userErrors: []}) - const promise = cancelMigrationOperations({clientId: 'client-id', operationIds: ['missing'], cancelOperation}) + const promise = cancelMigrationOperations({ + clientId: 'client-id', + operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/missing'], + cancelOperation, + }) await expect(promise).rejects.toBeInstanceOf(MigrationCancellationProtocolError) - await expect(promise).rejects.toMatchObject({operationId: 'missing'}) + await expect(promise).rejects.toMatchObject({ + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/missing', + }) await expect(promise).rejects.toThrow( - 'Migration cancellation for missing returned neither an operation nor user errors', + 'Migration cancellation for gid://shopify/AppSubscriptionMigrationOperation/missing returned neither an operation nor user errors', ) }) + test('retains successful cancellations and every requested operation when another request fails', async () => { + const transportError = new Error('Network unavailable') + const cancelOperation = vi + .fn() + .mockResolvedValueOnce(payload('gid://shopify/AppSubscriptionMigrationOperation/one')) + .mockRejectedValueOnce(transportError) + const result = await cancelMigrationOperations({ + clientId: 'client-id', + operationIds: [ + 'gid://shopify/AppSubscriptionMigrationOperation/one', + 'gid://shopify/AppSubscriptionMigrationOperation/two', + ], + cancelOperation, + }) + expect(result).toEqual({ + outcomes: [ + { + status: 'success', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/one'), + }, + { + status: 'failed', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/two', + operation: null, + error: transportError, + }, + ], + }) + }) + test('preserves transport errors', async () => { const transportError = new Error('Network unavailable') const cancelOperation = vi.fn().mockRejectedValue(transportError) - const promise = cancelMigrationOperations({clientId: 'client-id', operationIds: ['one'], cancelOperation}) + const promise = cancelMigrationOperations({ + clientId: 'client-id', + operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/one'], + cancelOperation, + }) await expect(promise).rejects.toBe(transportError) }) diff --git a/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts b/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts index 9a5a063ac44..5ab0c3f7f21 100644 --- a/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts +++ b/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts @@ -1,18 +1,7 @@ -import {cancelMigrationOperation, type MigrationUserError} from './partners-api.js' -import type {MigrationOperation} from '../../models/subscription-migrations.js' - -export type MigrationCancellationOutcome = - | {status: 'success'; operationId: string; operation: MigrationOperation} - | { - status: 'failed' - operationId: string - operation: MigrationOperation | null - userErrors: MigrationUserError[] - } - -export interface MigrationCancellationResult { - outcomes: MigrationCancellationOutcome[] -} +import {cancelMigrationOperation} from './partners-api.js' +import {MigrationOperationGidSchema} from './types.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import type {MigrationCancellationOutcome, MigrationCancellationResult} from './types.js' export class MigrationCancellationProtocolError extends Error { readonly operationId: string @@ -35,19 +24,39 @@ export async function cancelMigrationOperations({ operationIds, cancelOperation = cancelMigrationOperation, }: CancelMigrationOperationsOptions): Promise { + const invalidOperationIds = operationIds.filter( + (operationId) => !MigrationOperationGidSchema.safeParse(operationId).success, + ) + if (invalidOperationIds.length > 0) { + throw new AbortError( + `Invalid subscription migration operation IDs: ${invalidOperationIds.join(', ')}.`, + 'Use Shopify AppSubscriptionMigrationOperation GIDs returned by a migration submission.', + ) + } const outcomes = await Promise.all( operationIds.map(async (operationId): Promise => { - const payload = await cancelOperation({clientId, operationId}) - if (payload.userErrors.length > 0) { + try { + const payload = await cancelOperation({clientId, operationId}) + if (payload.userErrors.length > 0) { + return { + status: 'failed', + operationId, + operation: payload.operation, + userErrors: payload.userErrors, + } + } + if (!payload.operation) throw new MigrationCancellationProtocolError(operationId) + return {status: 'success', operationId, operation: payload.operation} + } catch (error) { + if (operationIds.length === 1) throw error + // Preserve every batch outcome even if one request fails after another cancellation succeeds. return { status: 'failed', operationId, - operation: payload.operation, - userErrors: payload.userErrors, + operation: null, + error, } } - if (!payload.operation) throw new MigrationCancellationProtocolError(operationId) - return {status: 'success', operationId, operation: payload.operation} }), ) return {outcomes} 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 03703eec3e4..3360335bc43 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 @@ -39,10 +39,10 @@ describe('operation command output', () => { outputOperations(operations, true) expect(outputResult).toHaveBeenCalledOnce() - expect(outputResult).toHaveBeenCalledWith(JSON.stringify({schemaVersion: 1, operations}, null, 2)) + expect(outputResult).toHaveBeenCalledWith(JSON.stringify({operations}, 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({schemaVersion: 1, operations}) + expect(JSON.parse(jsonDocument)).toEqual({operations}) expect(renderInfo).not.toHaveBeenCalled() }) 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 e7338f6baaa..61a36433d89 100644 --- a/packages/app/src/cli/services/subscription-migrations/command-output.ts +++ b/packages/app/src/cli/services/subscription-migrations/command-output.ts @@ -8,7 +8,7 @@ export function formatMigrationOperationsStatus(operations: MigrationOperation[] export function outputOperations(operations: MigrationOperation[], json: boolean): void { if (json) { - outputResult(JSON.stringify({schemaVersion: 1, operations}, null, 2)) + outputResult(JSON.stringify({operations}, null, 2)) return } 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 148a52dc32f..70f4b7a9ee6 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 @@ -72,7 +72,6 @@ describe('migration list serialization', () => { test('serializes the exact pretty JSON schema without a trailing newline', () => { const subscriptions = [subscription()] const expected = `{ - "schemaVersion": 1, "subscriptions": [ { "shopId": "gid://shopify/Shop/1", @@ -238,10 +237,10 @@ 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({schemaVersion: 1, subscriptions: [...pageOne, ...pageTwo]}) + expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo]}) }) - test('writes an empty versioned JSON document for an empty result', async () => { + test('writes an empty JSON document for an empty result', async () => { await outputMigrationList({pages: pagesOf([]), json: true}) expect(outputResult).toHaveBeenCalledOnce() 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 b1def5c1997..700c6c2efd7 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-output.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-output.ts @@ -10,7 +10,7 @@ interface MigrationListOutputOptions { } export function serializeMigrationListJson(subscriptions: MigratableSubscription[]): string { - return JSON.stringify({schemaVersion: 1, subscriptions}, null, 2) + return JSON.stringify({subscriptions}, null, 2) } 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 new file mode 100644 index 00000000000..0f1a53cc777 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/result-codec.ts @@ -0,0 +1,15 @@ +import type {MigrationOperation} from '../../models/subscription-migrations.js' +import type {MigrationUserError} from './partners-api.js' + +export function projectMigrationOperation(operation: MigrationOperation) { + return { + gid: operation.id, + status: operation.status, + total: operation.total, + results: operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), + } +} + +export function projectMigrationUserErrors(errors: MigrationUserError[]) { + return errors.map(({message, field}) => ({message, fieldPath: field})) +} diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts new file mode 100644 index 00000000000..adaead18a68 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -0,0 +1,82 @@ +import {JsonAbortErrorSchema, JsonErrorSchema} from '@shopify/cli-kit/node/error/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' + +export const MigrationOperationGidSchema = zod + .string() + .regex(/^gid:\/\/shopify\/AppSubscriptionMigrationOperation\/[^/]+$/) + .describe('The Shopify AppSubscriptionMigrationOperation GID.') + +const ShopGidSchema = zod + .string() + .regex(/^gid:\/\/shopify\/Shop\/\d+$/) + .describe('The Shopify Shop GID.') + +const MigrationOperationSchema = zod + .object({ + gid: MigrationOperationGidSchema, + status: zod.string().min(1).describe('Upstream status: RUNNING, COMPLETED, FAILED, or CANCELED.'), + total: zod.number().int().nonnegative(), + results: zod.array( + zod + .object({ + shopGid: ShopGidSchema, + code: zod.string().min(1).describe('The upstream per-shop migration result code.'), + }) + .strict(), + ), + }) + .strict() + +const MigrationUserErrorSchema = zod + .object({message: zod.string(), fieldPath: zod.array(zod.string()).nullable()}) + .strict() + +const MigrationCancellationOutcomeSchema = zod.discriminatedUnion('status', [ + zod + .object({ + status: zod.literal('success'), + operationGid: MigrationOperationGidSchema, + operation: MigrationOperationSchema, + }) + .strict(), + zod + .object({ + status: zod.literal('failed'), + operationGid: MigrationOperationGidSchema, + operation: MigrationOperationSchema.nullable(), + error: zod.union([ + JsonAbortErrorSchema.extend({ + details: zod.object({userErrors: zod.array(MigrationUserErrorSchema)}).strict(), + }), + JsonErrorSchema, + ]), + }) + .strict(), +]) + +export const migrationCancellationJsonOutputSchema = defineJsonOutputSchema({ + name: 'MigrationCancellationResult', + schema: zod + .object({ + status: zod.enum(['success', 'partial']), + operations: zod.array(MigrationCancellationOutcomeSchema), + }) + .strict(), + definitions: { + MigrationCancellationOutcome: MigrationCancellationOutcomeSchema, + MigrationOperation: MigrationOperationSchema, + MigrationUserError: MigrationUserErrorSchema, + }, +}) + +export interface MigrationCancellationResult { + outcomes: MigrationCancellationOutcome[] +} + +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} diff --git a/packages/cli-kit/src/private/node/json-error.ts b/packages/cli-kit/src/private/node/json-error.ts index 3f36df64262..7777dcdf15c 100644 --- a/packages/cli-kit/src/private/node/json-error.ts +++ b/packages/cli-kit/src/private/node/json-error.ts @@ -1,133 +1,7 @@ -import {tokenItemToJsonString, type TokenItem} from './ui/components/token-item.js' import {FatalErrorType} from '../../public/node/error.js' import {jsonErrorOutputSchema} from '../../public/node/error/schema.js' -import {outputResult, unstyled} from '../../public/node/output.js' -import type { - JsonError, - JsonErrorCustomSection, - JsonErrorDocument, - JsonErrorType, -} from '../../public/node/error/types.js' - -interface FatalErrorLike { - type?: number - message?: unknown - formattedMessage?: unknown - tryMessage?: unknown - nextSteps?: unknown - customSections?: unknown - stack?: unknown - command?: unknown - args?: unknown - details?: unknown - code?: unknown -} - -interface ExternalCommand { - command: string - args: string[] -} - -function externalCommand(error: FatalErrorLike): ExternalCommand | undefined { - if (typeof error.command !== 'string' || !Array.isArray(error.args)) return - if (!error.args.every((arg) => typeof arg === 'string')) return - - return {command: error.command, args: error.args} -} - -function jsonErrorType(error: FatalErrorLike, external: ExternalCommand | undefined): JsonErrorType { - if (error.type === FatalErrorType.Abort) { - return external ? 'external' : 'abort' - } - return 'bug' -} - -function jsonTokenItem(token: unknown): string | undefined { - if (token === null || token === undefined) return - - try { - const message = tokenItemToJsonString(token as TokenItem) - return typeof message === 'string' ? unstyled(message) : undefined - } catch (error) { - if (error instanceof TypeError) return undefined - throw error - } -} - -function jsonTokenItems(items: unknown): string[] | undefined { - if (!Array.isArray(items)) return - - const renderedItems = items.map(jsonTokenItem).filter((item): item is string => item !== undefined) - return renderedItems.length > 0 ? renderedItems : undefined -} - -function jsonTable(data: unknown): string[][] | undefined { - if (!Array.isArray(data)) return - - return data - .filter((row): row is unknown[] => Array.isArray(row)) - .map((row) => row.map((cell) => jsonTokenItem(cell) ?? '')) -} - -function jsonCustomSection(section: unknown): JsonErrorCustomSection | undefined { - if (typeof section !== 'object' || section === null || !('body' in section)) return - - const title = 'title' in section && typeof section.title === 'string' ? unstyled(section.title) : undefined - const sectionBody = section.body - const body = - typeof sectionBody === 'object' && sectionBody !== null && 'tabularData' in sectionBody - ? jsonTable(sectionBody.tabularData) - : jsonTokenItem(sectionBody) - - if (body === undefined) return - return {...(title ? {title} : {}), body} -} - -function jsonCustomSections(sections: unknown): JsonErrorCustomSection[] | undefined { - if (!Array.isArray(sections)) return - - const renderedSections = sections - .map(jsonCustomSection) - .filter((section): section is JsonErrorCustomSection => section !== undefined) - return renderedSections.length > 0 ? renderedSections : undefined -} - -function jsonErrorDocument(error: FatalErrorLike): JsonErrorDocument | undefined { - if (error.type === FatalErrorType.AbortSilent) return - - const external = externalCommand(error) - const type = jsonErrorType(error, external) - const formattedMessage = jsonTokenItem(error.formattedMessage) - const message = formattedMessage ?? (typeof error.message === 'string' ? unstyled(error.message) : 'Unknown error') - const tryMessage = jsonTokenItem(error.tryMessage) - const nextSteps = jsonTokenItems(error.nextSteps) - const customSections = jsonCustomSections(error.customSections) - const details = error.details - - const commonFields = { - message, - ...(typeof error.code === 'string' && error.code.length > 0 ? {code: error.code} : {}), - ...(tryMessage === undefined ? {} : {tryMessage}), - ...(nextSteps === undefined ? {} : {nextSteps}), - ...(customSections === undefined ? {} : {customSections}), - ...(details === undefined ? {} : {details}), - } - - let jsonError: JsonError - if (type === 'bug') { - jsonError = { - type, - ...commonFields, - ...(typeof error.stack === 'string' ? {stack: unstyled(error.stack)} : {}), - } - } else if (type === 'external' && external) { - jsonError = {type, ...commonFields, ...external} - } else { - jsonError = {type: 'abort', ...commonFields} - } - - return {error: jsonError} -} +import {errorToJson} from '../../public/node/error/serialization.js' +import {outputResult} from '../../public/node/output.js' /** * Writes the public JSON representation of a fatal error to stdout. @@ -137,11 +11,12 @@ function jsonErrorDocument(error: FatalErrorLike): JsonErrorDocument | undefined * * @param error - Fatal error to serialize. */ -export function renderFatalErrorAsJson(error: FatalErrorLike): void { +export function renderFatalErrorAsJson(error: unknown): void { let serializedDocument: string try { - const document = jsonErrorDocument(error) - if (!document) return + if (typeof error === 'object' && error !== null && 'type' in error && error.type === FatalErrorType.AbortSilent) + return + const document = {error: errorToJson(error)} serializedDocument = JSON.stringify(jsonErrorOutputSchema.validate(document)) // Serialization must not replace the JSON contract with a text banner. // eslint-disable-next-line no-catch-all/no-catch-all diff --git a/packages/cli-kit/src/public/node/error/serialization.test.ts b/packages/cli-kit/src/public/node/error/serialization.test.ts new file mode 100644 index 00000000000..bd9fa47e3c4 --- /dev/null +++ b/packages/cli-kit/src/public/node/error/serialization.test.ts @@ -0,0 +1,51 @@ +import {AbortError, ExternalError} from './index.js' +import {errorToJson} from './serialization.js' +import {GraphQLClientError} from '../../../private/node/api/headers.js' +import {expect, test} from 'vitest' + +test('preserves account-switching guidance for GraphQL HTTP 403 errors', () => { + const error = Object.assign(new GraphQLClientError('Forbidden', 403), { + accessToken: 'secret', + request: {authorization: 'secret'}, + }) + + expect(errorToJson(error)).toEqual({ + type: 'abort', + message: 'Forbidden', + tryMessage: 'Ensure you are using the correct account. You can switch with `shopify auth login`', + }) +}) + +test('preserves recovery fields and selected details without copying arbitrary properties', () => { + const error = Object.assign( + new AbortError( + 'Failed', + [{command: 'shopify auth login'}], + ['Retry the command'], + [{title: 'Account', body: 'Select an account with app access'}], + ), + {details: {operationGid: 'gid://shopify/AppSubscriptionMigrationOperation/one'}, accessToken: 'secret'}, + ) + + expect(errorToJson(error)).toEqual({ + type: 'abort', + message: 'Failed', + tryMessage: 'shopify auth login', + nextSteps: ['Retry the command'], + customSections: [{title: 'Account', body: 'Select an account with app access'}], + details: {operationGid: 'gid://shopify/AppSubscriptionMigrationOperation/one'}, + }) +}) + +test('preserves the external error classification and command', () => { + expect(errorToJson(new ExternalError('Failed', 'npm', ['install']))).toEqual({ + type: 'external', + message: 'Failed', + command: 'npm', + args: ['install'], + }) +}) + +test.each(['Failed', null, undefined])('projects thrown primitives into bug errors: %s', (source) => { + expect(errorToJson(source)).toEqual({type: 'bug', message: source ?? 'Unknown error'}) +}) diff --git a/packages/cli-kit/src/public/node/error/serialization.ts b/packages/cli-kit/src/public/node/error/serialization.ts new file mode 100644 index 00000000000..06c97ff03e6 --- /dev/null +++ b/packages/cli-kit/src/public/node/error/serialization.ts @@ -0,0 +1,130 @@ +import {FatalErrorType} from './index.js' +import {tokenItemToJsonString, type TokenItem} from '../../../private/node/ui/components/token-item.js' +import {unstyled} from '../output.js' +import type {JsonError, JsonErrorCustomSection, JsonErrorType} from './types.js' + +interface FatalErrorLike { + type?: number + message?: unknown + formattedMessage?: unknown + tryMessage?: unknown + nextSteps?: unknown + customSections?: unknown + stack?: unknown + command?: unknown + args?: unknown + details?: unknown + code?: unknown +} + +interface ExternalCommand { + command: string + args: string[] +} + +function externalCommand(error: FatalErrorLike): ExternalCommand | undefined { + if (typeof error.command !== 'string' || !Array.isArray(error.args)) return + if (!error.args.every((arg) => typeof arg === 'string')) return + + return {command: error.command, args: error.args} +} + +function jsonErrorType(error: FatalErrorLike, external: ExternalCommand | undefined): JsonErrorType { + if (error.type === FatalErrorType.Abort) { + return external ? 'external' : 'abort' + } + return 'bug' +} + +function jsonTokenItem(token: unknown): string | undefined { + if (token === null || token === undefined) return + + try { + const message = tokenItemToJsonString(token as TokenItem) + return typeof message === 'string' ? unstyled(message) : undefined + } catch (error) { + if (error instanceof TypeError) return undefined + throw error + } +} + +function jsonTokenItems(items: unknown): string[] | undefined { + if (!Array.isArray(items)) return + + const renderedItems = items.map(jsonTokenItem).filter((item): item is string => item !== undefined) + return renderedItems.length > 0 ? renderedItems : undefined +} + +function jsonTable(data: unknown): string[][] | undefined { + if (!Array.isArray(data)) return + + return data + .filter((row): row is unknown[] => Array.isArray(row)) + .map((row) => row.map((cell) => jsonTokenItem(cell) ?? '')) +} + +function jsonCustomSection(section: unknown): JsonErrorCustomSection | undefined { + if (typeof section !== 'object' || section === null || !('body' in section)) return + + const title = 'title' in section && typeof section.title === 'string' ? unstyled(section.title) : undefined + const sectionBody = section.body + const body = + typeof sectionBody === 'object' && sectionBody !== null && 'tabularData' in sectionBody + ? jsonTable(sectionBody.tabularData) + : jsonTokenItem(sectionBody) + + if (body === undefined) return + return {...(title ? {title} : {}), body} +} + +function jsonCustomSections(sections: unknown): JsonErrorCustomSection[] | undefined { + if (!Array.isArray(sections)) return + + const renderedSections = sections + .map(jsonCustomSection) + .filter((section): section is JsonErrorCustomSection => section !== undefined) + return renderedSections.length > 0 ? renderedSections : undefined +} + +/** + * Projects an error into the shared JSON error contract, retaining only allowlisted fields. + * + * @param source - Error to project. + * @returns The error classification, message, and available recovery information. + */ +export function errorToJson(source: unknown): JsonError { + const error: FatalErrorLike = typeof source === 'object' && source !== null ? source : {message: source} + + const external = externalCommand(error) + const type = jsonErrorType(error, external) + const formattedMessage = jsonTokenItem(error.formattedMessage) + const message = formattedMessage ?? (typeof error.message === 'string' ? unstyled(error.message) : 'Unknown error') + const tryMessage = jsonTokenItem(error.tryMessage) + const nextSteps = jsonTokenItems(error.nextSteps) + const customSections = jsonCustomSections(error.customSections) + const details = error.details + + const commonFields = { + message, + ...(typeof error.code === 'string' && error.code.length > 0 ? {code: error.code} : {}), + ...(tryMessage === undefined ? {} : {tryMessage}), + ...(nextSteps === undefined ? {} : {nextSteps}), + ...(customSections === undefined ? {} : {customSections}), + ...(details === undefined ? {} : {details}), + } + + let jsonError: JsonError + if (type === 'bug') { + jsonError = { + type, + ...commonFields, + ...(typeof error.stack === 'string' ? {stack: unstyled(error.stack)} : {}), + } + } else if (type === 'external' && external) { + jsonError = {type, ...commonFields, ...external} + } else { + jsonError = {type: 'abort', ...commonFields} + } + + return jsonError +} diff --git a/packages/cli/README.md b/packages/cli/README.md index f9de295f888..59285135d43 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4059,6 +4059,364 @@ 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 `MigrationCancellationResult` schema. + + ```json + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "partial" + ] + }, + "operations": { + "type": "array", + "items": { + "$ref": "#/definitions/MigrationCancellationOutcome" + } + } + }, + "required": [ + "status", + "operations" + ], + "additionalProperties": false, + "title": "MigrationCancellationResult", + "definitions": { + "MigrationCancellationOutcome": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "operationGid": { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/AppSubscriptionMigrationOperation\\/[^/]+$", + "description": "The Shopify AppSubscriptionMigrationOperation GID." + }, + "operation": { + "$ref": "#/definitions/MigrationOperation" + } + }, + "required": [ + "status", + "operationGid", + "operation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "failed" + }, + "operationGid": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/0/properties/operationGid" + }, + "operation": { + "anyOf": [ + { + "$ref": "#/definitions/MigrationOperation" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "abort" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, + "tryMessage": { + "type": "string" + }, + "nextSteps": { + "type": "array", + "items": { + "type": "string" + } + }, + "customSections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + ] + } + }, + "required": [ + "body" + ], + "additionalProperties": false + } + }, + "details": { + "type": "object", + "properties": { + "userErrors": { + "type": "array", + "items": { + "$ref": "#/definitions/MigrationUserError" + } + } + }, + "required": [ + "userErrors" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "message", + "details" + ], + "additionalProperties": false + }, + { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/type" + }, + "message": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "code": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "tryMessage": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bug" + }, + "message": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "code": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "tryMessage": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/1/anyOf/0/properties/details" + }, + "stack": { + "type": "string" + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "external" + }, + "message": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "code": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "tryMessage": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/1/anyOf/0/properties/details" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "message", + "command", + "args" + ], + "additionalProperties": false + } + ] + } + ] + } + }, + "required": [ + "status", + "operationGid", + "operation", + "error" + ], + "additionalProperties": false + } + ] + }, + "MigrationOperation": { + "type": "object", + "properties": { + "gid": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/0/properties/operationGid" + }, + "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 + }, + "MigrationUserError": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "fieldPath": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "message", + "fieldPath" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` + EXAMPLES $ shopify app subscription-migrations cancel --id @@ -4128,9 +4486,9 @@ DESCRIPTION Lists every app subscription eligible for migration. By 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`. + 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`. Use `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 9575a999b11..efd0423ae9c 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4623,7 +4623,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and 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": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and 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 `MigrationCancellationResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"partial\"\n ]\n },\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"operations\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationCancellationResult\",\n \"definitions\": {\n \"MigrationCancellationOutcome\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"operationGid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/AppSubscriptionMigrationOperation\\\\/[^/]+$\",\n \"description\": \"The Shopify AppSubscriptionMigrationOperation GID.\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"status\",\n \"operationGid\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"failed\"\n },\n \"operationGid\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/0/properties/operationGid\"\n },\n \"operation\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigrationOperation\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {\n \"type\": \"object\",\n \"properties\": {\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"userErrors\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"details\"\n ],\n \"additionalProperties\": false\n },\n {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/type\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/1/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/1/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n ]\n }\n },\n \"required\": [\n \"status\",\n \"operationGid\",\n \"operation\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/0/properties/operationGid\"\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 \"MigrationUserError\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"fieldPath\": {\n \"anyOf\": [\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"message\",\n \"fieldPath\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and 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 ", @@ -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 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.", - "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.", + "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", 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 3ab142dc901..0bbd504807f 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/cancel.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',