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