diff --git a/packages/app/src/cli/api/graphql/subscription_migrations.ts b/packages/app/src/cli/api/graphql/subscription_migrations.ts index af3ee74cf3f..fabe1c226ab 100644 --- a/packages/app/src/cli/api/graphql/subscription_migrations.ts +++ b/packages/app/src/cli/api/graphql/subscription_migrations.ts @@ -1,5 +1,44 @@ import {gql} from 'graphql-request' +// eslint-disable-next-line @shopify/cli/no-inline-graphql +export const MigratableAppSubscriptionsQuery = gql` + query MigratableAppSubscriptions( + $apiKey: String! + $first: Int! + $after: String + $status: AppSubscriptionMigrationStatus + ) { + migratableAppSubscriptions(apiKey: $apiKey, first: $first, after: $after, status: $status) { + edges { + cursor + node { + shopId + status + manualSubscriptionName + manualSubscriptionPrice { + amount + currencyCode + } + manualSubscriptionInterval + targetPlanHandle + notification { + kind + optOutDeadline + sentAt + } + priceBehavior + effectiveDate + lastFailureReason + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +` + // 250 matches the maximum migration submission batch size; operation results are currently bounded by that contract. // eslint-disable-next-line @shopify/cli/no-inline-graphql export const AppSubscriptionMigrationOperationCreateMutation = gql` 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 4a87e69f2a0..6da38dcd501 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 @@ -1,4 +1,5 @@ import Cancel from './cancel.js' +import List from './list.js' import Schedule from './schedule.js' import Status from './status.js' import Unschedule from './unschedule.js' @@ -12,7 +13,7 @@ import {getMigrationOperations} from '../../../services/subscription-migrations/ import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js' import {watchMigrationOperations} from '../../../services/subscription-migrations/watch-operations.js' import AppLinkedCommand from '../../../utilities/app-linked-command.js' -import {jsonFlag} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' 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' @@ -360,7 +361,7 @@ describe('subscription migration operation commands', () => { }) describe('subscription migration command metadata', () => { - test.each([Schedule, Unschedule, Status, Cancel])('$name is hidden from command discovery', (Command) => { + test.each([Schedule, Unschedule, Status, Cancel, List])('$name is hidden from command discovery', (Command) => { expect(Command.hidden).toBe(true) }) @@ -416,7 +417,7 @@ describe('subscription migration command metadata', () => { }) }) - test.each([Schedule, Unschedule, Status, Cancel])('$name uses all canonical app context flags', (Command) => { + test.each([Schedule, Unschedule, Status, Cancel, List])('$name uses all canonical app context flags', (Command) => { expect(Command.flags.path).toBe(appFlags.path) expect(Command.flags.config).toBe(appFlags.config) expect(Command.flags['client-id']).toBe(appFlags['client-id']) @@ -425,30 +426,32 @@ describe('subscription migration command metadata', () => { expect(Command.flags['client-id']?.exclusive).toEqual(['config']) }) - test.each([Schedule, Unschedule, Status, Cancel])('$name uses the canonical JSON flag', (Command) => { + test.each([Schedule, Unschedule, Status, Cancel, List])('$name uses the canonical JSON flag', (Command) => { expect(Command.flags.json).toBe(jsonFlag.json) }) - test.each([Schedule, Unschedule, Status, Cancel])('$name has no legacy migration flags', (Command) => { + test.each([Schedule, Unschedule, Status, Cancel, List])('$name has no legacy migration flags', (Command) => { expect(Object.keys(Command.flags)).not.toEqual( expect.arrayContaining(['yes', 'operation', 'operation-id', 'run', 'run-id', 'idempotency-key']), ) }) - test.each([Schedule, Unschedule, Status, Cancel])('$name extends AppLinkedCommand', (Command) => { + test.each([Schedule, Unschedule, Status, Cancel, List])('$name extends AppLinkedCommand', (Command) => { expect(Object.getPrototypeOf(Command)).toBe(AppLinkedCommand) expect(Command.baseFlags).toBe(AppLinkedCommand.baseFlags) expect(Command.flags['auth-alias' as keyof typeof Command.flags]).toBeUndefined() }) - test('registers the four command IDs with their exact classes', () => { + test('registers the five command IDs with their exact classes', () => { expect({ 'app:subscription-migrations:cancel': commands['app:subscription-migrations:cancel'], + 'app:subscription-migrations:list': commands['app:subscription-migrations:list'], 'app:subscription-migrations:schedule': commands['app:subscription-migrations:schedule'], 'app:subscription-migrations:status': commands['app:subscription-migrations:status'], 'app:subscription-migrations:unschedule': commands['app:subscription-migrations:unschedule'], }).toEqual({ 'app:subscription-migrations:cancel': Cancel, + 'app:subscription-migrations:list': List, 'app:subscription-migrations:schedule': Schedule, 'app:subscription-migrations:status': Status, 'app:subscription-migrations:unschedule': Unschedule, @@ -460,11 +463,12 @@ describe('subscription migration command metadata', () => { [Unschedule, 'Reverses app subscription migrations that are still scheduled.'], [Status, 'Checks the status of app subscription migration operations.'], [Cancel, 'Cancels app subscription migration operations.'], + [List, 'Lists app subscriptions eligible for migration.'], ])('$Command.name has an exact third-person summary', (Command, summary) => { expect(Command.summary).toBe(summary) }) - test.each([Schedule, Unschedule, Status, Cancel])( + test.each([Schedule, Unschedule, Status, Cancel, List])( '$name provides action-oriented command documentation', (Command) => { expect(Command.summary).toMatch(/[.!]$/) @@ -482,6 +486,49 @@ describe('subscription migration command metadata', () => { }, ) + test('list defines exact status metadata without file destination flags', () => { + expect('output' in List.flags).toBe(false) + expect('force' in List.flags).toBe(false) + expect(List.flags.status).toMatchObject({ + description: 'Filter subscriptions by migration status.', + env: 'SHOPIFY_FLAG_STATUS', + options: ['UNSCHEDULED', 'SCHEDULED', 'MIGRATED'], + }) + expect(List.flags.json).toBe(jsonFlag.json) + expect(List.flags.path).toBe(appFlags.path) + expect(List.flags.config).toBe(appFlags.config) + expect(List.flags['client-id']).toBe(appFlags['client-id']) + expect(List.flags.reset).toBe(appFlags.reset) + }) + + test('list spreads the canonical global flags', () => { + expect( + Object.entries(globalFlags).every(([flagName, flag]) => List.flags[flagName as keyof typeof List.flags] === flag), + ).toBe(true) + }) + + test('list documents stdout formats, shell redirection, and filters', () => { + expect(List.descriptionWithMarkdown).toContain('all pages') + expect(List.descriptionWithMarkdown).toContain('CSV') + expect(List.descriptionWithMarkdown).toContain('stdout') + expect(List.descriptionWithMarkdown).toContain('`--json`') + expect(List.descriptionWithMarkdown).toContain('> subscriptions.csv') + expect(List.descriptionWithMarkdown).toContain('> subscriptions.json') + expect(List.descriptionWithMarkdown).not.toContain('`--output') + expect(List.descriptionWithMarkdown).not.toContain('`--force`') + expect(List.examples.some((example) => example.includes('> subscriptions.csv'))).toBe(true) + expect(List.examples.some((example) => example.includes('--json > subscriptions.json'))).toBe(true) + expect(List.examples.every((example) => !example.includes('--output') && !example.includes('--force'))).toBe(true) + expect(List.descriptionWithMarkdown).toContain('`--status`') + expect(List.descriptionWithMarkdown).toContain('`UNSCHEDULED`') + expect(List.descriptionWithMarkdown).toContain('`SCHEDULED`') + expect(List.descriptionWithMarkdown).toContain('`MIGRATED`') + expect(List.descriptionWithMarkdown).toContain('`--path`') + expect(List.descriptionWithMarkdown).toContain('`--config`') + expect(List.descriptionWithMarkdown).toContain('`--client-id`') + expect(List.descriptionWithMarkdown).toContain('`--reset`') + }) + test.each([Schedule, Unschedule])( '$name documents input flag and stdin usage without positional syntax', (Command) => { @@ -495,14 +542,14 @@ describe('subscription migration command metadata', () => { }, ) - test.each([Schedule, Unschedule, Status, Cancel])( + test.each([Schedule, Unschedule, Status, Cancel, List])( '$name uses the configured binary and command ID in every example', (Command) => { expect(Command.examples.every((example) => example.includes('<%= config.bin %> <%= command.id %>'))).toBe(true) }, ) - test.each([Schedule, Unschedule, Status, Cancel])( + test.each([Schedule, Unschedule, Status, Cancel, List])( '$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/flags.ts b/packages/app/src/cli/commands/app/subscription-migrations/flags.ts index 11b3333a67f..efdb33ea3e2 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/flags.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/flags.ts @@ -1,4 +1,5 @@ import {appFlags} from '../../../flags.js' +import {MIGRATABLE_SUBSCRIPTION_STATUSES} from '../../../models/subscription-migrations.js' import {Flags} from '@oclif/core' import {globalFlags, jsonFlag, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' @@ -26,6 +27,15 @@ const statusWatchFlag = { }), } +export const listFlags = { + ...sharedFlags, + status: Flags.option({ + description: 'Filter subscriptions by migration status.', + env: 'SHOPIFY_FLAG_STATUS', + options: [...MIGRATABLE_SUBSCRIPTION_STATUSES], + })(), +} + export const submissionFlags = { ...sharedFlags, input: Flags.string({ 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 new file mode 100644 index 00000000000..379e099e8a4 --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts @@ -0,0 +1,300 @@ +import List from './list.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' +import {getMigratableSubscriptionPage} from '../../../services/subscription-migrations/partners-api.js' +import {Config} from '@oclif/core' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputResult} from '@shopify/cli-kit/node/output' +import {parse} from 'csv-parse/sync' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import type {MigratableSubscription} from '../../../models/subscription-migrations.js' +import type {MigratableSubscriptionPage} from '../../../services/subscription-migrations/partners-api.js' + +vi.mock('../../../services/app-context.js') +vi.mock('../../../services/subscription-migrations/partners-api.js') +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, outputResult: vi.fn()} +}) + +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 app = testAppLinked() +const remoteApp = testOrganizationApp({apiKey: 'remote-client-id'}) +const historicalSubscription: MigratableSubscription = { + shopId: 'gid://shopify/Shop/123456789', + status: 'MIGRATED', + manualSubscriptionName: 'Historical legacy plan', + manualSubscriptionPrice: {amount: '29.95', currencyCode: 'CAD'}, + manualSubscriptionInterval: 'ANNUAL', + targetPlanHandle: 'plus', + notification: { + kind: 'NONE', + optOutDeadline: '2025-12-01T00:00:00Z', + sentAt: '2025-11-01T00:00:00Z', + }, + priceBehavior: 'PLAN_PRICE', + effectiveDate: '2026-01-01T00:00:00Z', + lastFailureReason: 'SUPERSEDED', +} + +function subscription(shopId: string, overrides: Partial = {}): MigratableSubscription { + return {...historicalSubscription, shopId, ...overrides} +} + +function page( + subscriptions: MigratableSubscription[], + pageInfo: MigratableSubscriptionPage['pageInfo'] = {hasNextPage: false, endCursor: null}, +): MigratableSubscriptionPage { + return {subscriptions, pageInfo} +} + +function stdoutWrites(): string[] { + return vi.mocked(outputResult).mock.calls.map(([content]) => content as string) +} + +/** Reconstructs what a shell redirection would have captured: every write, each terminated by a newline. */ +function stdoutContent(): string { + return stdoutWrites() + .map((write) => `${write}\n`) + .join('') +} + +function parseCsvRows(csv: string): {shop_id: string; manual_subscription_name: string}[] { + return parse(csv, {columns: true}) +} + +function invocationOrder(mock: {mock: {invocationCallOrder: number[]}}): number[] { + return mock.mock.invocationCallOrder +} + +async function runListWithoutOclifErrorHandling(argv: string[]) { + const config = await Config.load() + return new List(argv, config).run() +} + +beforeEach(() => { + vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp} as Awaited>) +}) + +describe('subscription migration list command output integration', () => { + test('writes default CSV for a single page to stdout exactly once', async () => { + vi.mocked(getMigratableSubscriptionPage).mockResolvedValue(page([historicalSubscription])) + + await List.run([]) + + expect(getMigratableSubscriptionPage).toHaveBeenCalledOnce() + expect(getMigratableSubscriptionPage).toHaveBeenCalledWith({ + clientId: 'remote-client-id', + first: 250, + after: undefined, + status: undefined, + }) + expect(outputResult).toHaveBeenCalledOnce() + const output = stdoutWrites()[0]! + expect(output).toBe( + `${CSV_HEADER}\n` + + 'gid://shopify/Shop/123456789,MIGRATED,Historical legacy plan,29.95,CAD,ANNUAL,plus,NONE,2025-12-01T00:00:00Z,2025-11-01T00:00:00Z,PLAN_PRICE,2026-01-01T00:00:00Z,SUPERSEDED', + ) + expect(parse(output, {columns: true})).toEqual([ + { + shop_id: 'gid://shopify/Shop/123456789', + status: 'MIGRATED', + manual_subscription_name: 'Historical legacy plan', + manual_subscription_price_amount: '29.95', + manual_subscription_price_currency_code: 'CAD', + manual_subscription_interval: 'ANNUAL', + target_plan_handle: 'plus', + notification_kind: 'NONE', + notification_opt_out_deadline: '2025-12-01T00:00:00Z', + notification_sent_at: '2025-11-01T00:00:00Z', + price_behavior: 'PLAN_PRICE', + effective_date: '2026-01-01T00:00:00Z', + last_failure_reason: 'SUPERSEDED', + }, + ]) + }) + + test('writes only the CSV header when there are no subscriptions', async () => { + vi.mocked(getMigratableSubscriptionPage).mockResolvedValue(page([])) + + await List.run([]) + + expect(outputResult).toHaveBeenCalledOnce() + expect(outputResult).toHaveBeenCalledWith(CSV_HEADER) + expect(parse(stdoutContent(), {columns: true})).toEqual([]) + }) + + test('streams CSV page by page, writing the header and page one before requesting page two', async () => { + vi.mocked(getMigratableSubscriptionPage) + .mockResolvedValueOnce( + page([subscription('gid://shopify/Shop/1'), subscription('gid://shopify/Shop/2')], { + hasNextPage: true, + endCursor: 'cursor-one', + }), + ) + .mockResolvedValueOnce(page([subscription('gid://shopify/Shop/3')], {hasNextPage: true, endCursor: 'cursor-two'})) + .mockResolvedValueOnce(page([subscription('gid://shopify/Shop/4', {manualSubscriptionName: 'Plan, "Plus"'})])) + + await List.run(['--status', 'MIGRATED']) + + expect(getMigratableSubscriptionPage).toHaveBeenCalledTimes(3) + expect(getMigratableSubscriptionPage).toHaveBeenNthCalledWith(1, { + clientId: 'remote-client-id', + first: 250, + after: undefined, + status: 'MIGRATED', + }) + expect(getMigratableSubscriptionPage).toHaveBeenNthCalledWith(2, { + clientId: 'remote-client-id', + first: 250, + after: 'cursor-one', + status: 'MIGRATED', + }) + expect(getMigratableSubscriptionPage).toHaveBeenNthCalledWith(3, { + clientId: 'remote-client-id', + first: 250, + after: 'cursor-two', + status: 'MIGRATED', + }) + + // Each page is written to stdout before the next page is requested. + const [pageOneRequest, pageTwoRequest, pageThreeRequest] = invocationOrder(vi.mocked(getMigratableSubscriptionPage)) + const [pageOneWrite, pageTwoWrite, pageThreeWrite] = invocationOrder(vi.mocked(outputResult)) + expect(outputResult).toHaveBeenCalledTimes(3) + expect(pageOneRequest).toBeLessThan(pageOneWrite!) + expect(pageOneWrite).toBeLessThan(pageTwoRequest!) + expect(pageTwoRequest).toBeLessThan(pageTwoWrite!) + expect(pageTwoWrite).toBeLessThan(pageThreeRequest!) + expect(pageThreeRequest).toBeLessThan(pageThreeWrite!) + + const writes = stdoutWrites() + expect(writes[0]!.startsWith(`${CSV_HEADER}\n`)).toBe(true) + expect(writes[1]).not.toContain(CSV_HEADER) + expect(writes[2]).not.toContain(CSV_HEADER) + expect(writes[2]).toContain('"Plan, ""Plus"""') + + const rows = parseCsvRows(stdoutContent()) + expect(rows.map((row) => row.shop_id)).toEqual([ + 'gid://shopify/Shop/1', + 'gid://shopify/Shop/2', + 'gid://shopify/Shop/3', + 'gid://shopify/Shop/4', + ]) + expect(rows[3]!.manual_subscription_name).toBe('Plan, "Plus"') + }) + + test('keeps earlier CSV pages on stdout and propagates the error when a later page fails', async () => { + const apiError = new Error('Partners API unavailable') + vi.mocked(getMigratableSubscriptionPage) + .mockResolvedValueOnce(page([subscription('gid://shopify/Shop/1')], {hasNextPage: true, endCursor: 'cursor-one'})) + .mockRejectedValueOnce(apiError) + + await expect(runListWithoutOclifErrorHandling([])).rejects.toBe(apiError) + + expect(getMigratableSubscriptionPage).toHaveBeenCalledTimes(2) + expect(outputResult).toHaveBeenCalledOnce() + const rows = parseCsvRows(stdoutContent()) + expect(rows.map((row) => row.shop_id)).toEqual(['gid://shopify/Shop/1']) + }) + + test('keeps earlier CSV pages on stdout when a later page has an invalid cursor', async () => { + vi.mocked(getMigratableSubscriptionPage) + .mockResolvedValueOnce(page([subscription('gid://shopify/Shop/1')], {hasNextPage: true, endCursor: 'cursor-one'})) + .mockResolvedValueOnce(page([subscription('gid://shopify/Shop/2')], {hasNextPage: true, endCursor: ' '})) + + await expect(runListWithoutOclifErrorHandling([])).rejects.toBeInstanceOf(MigrationListProtocolError) + + expect(getMigratableSubscriptionPage).toHaveBeenCalledTimes(2) + const rows = parseCsvRows(stdoutContent()) + expect(rows.map((row) => row.shop_id)).toEqual(['gid://shopify/Shop/1', 'gid://shopify/Shop/2']) + }) + + test.each([ + {args: [], format: 'CSV'}, + {args: ['--json'], format: 'JSON'}, + ])('translates a first-page missing connection without writing $format output', async ({args}) => { + vi.mocked(getMigratableSubscriptionPage).mockResolvedValue(null) + + const command = runListWithoutOclifErrorHandling(args) + await expect(command).rejects.toBeInstanceOf(AbortError) + await expect(command).rejects.toThrow('App not found') + + expect(outputResult).not.toHaveBeenCalled() + }) + + test('keeps earlier CSV pages when a later page has a missing connection', async () => { + vi.mocked(getMigratableSubscriptionPage) + .mockResolvedValueOnce(page([subscription('gid://shopify/Shop/1')], {hasNextPage: true, endCursor: 'cursor-one'})) + .mockResolvedValueOnce(null) + + const command = runListWithoutOclifErrorHandling([]) + await expect(command).rejects.toBeInstanceOf(AbortError) + await expect(command).rejects.toThrow('App not found') + + expect(getMigratableSubscriptionPage).toHaveBeenCalledTimes(2) + expect(outputResult).toHaveBeenCalledOnce() + const rows = parseCsvRows(stdoutContent()) + expect(rows.map((row) => row.shop_id)).toEqual(['gid://shopify/Shop/1']) + }) + + test('writes nothing when the first page fails', async () => { + const apiError = new Error('Partners API unavailable') + vi.mocked(getMigratableSubscriptionPage).mockRejectedValue(apiError) + + await expect(runListWithoutOclifErrorHandling([])).rejects.toBe(apiError) + + expect(outputResult).not.toHaveBeenCalled() + }) + + test('writes exactly one complete versioned JSON document after every page succeeds', async () => { + const pageOne = [subscription('gid://shopify/Shop/1'), subscription('gid://shopify/Shop/2')] + const pageTwo = [subscription('gid://shopify/Shop/3')] + vi.mocked(getMigratableSubscriptionPage) + .mockResolvedValueOnce(page(pageOne, {hasNextPage: true, endCursor: 'cursor-one'})) + .mockResolvedValueOnce(page(pageTwo)) + + await List.run(['--json', '--status', 'SCHEDULED']) + + expect(getMigratableSubscriptionPage).toHaveBeenCalledTimes(2) + expect(getMigratableSubscriptionPage).toHaveBeenNthCalledWith(2, { + clientId: 'remote-client-id', + first: 250, + after: 'cursor-one', + status: 'SCHEDULED', + }) + + // Nothing is written until the last page has been fetched. + const lastPageRequest = invocationOrder(vi.mocked(getMigratableSubscriptionPage)).at(-1)! + const [jsonWrite] = invocationOrder(vi.mocked(outputResult)) + expect(outputResult).toHaveBeenCalledOnce() + expect(jsonWrite).toBeGreaterThan(lastPageRequest) + + const output = stdoutWrites()[0]! + expect(output).toBe(JSON.stringify({schemaVersion: 1, subscriptions: [...pageOne, ...pageTwo]}, null, 2)) + expect(JSON.parse(output)).toEqual({schemaVersion: 1, subscriptions: [...pageOne, ...pageTwo]}) + }) + + test('writes an empty versioned JSON document when there are no subscriptions', async () => { + vi.mocked(getMigratableSubscriptionPage).mockResolvedValue(page([])) + + await List.run(['--json']) + + expect(outputResult).toHaveBeenCalledOnce() + expect(JSON.parse(stdoutWrites()[0]!)).toEqual({schemaVersion: 1, subscriptions: []}) + }) + + test('writes no JSON at all when a later page fails', async () => { + const apiError = new Error('Partners API unavailable') + vi.mocked(getMigratableSubscriptionPage) + .mockResolvedValueOnce(page([subscription('gid://shopify/Shop/1')], {hasNextPage: true, endCursor: 'cursor-one'})) + .mockRejectedValueOnce(apiError) + + await expect(runListWithoutOclifErrorHandling(['--json'])).rejects.toBe(apiError) + + expect(getMigratableSubscriptionPage).toHaveBeenCalledTimes(2) + expect(outputResult).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts new file mode 100644 index 00000000000..b66cf80811c --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts @@ -0,0 +1,131 @@ +import List from './list.js' +import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' +import {linkedAppContext} from '../../../services/app-context.js' +import { + iterateMigratableSubscriptionPages, + MigrationListProtocolError, + MigratableSubscriptionsNotFoundError, +} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' +import {outputMigrationList} from '../../../services/subscription-migrations/list-output.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {Config} from '@oclif/core' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import type {MigratableSubscription} from '../../../models/subscription-migrations.js' + +vi.mock('../../../services/app-context.js') +vi.mock('../../../services/subscription-migrations/list-migratable-subscriptions.js') +vi.mock('../../../services/subscription-migrations/list-output.js', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, outputMigrationList: vi.fn()} +}) + +const app = testAppLinked() +const remoteApp = testOrganizationApp({apiKey: 'remote-client-id'}) +const subscriptions: MigratableSubscription[] = [ + { + shopId: 'gid://shopify/Shop/1', + status: 'SCHEDULED', + manualSubscriptionName: 'Legacy plan', + manualSubscriptionPrice: {amount: '19.99', currencyCode: 'USD'}, + manualSubscriptionInterval: 'EVERY_30_DAYS', + targetPlanHandle: 'standard', + notification: {kind: 'NONE', optOutDeadline: null, sentAt: null}, + priceBehavior: 'HONOR_BILLING_PRICE', + effectiveDate: '2026-05-01T00:00:00Z', + lastFailureReason: null, + }, +] + +async function* singlePage(): AsyncGenerator { + yield subscriptions +} + +async function runListWithoutOclifErrorHandling(argv: string[]) { + const config = await Config.load() + return new List(argv, config).run() +} + +let pages: AsyncGenerator + +beforeEach(() => { + pages = singlePage() + vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp} as Awaited>) + vi.mocked(iterateMigratableSubscriptionPages).mockReturnValue(pages) + vi.mocked(outputMigrationList).mockResolvedValue(undefined) +}) + +describe('subscription migration list command', () => { + test('streams the linked app subscription pages as CSV to stdout by default', async () => { + const result = await List.run(['--path', '/selected/app', '--config', 'staging']) + + expect(linkedAppContext).toHaveBeenCalledWith({ + directory: '/selected/app', + clientId: undefined, + forceRelink: false, + userProvidedConfigName: 'staging', + }) + expect(iterateMigratableSubscriptionPages).toHaveBeenCalledWith({ + clientId: 'remote-client-id', + status: undefined, + }) + expect(outputMigrationList).toHaveBeenCalledOnce() + expect(outputMigrationList).toHaveBeenCalledWith({pages, json: false}) + expect(result).toEqual({app}) + }) + + test('delegates the same page stream to JSON stdout output when requested', async () => { + await List.run(['--json', '--client-id', 'selected-client-id', '--reset']) + + expect(linkedAppContext).toHaveBeenCalledWith({ + directory: expect.any(String), + clientId: 'selected-client-id', + forceRelink: true, + userProvidedConfigName: undefined, + }) + expect(outputMigrationList).toHaveBeenCalledWith({pages, json: true}) + }) + + test('forwards a status filter to the API', async () => { + await List.run(['--status', 'SCHEDULED']) + + expect(iterateMigratableSubscriptionPages).toHaveBeenCalledWith({ + clientId: 'remote-client-id', + status: 'SCHEDULED', + }) + expect(outputMigrationList).toHaveBeenCalledWith({pages, json: false}) + }) + + test('rejects an invalid status during parsing before resolving app context or fetching subscriptions', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await expect(runListWithoutOclifErrorHandling(['--status', 'PENDING'])).rejects.toThrow() + + expect(linkedAppContext).not.toHaveBeenCalled() + expect(iterateMigratableSubscriptionPages).not.toHaveBeenCalled() + expect(outputMigrationList).not.toHaveBeenCalled() + }) + + test('translates a missing migratable-subscriptions connection at the command boundary', async () => { + const missingConnectionError = new MigratableSubscriptionsNotFoundError() + vi.mocked(outputMigrationList).mockRejectedValue(missingConnectionError) + + await expect(runListWithoutOclifErrorHandling([])).rejects.toMatchObject({ + constructor: AbortError, + message: 'App not found', + }) + }) + + test('propagates a migration list protocol error without rewriting it', async () => { + const protocolError = new MigrationListProtocolError('Migratable subscription page has no cursor for its next page') + vi.mocked(outputMigrationList).mockRejectedValue(protocolError) + + await expect(runListWithoutOclifErrorHandling([])).rejects.toBe(protocolError) + }) + + test('propagates an output failure without returning a result', async () => { + const apiError = new Error('Partners API unavailable') + vi.mocked(outputMigrationList).mockRejectedValue(apiError) + + await expect(runListWithoutOclifErrorHandling([])).rejects.toBe(apiError) + }) +}) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.ts new file mode 100644 index 00000000000..3ee0e8c52de --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.ts @@ -0,0 +1,55 @@ +import {listFlags} from './flags.js' +import {linkedAppContext} from '../../../services/app-context.js' +import { + iterateMigratableSubscriptionPages, + MigratableSubscriptionsNotFoundError, +} 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 {AbortError} from '@shopify/cli-kit/node/error' + +export default class List extends AppLinkedCommand { + static hidden = true + static summary = 'Lists app subscriptions eligible for migration.' + + static descriptionWithMarkdown = `Lists every app subscription eligible for migration. + +By default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use \`--json\` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example \`shopify app subscription-migrations list > subscriptions.csv\` or \`shopify app subscription-migrations list --json > subscriptions.json\`. + +Use \`--status\` to filter subscriptions by migration status. Supported values are \`UNSCHEDULED\`, \`SCHEDULED\`, and \`MIGRATED\`. + +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 examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --status SCHEDULED > scheduled-subscriptions.csv', + '<%= config.bin %> <%= command.id %> --json', + '<%= config.bin %> <%= command.id %> --json > subscriptions.json', + '<%= config.bin %> <%= command.id %> --client-id > subscriptions.csv', + ] + + static flags = {...listFlags} + + async run(): Promise { + const {flags} = await this.parse(List) + const {app, remoteApp} = await linkedAppContext({ + directory: flags.path, + clientId: flags['client-id'], + forceRelink: flags.reset, + userProvidedConfigName: flags.config, + }) + try { + const pages = iterateMigratableSubscriptionPages({ + clientId: remoteApp.apiKey, + status: flags.status, + }) + await outputMigrationList({pages, json: flags.json}) + } catch (error) { + if (error instanceof MigratableSubscriptionsNotFoundError) throw new AbortError('App not found') + throw error + } + return {app} + } +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index b08883345a5..ba49419e490 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -28,6 +28,7 @@ import Init from './commands/app/init.js' import ConfigValidate from './commands/app/config/validate.js' import Release from './commands/app/release.js' import SubscriptionMigrationsCancel from './commands/app/subscription-migrations/cancel.js' +import SubscriptionMigrationsList from './commands/app/subscription-migrations/list.js' import SubscriptionMigrationsSchedule from './commands/app/subscription-migrations/schedule.js' import SubscriptionMigrationsStatus from './commands/app/subscription-migrations/status.js' import SubscriptionMigrationsUnschedule from './commands/app/subscription-migrations/unschedule.js' @@ -65,6 +66,7 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:config:validate': ConfigValidate, 'app:release': Release, 'app:subscription-migrations:cancel': SubscriptionMigrationsCancel, + 'app:subscription-migrations:list': SubscriptionMigrationsList, 'app:subscription-migrations:schedule': SubscriptionMigrationsSchedule, 'app:subscription-migrations:status': SubscriptionMigrationsStatus, 'app:subscription-migrations:unschedule': SubscriptionMigrationsUnschedule, diff --git a/packages/app/src/cli/models/subscription-migrations.test.ts b/packages/app/src/cli/models/subscription-migrations.test.ts index 990898b2136..b5479a9b736 100644 --- a/packages/app/src/cli/models/subscription-migrations.test.ts +++ b/packages/app/src/cli/models/subscription-migrations.test.ts @@ -1,6 +1,6 @@ -import {NOTIFICATION_KINDS, PRICE_BEHAVIORS} from './subscription-migrations.js' +import {MIGRATABLE_SUBSCRIPTION_STATUSES, NOTIFICATION_KINDS, PRICE_BEHAVIORS} from './subscription-migrations.js' import {describe, expect, test} from 'vitest' -import type {MigrationOperation, MigrationPlanResult} from './subscription-migrations.js' +import type {MigratableSubscription, MigrationOperation, MigrationPlanResult} from './subscription-migrations.js' describe('subscription migration domain models', () => { test('matches the Partners API price behaviors', () => { @@ -11,6 +11,47 @@ describe('subscription migration domain models', () => { expect(NOTIFICATION_KINDS).toEqual(['OPT_OUT', 'WHEN_REQUIRED']) }) + test('matches the Partners API migratable subscription statuses', () => { + expect(MIGRATABLE_SUBSCRIPTION_STATUSES).toEqual(['UNSCHEDULED', 'SCHEDULED', 'MIGRATED']) + }) + + test('represents every field returned for a migratable subscription', () => { + const subscription: MigratableSubscription = { + shopId: 'gid://shopify/Shop/1', + status: 'MIGRATED', + manualSubscriptionName: 'Legacy plan', + manualSubscriptionPrice: {amount: '19.99', currencyCode: 'USD'}, + manualSubscriptionInterval: 'EVERY_30_DAYS', + targetPlanHandle: 'pro', + notification: { + kind: 'NONE', + optOutDeadline: '2025-01-02T03:04:05Z', + sentAt: '2025-01-01T03:04:05Z', + }, + priceBehavior: 'HONOR_BILLING_PRICE', + effectiveDate: '2025-02-01', + lastFailureReason: 'SUPERSEDED', + } + + expect(subscription).toEqual({ + shopId: 'gid://shopify/Shop/1', + status: 'MIGRATED', + manualSubscriptionName: 'Legacy plan', + manualSubscriptionPrice: {amount: '19.99', currencyCode: 'USD'}, + manualSubscriptionInterval: 'EVERY_30_DAYS', + targetPlanHandle: 'pro', + notification: { + kind: 'NONE', + optOutDeadline: '2025-01-02T03:04:05Z', + sentAt: '2025-01-01T03:04:05Z', + }, + priceBehavior: 'HONOR_BILLING_PRICE', + effectiveDate: '2025-02-01', + lastFailureReason: 'SUPERSEDED', + }) + expect(NOTIFICATION_KINDS).toEqual(['OPT_OUT', 'WHEN_REQUIRED']) + }) + test('represents successful and failed planning results', () => { const success: MigrationPlanResult = { ok: true, diff --git a/packages/app/src/cli/models/subscription-migrations.ts b/packages/app/src/cli/models/subscription-migrations.ts index bb2f0482dd6..4384897ad2f 100644 --- a/packages/app/src/cli/models/subscription-migrations.ts +++ b/packages/app/src/cli/models/subscription-migrations.ts @@ -4,6 +4,34 @@ export type PriceBehavior = (typeof PRICE_BEHAVIORS)[number] export const NOTIFICATION_KINDS = ['OPT_OUT', 'WHEN_REQUIRED'] as const export type NotificationKind = (typeof NOTIFICATION_KINDS)[number] +export const MIGRATABLE_SUBSCRIPTION_STATUSES = ['UNSCHEDULED', 'SCHEDULED', 'MIGRATED'] as const +export type MigratableSubscriptionStatus = (typeof MIGRATABLE_SUBSCRIPTION_STATUSES)[number] + +export type ManualSubscriptionInterval = 'EVERY_30_DAYS' | 'ANNUAL' +export type MigrationFailureReason = 'SUPERSEDED' | 'SCHEDULING_FAILED' +export type MigratableSubscriptionNotificationKind = 'NONE' | 'OPT_OUT' | 'WHEN_REQUIRED' +export type MigratableSubscriptionPriceBehavior = 'HONOR_BILLING_PRICE' | 'PLAN_PRICE' + +export interface MigratableSubscription { + shopId: string + status: MigratableSubscriptionStatus + manualSubscriptionName: string | null + manualSubscriptionPrice: { + amount: string + currencyCode: string + } | null + manualSubscriptionInterval: ManualSubscriptionInterval + targetPlanHandle: string | null + notification: { + kind: MigratableSubscriptionNotificationKind + optOutDeadline: string | null + sentAt: string | null + } | null + priceBehavior: MigratableSubscriptionPriceBehavior | null + effectiveDate: string | null + lastFailureReason: MigrationFailureReason | null +} + export type MigrationAction = 'schedule' | 'unschedule' export interface RawMigrationRow { diff --git a/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts new file mode 100644 index 00000000000..5b97f2c1a92 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts @@ -0,0 +1,198 @@ +import { + iterateMigratableSubscriptionPages, + MigrationListProtocolError, + MigratableSubscriptionsNotFoundError, +} from './list-migratable-subscriptions.js' +import {MIGRATABLE_SUBSCRIPTION_STATUSES} from '../../models/subscription-migrations.js' +import {describe, expect, test, vi} from 'vitest' +import type {MigratableSubscription} from '../../models/subscription-migrations.js' +import type {MigratableSubscriptionPage} from './partners-api.js' + +function subscription(shopId: string): MigratableSubscription { + return { + shopId, + status: 'UNSCHEDULED', + manualSubscriptionName: null, + manualSubscriptionPrice: null, + manualSubscriptionInterval: 'EVERY_30_DAYS', + targetPlanHandle: null, + notification: null, + priceBehavior: null, + effectiveDate: null, + lastFailureReason: null, + } +} + +function page( + subscriptions: MigratableSubscription[], + pageInfo: MigratableSubscriptionPage['pageInfo'] = {hasNextPage: false, endCursor: null}, +): MigratableSubscriptionPage { + return {subscriptions, pageInfo} +} + +async function collectPages(pages: AsyncIterable): Promise { + const collected: MigratableSubscription[][] = [] + for await (const subscriptions of pages) collected.push(subscriptions) + return collected +} + +describe('iterateMigratableSubscriptionPages', () => { + test('yields one page in API order and sends the exact initial request', async () => { + const subscriptions = [subscription('shop-two'), subscription('shop-one')] + const getPage = vi.fn().mockResolvedValue(page(subscriptions)) + + await expect(collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', getPage}))).resolves.toEqual([ + subscriptions, + ]) + + expect(getPage).toHaveBeenCalledOnce() + expect(getPage).toHaveBeenCalledWith({ + clientId: 'client-id', + first: 250, + after: undefined, + status: undefined, + }) + }) + + test('yields a single empty page for an empty result', async () => { + const getPage = vi.fn().mockResolvedValue(page([])) + + await expect(collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', getPage}))).resolves.toEqual([ + [], + ]) + }) + + test('does not request a page until the consumer asks for it', async () => { + const getPage = vi.fn().mockResolvedValue(page([])) + + const pages = iterateMigratableSubscriptionPages({clientId: 'client-id', getPage}) + expect(getPage).not.toHaveBeenCalled() + + await pages.next() + expect(getPage).toHaveBeenCalledOnce() + }) + + test('yields every page in order and forwards the exact opaque cursor and status on each request', async () => { + const firstSubscription = subscription('shop-one') + const secondSubscription = subscription('shop-two') + const thirdSubscription = subscription('shop-three') + const opaqueCursor = ' opaque cursor ' + const getPage = vi + .fn() + .mockResolvedValueOnce(page([firstSubscription], {hasNextPage: true, endCursor: opaqueCursor})) + .mockResolvedValueOnce(page([secondSubscription], {hasNextPage: true, endCursor: 'second-cursor'})) + .mockResolvedValueOnce(page([thirdSubscription])) + + await expect( + collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', status: 'SCHEDULED', getPage})), + ).resolves.toEqual([[firstSubscription], [secondSubscription], [thirdSubscription]]) + + expect(getPage).toHaveBeenCalledTimes(3) + expect(getPage).toHaveBeenNthCalledWith(1, { + clientId: 'client-id', + first: 250, + after: undefined, + status: 'SCHEDULED', + }) + expect(getPage).toHaveBeenNthCalledWith(2, { + clientId: 'client-id', + first: 250, + after: opaqueCursor, + status: 'SCHEDULED', + }) + expect(getPage).toHaveBeenNthCalledWith(3, { + clientId: 'client-id', + first: 250, + after: 'second-cursor', + status: 'SCHEDULED', + }) + }) + + test('yields page one before requesting page two', async () => { + const firstSubscription = subscription('shop-one') + const secondSubscription = subscription('shop-two') + const getPage = vi + .fn() + .mockResolvedValueOnce(page([firstSubscription], {hasNextPage: true, endCursor: 'cursor'})) + .mockResolvedValueOnce(page([secondSubscription])) + + const pages = iterateMigratableSubscriptionPages({clientId: 'client-id', getPage}) + + await expect(pages.next()).resolves.toEqual({done: false, value: [firstSubscription]}) + expect(getPage).toHaveBeenCalledOnce() + + await expect(pages.next()).resolves.toEqual({done: false, value: [secondSubscription]}) + expect(getPage).toHaveBeenCalledTimes(2) + + await expect(pages.next()).resolves.toEqual({done: true, value: undefined}) + expect(getPage).toHaveBeenCalledTimes(2) + }) + + test.each(MIGRATABLE_SUBSCRIPTION_STATUSES)('forwards the %s status on every page', async (status) => { + const getPage = vi + .fn() + .mockResolvedValueOnce(page([], {hasNextPage: true, endCursor: 'cursor'})) + .mockResolvedValueOnce(page([])) + + await collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', status, getPage})) + + expect(getPage).toHaveBeenCalledTimes(2) + expect(getPage).toHaveBeenNthCalledWith(1, { + clientId: 'client-id', + first: 250, + after: undefined, + status, + }) + expect(getPage).toHaveBeenNthCalledWith(2, { + clientId: 'client-id', + first: 250, + after: 'cursor', + status, + }) + }) + + test('throws a domain-specific error when the app connection is null', async () => { + const getPage = vi.fn().mockResolvedValue(null) + const promise = collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', getPage})) + + await expect(promise).rejects.toBeInstanceOf(MigratableSubscriptionsNotFoundError) + await expect(promise).rejects.toMatchObject({ + name: 'MigratableSubscriptionsNotFoundError', + message: 'Migratable subscriptions were not found', + }) + }) + + test.each([null, '', ' \t'])('rejects a next page with an invalid cursor: %j', async (endCursor) => { + const getPage = vi.fn().mockResolvedValue(page([], {hasNextPage: true, endCursor})) + const promise = collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', getPage})) + + await expect(promise).rejects.toBeInstanceOf(MigrationListProtocolError) + expect(getPage).toHaveBeenCalledOnce() + }) + + test('rejects a repeated cursor instead of requesting the same page again', async () => { + const getPage = vi + .fn() + .mockResolvedValueOnce(page([], {hasNextPage: true, endCursor: 'cursor'})) + .mockResolvedValueOnce(page([], {hasNextPage: true, endCursor: 'cursor'})) + const promise = collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', getPage})) + + await expect(promise).rejects.toBeInstanceOf(MigrationListProtocolError) + expect(getPage).toHaveBeenCalledTimes(2) + }) + + test('yields earlier pages and then propagates a later-page API failure', async () => { + const apiError = new Error('Partners API unavailable') + const firstSubscription = subscription('shop-one') + const getPage = vi + .fn() + .mockResolvedValueOnce(page([firstSubscription], {hasNextPage: true, endCursor: 'next'})) + .mockRejectedValueOnce(apiError) + + const pages = iterateMigratableSubscriptionPages({clientId: 'client-id', getPage}) + + await expect(pages.next()).resolves.toEqual({done: false, value: [firstSubscription]}) + await expect(pages.next()).rejects.toBe(apiError) + expect(getPage).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts new file mode 100644 index 00000000000..ee6296caa1f --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts @@ -0,0 +1,59 @@ +import {getMigratableSubscriptionPage} from './partners-api.js' +import type {MigratableSubscription, MigratableSubscriptionStatus} from '../../models/subscription-migrations.js' + +const PAGE_SIZE = 250 + +export class MigratableSubscriptionsNotFoundError extends Error { + constructor() { + super('Migratable subscriptions were not found') + this.name = 'MigratableSubscriptionsNotFoundError' + } +} + +export class MigrationListProtocolError extends Error { + constructor(message: string) { + super(message) + this.name = 'MigrationListProtocolError' + } +} + +interface IterateMigratableSubscriptionPagesOptions { + clientId: string + status?: MigratableSubscriptionStatus + getPage?: typeof getMigratableSubscriptionPage +} + +/** + * Yields the subscriptions of each API page as soon as that page arrives, so callers can process + * (for example, print) a page before the next one is requested instead of holding every result in memory. + * The next page is only requested when the consumer asks for it. + */ +export async function* iterateMigratableSubscriptionPages({ + clientId, + status, + getPage = getMigratableSubscriptionPage, +}: IterateMigratableSubscriptionPagesOptions): AsyncGenerator { + const seenCursors = new Set() + let after: string | undefined + + while (true) { + // Pages must be requested sequentially because each request depends on the previous opaque cursor. + // eslint-disable-next-line no-await-in-loop + const page = await getPage({clientId, first: PAGE_SIZE, after, status}) + if (page === null) throw new MigratableSubscriptionsNotFoundError() + + yield page.subscriptions + if (!page.pageInfo.hasNextPage) return + + const {endCursor} = page.pageInfo + if (endCursor === null || endCursor.trim() === '') { + throw new MigrationListProtocolError('Migratable subscription page has no cursor for its next page') + } + if (seenCursors.has(endCursor)) { + throw new MigrationListProtocolError(`Migratable subscription pagination repeated cursor: ${endCursor}`) + } + + seenCursors.add(endCursor) + after = endCursor + } +} 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 new file mode 100644 index 00000000000..148a52dc32f --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts @@ -0,0 +1,259 @@ +import {outputMigrationList, serializeMigrationListCsv, serializeMigrationListJson} from './list-output.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {describe, expect, test, vi} from 'vitest' +import type {MigratableSubscription} from '../../models/subscription-migrations.js' + +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, outputResult: vi.fn()} +}) + +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' + +function subscription(overrides: Partial = {}): MigratableSubscription { + return { + shopId: 'gid://shopify/Shop/1', + status: 'SCHEDULED', + manualSubscriptionName: 'Legacy plan', + manualSubscriptionPrice: {amount: '19.99', currencyCode: 'USD'}, + manualSubscriptionInterval: 'EVERY_30_DAYS', + targetPlanHandle: 'standard', + notification: { + kind: 'NONE', + optOutDeadline: '2026-04-01T00:00:00Z', + sentAt: '2026-03-01T00:00:00Z', + }, + priceBehavior: 'HONOR_BILLING_PRICE', + effectiveDate: '2026-05-01T00:00:00Z', + lastFailureReason: 'SCHEDULING_FAILED', + ...overrides, + } +} + +async function* pagesOf(...pages: MigratableSubscription[][]): AsyncGenerator { + for (const page of pages) yield page +} + +/** + * Yields the given pages and records how many times stdout had been written to when each page was requested, + * so tests can prove that output for a page happens before the next page is pulled. + */ +function observedPages(...pages: MigratableSubscription[][]) { + const outputCallsWhenPageRequested: number[] = [] + async function* generator(): AsyncGenerator { + for (const page of pages) { + outputCallsWhenPageRequested.push(vi.mocked(outputResult).mock.calls.length) + yield page + } + } + return {pages: generator(), outputCallsWhenPageRequested} +} + +async function* pagesThenFailure( + pages: MigratableSubscription[][], + error: Error, +): AsyncGenerator { + for (const page of pages) yield page + throw error +} + +function outputWrittenToStdout(): string { + return vi + .mocked(outputResult) + .mock.calls.map(([content]) => `${content as string}\n`) + .join('') +} + +describe('migration list serialization', () => { + test('serializes the exact pretty JSON schema without a trailing newline', () => { + const subscriptions = [subscription()] + const expected = `{ + "schemaVersion": 1, + "subscriptions": [ + { + "shopId": "gid://shopify/Shop/1", + "status": "SCHEDULED", + "manualSubscriptionName": "Legacy plan", + "manualSubscriptionPrice": { + "amount": "19.99", + "currencyCode": "USD" + }, + "manualSubscriptionInterval": "EVERY_30_DAYS", + "targetPlanHandle": "standard", + "notification": { + "kind": "NONE", + "optOutDeadline": "2026-04-01T00:00:00Z", + "sentAt": "2026-03-01T00:00:00Z" + }, + "priceBehavior": "HONOR_BILLING_PRICE", + "effectiveDate": "2026-05-01T00:00:00Z", + "lastFailureReason": "SCHEDULING_FAILED" + } + ] +}` + + expect(serializeMigrationListJson(subscriptions)).toBe(expected) + expect(serializeMigrationListJson(subscriptions)).not.toMatch(/\n$/) + }) + + test('serializes CSV fields in the fixed header order without a trailing newline', () => { + expect(serializeMigrationListCsv([subscription()])).toBe(`${CSV_HEADER}\n${CSV_ROW}`) + }) + + test('serializes null top-level and nested fields as empty CSV values', () => { + const input = subscription({ + manualSubscriptionName: null, + manualSubscriptionPrice: null, + targetPlanHandle: null, + notification: null, + priceBehavior: null, + effectiveDate: null, + lastFailureReason: null, + }) + + expect(serializeMigrationListCsv([input])).toBe( + `${CSV_HEADER}\ngid://shopify/Shop/1,SCHEDULED,,,,EVERY_30_DAYS,,,,,,,`, + ) + }) + + test('escapes commas, double quotes, carriage returns, and line feeds in CSV values', () => { + const input = subscription({ + manualSubscriptionName: 'Legacy, "Plus"\r\nAnnual', + targetPlanHandle: 'standard,plus', + }) + + const csv = serializeMigrationListCsv([input]) + + expect(csv).toContain('"Legacy, ""Plus""\r\nAnnual"') + expect(csv).toContain(',"standard,plus",') + expect(csv.endsWith('\n')).toBe(false) + }) + + test('returns only the header for an empty CSV result', () => { + expect(serializeMigrationListCsv([])).toBe(CSV_HEADER) + }) +}) + +describe('outputMigrationList CSV streaming', () => { + test('writes the header and first page rows to stdout exactly once for a single page', async () => { + await outputMigrationList({pages: pagesOf([subscription()]), json: false}) + + expect(outputResult).toHaveBeenCalledOnce() + expect(outputResult).toHaveBeenCalledWith(`${CSV_HEADER}\n${CSV_ROW}`) + }) + + test('writes only the header for an empty result', async () => { + await outputMigrationList({pages: pagesOf([]), json: false}) + + expect(outputResult).toHaveBeenCalledOnce() + expect(outputResult).toHaveBeenCalledWith(CSV_HEADER) + }) + + test('writes the header and page one before page two is requested', async () => { + const {pages, outputCallsWhenPageRequested} = observedPages( + [subscription({shopId: 'gid://shopify/Shop/1'})], + [subscription({shopId: 'gid://shopify/Shop/2'})], + ) + + await outputMigrationList({pages, json: false}) + + expect(outputCallsWhenPageRequested).toEqual([0, 1]) + expect(vi.mocked(outputResult).mock.calls[0]![0]).toBe(`${CSV_HEADER}\n${CSV_ROW}`) + }) + + test('writes each page in API order and the combined stdout equals the single-document CSV', async () => { + const pageOne = [subscription({shopId: 'gid://shopify/Shop/1'}), subscription({shopId: 'gid://shopify/Shop/2'})] + const pageTwo = [subscription({shopId: 'gid://shopify/Shop/3'})] + const pageThree = [subscription({shopId: 'gid://shopify/Shop/4', manualSubscriptionName: 'Legacy, "Plus"'})] + + await outputMigrationList({pages: pagesOf(pageOne, pageTwo, pageThree), json: false}) + + expect(outputResult).toHaveBeenCalledTimes(3) + expect(vi.mocked(outputResult).mock.calls.map(([content]) => content)).toEqual([ + `${CSV_HEADER}\n${CSV_ROW}\n${CSV_ROW.replace('Shop/1', 'Shop/2')}`, + CSV_ROW.replace('Shop/1', 'Shop/3'), + CSV_ROW.replace('Shop/1', 'Shop/4').replace('Legacy plan', '"Legacy, ""Plus"""'), + ]) + expect(outputWrittenToStdout()).toBe(`${serializeMigrationListCsv([...pageOne, ...pageTwo, ...pageThree])}\n`) + }) + + test('does not write blank lines for empty later pages', async () => { + const pageOne = [subscription({shopId: 'gid://shopify/Shop/1'})] + const pageThree = [subscription({shopId: 'gid://shopify/Shop/3'})] + + await outputMigrationList({pages: pagesOf(pageOne, [], pageThree), json: false}) + + expect(outputResult).toHaveBeenCalledTimes(2) + expect(outputWrittenToStdout()).toBe(`${serializeMigrationListCsv([...pageOne, ...pageThree])}\n`) + }) + + test('writes the header with the first non-empty page when the first page is empty', async () => { + const pageTwo = [subscription({shopId: 'gid://shopify/Shop/2'})] + + await outputMigrationList({pages: pagesOf([], pageTwo), json: false}) + + expect(outputResult).toHaveBeenCalledTimes(2) + expect(vi.mocked(outputResult).mock.calls.map(([content]) => content)).toEqual([ + CSV_HEADER, + CSV_ROW.replace('Shop/1', 'Shop/2'), + ]) + expect(outputWrittenToStdout()).toBe(`${serializeMigrationListCsv(pageTwo)}\n`) + }) + + test('leaves earlier pages on stdout as valid partial CSV and propagates a later page failure', async () => { + const apiError = new Error('Partners API unavailable') + const pageOne = [subscription({shopId: 'gid://shopify/Shop/1'})] + const pageTwo = [subscription({shopId: 'gid://shopify/Shop/2'})] + + await expect( + outputMigrationList({pages: pagesThenFailure([pageOne, pageTwo], apiError), json: false}), + ).rejects.toBe(apiError) + + expect(outputResult).toHaveBeenCalledTimes(2) + expect(outputWrittenToStdout()).toBe(`${serializeMigrationListCsv([...pageOne, ...pageTwo])}\n`) + }) + + test('writes nothing when the first page fails', async () => { + const apiError = new Error('Partners API unavailable') + + await expect(outputMigrationList({pages: pagesThenFailure([], apiError), json: false})).rejects.toBe(apiError) + + expect(outputResult).not.toHaveBeenCalled() + }) +}) + +describe('outputMigrationList JSON', () => { + test('writes exactly one complete JSON document only after every page succeeds', async () => { + const pageOne = [subscription({shopId: 'gid://shopify/Shop/1'})] + const pageTwo = [subscription({shopId: 'gid://shopify/Shop/2'})] + const {pages, outputCallsWhenPageRequested} = observedPages(pageOne, pageTwo) + + await outputMigrationList({pages, json: true}) + + expect(outputCallsWhenPageRequested).toEqual([0, 0]) + expect(outputResult).toHaveBeenCalledOnce() + const output = vi.mocked(outputResult).mock.calls[0]![0] as string + expect(output).toBe(serializeMigrationListJson([...pageOne, ...pageTwo])) + expect(JSON.parse(output)).toEqual({schemaVersion: 1, subscriptions: [...pageOne, ...pageTwo]}) + }) + + test('writes an empty versioned JSON document for an empty result', async () => { + await outputMigrationList({pages: pagesOf([]), json: true}) + + expect(outputResult).toHaveBeenCalledOnce() + expect(outputResult).toHaveBeenCalledWith(serializeMigrationListJson([])) + }) + + test('writes nothing and propagates the error when a later page fails', async () => { + const apiError = new Error('Partners API unavailable') + const pageOne = [subscription({shopId: 'gid://shopify/Shop/1'})] + + await expect(outputMigrationList({pages: pagesThenFailure([pageOne], apiError), json: true})).rejects.toBe(apiError) + + expect(outputResult).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.ts b/packages/app/src/cli/services/subscription-migrations/list-output.ts new file mode 100644 index 00000000000..b1def5c1997 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/list-output.ts @@ -0,0 +1,87 @@ +import {outputResult} from '@shopify/cli-kit/node/output' +import type {MigratableSubscription} from '../../models/subscription-migrations.js' + +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' + +interface MigrationListOutputOptions { + pages: AsyncIterable + json: boolean +} + +export function serializeMigrationListJson(subscriptions: MigratableSubscription[]): string { + return JSON.stringify({schemaVersion: 1, subscriptions}, null, 2) +} + +export function serializeMigrationListCsv(subscriptions: MigratableSubscription[]): string { + return [CSV_HEADER, ...serializeCsvRows(subscriptions)].join('\n') +} + +/** + * Writes the migration list to stdout. + * + * CSV is streamed page by page as the API returns each page, so a 100k-row export never has to be held in memory. + * If a later page fails, the rows already written remain on stdout as a valid (partial) CSV and the error propagates. + * + * JSON must be a single valid document, so every page is collected first and the document is written only + * after all pages succeed. A failure produces no JSON output at all. + */ +export async function outputMigrationList({pages, json}: MigrationListOutputOptions): Promise { + if (json) { + outputResult(serializeMigrationListJson(await collectPages(pages))) + } else { + await streamMigrationListCsv(pages) + } +} + +async function collectPages(pages: AsyncIterable): Promise { + const subscriptions: MigratableSubscription[] = [] + for await (const page of pages) subscriptions.push(...page) + return subscriptions +} + +async function streamMigrationListCsv(pages: AsyncIterable): Promise { + let headerWritten = false + + for await (const page of pages) { + const rows = serializeCsvRows(page) + // The header goes out with the first page so it reaches stdout before page two is requested. Later pages only + // write when they have rows, because outputResult terminates every write with a newline and an empty write + // would leave a blank line in the CSV. + if (!headerWritten) { + outputResult([CSV_HEADER, ...rows].join('\n')) + headerWritten = true + } else if (rows.length > 0) { + outputResult(rows.join('\n')) + } + } + + if (!headerWritten) outputResult(CSV_HEADER) +} + +function serializeCsvRows(subscriptions: MigratableSubscription[]): string[] { + return subscriptions.map((subscription) => + [ + subscription.shopId, + subscription.status, + subscription.manualSubscriptionName, + subscription.manualSubscriptionPrice?.amount, + subscription.manualSubscriptionPrice?.currencyCode, + subscription.manualSubscriptionInterval, + subscription.targetPlanHandle, + subscription.notification?.kind, + subscription.notification?.optOutDeadline, + subscription.notification?.sentAt, + subscription.priceBehavior, + subscription.effectiveDate, + subscription.lastFailureReason, + ] + .map(serializeCsvValue) + .join(','), + ) +} + +function serializeCsvValue(value: string | null | undefined): string { + const serializedValue = value ?? '' + return /[",\r\n]/.test(serializedValue) ? `"${serializedValue.replaceAll('"', '""')}"` : serializedValue +} diff --git a/packages/app/src/cli/services/subscription-migrations/partners-api.test.ts b/packages/app/src/cli/services/subscription-migrations/partners-api.test.ts index 31b188a222b..816dba59f2c 100644 --- a/packages/app/src/cli/services/subscription-migrations/partners-api.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/partners-api.test.ts @@ -1,6 +1,7 @@ import { cancelMigrationOperation, createMigrationOperation, + getMigratableSubscriptionPage, getMigrationOperation, type MigrationApiInput, } from './partners-api.js' @@ -8,9 +9,11 @@ import { AppSubscriptionMigrationOperationCancelMutation, AppSubscriptionMigrationOperationCreateMutation, AppSubscriptionMigrationOperationQuery, + MigratableAppSubscriptionsQuery, } from '../../api/graphql/subscription_migrations.js' import {PartnersClient} from '../../utilities/developer-platform-client/partners-client.js' import {beforeEach, describe, expect, test, vi} from 'vitest' +import type {MigratableSubscription} from '../../models/subscription-migrations.js' vi.mock('../../utilities/developer-platform-client/partners-client.js') @@ -48,6 +51,23 @@ const canceledMigration: MigrationApiInput = { action: {cancelMigration: true}, } +const migratableSubscription: MigratableSubscription = { + shopId: 'gid://shopify/Shop/1001', + status: 'SCHEDULED', + manualSubscriptionName: 'Legacy plan', + manualSubscriptionPrice: {amount: '19.99', currencyCode: 'USD'}, + manualSubscriptionInterval: 'ANNUAL', + targetPlanHandle: 'pro', + notification: { + kind: 'NONE', + optOutDeadline: '2025-01-02T03:04:05Z', + sentAt: '2025-01-01T03:04:05Z', + }, + priceBehavior: 'PLAN_PRICE', + effectiveDate: '2025-02-01', + lastFailureReason: 'SCHEDULING_FAILED', +} + describe('Partners migration API', () => { beforeEach(() => { request.mockReset() @@ -55,6 +75,93 @@ describe('Partners migration API', () => { vi.mocked(PartnersClient.getInstance).mockReturnValue({request} as unknown as PartnersClient) }) + test('defines the complete migratable subscriptions query', () => { + expect(MigratableAppSubscriptionsQuery.replace(/[\s,]/g, '')).toBe( + 'queryMigratableAppSubscriptions($apiKey:String!$first:Int!$after:String$status:AppSubscriptionMigrationStatus){migratableAppSubscriptions(apiKey:$apiKeyfirst:$firstafter:$afterstatus:$status){edges{cursornode{shopIdstatusmanualSubscriptionNamemanualSubscriptionPrice{amountcurrencyCode}manualSubscriptionIntervaltargetPlanHandlenotification{kindoptOutDeadlinesentAt}priceBehavioreffectiveDatelastFailureReason}}pageInfo{hasNextPageendCursor}}}', + ) + }) + + test('gets a migratable subscription page with the exported document and exact variables', async () => { + request.mockResolvedValue({ + migratableAppSubscriptions: { + edges: [{cursor: 'next-cursor', node: migratableSubscription}], + pageInfo: {hasNextPage: true, endCursor: 'next-cursor'}, + }, + }) + + await expect( + getMigratableSubscriptionPage({ + clientId: 'client-id', + first: 25, + after: 'previous-cursor', + status: 'SCHEDULED', + }), + ).resolves.toEqual({ + subscriptions: [migratableSubscription], + pageInfo: {hasNextPage: true, endCursor: 'next-cursor'}, + }) + + expect(request).toHaveBeenCalledWith(MigratableAppSubscriptionsQuery, { + apiKey: 'client-id', + first: 25, + after: 'previous-cursor', + status: 'SCHEDULED', + }) + }) + + test('always sends optional migratable subscription variables', async () => { + request.mockResolvedValue({ + migratableAppSubscriptions: { + edges: [], + pageInfo: {hasNextPage: false, endCursor: null}, + }, + }) + + await getMigratableSubscriptionPage({clientId: 'client-id', first: 250}) + + expect(request).toHaveBeenCalledWith(MigratableAppSubscriptionsQuery, { + apiKey: 'client-id', + first: 250, + after: undefined, + status: undefined, + }) + }) + + test('preserves a nullable migratable subscription connection', async () => { + request.mockResolvedValue({migratableAppSubscriptions: null}) + + await expect(getMigratableSubscriptionPage({clientId: 'client-id', first: 250})).resolves.toBeNull() + }) + + test('normalizes nullable migratable subscription edges', async () => { + request.mockResolvedValue({ + migratableAppSubscriptions: { + edges: null, + pageInfo: {hasNextPage: false, endCursor: null}, + }, + }) + + await expect(getMigratableSubscriptionPage({clientId: 'client-id', first: 250})).resolves.toEqual({ + subscriptions: [], + pageInfo: {hasNextPage: false, endCursor: null}, + }) + }) + + test('filters nullable migratable subscription edge elements while preserving order', async () => { + const secondSubscription = {...migratableSubscription, shopId: 'gid://shopify/Shop/1002'} + request.mockResolvedValue({ + migratableAppSubscriptions: { + edges: [{cursor: 'one', node: migratableSubscription}, null, {cursor: 'two', node: secondSubscription}], + pageInfo: {hasNextPage: false, endCursor: 'two'}, + }, + }) + + await expect(getMigratableSubscriptionPage({clientId: 'client-id', first: 250})).resolves.toEqual({ + subscriptions: [migratableSubscription, secondSubscription], + pageInfo: {hasNextPage: false, endCursor: 'two'}, + }) + }) + test('creates a migration operation with the exported document and exact variables', async () => { const payload = { operation, diff --git a/packages/app/src/cli/services/subscription-migrations/partners-api.ts b/packages/app/src/cli/services/subscription-migrations/partners-api.ts index 0739357cfef..59f95278971 100644 --- a/packages/app/src/cli/services/subscription-migrations/partners-api.ts +++ b/packages/app/src/cli/services/subscription-migrations/partners-api.ts @@ -1,4 +1,6 @@ import { + type MigratableSubscription, + type MigratableSubscriptionStatus, type MigrationOperation, type NotificationKind, type PriceBehavior, @@ -7,6 +9,7 @@ import { AppSubscriptionMigrationOperationCancelMutation, AppSubscriptionMigrationOperationCreateMutation, AppSubscriptionMigrationOperationQuery, + MigratableAppSubscriptionsQuery, } from '../../api/graphql/subscription_migrations.js' import {PartnersClient} from '../../utilities/developer-platform-client/partners-client.js' @@ -48,6 +51,15 @@ interface RawMigrationOperationPayload { userErrors: MigrationUserError[] | null } +interface RawMigratableSubscriptionConnection { + edges: ({cursor: string; node: MigratableSubscription} | null)[] | null + pageInfo: MigratableSubscriptionPageInfo +} + +interface MigratableAppSubscriptionsResponse { + migratableAppSubscriptions: RawMigratableSubscriptionConnection | null +} + interface CreateMigrationOperationResponse { appSubscriptionMigrationOperationCreate: RawMigrationOperationPayload } @@ -60,6 +72,23 @@ interface CancelMigrationOperationResponse { appSubscriptionMigrationOperationCancel: RawMigrationOperationPayload } +export interface MigratableSubscriptionPageInfo { + hasNextPage: boolean + endCursor: string | null +} + +export interface MigratableSubscriptionPage { + subscriptions: MigratableSubscription[] + pageInfo: MigratableSubscriptionPageInfo +} + +interface GetMigratableSubscriptionPageOptions { + clientId: string + first: number + after?: string + status?: MigratableSubscriptionStatus +} + interface CreateMigrationOperationOptions { clientId: string idempotencyKey: string @@ -89,6 +118,25 @@ function normalizeMigrationOperationPayload(payload: RawMigrationOperationPayloa } } +export async function getMigratableSubscriptionPage({ + clientId, + first, + after, + status, +}: GetMigratableSubscriptionPageOptions): Promise { + const response = await PartnersClient.getInstance().request( + MigratableAppSubscriptionsQuery, + {apiKey: clientId, first, after, status}, + ) + const connection = response.migratableAppSubscriptions + if (connection === null) return null + + return { + subscriptions: connection.edges?.flatMap((edge) => (edge === null ? [] : [edge.node])) ?? [], + pageInfo: connection.pageInfo, + } +} + export async function createMigrationOperation({ clientId, idempotencyKey, diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 692da2c1faa..e756e454132 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3512,6 +3512,122 @@ "strict": true, "summary": "Cancels app subscription migration operations." }, + "app:subscription-migrations:list": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", + "descriptionWithMarkdown": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --status SCHEDULED > scheduled-subscriptions.csv", + "<%= config.bin %> <%= command.id %> --json", + "<%= config.bin %> <%= command.id %> --json > subscriptions.json", + "<%= config.bin %> <%= command.id %> --client-id > subscriptions.csv" + ], + "flags": { + "auth-alias": { + "description": "Alias of the Shopify account to use for authentication.", + "env": "SHOPIFY_FLAG_AUTH_ALIAS", + "hasDynamicHelp": false, + "multiple": false, + "name": "auth-alias", + "type": "option" + }, + "client-id": { + "description": "The Client ID of your app.", + "env": "SHOPIFY_FLAG_CLIENT_ID", + "exclusive": [ + "config" + ], + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "client-id", + "type": "option" + }, + "config": { + "char": "c", + "description": "The name of the app configuration.", + "env": "SHOPIFY_FLAG_APP_CONFIG", + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "config", + "type": "option" + }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "path": { + "description": "The path to your app directory.", + "env": "SHOPIFY_FLAG_PATH", + "hasDynamicHelp": false, + "multiple": false, + "name": "path", + "noCacheDefault": true, + "type": "option" + }, + "reset": { + "allowNo": false, + "description": "Reset all your settings.", + "env": "SHOPIFY_FLAG_RESET", + "exclusive": [ + "config" + ], + "hidden": false, + "name": "reset", + "type": "boolean" + }, + "status": { + "description": "Filter subscriptions by migration status.", + "env": "SHOPIFY_FLAG_STATUS", + "hasDynamicHelp": false, + "multiple": false, + "name": "status", + "options": [ + "UNSCHEDULED", + "SCHEDULED", + "MIGRATED" + ], + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "app:subscription-migrations:list", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Lists app subscriptions eligible for migration." + }, "app:subscription-migrations:schedule": { "aliases": [ ],