From 357af9510e9993faf26970526f8b6e8a25ac32e6 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 18 Sep 2026 14:18:20 +0200 Subject: [PATCH 1/2] Add JSON Schema for subscription migration scheduling Co-Authored-By: Claude Fable 5 --- .../subscription-migrations/commands.test.ts | 15 +++++-- .../result-codec.test.ts | 34 ++++++++++++-- .../subscription-migrations/result-codec.ts | 5 ++- .../result-presenter-output.test.ts | 34 +++++++++++++- .../result-presenter.test.ts | 4 +- .../result-presenter.ts | 4 +- .../app/subscription-migrations/schedule.ts | 8 +++- .../run-submission-command.test.ts | 2 +- .../run-submission-command.ts | 3 +- .../submit-migration-plan.ts | 32 ++----------- .../services/subscription-migrations/types.ts | 45 +++++++++++++++++++ packages/cli/oclif.manifest.json | 2 +- .../rules/json-output-command-exceptions.js | 1 - 13 files changed, 141 insertions(+), 48 deletions(-) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts index 8e399c1d8e7..7693b9728ce 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 @@ -11,6 +11,7 @@ import {cancelMigrationOperations} from '../../../services/subscription-migratio import { migrationCancellationJsonOutputSchema, migrationListJsonOutputSchema, + migrationSubmissionJsonOutputSchema, } from '../../../services/subscription-migrations/types.js' import {outputOperations} from '../../../services/subscription-migrations/command-output.js' import {getMigrationOperations} from '../../../services/subscription-migrations/get-operations.js' @@ -22,8 +23,10 @@ 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/types.js' -import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js' +import type { + MigrationCancellationResult, + MigrationSubmissionResult, +} from '../../../services/subscription-migrations/types.js' vi.mock('../../../services/app-context.js') vi.mock('../../../services/subscription-migrations/cancel-operations.js', async (importOriginal) => ({ @@ -462,6 +465,12 @@ describe('subscription migration command metadata', () => { expect(List.description).toContain('```json') }) + test('schedule exposes and documents the submission JSON output schema', () => { + expect(Schedule.jsonOutputSchema).toBe(migrationSubmissionJsonOutputSchema) + expect(Schedule.description).toContain('`MigrationSubmissionResult` schema') + expect(Schedule.description).toContain('```json') + }) + test('cancel exposes its JSON output schema', () => { expect(Cancel.jsonOutputSchema).toBe(migrationCancellationJsonOutputSchema) }) @@ -585,7 +594,7 @@ describe('subscription migration command metadata', () => { }, ) - test.each([Schedule, Unschedule, Status])('$name has no fenced-code markers in its plain description', (Command) => { + test.each([Unschedule, Status])('$name has no fenced-code markers in its plain description', (Command) => { expect(Command.description).not.toContain('```') }) 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 5de71483be4..4d9a3a26623 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,12 +1,15 @@ import {encodeMigrationCancellationResult, encodeMigrationSubmissionResult} from './result-codec.js' -import {migrationCancellationJsonOutputSchema} from '../../../services/subscription-migrations/types.js' +import { + migrationCancellationJsonOutputSchema, + migrationSubmissionJsonOutputSchema, +} 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/types.js' import type { + MigrationCancellationResult, MigrationSubmission, MigrationSubmissionResult, -} from '../../../services/subscription-migrations/submit-migration-plan.js' +} from '../../../services/subscription-migrations/types.js' function operation(id: string): MigrationOperation { return {id, status: 'RUNNING', total: 1, results: {edges: []}} @@ -215,3 +218,28 @@ describe('subscription migration result codecs', () => { ) }) }) + +describe('migration submission JSON contract', () => { + test('preserves an empty successful submission without failure details', () => { + const value = {...submission(), total: 0, operations: []} + expect(encodeMigrationSubmissionResult({status: 'success', submission: value})).toBe(JSON.stringify(value, null, 2)) + }) + + test('preserves total submission failure with nullable error fields', () => { + const value = {...submission(), operations: []} + const failure = {type: 'submission' as const, batchIndex: 0, userErrors: [{message: 'Rejected', field: null}]} + expect(encodeMigrationSubmissionResult({status: 'failed', submission: value, failure})).toBe( + JSON.stringify({...value, failure}, null, 2), + ) + }) + + test.each([ + {action: 'cancel'}, + {total: '1'}, + {failure: {type: 'operations'}}, + {failure: {type: 'submission', batchIndex: 0, userErrors: [{message: 'Rejected'}]}}, + {operations: [{batchIndex: 0, batchPayloadDigest: 'digest', operation: {...operation('one'), status: 'UNKNOWN'}}]}, + ])('rejects invalid submission fields: %j', (fields) => { + expect(() => migrationSubmissionJsonOutputSchema.validate({...submission(), ...fields})).toThrow() + }) +}) 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 eed91083040..183d44ffde2 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,13 @@ import { migrationCancellationJsonOutputSchema, + migrationSubmissionJsonOutputSchema, type MigrationCancellationResult, + type MigrationSubmissionResult, } from '../../../services/subscription-migrations/types.js' import { projectMigrationOperation, projectMigrationUserErrors, } from '../../../services/subscription-migrations/result-codec.js' -import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js' export function encodeMigrationSubmissionResult(result: MigrationSubmissionResult): string { const document = @@ -16,7 +17,7 @@ export function encodeMigrationSubmissionResult(result: MigrationSubmissionResul ...result.submission, failure: result.failure, } - return JSON.stringify(document, null, 2) + return migrationSubmissionJsonOutputSchema.encode(document) } export function encodeMigrationCancellationResult(result: MigrationCancellationResult): string { diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts index 4be0f55363a..5c7a9402b5c 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts @@ -1,4 +1,4 @@ -import {presentMigrationCancellationResult} from './result-presenter.js' +import {presentMigrationCancellationResult, presentMigrationSubmissionResult} from './result-presenter.js' import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' @@ -72,3 +72,35 @@ describe('migration cancellation JSON output', () => { } }) }) + +describe('migration submission JSON output', () => { + test.each([false, true])('writes one final failure document with watch=%s', (watch) => { + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const submission = { + clientId: 'client-id', + action: 'schedule' as const, + inputDigest: 'input-digest', + total: 2, + operations: [ + { + batchIndex: 0, + batchPayloadDigest: 'batch-digest', + operation: {id: 'operation-one', status: 'RUNNING' as const, total: 1, results: {edges: []}}, + }, + ], + } + const failure = {type: 'submission' as const, batchIndex: 1, userErrors: [{message: 'Rejected', field: null}]} + + try { + expect(presentMigrationSubmissionResult({status: 'failed', submission, failure}, {json: true, watch})).toBe(1) + + expect(stdout).toHaveBeenCalledOnce() + expect(stdout.mock.calls[0]?.[0]).toBe(`${JSON.stringify({...submission, failure}, null, 2)}\n`) + 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 a45421f0b5e..92106ea4230 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,11 +7,11 @@ 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/types.js' import type { + MigrationCancellationResult, MigrationSubmission, MigrationSubmissionResult, -} from '../../../services/subscription-migrations/submit-migration-plan.js' +} from '../../../services/subscription-migrations/types.js' vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { const actual = await importOriginal() 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 2db38600146..917ebfe7942 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 @@ -7,11 +7,9 @@ import type {MigrationOperation} from '../../../models/subscription-migrations.j import type { MigrationCancellationOutcome, MigrationCancellationResult, -} from '../../../services/subscription-migrations/types.js' -import type { MigrationSubmission, MigrationSubmissionResult, -} from '../../../services/subscription-migrations/submit-migration-plan.js' +} from '../../../services/subscription-migrations/types.js' interface SubmissionPresentationOptions { json: boolean diff --git a/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts b/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts index 694958d03ee..69ffabda000 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts @@ -1,8 +1,10 @@ import {submissionFlags} from './flags.js' import {presentAcceptedMigrationSubmission, presentMigrationSubmissionResult} from './result-presenter.js' +import {migrationSubmissionJsonOutputSchema} from '../../../services/subscription-migrations/types.js' import {linkedAppContext} from '../../../services/app-context.js' import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' +import {jsonFlag} from '@shopify/cli-kit/node/cli' export default class Schedule extends AppLinkedCommand { static summary = 'Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.' @@ -34,7 +36,11 @@ Use \`--force\` to skip confirmation and immediately submit every valid row. Wit '<%= config.bin %> <%= command.id %> --input - --force --watch', ] - static flags = {...submissionFlags} + static flags = {...submissionFlags, ...jsonFlag} + + static get jsonOutputSchema() { + return migrationSubmissionJsonOutputSchema + } async run(): Promise { const {flags} = await this.parse(Schedule) diff --git a/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts b/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts index 1c53a8aa1b4..c53572be2bf 100644 --- a/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts @@ -6,7 +6,7 @@ import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigrationOperation} from '../../models/subscription-migrations.js' -import type {MigrationSubmission, MigrationSubmissionResult} from './submit-migration-plan.js' +import type {MigrationSubmission, MigrationSubmissionResult} from './types.js' vi.mock('./plan/plan-migration-input.js') vi.mock('./submit-migration-plan.js', async (importOriginal) => { diff --git a/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts b/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts index ffdf3e7d2be..3547f7a5978 100644 --- a/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts +++ b/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts @@ -1,8 +1,9 @@ import {planMigrationInput} from './plan/plan-migration-input.js' -import {submitMigrationPlan, type MigrationSubmission, type MigrationSubmissionResult} from './submit-migration-plan.js' +import {submitMigrationPlan} from './submit-migration-plan.js' import {watchMigrationOperations} from './watch-operations.js' import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' +import type {MigrationSubmission, MigrationSubmissionResult} from './types.js' import type { MigrationAction, MigrationOperation, diff --git a/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts index bbe5d1ebd22..16940506187 100644 --- a/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts +++ b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts @@ -1,33 +1,7 @@ -import {createMigrationOperation, type MigrationApiInput, type MigrationUserError} from './partners-api.js' +import {createMigrationOperation, type MigrationApiInput} from './partners-api.js' import {deriveBatchIdempotencyKey, generateInvocationId} from './plan/idempotency.js' -import type { - MigrationOperation, - MigrationPlan, - PlannedMigrationRow, - ScheduledMigrationRow, -} from '../../models/subscription-migrations.js' - -export interface SubmittedMigrationOperation { - batchIndex: number - batchPayloadDigest: string - operation: MigrationOperation -} - -export interface MigrationSubmission { - clientId: string - action: MigrationPlan['action'] - inputDigest: string - total: number - operations: SubmittedMigrationOperation[] -} - -export type MigrationSubmissionFailure = - | {type: 'submission'; batchIndex: number; userErrors: MigrationUserError[]} - | {type: 'operations'; operationIds: string[]} - -export type MigrationSubmissionResult = - | {status: 'success'; submission: MigrationSubmission} - | {status: 'failed'; submission: MigrationSubmission; failure: MigrationSubmissionFailure} +import type {MigrationSubmission, MigrationSubmissionResult} from './types.js' +import type {MigrationPlan, PlannedMigrationRow, ScheduledMigrationRow} from '../../models/subscription-migrations.js' export class MigrationSubmissionProtocolError extends Error { readonly batchIndex: number diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index 8f8ecf2165c..2b2c9f0db7e 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -133,3 +133,48 @@ export const migrationListJsonOutputSchema = defineJsonOutputSchema({ MigratableSubscriptionNotification: MigratableSubscriptionNotificationSchema, }, }) + +const SubmittedMigrationOperationSchema = zod.object({ + batchIndex: zod.number(), + batchPayloadDigest: zod.string(), + operation: MigrationOperationSchema, +}) + +const MigrationSubmissionFailureSchema = zod.discriminatedUnion('type', [ + zod.object({ + type: zod.literal('submission'), + batchIndex: zod.number(), + userErrors: zod.array(MigrationUserErrorSchema), + }), + zod.object({ + type: zod.literal('operations'), + operationIds: zod.array(zod.string()), + }), +]) + +export const migrationSubmissionJsonOutputSchema = defineJsonOutputSchema({ + name: 'MigrationSubmissionResult', + schema: zod.object({ + clientId: zod.string(), + action: zod.enum(['schedule', 'unschedule']), + inputDigest: zod.string(), + total: zod.number(), + operations: zod.array(SubmittedMigrationOperationSchema), + failure: MigrationSubmissionFailureSchema.optional(), + }), + definitions: { + SubmittedMigrationOperation: SubmittedMigrationOperationSchema, + MigrationOperation: MigrationOperationSchema, + MigrationOperationResultEdge: MigrationOperationResultEdgeSchema, + MigrationOperationResultNode: MigrationOperationResultNodeSchema, + MigrationSubmissionFailure: MigrationSubmissionFailureSchema, + MigrationUserError: MigrationUserErrorSchema, + }, +}) + +export type MigrationSubmissionJsonOutput = InferJsonOutputSchema +export type MigrationSubmission = Omit +type MigrationSubmissionFailure = NonNullable +export type MigrationSubmissionResult = + | {status: 'success'; submission: MigrationSubmission} + | {status: 'failed'; submission: MigrationSubmission; failure: MigrationSubmissionFailure} diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 0f9335c273c..6afe5055a57 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4879,7 +4879,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.", + "description": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.\n\nOutput from `--json` conforms to the `MigrationSubmissionResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"clientId\": {\n \"type\": \"string\"\n },\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"schedule\",\n \"unschedule\"\n ]\n },\n \"inputDigest\": {\n \"type\": \"string\"\n },\n \"total\": {\n \"type\": \"number\"\n },\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/SubmittedMigrationOperation\"\n }\n },\n \"failure\": {\n \"$ref\": \"#/definitions/MigrationSubmissionFailure\"\n }\n },\n \"required\": [\n \"clientId\",\n \"action\",\n \"inputDigest\",\n \"total\",\n \"operations\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationSubmissionResult\",\n \"definitions\": {\n \"SubmittedMigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"batchIndex\": {\n \"type\": \"number\"\n },\n \"batchPayloadDigest\": {\n \"type\": \"string\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"batchIndex\",\n \"batchPayloadDigest\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"RUNNING\",\n \"COMPLETED\",\n \"FAILED\",\n \"CANCELED\"\n ]\n },\n \"total\": {\n \"type\": \"number\"\n },\n \"results\": {\n \"type\": \"object\",\n \"properties\": {\n \"edges\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperationResultEdge\"\n }\n }\n },\n \"required\": [\n \"edges\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"id\",\n \"status\",\n \"total\",\n \"results\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultEdge\": {\n \"type\": \"object\",\n \"properties\": {\n \"node\": {\n \"$ref\": \"#/definitions/MigrationOperationResultNode\"\n }\n },\n \"required\": [\n \"node\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultNode\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopId\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"enum\": [\n \"SCHEDULED\",\n \"CANCELED\",\n \"INVALID_PLAN\",\n \"INELIGIBLE\",\n \"BLOCKED\",\n \"ALREADY_SCHEDULED\",\n \"ALREADY_MIGRATED\",\n \"NOT_FOUND\",\n \"INTERNAL_ERROR\"\n ]\n }\n },\n \"required\": [\n \"shopId\",\n \"code\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationSubmissionFailure\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"submission\"\n },\n \"batchIndex\": {\n \"type\": \"number\"\n },\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"batchIndex\",\n \"userErrors\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"operations\"\n },\n \"operationIds\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"operationIds\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n \"MigrationUserError\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"field\": {\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 \"field\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.", "examples": [ "<%= config.bin %> <%= command.id %> --input migrations.csv --force", 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 5c8bdab64b4..2e0fe325a69 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -26,7 +26,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/import/dashboard-extensions.ts', 'packages/app/src/cli/commands/app/init.ts', 'packages/app/src/cli/commands/app/release.ts', - 'packages/app/src/cli/commands/app/subscription-migrations/schedule.ts', 'packages/app/src/cli/commands/app/subscription-migrations/status.ts', 'packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts', 'packages/app/src/cli/commands/app/webhook/trigger.ts', From 2dccbb206e341a0a22f0ac9d67cfa48946b91562 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 13:55:17 +0200 Subject: [PATCH 2/2] Normalize migration submission JSON outcomes --- .../normalize-migration-submission-json.md | 5 + .../subscription-migrations/commands.test.ts | 10 +- .../result-codec.test.ts | 118 ++++---- .../subscription-migrations/result-codec.ts | 10 +- .../result-presenter-output.test.ts | 12 +- .../result-presenter.test.ts | 55 ++-- .../result-presenter.ts | 15 + .../app/subscription-migrations/schedule.ts | 2 +- .../subscription-migrations/result-codec.ts | 30 ++ .../run-submission-command.test.ts | 15 +- .../run-submission-command.ts | 5 +- .../submit-migration-plan.test.ts | 14 + .../submit-migration-plan.ts | 30 +- .../services/subscription-migrations/types.ts | 84 ++++-- packages/cli/README.md | 280 ++++++++++++++++++ packages/cli/oclif.manifest.json | 2 +- 16 files changed, 548 insertions(+), 139 deletions(-) create mode 100644 .changeset/normalize-migration-submission-json.md diff --git a/.changeset/normalize-migration-submission-json.md b/.changeset/normalize-migration-submission-json.md new file mode 100644 index 00000000000..e097dfabd38 --- /dev/null +++ b/.changeset/normalize-migration-submission-json.md @@ -0,0 +1,5 @@ +--- +"@shopify/cli": major +--- + +Normalize migration submission JSON with outcome statuses, changed, GID fields, flattened results, fieldPath errors, and successful cancellation results. diff --git a/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts index 7693b9728ce..163cd105477 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 @@ -3,6 +3,7 @@ import List from './list.js' import Schedule from './schedule.js' import Status from './status.js' import Unschedule from './unschedule.js' +import {projectMigrationSubmissionResult} from '../../../services/subscription-migrations/result-codec.js' import {appFlags} from '../../../flags.js' import {commands} from '../../../index.js' import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' @@ -120,7 +121,7 @@ describe('subscription migration submission commands', () => { }) expect(outputResult).toHaveBeenCalledOnce() expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual( - successfulSubmissionResult.submission, + projectMigrationSubmissionResult(successfulSubmissionResult), ) expect(result).toEqual({app}) }) @@ -200,10 +201,9 @@ 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({ - ...failedResult.submission, - failure: failedResult.failure, - }) + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual( + projectMigrationSubmissionResult(failedResult), + ) expect(renderWarning).not.toHaveBeenCalled() }) 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 4d9a3a26623..0c2b34fbada 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 @@ -32,69 +32,78 @@ function submission(): MigrationSubmission { } describe('subscription migration result codecs', () => { - test('encodes a successful submission with the existing JSON shape', () => { - const value = submission() - const result: MigrationSubmissionResult = {status: 'success', submission: value} - - const document = encodeMigrationSubmissionResult(result) + const publicSubmission = { + clientId: 'client-id', + action: 'schedule', + inputDigest: 'input-digest', + total: 1, + operations: [ + { + batchIndex: 0, + batchPayloadDigest: 'batch-digest', + operation: { + gid: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one', + status: 'RUNNING', + total: 1, + results: [], + }, + }, + ], + } - expect(JSON.parse(document)).toEqual(value) - expect(document).toBe(JSON.stringify(value, null, 2)) + test('encodes a successful submission with the public projection', () => { + const document = encodeMigrationSubmissionResult({status: 'success', submission: submission()}) + const expected = {status: 'success', changed: true, ...publicSubmission} + expect(JSON.parse(document)).toEqual(expected) + expect(document).toBe(JSON.stringify(expected, null, 2)) expect(document).not.toContain('idempotencyKey') }) - test('encodes accepted submission evidence and failure details in one JSON document', () => { - const value = submission() + test('retains accepted work and projects submission diagnostics', () => { const result: MigrationSubmissionResult = { status: 'failed', - submission: value, + submission: submission(), failure: { type: 'submission', batchIndex: 1, userErrors: [{message: 'Rejected remaining shops', field: ['input']}], }, } - - const document = encodeMigrationSubmissionResult(result) - - expect(JSON.parse(document)).toEqual({ - ...value, + expect(JSON.parse(encodeMigrationSubmissionResult(result))).toEqual({ + status: 'partial', + changed: true, + ...publicSubmission, failure: { type: 'submission', batchIndex: 1, - userErrors: [{message: 'Rejected remaining shops', field: ['input']}], + userErrors: [{message: 'Rejected remaining shops', fieldPath: ['input']}], }, }) }) - test('encodes terminal operation failure evidence in one JSON document', () => { + test('retains terminal upstream failure states inside the resource', () => { const value = submission() - value.operations[0]!.operation = {...value.operations[0]!.operation, status: 'FAILED'} + value.operations[0]!.operation.status = 'FAILED' const result: MigrationSubmissionResult = { status: 'failed', submission: value, - failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, + failure: { + type: 'operations', + operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one'], + }, } - - const document = encodeMigrationSubmissionResult(result) - - expect(JSON.parse(document)).toEqual({ - ...value, - failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, - }) - expect(document).toBe( - JSON.stringify( + expect(JSON.parse(encodeMigrationSubmissionResult(result))).toEqual({ + status: 'partial', + changed: true, + ...publicSubmission, + operations: [ { - ...value, - failure: { - type: 'operations', - operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one'], - }, + ...publicSubmission.operations[0], + operation: {...publicSubmission.operations[0]!.operation, status: 'FAILED'}, }, - null, - 2, - ), - ) + ], + failure: {type: 'operations', operationGids: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, + }) }) test('rejects cancellation documents with an invalid outcome', () => { @@ -220,26 +229,25 @@ describe('subscription migration result codecs', () => { }) describe('migration submission JSON contract', () => { - test('preserves an empty successful submission without failure details', () => { + test('reports an empty successful submission as an unchanged object', () => { const value = {...submission(), total: 0, operations: []} - expect(encodeMigrationSubmissionResult({status: 'success', submission: value})).toBe(JSON.stringify(value, null, 2)) - }) - - test('preserves total submission failure with nullable error fields', () => { - const value = {...submission(), operations: []} - const failure = {type: 'submission' as const, batchIndex: 0, userErrors: [{message: 'Rejected', field: null}]} - expect(encodeMigrationSubmissionResult({status: 'failed', submission: value, failure})).toBe( - JSON.stringify({...value, failure}, null, 2), - ) + expect(JSON.parse(encodeMigrationSubmissionResult({status: 'success', submission: value}))).toEqual({ + status: 'success', + changed: false, + ...value, + }) }) - test.each([ - {action: 'cancel'}, - {total: '1'}, - {failure: {type: 'operations'}}, - {failure: {type: 'submission', batchIndex: 0, userErrors: [{message: 'Rejected'}]}}, - {operations: [{batchIndex: 0, batchPayloadDigest: 'digest', operation: {...operation('one'), status: 'UNKNOWN'}}]}, - ])('rejects invalid submission fields: %j', (fields) => { - expect(() => migrationSubmissionJsonOutputSchema.validate({...submission(), ...fields})).toThrow() + test('rejects invalid public submission fields', () => { + const valid = JSON.parse(encodeMigrationSubmissionResult({status: 'success', submission: submission()})) + for (const fields of [ + {action: 'cancel'}, + {total: '1'}, + {total: -1}, + {unexpected: true}, + {operations: [{batchIndex: -1}]}, + ]) { + expect(() => migrationSubmissionJsonOutputSchema.validate({...valid, ...fields})).toThrow() + } }) }) 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 183d44ffde2..1f0fc77dd1c 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 @@ -6,18 +6,12 @@ import { } from '../../../services/subscription-migrations/types.js' import { projectMigrationOperation, + projectMigrationSubmissionResult, projectMigrationUserErrors, } from '../../../services/subscription-migrations/result-codec.js' export function encodeMigrationSubmissionResult(result: MigrationSubmissionResult): string { - const document = - result.status === 'success' - ? result.submission - : { - ...result.submission, - failure: result.failure, - } - return migrationSubmissionJsonOutputSchema.encode(document) + return migrationSubmissionJsonOutputSchema.encode(projectMigrationSubmissionResult(result)) } export function encodeMigrationCancellationResult(result: MigrationCancellationResult): string { diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts index 5c7a9402b5c..20dce54b97c 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts @@ -1,4 +1,5 @@ import {presentMigrationCancellationResult, presentMigrationSubmissionResult} from './result-presenter.js' +import {projectMigrationSubmissionResult} from '../../../services/subscription-migrations/result-codec.js' import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' @@ -86,7 +87,12 @@ describe('migration submission JSON output', () => { { batchIndex: 0, batchPayloadDigest: 'batch-digest', - operation: {id: 'operation-one', status: 'RUNNING' as const, total: 1, results: {edges: []}}, + operation: { + id: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one', + status: 'RUNNING' as const, + total: 1, + results: {edges: []}, + }, }, ], } @@ -96,7 +102,9 @@ describe('migration submission JSON output', () => { expect(presentMigrationSubmissionResult({status: 'failed', submission, failure}, {json: true, watch})).toBe(1) expect(stdout).toHaveBeenCalledOnce() - expect(stdout.mock.calls[0]?.[0]).toBe(`${JSON.stringify({...submission, failure}, null, 2)}\n`) + expect(stdout.mock.calls[0]?.[0]).toBe( + `${JSON.stringify(projectMigrationSubmissionResult({status: 'failed', submission, failure}), null, 2)}\n`, + ) expect(stderr).not.toHaveBeenCalled() } finally { stdout.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 92106ea4230..d1babd7062e 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 @@ -3,6 +3,7 @@ import { presentMigrationCancellationResult, presentMigrationSubmissionResult, } from './result-presenter.js' +import {projectMigrationSubmissionResult} from '../../../services/subscription-migrations/result-codec.js' 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' @@ -55,7 +56,9 @@ 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(value) + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual( + projectMigrationSubmissionResult(result), + ) expect(renderSuccess).not.toHaveBeenCalled() expect(renderWarning).not.toHaveBeenCalled() }) @@ -76,10 +79,9 @@ 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({ - ...value, - failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, - }) + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual( + projectMigrationSubmissionResult(result), + ) expect(renderSuccess).not.toHaveBeenCalled() expect(renderWarning).not.toHaveBeenCalled() }) @@ -193,26 +195,31 @@ describe('migration submission result presenter', () => { expect(outputResult).not.toHaveBeenCalled() }) - test('reports a failed submission without claiming operations were accepted', () => { - const value = submission() - value.operations = [] - const result: MigrationSubmissionResult = { - status: 'failed', - submission: value, - failure: { - type: 'submission', - batchIndex: 0, - userErrors: [{message: 'App not found', field: ['apiKey']}], - }, + test.each([false, true])( + 'throws a fatal error for a failed submission without accepted work with json=%s', + (json) => { + const value = {...submission(), operations: []} + const result: MigrationSubmissionResult = { + status: 'failed', + submission: value, + failure: {type: 'submission', batchIndex: 0, userErrors: [{message: 'App not found', field: ['apiKey']}]}, + } + expect(() => presentMigrationSubmissionResult(result, {json, watch: false})).toThrow( + 'Subscription migration submission failed.', + ) + expect(outputResult).not.toHaveBeenCalled() + }, + ) + + test('writes a declined confirmation result and exits zero', () => { + const result = { + status: 'cancelled' as const, + changed: false as const, + action: 'schedule' as const, + reason: 'Confirmation declined.', } - - const exitCode = presentMigrationSubmissionResult(result, {json: false, watch: false}) - - expect(exitCode).toBe(1) - expect(renderWarning).toHaveBeenCalledWith( - expect.objectContaining({headline: 'Subscription migration submission failed.'}), - ) - expect(JSON.stringify(vi.mocked(renderWarning).mock.calls[0]?.[0])).not.toContain('operations were accepted') + expect(presentMigrationSubmissionResult(result, {json: true, watch: false})).toBe(0) + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual(result) }) }) 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 917ebfe7942..c9105d5e486 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 @@ -28,6 +28,21 @@ export function presentMigrationSubmissionResult( result: MigrationSubmissionResult, options: SubmissionPresentationOptions, ): 0 | 1 { + if (result.status === 'failed' && result.submission.operations.length === 0) { + const error = new AbortError('Subscription migration submission failed.') + error.details = + result.failure.type === 'submission' + ? { + batchIndex: result.failure.batchIndex, + userErrors: result.failure.userErrors.map(({message, field}) => ({message, fieldPath: field})), + } + : {operationGids: result.failure.operationIds} + throw error + } + if (result.status === 'cancelled') { + if (options.json) outputResult(encodeMigrationSubmissionResult(result)) + return 0 + } if (options.json) { outputResult(encodeMigrationSubmissionResult(result)) } else if (result.status === 'failed') { diff --git a/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts b/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts index 69ffabda000..f9fdb3bfeda 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts @@ -26,7 +26,7 @@ Run the command from an app project. By default, it uses the Client ID from the Use \`--force\` to skip confirmation and immediately submit every valid row. With \`--watch\`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With \`--json --watch\`, the command outputs one structured JSON document after every operation reaches a terminal status.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %> --input migrations.csv --force', diff --git a/packages/app/src/cli/services/subscription-migrations/result-codec.ts b/packages/app/src/cli/services/subscription-migrations/result-codec.ts index e94ae3def15..737c1c28306 100644 --- a/packages/app/src/cli/services/subscription-migrations/result-codec.ts +++ b/packages/app/src/cli/services/subscription-migrations/result-codec.ts @@ -1,3 +1,4 @@ +import type {MigrationSubmissionResult, MigrationSubmissionJsonOutput} from './types.js' import type {MigratableSubscription, MigrationOperation} from '../../models/subscription-migrations.js' import type {MigrationUserError} from './partners-api.js' @@ -49,3 +50,32 @@ function normalizeInstant(value: string | null): string | null { if (value === null) return null return new Date(value).toISOString().replace(/\.\d{3}Z$/, 'Z') } + +export function projectMigrationSubmissionResult(result: MigrationSubmissionResult): MigrationSubmissionJsonOutput { + if (result.status === 'cancelled') return result + const submission = { + changed: result.submission.operations.length > 0, + clientId: result.submission.clientId, + action: result.submission.action, + inputDigest: result.submission.inputDigest, + total: result.submission.total, + operations: result.submission.operations.map(({batchIndex, batchPayloadDigest, operation}) => ({ + batchIndex, + batchPayloadDigest, + operation: projectMigrationOperation(operation), + })), + } + if (result.status === 'success') return {status: 'success', ...submission} + return { + status: 'partial', + ...submission, + failure: + result.failure.type === 'submission' + ? { + type: result.failure.type, + batchIndex: result.failure.batchIndex, + userErrors: projectMigrationUserErrors(result.failure.userErrors), + } + : {type: result.failure.type, operationGids: result.failure.operationIds}, + } +} diff --git a/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts b/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts index c53572be2bf..0e405bb65e3 100644 --- a/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts @@ -2,7 +2,7 @@ import {planMigrationInput} from './plan/plan-migration-input.js' import {runSubmissionCommand} from './run-submission-command.js' import {submitMigrationPlan} from './submit-migration-plan.js' import {watchMigrationOperations} from './watch-operations.js' -import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' +import {AbortError} from '@shopify/cli-kit/node/error' import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigrationOperation} from '../../models/subscription-migrations.js' @@ -57,7 +57,7 @@ function submission(): MigrationSubmission { } } -function successfulResult(): MigrationSubmissionResult { +function successfulResult(): Extract { return {status: 'success', submission: submission()} } @@ -96,13 +96,16 @@ describe('runSubmissionCommand', () => { expect(submitMigrationPlan).not.toHaveBeenCalled() }) - test('silently aborts when confirmation is refused', async () => { + test('returns a successful cancellation when confirmation is refused', async () => { vi.mocked(planMigrationInput).mockResolvedValue({ok: true, plan}) vi.mocked(renderConfirmationPrompt).mockResolvedValue(false) - await expect(runSubmissionCommand({...baseOptions, skipConfirmation: false})).rejects.toBeInstanceOf( - AbortSilentError, - ) + await expect(runSubmissionCommand({...baseOptions, skipConfirmation: false})).resolves.toEqual({ + status: 'cancelled', + changed: false, + action: 'schedule', + reason: 'Confirmation declined.', + }) expect(renderConfirmationPrompt).toHaveBeenCalledOnce() expect(submitMigrationPlan).not.toHaveBeenCalled() }) diff --git a/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts b/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts index 3547f7a5978..09d5ee822e6 100644 --- a/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts +++ b/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts @@ -1,7 +1,7 @@ import {planMigrationInput} from './plan/plan-migration-input.js' import {submitMigrationPlan} from './submit-migration-plan.js' import {watchMigrationOperations} from './watch-operations.js' -import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' +import {AbortError} from '@shopify/cli-kit/node/error' import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' import type {MigrationSubmission, MigrationSubmissionResult} from './types.js' import type { @@ -27,7 +27,8 @@ export async function runSubmissionCommand(options: RunSubmissionCommandOptions) const confirmed = await renderConfirmationPrompt({ message: `${options.action === 'schedule' ? 'Schedule' : 'Unschedule'} ${result.plan.rows.length} subscriptions?`, }) - if (!confirmed) throw new AbortSilentError() + if (!confirmed) + return {status: 'cancelled', changed: false, action: options.action, reason: 'Confirmation declined.'} } const submissionResult = await submitMigrationPlan({ diff --git a/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.test.ts b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.test.ts index 8e3364b8067..6223565a7b6 100644 --- a/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.test.ts @@ -97,6 +97,20 @@ describe('submitMigrationPlan', () => { expect(JSON.stringify(result)).not.toContain('idempotencyKey') }) + test('retains accepted operations when a later request fails', async () => { + const migrationPlan = plan('schedule', 251) + const createOperation = vi + .fn() + .mockResolvedValueOnce(payload(1)) + .mockRejectedValueOnce(new Error('Connection closed')) + const result = await submitMigrationPlan({clientId: 'client-id', plan: migrationPlan, createOperation}) + expect(result.status).toBe('failed') + expect(result.submission.operations).toHaveLength(1) + expect(result).toMatchObject({ + failure: {type: 'submission', batchIndex: 1, userErrors: [{message: 'Connection closed', field: null}]}, + }) + }) + test('uses different internal keys for separate invocations with identical input', async () => { const createOperation = vi.fn().mockResolvedValue(payload(1)) const migrationPlan = plan('schedule') diff --git a/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts index 16940506187..9174fd117a6 100644 --- a/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts +++ b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts @@ -25,7 +25,7 @@ export async function submitMigrationPlan({ plan, invocationId = generateInvocationId(), createOperation = createMigrationOperation, -}: SubmitMigrationPlanOptions): Promise { +}: SubmitMigrationPlanOptions): Promise> { const submission: MigrationSubmission = { clientId, action: plan.action, @@ -41,13 +41,27 @@ export async function submitMigrationPlan({ invocationId, canonicalBatchPayload: batch.canonicalPayload, }) - // Each accepted batch must be recorded before the next request can fail. - // eslint-disable-next-line no-await-in-loop - const payload = await createOperation({ - clientId, - idempotencyKey, - migrations: batch.rows.map(toMigrationApiInput), - }) + // Keep accepted work when a later request fails so callers can still inspect and cancel it. + let payload: Awaited> + try { + // eslint-disable-next-line no-await-in-loop + payload = await createOperation({ + clientId, + idempotencyKey, + migrations: batch.rows.map(toMigrationApiInput), + }) + } catch (error) { + if (submission.operations.length === 0) throw error + return { + status: 'failed', + submission, + failure: { + type: 'submission', + batchIndex: batch.index, + userErrors: [{message: error instanceof Error ? error.message : 'Migration request failed.', field: null}], + }, + } + } if (payload.operation) { submission.operations.push({ diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index 2b2c9f0db7e..e7e03d93f7c 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -1,4 +1,4 @@ -import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' import {zod} from '@shopify/cli-kit/node/schema' import type {MigrationOperation} from '../../models/subscription-migrations.js' import type {MigrationUserError} from './partners-api.js' @@ -134,47 +134,77 @@ export const migrationListJsonOutputSchema = defineJsonOutputSchema({ }, }) -const SubmittedMigrationOperationSchema = zod.object({ - batchIndex: zod.number(), - batchPayloadDigest: zod.string(), - operation: MigrationOperationSchema, -}) +const SubmittedMigrationOperationSchema = zod + .object({ + batchIndex: zod.number().int().nonnegative(), + batchPayloadDigest: zod.string().min(1), + operation: MigrationOperationSchema, + }) + .strict() const MigrationSubmissionFailureSchema = zod.discriminatedUnion('type', [ - zod.object({ - type: zod.literal('submission'), - batchIndex: zod.number(), - userErrors: zod.array(MigrationUserErrorSchema), - }), - zod.object({ - type: zod.literal('operations'), - operationIds: zod.array(zod.string()), - }), + zod + .object({ + type: zod.literal('submission'), + batchIndex: zod.number().int().nonnegative(), + userErrors: zod.array(MigrationUserErrorSchema), + }) + .strict(), + zod.object({type: zod.literal('operations'), operationGids: zod.array(MigrationOperationGidSchema)}).strict(), ]) -export const migrationSubmissionJsonOutputSchema = defineJsonOutputSchema({ - name: 'MigrationSubmissionResult', - schema: zod.object({ - clientId: zod.string(), +const MigrationSubmissionSchema = zod + .object({ + clientId: zod.string().min(1).describe('The app client ID, not a Shopify GID.'), action: zod.enum(['schedule', 'unschedule']), - inputDigest: zod.string(), - total: zod.number(), + inputDigest: zod.string().min(1), + total: zod.number().int().nonnegative(), operations: zod.array(SubmittedMigrationOperationSchema), - failure: MigrationSubmissionFailureSchema.optional(), - }), + }) + .strict() + +export const migrationSubmissionJsonOutputSchema = defineJsonOutputSchema({ + name: 'MigrationSubmissionResult', + schema: zod.discriminatedUnion('status', [ + zod.object({status: zod.literal('success'), changed: zod.boolean(), ...MigrationSubmissionSchema.shape}).strict(), + zod + .object({ + status: zod.literal('partial'), + changed: zod.boolean(), + ...MigrationSubmissionSchema.shape, + failure: MigrationSubmissionFailureSchema, + }) + .strict(), + zod + .object({ + status: zod.literal('cancelled'), + changed: zod.literal(false), + action: zod.enum(['schedule', 'unschedule']), + reason: zod.string(), + }) + .strict(), + ]), definitions: { SubmittedMigrationOperation: SubmittedMigrationOperationSchema, MigrationOperation: MigrationOperationSchema, - MigrationOperationResultEdge: MigrationOperationResultEdgeSchema, - MigrationOperationResultNode: MigrationOperationResultNodeSchema, MigrationSubmissionFailure: MigrationSubmissionFailureSchema, MigrationUserError: MigrationUserErrorSchema, }, }) export type MigrationSubmissionJsonOutput = InferJsonOutputSchema -export type MigrationSubmission = Omit -type MigrationSubmissionFailure = NonNullable +export interface MigrationSubmission { + clientId: string + action: 'schedule' | 'unschedule' + inputDigest: string + total: number + operations: {batchIndex: number; batchPayloadDigest: string; operation: MigrationOperation}[] +} + +type MigrationSubmissionFailure = + | {type: 'submission'; batchIndex: number; userErrors: MigrationUserError[]} + | {type: 'operations'; operationIds: string[]} export type MigrationSubmissionResult = | {status: 'success'; submission: MigrationSubmission} | {status: 'failed'; submission: MigrationSubmission; failure: MigrationSubmissionFailure} + | {status: 'cancelled'; changed: false; action: 'schedule' | 'unschedule'; reason: string} diff --git a/packages/cli/README.md b/packages/cli/README.md index 6d7f724237b..bf61190b2dc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3673,6 +3673,286 @@ DESCRIPTION accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status. + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `MigrationSubmissionResult` schema. + + ```json + { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "changed": { + "type": "boolean" + }, + "clientId": { + "type": "string", + "minLength": 1, + "description": "The app client ID, not a Shopify GID." + }, + "action": { + "type": "string", + "enum": [ + "schedule", + "unschedule" + ] + }, + "inputDigest": { + "type": "string", + "minLength": 1 + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "operations": { + "type": "array", + "items": { + "$ref": "#/definitions/SubmittedMigrationOperation" + } + } + }, + "required": [ + "status", + "changed", + "clientId", + "action", + "inputDigest", + "total", + "operations" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "partial" + }, + "changed": { + "type": "boolean" + }, + "clientId": { + "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/clientId" + }, + "action": { + "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/action" + }, + "inputDigest": { + "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/inputDigest" + }, + "total": { + "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/total" + }, + "operations": { + "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/operations" + }, + "failure": { + "$ref": "#/definitions/MigrationSubmissionFailure" + } + }, + "required": [ + "status", + "changed", + "clientId", + "action", + "inputDigest", + "total", + "operations", + "failure" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "cancelled" + }, + "changed": { + "type": "boolean", + "const": false + }, + "action": { + "type": "string", + "enum": [ + "schedule", + "unschedule" + ] + }, + "reason": { + "type": "string" + } + }, + "required": [ + "status", + "changed", + "action", + "reason" + ], + "additionalProperties": false + } + ], + "title": "MigrationSubmissionResult", + "definitions": { + "SubmittedMigrationOperation": { + "type": "object", + "properties": { + "batchIndex": { + "type": "integer", + "minimum": 0 + }, + "batchPayloadDigest": { + "type": "string", + "minLength": 1 + }, + "operation": { + "$ref": "#/definitions/MigrationOperation" + } + }, + "required": [ + "batchIndex", + "batchPayloadDigest", + "operation" + ], + "additionalProperties": false + }, + "MigrationOperation": { + "type": "object", + "properties": { + "gid": { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/AppSubscriptionMigrationOperation\\/[^/]+$", + "description": "The Shopify AppSubscriptionMigrationOperation GID." + }, + "status": { + "type": "string", + "minLength": 1, + "description": "Upstream status: RUNNING, COMPLETED, FAILED, or CANCELED." + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "shopGid": { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/Shop\\/\\d+$", + "description": "The Shopify Shop GID." + }, + "code": { + "type": "string", + "minLength": 1, + "description": "The upstream per-shop migration result code." + } + }, + "required": [ + "shopGid", + "code" + ], + "additionalProperties": false + } + } + }, + "required": [ + "gid", + "status", + "total", + "results" + ], + "additionalProperties": false + }, + "MigrationSubmissionFailure": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "submission" + }, + "batchIndex": { + "type": "integer", + "minimum": 0 + }, + "userErrors": { + "type": "array", + "items": { + "$ref": "#/definitions/MigrationUserError" + } + } + }, + "required": [ + "type", + "batchIndex", + "userErrors" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "operations" + }, + "operationGids": { + "type": "array", + "items": { + "$ref": "#/definitions/MigrationOperation/properties/gid" + } + } + }, + "required": [ + "type", + "operationGids" + ], + "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 schedule --input migrations.csv --force diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 6afe5055a57..bfb02e6ff51 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4879,7 +4879,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.\n\nOutput from `--json` conforms to the `MigrationSubmissionResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"clientId\": {\n \"type\": \"string\"\n },\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"schedule\",\n \"unschedule\"\n ]\n },\n \"inputDigest\": {\n \"type\": \"string\"\n },\n \"total\": {\n \"type\": \"number\"\n },\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/SubmittedMigrationOperation\"\n }\n },\n \"failure\": {\n \"$ref\": \"#/definitions/MigrationSubmissionFailure\"\n }\n },\n \"required\": [\n \"clientId\",\n \"action\",\n \"inputDigest\",\n \"total\",\n \"operations\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationSubmissionResult\",\n \"definitions\": {\n \"SubmittedMigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"batchIndex\": {\n \"type\": \"number\"\n },\n \"batchPayloadDigest\": {\n \"type\": \"string\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"batchIndex\",\n \"batchPayloadDigest\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"RUNNING\",\n \"COMPLETED\",\n \"FAILED\",\n \"CANCELED\"\n ]\n },\n \"total\": {\n \"type\": \"number\"\n },\n \"results\": {\n \"type\": \"object\",\n \"properties\": {\n \"edges\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperationResultEdge\"\n }\n }\n },\n \"required\": [\n \"edges\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"id\",\n \"status\",\n \"total\",\n \"results\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultEdge\": {\n \"type\": \"object\",\n \"properties\": {\n \"node\": {\n \"$ref\": \"#/definitions/MigrationOperationResultNode\"\n }\n },\n \"required\": [\n \"node\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultNode\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopId\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"enum\": [\n \"SCHEDULED\",\n \"CANCELED\",\n \"INVALID_PLAN\",\n \"INELIGIBLE\",\n \"BLOCKED\",\n \"ALREADY_SCHEDULED\",\n \"ALREADY_MIGRATED\",\n \"NOT_FOUND\",\n \"INTERNAL_ERROR\"\n ]\n }\n },\n \"required\": [\n \"shopId\",\n \"code\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationSubmissionFailure\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"submission\"\n },\n \"batchIndex\": {\n \"type\": \"number\"\n },\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"batchIndex\",\n \"userErrors\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"operations\"\n },\n \"operationIds\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"operationIds\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n \"MigrationUserError\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"field\": {\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 \"field\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `MigrationSubmissionResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"clientId\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The app client ID, not a Shopify GID.\"\n },\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"schedule\",\n \"unschedule\"\n ]\n },\n \"inputDigest\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"total\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/SubmittedMigrationOperation\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"clientId\",\n \"action\",\n \"inputDigest\",\n \"total\",\n \"operations\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"partial\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"clientId\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/clientId\"\n },\n \"action\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/action\"\n },\n \"inputDigest\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/inputDigest\"\n },\n \"total\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/total\"\n },\n \"operations\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/operations\"\n },\n \"failure\": {\n \"$ref\": \"#/definitions/MigrationSubmissionFailure\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"clientId\",\n \"action\",\n \"inputDigest\",\n \"total\",\n \"operations\",\n \"failure\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n },\n \"changed\": {\n \"type\": \"boolean\",\n \"const\": false\n },\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"schedule\",\n \"unschedule\"\n ]\n },\n \"reason\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"action\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"MigrationSubmissionResult\",\n \"definitions\": {\n \"SubmittedMigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"batchIndex\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"batchPayloadDigest\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"batchIndex\",\n \"batchPayloadDigest\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/AppSubscriptionMigrationOperation\\\\/[^/]+$\",\n \"description\": \"The Shopify AppSubscriptionMigrationOperation GID.\"\n },\n \"status\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"Upstream status: RUNNING, COMPLETED, FAILED, or CANCELED.\"\n },\n \"total\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"results\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopGid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/Shop\\\\/\\\\d+$\",\n \"description\": \"The Shopify Shop GID.\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The upstream per-shop migration result code.\"\n }\n },\n \"required\": [\n \"shopGid\",\n \"code\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"gid\",\n \"status\",\n \"total\",\n \"results\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationSubmissionFailure\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"submission\"\n },\n \"batchIndex\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"batchIndex\",\n \"userErrors\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"operations\"\n },\n \"operationGids\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperation/properties/gid\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"operationGids\"\n ],\n \"additionalProperties\": false\n }\n ]\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": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.", "examples": [ "<%= config.bin %> <%= command.id %> --input migrations.csv --force",