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 4f592e29a0a..e485b057469 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' @@ -434,6 +437,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) }) @@ -557,12 +566,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.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.ts index 3ee0e8c52de..4d9a4a026eb 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 { @@ -20,7 +22,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 %>', @@ -30,7 +32,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 148a52dc32f..d122521e49f 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,4 @@ +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,6 +9,15 @@ 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' @@ -257,3 +267,75 @@ 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({schemaVersion: 1, subscriptions: [value]}, null, 2), + ) + }) + + test.each([ + {manualSubscriptionPrice: {amount: 19.99, currencyCode: 'USD'}}, + {notification: {kind: 'NONE', optOutDeadline: null}}, + ])('rejects invalid subscription fields: %j', (fields) => { + expect(() => + migrationListJsonOutputSchema.validate({ + schemaVersion: 1, + subscriptions: [{...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 = {...subscription(), ...fields} + + const encoded = migrationListJsonOutputSchema.encode({schemaVersion: 1, subscriptions: [value]}) + + expect(JSON.parse(encoded)).toEqual({schemaVersion: 1, 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({schemaVersion: 1, subscriptions: [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 b1def5c1997..775882c4885 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,4 @@ +import {migrationListJsonOutputSchema} from './types.js' import {outputResult} from '@shopify/cli-kit/node/output' import type {MigratableSubscription} from '../../models/subscription-migrations.js' @@ -10,7 +11,7 @@ interface MigrationListOutputOptions { } export function serializeMigrationListJson(subscriptions: MigratableSubscription[]): string { - return JSON.stringify({schemaVersion: 1, subscriptions}, null, 2) + return migrationListJsonOutputSchema.encode({schemaVersion: 1, subscriptions}) } export function serializeMigrationListCsv(subscriptions: MigratableSubscription[]): string { diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index 55162691cda..0c4fd54ed6c 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -62,3 +62,43 @@ export const migrationCancellationJsonOutputSchema = defineJsonOutputSchema({ export type MigrationCancellationJsonOutput = InferJsonOutputSchema export type MigrationCancellationResult = Omit export type MigrationCancellationOutcome = MigrationCancellationResult['outcomes'][number] + +const MigratableSubscriptionPriceSchema = zod.object({ + amount: zod.string(), + currencyCode: zod.string(), +}) + +const MigratableSubscriptionNotificationSchema = zod.object({ + kind: zod.string().describe('Known values: NONE, OPT_OUT, WHEN_REQUIRED.'), + optOutDeadline: zod.string().nullable(), + sentAt: zod.string().nullable(), +}) + +// Server-provided values are typed as strings (with the known values documented) instead of enums, so a new +// server-side value never makes `--json` output fail validation. Compatibility with the `MigratableSubscription` +// model is enforced where `serializeMigrationListJson` passes the model into this schema's `encode`. +const MigratableSubscriptionSchema = zod.object({ + shopId: zod.string(), + 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.string().nullable(), + lastFailureReason: zod.string().nullable().describe('Known values: SUPERSEDED, SCHEDULING_FAILED.'), +}) + +export const migrationListJsonOutputSchema = defineJsonOutputSchema({ + name: 'MigrationListResult', + schema: zod.object({ + schemaVersion: zod.literal(1), + subscriptions: zod.array(MigratableSubscriptionSchema), + }), + definitions: { + MigratableSubscription: MigratableSubscriptionSchema, + MigratableSubscriptionPrice: MigratableSubscriptionPriceSchema, + MigratableSubscriptionNotification: MigratableSubscriptionNotificationSchema, + }, +}) diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index c013093e3a7..83731e524f9 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4059,7 +4059,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 versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", + "description": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.\n\nOutput from `--json` conforms to the `MigrationListResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"schemaVersion\": {\n \"type\": \"number\",\n \"const\": 1\n },\n \"subscriptions\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigratableSubscription\"\n }\n }\n },\n \"required\": [\n \"schemaVersion\",\n \"subscriptions\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationListResult\",\n \"definitions\": {\n \"MigratableSubscription\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopId\": {\n \"type\": \"string\"\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 \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"lastFailureReason\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"Known values: SUPERSEDED, SCHEDULING_FAILED.\"\n }\n },\n \"required\": [\n \"shopId\",\n \"status\",\n \"manualSubscriptionName\",\n \"manualSubscriptionPrice\",\n \"manualSubscriptionInterval\",\n \"targetPlanHandle\",\n \"notification\",\n \"priceBehavior\",\n \"effectiveDate\",\n \"lastFailureReason\"\n ],\n \"additionalProperties\": false\n },\n \"MigratableSubscriptionPrice\": {\n \"type\": \"object\",\n \"properties\": {\n \"amount\": {\n \"type\": \"string\"\n },\n \"currencyCode\": {\n \"type\": \"string\"\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 \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"sentAt\": {\n \"type\": [\n \"string\",\n \"null\"\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 versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", "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 28f7d494915..6824e458d33 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -33,7 +33,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/info.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',