diff --git a/.changeset/schedule-subscription-migrations.md b/.changeset/schedule-subscription-migrations.md new file mode 100644 index 00000000000..6ddeb1de171 --- /dev/null +++ b/.changeset/schedule-subscription-migrations.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Add commands for scheduling and managing app subscription migrations with polling progress. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 2520c347e81..6c4b2da1e5a 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -2809,6 +2809,399 @@ "value": "export interface apprelease {\n /**\n * Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates. Required in non-interactive environments unless --allow-updates is provided.\n * @environment SHOPIFY_FLAG_ALLOW_DELETES\n */\n '--allow-deletes'?: ''\n\n /**\n * Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments. Required in non-interactive environments unless --allow-deletes is provided.\n * @environment SHOPIFY_FLAG_ALLOW_UPDATES\n */\n '--allow-updates'?: ''\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The name of the app version to release.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version ': string\n}" } }, + "appsubscriptionmigrationscancel": { + "docs-shopify.dev/commands/interfaces/app-subscription-migrations-cancel.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-cancel.interface.ts", + "name": "appsubscriptionmigrationscancel", + "description": "The following flags are available for the `app subscription-migrations cancel` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-cancel.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--auth-alias ", + "value": "string", + "description": "Alias of the Shopify account to use for authentication.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-cancel.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--client-id ", + "value": "string", + "description": "The Client ID of your app.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-cancel.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--id ", + "value": "string", + "description": "The app subscription migration operation ID. Can be specified multiple times.", + "environmentValue": "SHOPIFY_FLAG_ID" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-cancel.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-cancel.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--path ", + "value": "string", + "description": "The path to your app directory.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PATH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-cancel.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output. May include sensitive data.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-cancel.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-c, --config ", + "value": "string", + "description": "The name of the app configuration.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-cancel.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + } + ], + "value": "export interface appsubscriptionmigrationscancel {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * The app subscription migration operation ID. Can be specified multiple times.\n * @environment SHOPIFY_FLAG_ID\n */\n '--id ': string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + } + }, + "appsubscriptionmigrationsschedule": { + "docs-shopify.dev/commands/interfaces/app-subscription-migrations-schedule.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-schedule.interface.ts", + "name": "appsubscriptionmigrationsschedule", + "description": "The following flags are available for the `app subscription-migrations schedule` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-schedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--auth-alias ", + "value": "string", + "description": "Alias of the Shopify account to use for authentication.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-schedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--client-id ", + "value": "string", + "description": "The Client ID of your app.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-schedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--idempotency-key ", + "value": "string", + "description": "Reuse an existing root idempotency key for the same action and input.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_IDEMPOTENCY_KEY" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-schedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-schedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--path ", + "value": "string", + "description": "The path to your app directory.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PATH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-schedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output. May include sensitive data.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-schedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--watch", + "value": "''", + "description": "Display the current operation state while polling, then output the final outcome when every operation reaches a terminal status.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_WATCH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-schedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-c, --config ", + "value": "string", + "description": "The name of the app configuration.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-schedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-f, --force", + "value": "''", + "description": "Skip confirmation. Required if non interactive.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_FORCE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-schedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-i, --input ", + "value": "string", + "description": "Path to the migration CSV. If omitted, standard input is used.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_INPUT" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-schedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + } + ], + "value": "export interface appsubscriptionmigrationsschedule {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Skip confirmation. Required if non interactive.\n * @environment SHOPIFY_FLAG_FORCE\n */\n '-f, --force'?: ''\n\n /**\n * Reuse an existing root idempotency key for the same action and input.\n * @environment SHOPIFY_FLAG_IDEMPOTENCY_KEY\n */\n '--idempotency-key '?: string\n\n /**\n * Path to the migration CSV. If omitted, standard input is used.\n * @environment SHOPIFY_FLAG_INPUT\n */\n '-i, --input '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * Display the current operation state while polling, then output the final outcome when every operation reaches a terminal status.\n * @environment SHOPIFY_FLAG_WATCH\n */\n '--watch'?: ''\n}" + } + }, + "appsubscriptionmigrationsstatus": { + "docs-shopify.dev/commands/interfaces/app-subscription-migrations-status.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-status.interface.ts", + "name": "appsubscriptionmigrationsstatus", + "description": "The following flags are available for the `app subscription-migrations status` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-status.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--auth-alias ", + "value": "string", + "description": "Alias of the Shopify account to use for authentication.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-status.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--client-id ", + "value": "string", + "description": "The Client ID of your app.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-status.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--id ", + "value": "string", + "description": "The app subscription migration operation ID. Can be specified multiple times.", + "environmentValue": "SHOPIFY_FLAG_ID" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-status.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-status.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--path ", + "value": "string", + "description": "The path to your app directory.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PATH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-status.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output. May include sensitive data.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-status.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--watch", + "value": "''", + "description": "Display the current operation state while polling, then output the final state when every operation reaches a terminal status.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_WATCH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-status.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-c, --config ", + "value": "string", + "description": "The name of the app configuration.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-status.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + } + ], + "value": "export interface appsubscriptionmigrationsstatus {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * The app subscription migration operation ID. Can be specified multiple times.\n * @environment SHOPIFY_FLAG_ID\n */\n '--id ': string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * Display the current operation state while polling, then output the final state when every operation reaches a terminal status.\n * @environment SHOPIFY_FLAG_WATCH\n */\n '--watch'?: ''\n}" + } + }, + "appsubscriptionmigrationsunschedule": { + "docs-shopify.dev/commands/interfaces/app-subscription-migrations-unschedule.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-unschedule.interface.ts", + "name": "appsubscriptionmigrationsunschedule", + "description": "The following flags are available for the `app subscription-migrations unschedule` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-unschedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--auth-alias ", + "value": "string", + "description": "Alias of the Shopify account to use for authentication.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-unschedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--client-id ", + "value": "string", + "description": "The Client ID of your app.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-unschedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--idempotency-key ", + "value": "string", + "description": "Reuse an existing root idempotency key for the same action and input.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_IDEMPOTENCY_KEY" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-unschedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-unschedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--path ", + "value": "string", + "description": "The path to your app directory.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PATH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-unschedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output. May include sensitive data.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-unschedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--watch", + "value": "''", + "description": "Display the current operation state while polling, then output the final outcome when every operation reaches a terminal status.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_WATCH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-unschedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-c, --config ", + "value": "string", + "description": "The name of the app configuration.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-unschedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-f, --force", + "value": "''", + "description": "Skip confirmation. Required if non interactive.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_FORCE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-unschedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-i, --input ", + "value": "string", + "description": "Path to the migration CSV. If omitted, standard input is used.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_INPUT" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-subscription-migrations-unschedule.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + } + ], + "value": "export interface appsubscriptionmigrationsunschedule {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Skip confirmation. Required if non interactive.\n * @environment SHOPIFY_FLAG_FORCE\n */\n '-f, --force'?: ''\n\n /**\n * Reuse an existing root idempotency key for the same action and input.\n * @environment SHOPIFY_FLAG_IDEMPOTENCY_KEY\n */\n '--idempotency-key '?: string\n\n /**\n * Path to the migration CSV. If omitted, standard input is used.\n * @environment SHOPIFY_FLAG_INPUT\n */\n '-i, --input '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * Display the current operation state while polling, then output the final outcome when every operation reaches a terminal status.\n * @environment SHOPIFY_FLAG_WATCH\n */\n '--watch'?: ''\n}" + } + }, "appversionslist": { "docs-shopify.dev/commands/interfaces/app-versions-list.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/app-versions-list.interface.ts", diff --git a/packages/app/package.json b/packages/app/package.json index 48b89d3965b..ba433329cfb 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -64,6 +64,7 @@ "@shopify/theme-check-node": "3.27.0", "@shopify/toml-patch": "0.3.0", "chokidar": "3.6.0", + "csv-parse": "7.0.2", "diff": "5.2.2", "esbuild": "0.28.1", "graphql-request": "6.1.0", diff --git a/packages/app/src/cli/api/graphql/subscription_migrations.ts b/packages/app/src/cli/api/graphql/subscription_migrations.ts new file mode 100644 index 00000000000..af3ee74cf3f --- /dev/null +++ b/packages/app/src/cli/api/graphql/subscription_migrations.ts @@ -0,0 +1,71 @@ +import {gql} from 'graphql-request' + +// 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` + mutation AppSubscriptionMigrationOperationCreate($input: AppSubscriptionMigrationOperationCreateInput!) { + appSubscriptionMigrationOperationCreate(input: $input) { + operation { + id + status + total + results(first: 250) { + edges { + node { + shopId + code + } + } + } + } + userErrors { + message + field + } + } + } +` + +// eslint-disable-next-line @shopify/cli/no-inline-graphql +export const AppSubscriptionMigrationOperationQuery = gql` + query AppSubscriptionMigrationOperation($apiKey: String!, $id: ID!) { + appSubscriptionMigrationOperation(apiKey: $apiKey, id: $id) { + id + status + total + results(first: 250) { + edges { + node { + shopId + code + } + } + } + } + } +` + +// eslint-disable-next-line @shopify/cli/no-inline-graphql +export const AppSubscriptionMigrationOperationCancelMutation = gql` + mutation AppSubscriptionMigrationOperationCancel($input: AppSubscriptionMigrationOperationCancelInput!) { + appSubscriptionMigrationOperationCancel(input: $input) { + operation { + id + status + total + results(first: 250) { + edges { + node { + shopId + code + } + } + } + } + userErrors { + message + field + } + } + } +` diff --git a/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts b/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts new file mode 100644 index 00000000000..59788454724 --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts @@ -0,0 +1,42 @@ +import {operationFlags} from './flags.js' +import {presentMigrationCancellationResult} from './result-presenter.js' +import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js' +import {resolveSubscriptionMigrationClientId} from '../../../services/subscription-migrations/resolve-client-id.js' +import BaseCommand from '@shopify/cli-kit/node/base-command' + +export default class Cancel extends BaseCommand { + static summary = 'Cancels app subscription migration operations.' + + static descriptionWithMarkdown = `Cancels app subscription migration operations. + +Canceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use \`unschedule\` for reversible schedules. + +Repeat \`--id\` to cancel every operation GID returned by a multi-batch submission. Use \`--json\` to output the resulting operation states and per-shop results as structured JSON. + +By default, the command 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 explicitly override the active configuration; this only selects the app and does not change Partners authentication.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> --id ', + '<%= config.bin %> <%= command.id %> --path ../my-app --config staging --id --id ', + '<%= config.bin %> <%= command.id %> --client-id --id --json', + ] + + static flags = {...operationFlags} + + async run(): Promise { + const {flags} = await this.parse(Cancel) + const clientId = await resolveSubscriptionMigrationClientId({ + clientId: flags['client-id'], + directory: flags.path, + configName: flags.config, + }) + const result = await cancelMigrationOperations({ + clientId, + operationIds: flags.id, + }) + const exitCode = presentMigrationCancellationResult(result, {json: flags.json}) + if (exitCode !== 0) process.exitCode = exitCode + } +} 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 new file mode 100644 index 00000000000..ed43ac13acc --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts @@ -0,0 +1,487 @@ +import Cancel from './cancel.js' +import Schedule from './schedule.js' +import Status from './status.js' +import Unschedule from './unschedule.js' +import {appFlags} from '../../../flags.js' +import {commands} from '../../../index.js' +import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js' +import {outputOperations} from '../../../services/subscription-migrations/command-output.js' +import {getMigrationOperations} from '../../../services/subscription-migrations/get-operations.js' +import {resolveSubscriptionMigrationClientId} from '../../../services/subscription-migrations/resolve-client-id.js' +import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js' +import {watchMigrationOperations} from '../../../services/subscription-migrations/watch-operations.js' +import {jsonFlag} from '@shopify/cli-kit/node/cli' +import BaseCommand from '@shopify/cli-kit/node/base-command' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import type {MigrationOperation} from '../../../models/subscription-migrations.js' +import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js' +import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js' + +vi.mock('../../../services/subscription-migrations/cancel-operations.js') +vi.mock('../../../services/subscription-migrations/command-output.js') +vi.mock('../../../services/subscription-migrations/get-operations.js') +vi.mock('../../../services/subscription-migrations/resolve-client-id.js') +vi.mock('../../../services/subscription-migrations/run-submission-command.js') +vi.mock('../../../services/subscription-migrations/watch-operations.js') +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, outputResult: vi.fn()} +}) +vi.mock('@shopify/cli-kit/node/ui') + +const originalExitCode = process.exitCode + +beforeEach(() => { + process.exitCode = undefined + vi.mocked(resolveSubscriptionMigrationClientId).mockImplementation( + async ({clientId}) => clientId ?? 'active-client-id', + ) + vi.mocked(runSubmissionCommand).mockResolvedValue(successfulSubmissionResult) + vi.mocked(cancelMigrationOperations).mockResolvedValue(successfulCancellationResult) +}) + +afterEach(() => { + process.exitCode = originalExitCode +}) + +const completedOperation: MigrationOperation = { + id: 'gid://shopify/AppSubscriptionMigrationOperation/1', + status: 'COMPLETED', + total: 1, + results: {edges: [{node: {shopId: 'gid://shopify/Shop/1', code: 'SCHEDULED'}}]}, +} + +const successfulSubmissionResult: MigrationSubmissionResult = { + status: 'success', + submission: { + clientId: 'active-client-id', + action: 'schedule', + rootIdempotencyKey: 'root-key', + inputDigest: 'input-digest', + total: 1, + operations: [ + { + batchIndex: 0, + batchPayloadDigest: 'batch-digest', + idempotencyKey: 'batch-key', + operation: completedOperation, + }, + ], + }, +} + +const successfulCancellationResult: MigrationCancellationResult = { + outcomes: [ + { + status: 'success', + operationId: completedOperation.id, + operation: completedOperation, + }, + ], +} + +describe('subscription migration submission commands', () => { + test('schedule delegates every submission option with an explicit root idempotency key', async () => { + await Schedule.run([ + '--input', + 'migrations.csv', + '--client-id', + 'schedule-client-id', + '--idempotency-key', + 'root-key', + '--force', + '--json', + '--watch', + ]) + + expect(resolveSubscriptionMigrationClientId).toHaveBeenCalledWith({ + clientId: 'schedule-client-id', + directory: expect.any(String), + configName: undefined, + }) + expect(runSubmissionCommand).toHaveBeenCalledWith({ + action: 'schedule', + input: 'migrations.csv', + clientId: 'schedule-client-id', + rootIdempotencyKey: 'root-key', + skipConfirmation: true, + watch: true, + }) + expect(outputResult).toHaveBeenCalledOnce() + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ + schemaVersion: 1, + ...successfulSubmissionResult.submission, + }) + }) + + test('schedule resolves the active configuration Client ID selected by path and config', async () => { + await Schedule.run(['--input', 'migrations.csv', '--path', '/selected/app', '--config', 'staging', '--force']) + + expect(resolveSubscriptionMigrationClientId).toHaveBeenCalledWith({ + clientId: undefined, + directory: '/selected/app', + configName: 'staging', + }) + expect(runSubmissionCommand).toHaveBeenCalledWith( + expect.objectContaining({action: 'schedule', clientId: 'active-client-id'}), + ) + }) + + test('unschedule delegates without an omitted root idempotency key', async () => { + await Unschedule.run(['--input', '-', '--client-id', 'unschedule-client-id', '--force']) + + expect(resolveSubscriptionMigrationClientId).toHaveBeenCalledWith({ + clientId: 'unschedule-client-id', + directory: expect.any(String), + configName: undefined, + }) + expect(runSubmissionCommand).toHaveBeenCalledWith({ + action: 'unschedule', + input: '-', + clientId: 'unschedule-client-id', + rootIdempotencyKey: undefined, + skipConfirmation: true, + watch: false, + }) + }) + + test.each([ + {Command: Schedule, action: 'schedule'}, + {Command: Unschedule, action: 'unschedule'}, + ])('$Command.name uses stdin when input is omitted', async ({Command, action}) => { + await Command.run(['--client-id', 'client-id', '--force']) + + expect(runSubmissionCommand).toHaveBeenCalledWith( + expect.objectContaining({action, input: '-', clientId: 'client-id'}), + ) + }) + + test('passes an accepted-submission presenter only for watched human output', async () => { + await Schedule.run(['--input', 'migrations.csv', '--client-id', 'client-id', '--force', '--watch']) + + expect(runSubmissionCommand).toHaveBeenCalledWith( + expect.objectContaining({onSubmissionAccepted: expect.any(Function)}), + ) + const callback = vi.mocked(runSubmissionCommand).mock.calls[0]?.[0].onSubmissionAccepted + callback?.(successfulSubmissionResult.submission) + expect(renderSuccess).toHaveBeenCalledOnce() + }) + + test('renders an expected JSON submission failure once and sets exit 1 without throwing', async () => { + const failedResult: MigrationSubmissionResult = { + status: 'failed', + submission: successfulSubmissionResult.submission, + failedBatchIndex: 1, + userErrors: [{message: 'Rejected remaining shops', field: ['input']}], + } + vi.mocked(runSubmissionCommand).mockResolvedValue(failedResult) + + await expect( + Schedule.run(['--input', 'migrations.csv', '--client-id', 'client-id', '--force', '--json']), + ).resolves.toBeUndefined() + + expect(process.exitCode).toBe(1) + expect(outputResult).toHaveBeenCalledOnce() + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ + schemaVersion: 1, + ...failedResult.submission, + failure: {batchIndex: 1, userErrors: failedResult.userErrors}, + }) + expect(renderWarning).not.toHaveBeenCalled() + }) + + test('renders one expected human submission warning and sets exit 1 without throwing', async () => { + vi.mocked(runSubmissionCommand).mockResolvedValue({ + status: 'failed', + submission: successfulSubmissionResult.submission, + failedBatchIndex: 1, + userErrors: [{message: 'Rejected remaining shops', field: ['input']}], + }) + + await expect( + Unschedule.run(['--input', 'migrations.csv', '--client-id', 'client-id', '--force']), + ).resolves.toBeUndefined() + + expect(process.exitCode).toBe(1) + expect(renderWarning).toHaveBeenCalledOnce() + expect(outputResult).not.toHaveBeenCalled() + }) + + test.each([Schedule, Unschedule])( + '$name rejects positional CSV input before calling its service', + async (Command) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await expect(Command.run(['migrations.csv', '--force'])).rejects.toThrow() + + expect(runSubmissionCommand).not.toHaveBeenCalled() + }, + ) +}) + +describe('subscription migration operation commands', () => { + test('status watches every repeated ID and outputs final JSON once', async () => { + vi.mocked(watchMigrationOperations).mockResolvedValue([completedOperation]) + + await Status.run([ + '--client-id', + 'status-client-id', + '--id', + 'gid://shopify/AppSubscriptionMigrationOperation/1', + '--id', + 'gid://shopify/AppSubscriptionMigrationOperation/2', + '--watch', + '--json', + ]) + + expect(resolveSubscriptionMigrationClientId).toHaveBeenCalledWith({ + clientId: 'status-client-id', + directory: expect.any(String), + configName: undefined, + }) + expect(watchMigrationOperations).toHaveBeenCalledWith({ + clientId: 'status-client-id', + operationIds: [ + 'gid://shopify/AppSubscriptionMigrationOperation/1', + 'gid://shopify/AppSubscriptionMigrationOperation/2', + ], + }) + expect(getMigrationOperations).not.toHaveBeenCalled() + expect(outputOperations).toHaveBeenCalledOnce() + expect(outputOperations).toHaveBeenCalledWith([completedOperation], true) + }) + + test('status resolves the Client ID from the active configuration', async () => { + vi.mocked(getMigrationOperations).mockResolvedValue([completedOperation]) + + await Status.run(['--id', 'operation-id']) + + expect(resolveSubscriptionMigrationClientId).toHaveBeenCalledWith({ + clientId: undefined, + directory: expect.any(String), + configName: undefined, + }) + expect(getMigrationOperations).toHaveBeenCalledWith({ + clientId: 'active-client-id', + operationIds: ['operation-id'], + }) + expect(watchMigrationOperations).not.toHaveBeenCalled() + }) + + test('cancel presents every repeated ID in exactly one JSON document', async () => { + const secondOperation = {...completedOperation, id: 'gid://shopify/AppSubscriptionMigrationOperation/2'} + const result: MigrationCancellationResult = { + outcomes: [ + successfulCancellationResult.outcomes[0]!, + { + status: 'failed', + operationId: secondOperation.id, + operation: secondOperation, + userErrors: [{message: 'Already completed', field: ['id']}], + }, + ], + } + vi.mocked(cancelMigrationOperations).mockResolvedValue(result) + + await expect( + Cancel.run([ + '--client-id', + 'cancel-client-id', + '--id', + 'gid://shopify/AppSubscriptionMigrationOperation/1', + '--id', + 'gid://shopify/AppSubscriptionMigrationOperation/2', + '--json', + ]), + ).resolves.toBeUndefined() + + expect(resolveSubscriptionMigrationClientId).toHaveBeenCalledWith({ + clientId: 'cancel-client-id', + directory: expect.any(String), + configName: undefined, + }) + expect(cancelMigrationOperations).toHaveBeenCalledWith({ + clientId: 'cancel-client-id', + operationIds: [ + 'gid://shopify/AppSubscriptionMigrationOperation/1', + 'gid://shopify/AppSubscriptionMigrationOperation/2', + ], + }) + expect(outputResult).toHaveBeenCalledOnce() + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ + schemaVersion: 1, + outcomes: result.outcomes, + }) + expect(process.exitCode).toBe(1) + }) + + test('cancel resolves the Client ID from the active configuration', async () => { + await Cancel.run(['--id', 'operation-id']) + + expect(resolveSubscriptionMigrationClientId).toHaveBeenCalledWith({ + clientId: undefined, + directory: expect.any(String), + configName: undefined, + }) + expect(cancelMigrationOperations).toHaveBeenCalledWith({ + clientId: 'active-client-id', + operationIds: ['operation-id'], + }) + }) + + test.each([ + {Command: Status, service: getMigrationOperations}, + {Command: Cancel, service: cancelMigrationOperations}, + ])('$Command.name rejects a missing operation ID before calling its service', async ({Command, service}) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await expect(Command.run(['--client-id', 'client-id'])).rejects.toThrow() + + expect(service).not.toHaveBeenCalled() + expect(outputOperations).not.toHaveBeenCalled() + }) +}) + +describe('subscription migration command metadata', () => { + test.each([Schedule, Unschedule])('$name defines optional input flag metadata', (Command) => { + expect(Command.flags.input).toMatchObject({ + char: 'i', + env: 'SHOPIFY_FLAG_INPUT', + description: 'Path to the migration CSV. If omitted, standard input is used.', + }) + expect(Command.flags.input?.required).not.toBe(true) + }) + + test.each([Schedule, Unschedule])('$name has no static positional args', (Command) => { + expect(Object.hasOwn(Command, 'args')).toBe(false) + }) + + test.each([Schedule, Unschedule])('$name marks force as required when non-interactive', (Command) => { + expect(Command.flags.force).toMatchObject({ + char: 'f', + env: 'SHOPIFY_FLAG_FORCE', + default: false, + requiredIfNonInteractive: true, + description: 'Skip confirmation. Required if non interactive.', + }) + }) + + test.each([Schedule, Unschedule])('$name describes visible submission watch progress and final output', (Command) => { + expect(Command.flags.watch.description).toBe( + 'Display the current operation state while polling, then output the final outcome when every operation reaches a terminal status.', + ) + expect(Command.descriptionWithMarkdown).toContain( + 'human-readable output shows accepted identifiers before polling begins', + ) + expect(Command.descriptionWithMarkdown).toContain( + 'outputs one structured JSON document after every operation reaches a terminal status', + ) + }) + + test('status describes visible watch progress and final output', () => { + expect(Status.flags.watch.description).toBe( + 'Display the current operation state while polling, then output the final state when every operation reaches a terminal status.', + ) + expect(Status.descriptionWithMarkdown).toContain('displays the current state while polling') + expect(Status.descriptionWithMarkdown).toContain('outputs the final state') + }) + + test.each([Status, Cancel])('$name requires repeatable operation IDs', (Command) => { + expect(Command.flags.id).toMatchObject({ + env: 'SHOPIFY_FLAG_ID', + required: true, + multiple: true, + description: 'The app subscription migration operation ID. Can be specified multiple times.', + }) + }) + + test.each([Schedule, Unschedule, Status, Cancel])('$name uses 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']) + expect(Command.flags['client-id']?.required).not.toBe(true) + expect(Command.flags['client-id']?.exclusive).toEqual(['config']) + }) + + test.each([Schedule, Unschedule, Status, Cancel])('$name uses the canonical JSON flag', (Command) => { + expect(Command.flags.json).toBe(jsonFlag.json) + }) + + test.each([Schedule, Unschedule, Status, Cancel])('$name has no legacy or reset migration flags', (Command) => { + expect(Object.keys(Command.flags)).not.toEqual( + expect.arrayContaining(['reset', 'yes', 'operation', 'operation-id', 'run', 'run-id']), + ) + }) + + test.each([Schedule, Unschedule, Status, Cancel])('$name extends BaseCommand directly', (Command) => { + expect(Object.getPrototypeOf(Command)).toBe(BaseCommand) + }) + + test('registers the four command IDs with their exact classes', () => { + expect({ + 'app:subscription-migrations:cancel': commands['app:subscription-migrations:cancel'], + '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:schedule': Schedule, + 'app:subscription-migrations:status': Status, + 'app:subscription-migrations:unschedule': Unschedule, + }) + }) + + test.each([ + [Schedule, 'Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.'], + [Unschedule, 'Reverses app subscription migrations that are still scheduled.'], + [Status, 'Checks the status of app subscription migration operations.'], + [Cancel, 'Cancels app subscription migration operations.'], + ])('$Command.name has an exact third-person summary', (Command, summary) => { + expect(Command.summary).toBe(summary) + }) + + test.each([Schedule, Unschedule, Status, Cancel])( + '$name provides action-oriented command documentation', + (Command) => { + expect(Command.summary).toMatch(/[.!]$/) + expect(Command.descriptionWithMarkdown.length).toBeGreaterThan(100) + expect(Command.description).toBe(Command.descriptionWithoutMarkdown()) + expect(Command.examples.length).toBeGreaterThan(1) + expect(Command.examples[0]).not.toContain('--client-id') + expect(Command.examples.some((example) => example.includes('--client-id '))).toBe(true) + expect(Command.descriptionWithMarkdown).toContain('active app configuration') + expect(Command.descriptionWithMarkdown).toContain('--path') + expect(Command.descriptionWithMarkdown).toContain('--config') + expect(Command.descriptionWithMarkdown).toContain('--client-id') + }, + ) + + test.each([Schedule, Unschedule])( + '$name documents input flag and stdin usage without positional syntax', + (Command) => { + expect(Command.descriptionWithMarkdown).toContain('When `--input` is omitted') + expect(Command.descriptionWithMarkdown).toContain('`--input `') + expect(Command.descriptionWithMarkdown).toContain('`--input -`') + expect(Command.descriptionWithMarkdown).not.toContain('required CSV path') + expect(Command.examples.some((example) => example.includes('--input migrations.csv'))).toBe(true) + expect(Command.examples.some((example) => example.startsWith('cat migrations.csv | '))).toBe(true) + expect(Command.examples.every((example) => !example.includes('<%= command.id %> migrations.csv'))).toBe(true) + }, + ) + + test.each([Schedule, Unschedule, Status, Cancel])( + '$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])( + '$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 new file mode 100644 index 00000000000..bacaf2ab25f --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/flags.ts @@ -0,0 +1,67 @@ +import {appFlags} from '../../../flags.js' +import {Flags} from '@oclif/core' +import {authAliasFlag, globalFlags, jsonFlag, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' + +const sharedFlags = { + ...globalFlags, + ...jsonFlag, + ...authAliasFlag, + path: appFlags.path, + config: appFlags.config, + 'client-id': appFlags['client-id'], +} + +const watchFlag = { + watch: Flags.boolean({ + description: + 'Display the current operation state while polling, then output the final outcome when every operation reaches a terminal status.', + env: 'SHOPIFY_FLAG_WATCH', + default: false, + }), +} + +const statusWatchFlag = { + watch: Flags.boolean({ + description: + 'Display the current operation state while polling, then output the final state when every operation reaches a terminal status.', + env: 'SHOPIFY_FLAG_WATCH', + default: false, + }), +} + +export const submissionFlags = { + ...sharedFlags, + input: Flags.string({ + char: 'i', + description: 'Path to the migration CSV. If omitted, standard input is used.', + env: 'SHOPIFY_FLAG_INPUT', + }), + 'idempotency-key': Flags.string({ + description: 'Reuse an existing root idempotency key for the same action and input.', + env: 'SHOPIFY_FLAG_IDEMPOTENCY_KEY', + }), + force: requiredIfNonInteractive( + Flags.boolean({ + char: 'f', + description: 'Skip confirmation.', + env: 'SHOPIFY_FLAG_FORCE', + default: false, + }), + ), + ...watchFlag, +} + +export const operationFlags = { + ...sharedFlags, + id: Flags.string({ + description: 'The app subscription migration operation ID. Can be specified multiple times.', + env: 'SHOPIFY_FLAG_ID', + required: true, + multiple: true, + }), +} + +export const statusFlags = { + ...operationFlags, + ...statusWatchFlag, +} diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts new file mode 100644 index 00000000000..a237ff66dc2 --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts @@ -0,0 +1,88 @@ +import {encodeMigrationCancellationResult, encodeMigrationSubmissionResult} from './result-codec.js' +import {describe, expect, test} from 'vitest' +import type {MigrationOperation} from '../../../models/subscription-migrations.js' +import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js' +import type { + MigrationSubmission, + MigrationSubmissionResult, +} from '../../../services/subscription-migrations/submit-migration-plan.js' + +function operation(id: string): MigrationOperation { + return {id, status: 'RUNNING', total: 1, results: {edges: []}} +} + +function submission(): MigrationSubmission { + return { + clientId: 'client-id', + action: 'schedule', + rootIdempotencyKey: 'root-key', + inputDigest: 'input-digest', + total: 1, + operations: [ + { + batchIndex: 0, + batchPayloadDigest: 'batch-digest', + idempotencyKey: 'batch-key', + operation: operation('operation-one'), + }, + ], + } +} + +describe('subscription migration result codecs', () => { + test('encodes a successful submission with the existing JSON shape', () => { + const value = submission() + const result: MigrationSubmissionResult = {status: 'success', submission: value} + + const document = encodeMigrationSubmissionResult(result) + + expect(JSON.parse(document)).toEqual({schemaVersion: 1, ...value}) + expect(document).toBe(JSON.stringify({schemaVersion: 1, ...value}, null, 2)) + }) + + test('encodes accepted submission evidence and failure details in one JSON document', () => { + const value = submission() + const result: MigrationSubmissionResult = { + status: 'failed', + submission: value, + failedBatchIndex: 1, + userErrors: [{message: 'Rejected remaining shops', field: ['input']}], + } + + const document = encodeMigrationSubmissionResult(result) + + expect(JSON.parse(document)).toEqual({ + schemaVersion: 1, + ...value, + failure: { + batchIndex: 1, + userErrors: [{message: 'Rejected remaining shops', field: ['input']}], + }, + }) + }) + + test('encodes every cancellation outcome in one JSON document', () => { + const result: MigrationCancellationResult = { + outcomes: [ + {status: 'success', operationId: 'one', operation: operation('one')}, + { + status: 'failed', + operationId: 'two', + operation: operation('two'), + userErrors: [{message: 'Already completed', field: ['id']}], + }, + { + status: 'failed', + operationId: 'three', + operation: null, + userErrors: [{message: 'Operation not found', field: null}], + }, + ], + } + + const document = encodeMigrationCancellationResult(result) + + expect(JSON.parse(document)).toEqual({schemaVersion: 1, outcomes: result.outcomes}) + expect(document).toBe(JSON.stringify({schemaVersion: 1, outcomes: result.outcomes}, null, 2)) + }) +}) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts new file mode 100644 index 00000000000..9459570581a --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts @@ -0,0 +1,18 @@ +import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js' +import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js' + +export function encodeMigrationSubmissionResult(result: MigrationSubmissionResult): string { + const document = + result.status === 'success' + ? {schemaVersion: 1, ...result.submission} + : { + schemaVersion: 1, + ...result.submission, + failure: {batchIndex: result.failedBatchIndex, userErrors: result.userErrors}, + } + return JSON.stringify(document, null, 2) +} + +export function encodeMigrationCancellationResult(result: MigrationCancellationResult): string { + return JSON.stringify({schemaVersion: 1, outcomes: result.outcomes}, null, 2) +} diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts new file mode 100644 index 00000000000..3fa55906bea --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts @@ -0,0 +1,228 @@ +import { + presentAcceptedMigrationSubmission, + presentMigrationCancellationResult, + presentMigrationSubmissionResult, +} from './result-presenter.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderInfo, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import type {MigrationOperation} from '../../../models/subscription-migrations.js' +import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js' +import type { + MigrationSubmission, + MigrationSubmissionResult, +} from '../../../services/subscription-migrations/submit-migration-plan.js' + +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, outputResult: vi.fn()} +}) +vi.mock('@shopify/cli-kit/node/ui') + +function operation(id: string, status: MigrationOperation['status'] = 'RUNNING'): MigrationOperation { + return {id, status, total: 2, results: {edges: []}} +} + +function submission(): MigrationSubmission { + return { + clientId: 'client-id', + action: 'schedule', + rootIdempotencyKey: 'root-key', + inputDigest: 'input-digest', + total: 2, + operations: [ + { + batchIndex: 0, + batchPayloadDigest: 'batch-digest', + idempotencyKey: 'batch-key', + operation: operation('operation-one'), + }, + ], + } +} + +beforeEach(() => { + vi.mocked(outputResult).mockReset() + vi.mocked(renderInfo).mockReset() + vi.mocked(renderSuccess).mockReset() + vi.mocked(renderWarning).mockReset() +}) + +describe('migration submission result presenter', () => { + test('outputs exactly one successful JSON document', () => { + const value = submission() + const result: MigrationSubmissionResult = {status: 'success', submission: value} + + const exitCode = presentMigrationSubmissionResult(result, {json: true, watch: false}) + + expect(exitCode).toBe(0) + expect(outputResult).toHaveBeenCalledOnce() + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({schemaVersion: 1, ...value}) + expect(renderSuccess).not.toHaveBeenCalled() + expect(renderWarning).not.toHaveBeenCalled() + }) + + test('outputs exactly one failed JSON document without invoking a fatal renderer', () => { + const value = submission() + const result: MigrationSubmissionResult = { + status: 'failed', + submission: value, + failedBatchIndex: 1, + userErrors: [{message: 'Rejected remaining shops', field: ['input']}], + } + + const exitCode = presentMigrationSubmissionResult(result, {json: true, watch: true}) + + expect(exitCode).toBe(1) + expect(outputResult).toHaveBeenCalledOnce() + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ + schemaVersion: 1, + ...value, + failure: { + batchIndex: 1, + userErrors: [{message: 'Rejected remaining shops', field: ['input']}], + }, + }) + expect(renderSuccess).not.toHaveBeenCalled() + expect(renderWarning).not.toHaveBeenCalled() + }) + + test('renders successful unwatched submission evidence', () => { + const result: MigrationSubmissionResult = {status: 'success', submission: submission()} + + const exitCode = presentMigrationSubmissionResult(result, {json: false, watch: false}) + + expect(exitCode).toBe(0) + expect(renderSuccess).toHaveBeenCalledOnce() + const rendered = JSON.stringify(vi.mocked(renderSuccess).mock.calls[0]?.[0]) + expect(rendered).toContain('Subscription migrations scheduled.') + expect(rendered).toContain('Root idempotency key: root-key') + expect(rendered).toContain('operation-one') + expect(outputResult).not.toHaveBeenCalled() + }) + + test('renders terminal operations for watched human success', () => { + const value = submission() + value.operations[0]!.operation = operation('operation-one', 'COMPLETED') + const result: MigrationSubmissionResult = {status: 'success', submission: value} + + const exitCode = presentMigrationSubmissionResult(result, {json: false, watch: true}) + + expect(exitCode).toBe(0) + expect(renderInfo).toHaveBeenCalledWith({ + headline: 'Subscription migration operations.', + body: ['operation-one: COMPLETED (0/2 settled)'], + }) + expect(renderSuccess).not.toHaveBeenCalled() + }) + + test('renders accepted submission evidence for watched human progress', () => { + presentAcceptedMigrationSubmission(submission()) + + expect(renderSuccess).toHaveBeenCalledOnce() + expect(JSON.stringify(vi.mocked(renderSuccess).mock.calls[0]?.[0])).toContain('Root idempotency key: root-key') + }) + + test('renders one warning containing accepted IDs and every submission error', () => { + const value = submission() + value.operations.push({ + batchIndex: 1, + batchPayloadDigest: 'batch-digest-two', + idempotencyKey: 'batch-key-two', + operation: operation('operation-two'), + }) + const result: MigrationSubmissionResult = { + status: 'failed', + submission: value, + failedBatchIndex: 2, + userErrors: [ + {message: 'Rejected remaining shops', field: ['input']}, + {message: 'Invalid plan', field: null}, + ], + } + + const exitCode = presentMigrationSubmissionResult(result, {json: false, watch: false}) + + expect(exitCode).toBe(1) + expect(renderWarning).toHaveBeenCalledOnce() + const rendered = JSON.stringify(vi.mocked(renderWarning).mock.calls[0]?.[0]) + expect(rendered).toContain('Root idempotency key: root-key') + expect(rendered).toContain('operation-one') + expect(rendered).toContain('operation-two') + expect(rendered).toContain('Batch index: 2') + expect(rendered).toContain('Rejected remaining shops') + expect(rendered).toContain('Invalid plan') + expect(renderSuccess).not.toHaveBeenCalled() + expect(outputResult).not.toHaveBeenCalled() + }) +}) + +describe('migration cancellation result presenter', () => { + test('outputs exactly one JSON document and reports failure', () => { + const result: MigrationCancellationResult = { + outcomes: [ + {status: 'success', operationId: 'one', operation: operation('one', 'CANCELED')}, + { + status: 'failed', + operationId: 'two', + operation: null, + userErrors: [{message: 'Already completed', field: ['id']}], + }, + ], + } + + const exitCode = presentMigrationCancellationResult(result, {json: true}) + + expect(exitCode).toBe(1) + expect(outputResult).toHaveBeenCalledOnce() + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ + schemaVersion: 1, + outcomes: result.outcomes, + }) + expect(renderSuccess).not.toHaveBeenCalled() + expect(renderWarning).not.toHaveBeenCalled() + }) + + test('renders successful and failed cancellations together without discarding returned operations', () => { + const result: MigrationCancellationResult = { + outcomes: [ + {status: 'success', operationId: 'one', operation: operation('one', 'CANCELED')}, + { + status: 'failed', + operationId: 'two', + operation: operation('two', 'COMPLETED'), + userErrors: [ + {message: 'Already completed', field: ['id']}, + {message: 'Cancellation denied', field: null}, + ], + }, + ], + } + + const exitCode = presentMigrationCancellationResult(result, {json: false}) + + expect(exitCode).toBe(1) + expect(renderWarning).toHaveBeenCalledOnce() + const rendered = JSON.stringify(vi.mocked(renderWarning).mock.calls[0]?.[0]) + expect(rendered).toContain('one') + expect(rendered).toContain('CANCELED') + expect(rendered).toContain('two') + expect(rendered).toContain('COMPLETED') + expect(rendered).toContain('Already completed') + expect(rendered).toContain('Cancellation denied') + expect(renderSuccess).not.toHaveBeenCalled() + }) + + test('renders all successful cancellations and reports success', () => { + const result: MigrationCancellationResult = { + outcomes: [{status: 'success', operationId: 'one', operation: operation('one', 'CANCELED')}], + } + + const exitCode = presentMigrationCancellationResult(result, {json: false}) + + expect(exitCode).toBe(0) + expect(renderSuccess).toHaveBeenCalledOnce() + expect(JSON.stringify(vi.mocked(renderSuccess).mock.calls[0]?.[0])).toContain('one') + expect(renderWarning).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts new file mode 100644 index 00000000000..ee947e03722 --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts @@ -0,0 +1,125 @@ +import {encodeMigrationCancellationResult, encodeMigrationSubmissionResult} from './result-codec.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderInfo, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import type {MigrationOperation} from '../../../models/subscription-migrations.js' +import type { + MigrationCancellationOutcome, + MigrationCancellationResult, +} from '../../../services/subscription-migrations/cancel-operations.js' +import type { + MigrationSubmission, + MigrationSubmissionResult, +} from '../../../services/subscription-migrations/submit-migration-plan.js' + +interface SubmissionPresentationOptions { + json: boolean + watch: boolean +} + +interface CancellationPresentationOptions { + json: boolean +} + +export function presentAcceptedMigrationSubmission(submission: MigrationSubmission): void { + renderSubmissionSuccess(submission) +} + +export function presentMigrationSubmissionResult( + result: MigrationSubmissionResult, + options: SubmissionPresentationOptions, +): 0 | 1 { + if (options.json) { + outputResult(encodeMigrationSubmissionResult(result)) + } else if (result.status === 'failed') { + renderSubmissionFailure(result) + } else if (options.watch) { + renderInfo({ + headline: 'Subscription migration operations.', + body: result.submission.operations.map(({operation}) => formatMigrationOperationStatus(operation)), + }) + } else { + renderSubmissionSuccess(result.submission) + } + + return result.status === 'failed' ? 1 : 0 +} + +export function presentMigrationCancellationResult( + result: MigrationCancellationResult, + options: CancellationPresentationOptions, +): 0 | 1 { + const hasFailures = result.outcomes.some(({status}) => status === 'failed') + + if (options.json) { + outputResult(encodeMigrationCancellationResult(result)) + } else if (hasFailures) { + renderWarning({ + headline: 'Some subscription migration operations could not be canceled.', + body: formatCancellationOutcomes(result.outcomes), + }) + } else { + renderSuccess({ + headline: 'Subscription migration operations canceled.', + body: formatCancellationOutcomes(result.outcomes), + }) + } + + return hasFailures ? 1 : 0 +} + +function renderSubmissionSuccess(submission: MigrationSubmission): void { + const action = submission.action === 'schedule' ? 'scheduled' : 'unscheduled' + renderSuccess({ + headline: `Subscription migrations ${action}.`, + body: [ + `Root idempotency key: ${submission.rootIdempotencyKey}`, + `Shops: ${submission.total}`, + 'Operation IDs:', + ...submission.operations.map(({operation}) => operation.id), + 'Save the root idempotency key and every operation ID. You will need them to check or cancel this submission.', + ], + }) +} + +function renderSubmissionFailure(result: Extract): void { + const operationIds = result.submission.operations.map(({operation}) => operation.id) + renderWarning({ + headline: 'Some subscription migration operations were accepted before submission failed.', + body: [ + `Root idempotency key: ${result.submission.rootIdempotencyKey}`, + ...(operationIds.length === 0 ? ['Accepted operation IDs: None.'] : ['Accepted operation IDs:', ...operationIds]), + `Batch index: ${result.failedBatchIndex}`, + 'Errors:', + ...result.userErrors.map(({message}) => message), + 'Save the root idempotency key and every accepted operation ID. You will need them to check or cancel accepted operations.', + ], + }) +} + +function formatCancellationOutcomes(outcomes: MigrationCancellationOutcome[]): string[] { + const successes = outcomes.filter( + (outcome): outcome is Extract => outcome.status === 'success', + ) + const failures = outcomes.filter( + (outcome): outcome is Extract => outcome.status === 'failed', + ) + + return [ + ...(successes.length === 0 + ? ['Canceled operations: None.'] + : ['Canceled operations:', ...successes.map(({operation}) => formatMigrationOperationStatus(operation))]), + ...(failures.length === 0 + ? [] + : [ + 'Failed operations:', + ...failures.map(({operationId, operation, userErrors}) => { + const returnedStatus = operation ? ` (returned status: ${operation.status})` : '' + return `${operationId}: ${userErrors.map(({message}) => message).join('; ')}${returnedStatus}` + }), + ]), + ] +} + +function formatMigrationOperationStatus(operation: MigrationOperation): string { + return `${operation.id}: ${operation.status} (${operation.results.edges.length}/${operation.total} settled)` +} diff --git a/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts b/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts new file mode 100644 index 00000000000..07bf4e20e03 --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts @@ -0,0 +1,60 @@ +import {submissionFlags} from './flags.js' +import {presentAcceptedMigrationSubmission, presentMigrationSubmissionResult} from './result-presenter.js' +import {resolveSubscriptionMigrationClientId} from '../../../services/subscription-migrations/resolve-client-id.js' +import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js' +import BaseCommand from '@shopify/cli-kit/node/base-command' + +export default class Schedule extends BaseCommand { + static summary = 'Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.' + + static descriptionWithMarkdown = `Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing. + +When \`--input\` is omitted, the command reads CSV data from stdin. Use \`--input \` to read from a file. \`--input -\` is also supported as an explicit stdin path. + +- Required CSV columns: \`shop_id\`, \`target_plan_handle\`, and \`price_behavior\`. +- Optional CSV column: \`notification\`. +- Example header: \`shop_id,target_plan_handle,price_behavior,notification\`. +- Example row: \`123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED\`. + +\`price_behavior\` must be \`HONOR_BILLING_PRICE\` or \`PLAN_PRICE\`. \`notification\` can be \`NONE\`, \`OPT_OUT\`, or \`WHEN_REQUIRED\` and defaults to \`WHEN_REQUIRED\` when omitted or blank. + +Validation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve the root idempotency key and every operation GID printed by the command. Reusing the same root idempotency key with the same client ID, action, and input replays the same submission. + +By default, the command 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 explicitly override the active configuration; this only selects the app and does not change Partners authentication. + +Use \`--force\` to skip confirmation and immediately submit every valid row. With \`--watch\`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With \`--json --watch\`, the command outputs one structured JSON document after every operation reaches a terminal status.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> --input migrations.csv --force', + 'cat migrations.csv | <%= config.bin %> <%= command.id %> --force', + '<%= config.bin %> <%= command.id %> --input migrations.csv --path ../my-app --config staging --force --json', + '<%= config.bin %> <%= command.id %> --input migrations.csv --client-id --idempotency-key --force', + '<%= config.bin %> <%= command.id %> --input - --force --watch', + ] + + static flags = {...submissionFlags} + + async run(): Promise { + const {flags} = await this.parse(Schedule) + + const clientId = await resolveSubscriptionMigrationClientId({ + clientId: flags['client-id'], + directory: flags.path, + configName: flags.config, + }) + + const result = await runSubmissionCommand({ + action: 'schedule', + input: flags.input ?? '-', + clientId, + rootIdempotencyKey: flags['idempotency-key'], + skipConfirmation: flags.force, + watch: flags.watch, + ...(flags.watch && !flags.json ? {onSubmissionAccepted: presentAcceptedMigrationSubmission} : {}), + }) + const exitCode = presentMigrationSubmissionResult(result, {json: flags.json, watch: flags.watch}) + if (exitCode !== 0) process.exitCode = exitCode + } +} diff --git a/packages/app/src/cli/commands/app/subscription-migrations/status.ts b/packages/app/src/cli/commands/app/subscription-migrations/status.ts new file mode 100644 index 00000000000..5199f3bdf34 --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/status.ts @@ -0,0 +1,43 @@ +import {statusFlags} from './flags.js' +import {outputOperations} from '../../../services/subscription-migrations/command-output.js' +import {getMigrationOperations} from '../../../services/subscription-migrations/get-operations.js' +import {resolveSubscriptionMigrationClientId} from '../../../services/subscription-migrations/resolve-client-id.js' +import {watchMigrationOperations} from '../../../services/subscription-migrations/watch-operations.js' +import BaseCommand from '@shopify/cli-kit/node/base-command' + +export default class Status extends BaseCommand { + static summary = 'Checks the status of app subscription migration operations.' + + static descriptionWithMarkdown = `Checks app subscription migration operation status. + +Repeat \`--id\` for every operation GID returned by a multi-batch submission. With \`--watch\`, the command displays the current state while polling and outputs the final state after all requested operations reach a terminal status. + +\`RUNNING\` means an operation is still processing. \`COMPLETED\` means processing finished, but you must inspect the per-shop results to confirm each outcome. \`FAILED\` means the operation failed, and \`CANCELED\` means cancellation stopped further processing. + +Use \`--json\` to output every operation and its per-shop results as structured JSON. + +By default, the command 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 explicitly override the active configuration; this only selects the app and does not change Partners authentication.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> --id ', + '<%= config.bin %> <%= command.id %> --path ../my-app --config staging --id --id --watch', + '<%= config.bin %> <%= command.id %> --client-id --id --json', + ] + + static flags = {...statusFlags} + + async run(): Promise { + const {flags} = await this.parse(Status) + const clientId = await resolveSubscriptionMigrationClientId({ + clientId: flags['client-id'], + directory: flags.path, + configName: flags.config, + }) + const operations = flags.watch + ? await watchMigrationOperations({clientId, operationIds: flags.id}) + : await getMigrationOperations({clientId, operationIds: flags.id}) + outputOperations(operations, flags.json) + } +} diff --git a/packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts b/packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts new file mode 100644 index 00000000000..2a38b8ac12c --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts @@ -0,0 +1,58 @@ +import {submissionFlags} from './flags.js' +import {presentAcceptedMigrationSubmission, presentMigrationSubmissionResult} from './result-presenter.js' +import {resolveSubscriptionMigrationClientId} from '../../../services/subscription-migrations/resolve-client-id.js' +import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js' +import BaseCommand from '@shopify/cli-kit/node/base-command' + +export default class Unschedule extends BaseCommand { + static summary = 'Reverses app subscription migrations that are still scheduled.' + + static descriptionWithMarkdown = `Reverses scheduled app subscription migrations that have not migrated yet. + +When \`--input\` is omitted, the command reads CSV data from stdin. Use \`--input \` to read from a file. \`--input -\` is also supported as an explicit stdin path. + +- Required CSV header: \`shop_id\`. +- Example row: \`123456789\`. + +The CSV can contain only the \`shop_id\` header, or it can reuse the complete CSV supplied to \`schedule\`; schedule-only columns are ignored. + +Unscheduling is not a rollback after a subscription has migrated. The command validates the entire CSV before sending any mutation. Use \`--force\` to skip confirmation and immediately submit every valid row. + +Operations are submitted in batches of 250 shops. Preserve the root idempotency key and every operation GID printed by the command. Reusing the same root idempotency key with the same client ID, action, and input replays the same submission. With \`--watch\`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With \`--json --watch\`, the command outputs one structured JSON document after every operation reaches a terminal status. + +By default, the command 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 explicitly override the active configuration; this only selects the app and does not change Partners authentication.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> --input migrations.csv --force', + 'cat migrations.csv | <%= config.bin %> <%= command.id %> --force', + '<%= config.bin %> <%= command.id %> --input migrations.csv --path ../my-app --config staging --force --json', + '<%= config.bin %> <%= command.id %> --input migrations.csv --client-id --idempotency-key --force', + '<%= config.bin %> <%= command.id %> --input - --force --watch', + ] + + static flags = {...submissionFlags} + + async run(): Promise { + const {flags} = await this.parse(Unschedule) + + const clientId = await resolveSubscriptionMigrationClientId({ + clientId: flags['client-id'], + directory: flags.path, + configName: flags.config, + }) + + const result = await runSubmissionCommand({ + action: 'unschedule', + input: flags.input ?? '-', + clientId, + rootIdempotencyKey: flags['idempotency-key'], + skipConfirmation: flags.force, + watch: flags.watch, + ...(flags.watch && !flags.json ? {onSubmissionAccepted: presentAcceptedMigrationSubmission} : {}), + }) + const exitCode = presentMigrationSubmissionResult(result, {json: flags.json, watch: flags.watch}) + if (exitCode !== 0) process.exitCode = exitCode + } +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index fc2d9c42b10..4e747646e0f 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -25,6 +25,10 @@ import AppInfo from './commands/app/info.js' 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 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' import VersionsList from './commands/app/versions/list.js' import WebhookTrigger from './commands/app/webhook/trigger.js' import gatherPublicMetadata from './hooks/public_metadata.js' @@ -56,6 +60,10 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:init': Init, 'app:config:validate': ConfigValidate, 'app:release': Release, + 'app:subscription-migrations:cancel': SubscriptionMigrationsCancel, + 'app:subscription-migrations:schedule': SubscriptionMigrationsSchedule, + 'app:subscription-migrations:status': SubscriptionMigrationsStatus, + 'app:subscription-migrations:unschedule': SubscriptionMigrationsUnschedule, 'app:config:link': ConfigLink, 'app:config:use': ConfigUse, 'app:config:pull': ConfigPull, diff --git a/packages/app/src/cli/models/subscription-migrations.test.ts b/packages/app/src/cli/models/subscription-migrations.test.ts new file mode 100644 index 00000000000..f56018a36dc --- /dev/null +++ b/packages/app/src/cli/models/subscription-migrations.test.ts @@ -0,0 +1,53 @@ +import {NOTIFICATION_KINDS, PRICE_BEHAVIORS} from './subscription-migrations.js' +import {describe, expect, test} from 'vitest' +import type {MigrationOperation, MigrationPlanResult} from './subscription-migrations.js' + +describe('subscription migration domain models', () => { + test('matches the Partners API price behaviors', () => { + expect(PRICE_BEHAVIORS).toEqual(['HONOR_BILLING_PRICE', 'PLAN_PRICE']) + }) + + test('matches the Partners API notification kinds', () => { + expect(NOTIFICATION_KINDS).toEqual(['NONE', 'OPT_OUT', 'WHEN_REQUIRED']) + }) + + test('represents successful and failed planning results', () => { + const success: MigrationPlanResult = { + ok: true, + plan: { + action: 'unschedule', + rows: [{action: 'unschedule', shopId: 'gid://shopify/Shop/1'}], + batches: [ + { + index: 0, + rows: [{action: 'unschedule', shopId: 'gid://shopify/Shop/1'}], + canonicalPayload: '{}', + payloadDigest: 'digest', + }, + ], + canonicalInput: '{}', + inputDigest: 'digest', + }, + } + const failure: MigrationPlanResult = { + ok: false, + errors: [{row: 2, field: 'shop_id', message: 'Invalid shop ID'}], + } + + expect(success.ok).toBe(true) + expect(failure.ok).toBe(false) + }) + + test('represents downstream migration operations', () => { + const operation: MigrationOperation = { + id: 'gid://shopify/AppSubscriptionMigration/1', + status: 'COMPLETED', + total: 1, + results: { + edges: [{node: {shopId: 'gid://shopify/Shop/1', code: 'SCHEDULED'}}], + }, + } + + expect(operation.results.edges[0]?.node.code).toBe('SCHEDULED') + }) +}) diff --git a/packages/app/src/cli/models/subscription-migrations.ts b/packages/app/src/cli/models/subscription-migrations.ts new file mode 100644 index 00000000000..5508112ddcb --- /dev/null +++ b/packages/app/src/cli/models/subscription-migrations.ts @@ -0,0 +1,80 @@ +export const PRICE_BEHAVIORS = ['HONOR_BILLING_PRICE', 'PLAN_PRICE'] as const +export type PriceBehavior = (typeof PRICE_BEHAVIORS)[number] + +export const NOTIFICATION_KINDS = ['NONE', 'OPT_OUT', 'WHEN_REQUIRED'] as const +export type NotificationKind = (typeof NOTIFICATION_KINDS)[number] + +export type MigrationAction = 'schedule' | 'unschedule' + +export interface RawMigrationRow { + sourceRow: number + shopId?: string + targetPlanHandle?: string + priceBehavior?: string + notification?: string +} + +export interface ScheduledMigrationRow { + action: 'schedule' + shopId: string + targetPlanHandle: string + priceBehavior: PriceBehavior + notification: NotificationKind +} + +export interface UnscheduledMigrationRow { + action: 'unschedule' + shopId: string +} + +export type PlannedMigrationRow = ScheduledMigrationRow | UnscheduledMigrationRow + +export interface MigrationBatch { + index: number + rows: PlannedMigrationRow[] + canonicalPayload: string + payloadDigest: string +} + +export interface MigrationPlan { + action: MigrationAction + rows: PlannedMigrationRow[] + batches: MigrationBatch[] + canonicalInput: string + inputDigest: string +} + +export interface MigrationValidationError { + row?: number + field?: string + message: string +} + +export type MigrationPlanResult = {ok: true; plan: MigrationPlan} | {ok: false; errors: MigrationValidationError[]} + +export type MigrationOperationStatus = 'RUNNING' | 'COMPLETED' | 'FAILED' | 'CANCELED' + +export type MigrationResultCode = + | 'SCHEDULED' + | 'CANCELED' + | 'INVALID_PLAN' + | 'INELIGIBLE' + | 'BLOCKED' + | 'ALREADY_SCHEDULED' + | 'ALREADY_MIGRATED' + | 'NOT_FOUND' + | 'INTERNAL_ERROR' + +export interface MigrationOperation { + id: string + status: MigrationOperationStatus + total: number + results: { + edges: { + node: { + shopId: string + code: MigrationResultCode + } + }[] + } +} diff --git a/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts b/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts new file mode 100644 index 00000000000..7e242de88a2 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts @@ -0,0 +1,95 @@ +import {MigrationCancellationProtocolError, cancelMigrationOperations} from './cancel-operations.js' +import {describe, expect, test, vi} from 'vitest' +import type {MigrationOperation} from '../../models/subscription-migrations.js' +import type {MigrationOperationPayload} from './partners-api.js' + +function operation(id: string): MigrationOperation { + return {id, status: 'CANCELED', total: 1, results: {edges: []}} +} + +function payload(id: string): MigrationOperationPayload { + return {operation: operation(id), userErrors: []} +} + +describe('cancelMigrationOperations', () => { + test('returns mixed success and failure outcomes in input order', async () => { + const cancelOperation = vi.fn(({operationId}: {operationId: string}) => { + if (operationId === 'failed') { + return Promise.resolve({ + operation: operation(operationId), + userErrors: [{message: 'Already completed', field: ['id']}], + }) + } + return Promise.resolve(payload(operationId)) + }) + + const result = await cancelMigrationOperations({ + clientId: 'client-id', + operationIds: ['two', 'failed', 'one'], + cancelOperation, + }) + + expect(cancelOperation).toHaveBeenNthCalledWith(1, {clientId: 'client-id', operationId: 'two'}) + expect(cancelOperation).toHaveBeenNthCalledWith(2, {clientId: 'client-id', operationId: 'failed'}) + expect(cancelOperation).toHaveBeenNthCalledWith(3, {clientId: 'client-id', operationId: 'one'}) + expect(result).toEqual({ + outcomes: [ + {status: 'success', operationId: 'two', operation: operation('two')}, + { + status: 'failed', + operationId: 'failed', + operation: operation('failed'), + userErrors: [{message: 'Already completed', field: ['id']}], + }, + {status: 'success', operationId: 'one', operation: operation('one')}, + ], + }) + }) + + test('returns every user error when cancellation has no operation', async () => { + const cancelOperation = vi.fn().mockResolvedValue({ + operation: null, + userErrors: [ + {message: 'Already completed', field: ['id']}, + {message: 'Cancellation denied', field: null}, + ], + }) + + const result = await cancelMigrationOperations({clientId: 'client-id', operationIds: ['one'], cancelOperation}) + + expect(result).toEqual({ + outcomes: [ + { + status: 'failed', + operationId: 'one', + operation: null, + userErrors: [ + {message: 'Already completed', field: ['id']}, + {message: 'Cancellation denied', field: null}, + ], + }, + ], + }) + }) + + test('throws a protocol error for an unexplained empty payload', async () => { + const cancelOperation = vi.fn().mockResolvedValue({operation: null, userErrors: []}) + + const promise = cancelMigrationOperations({clientId: 'client-id', operationIds: ['missing'], cancelOperation}) + + await expect(promise).rejects.toBeInstanceOf(MigrationCancellationProtocolError) + await expect(promise).rejects.toMatchObject({operationId: 'missing'}) + await expect(promise).rejects.toThrow( + 'Migration cancellation for missing returned neither an operation nor user errors', + ) + }) + + test('preserves transport errors', async () => { + const transportError = new Error('Network unavailable') + const cancelOperation = vi.fn().mockRejectedValue(transportError) + + const promise = cancelMigrationOperations({clientId: 'client-id', operationIds: ['one'], cancelOperation}) + + await expect(promise).rejects.toBe(transportError) + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts b/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts new file mode 100644 index 00000000000..9a5a063ac44 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts @@ -0,0 +1,54 @@ +import {cancelMigrationOperation, type MigrationUserError} from './partners-api.js' +import type {MigrationOperation} from '../../models/subscription-migrations.js' + +export type MigrationCancellationOutcome = + | {status: 'success'; operationId: string; operation: MigrationOperation} + | { + status: 'failed' + operationId: string + operation: MigrationOperation | null + userErrors: MigrationUserError[] + } + +export interface MigrationCancellationResult { + outcomes: MigrationCancellationOutcome[] +} + +export class MigrationCancellationProtocolError extends Error { + readonly operationId: string + + constructor(operationId: string) { + super(`Migration cancellation for ${operationId} returned neither an operation nor user errors`) + this.name = 'MigrationCancellationProtocolError' + this.operationId = operationId + } +} + +interface CancelMigrationOperationsOptions { + clientId: string + operationIds: string[] + cancelOperation?: typeof cancelMigrationOperation +} + +export async function cancelMigrationOperations({ + clientId, + operationIds, + cancelOperation = cancelMigrationOperation, +}: CancelMigrationOperationsOptions): Promise { + const outcomes = await Promise.all( + operationIds.map(async (operationId): Promise => { + const payload = await cancelOperation({clientId, operationId}) + if (payload.userErrors.length > 0) { + return { + status: 'failed', + operationId, + operation: payload.operation, + userErrors: payload.userErrors, + } + } + if (!payload.operation) throw new MigrationCancellationProtocolError(operationId) + return {status: 'success', operationId, operation: payload.operation} + }), + ) + return {outcomes} +} diff --git a/packages/app/src/cli/services/subscription-migrations/command-output.test.ts b/packages/app/src/cli/services/subscription-migrations/command-output.test.ts new file mode 100644 index 00000000000..03703eec3e4 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/command-output.test.ts @@ -0,0 +1,60 @@ +import {formatMigrationOperationsStatus, outputOperations} from './command-output.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderInfo} from '@shopify/cli-kit/node/ui' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import type {MigrationOperation} from '../../models/subscription-migrations.js' + +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, outputResult: vi.fn()} +}) +vi.mock('@shopify/cli-kit/node/ui') + +function operation(id: string, status: MigrationOperation['status'] = 'RUNNING'): MigrationOperation { + return { + id, + status, + total: 2, + results: {edges: [{node: {shopId: 'gid://shopify/Shop/1', code: 'SCHEDULED'}}]}, + } +} + +describe('operation command output', () => { + beforeEach(() => { + vi.mocked(outputResult).mockReset() + vi.mocked(renderInfo).mockReset() + }) + + test('formats operation progress in input order with status and settled counts', () => { + const operations = [operation('one', 'COMPLETED'), operation('two', 'RUNNING')] + + expect(formatMigrationOperationsStatus(operations)).toBe( + 'one: COMPLETED (1/2 settled) · two: RUNNING (1/2 settled)', + ) + }) + + test('outputs the exact operations JSON schema', () => { + const operations = [operation('one', 'COMPLETED'), operation('two', 'FAILED')] + + outputOperations(operations, true) + + expect(outputResult).toHaveBeenCalledOnce() + expect(outputResult).toHaveBeenCalledWith(JSON.stringify({schemaVersion: 1, operations}, null, 2)) + const jsonDocument = vi.mocked(outputResult).mock.calls[0]?.[0] + if (typeof jsonDocument !== 'string') throw new Error('Expected operations output to be one JSON document') + expect(JSON.parse(jsonDocument)).toEqual({schemaVersion: 1, operations}) + expect(renderInfo).not.toHaveBeenCalled() + }) + + test('renders each operation status and settled count for human output', () => { + const operations = [operation('one', 'COMPLETED'), operation('two', 'RUNNING')] + + outputOperations(operations, false) + + expect(renderInfo).toHaveBeenCalledWith({ + headline: 'Subscription migration operations.', + body: ['one: COMPLETED (1/2 settled)', 'two: RUNNING (1/2 settled)'], + }) + expect(outputResult).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/command-output.ts b/packages/app/src/cli/services/subscription-migrations/command-output.ts new file mode 100644 index 00000000000..e7338f6baaa --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/command-output.ts @@ -0,0 +1,23 @@ +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderInfo} from '@shopify/cli-kit/node/ui' +import type {MigrationOperation} from '../../models/subscription-migrations.js' + +export function formatMigrationOperationsStatus(operations: MigrationOperation[]): string { + return operations.map(formatMigrationOperationStatus).join(' · ') +} + +export function outputOperations(operations: MigrationOperation[], json: boolean): void { + if (json) { + outputResult(JSON.stringify({schemaVersion: 1, operations}, null, 2)) + return + } + + renderInfo({ + headline: 'Subscription migration operations.', + body: operations.map(formatMigrationOperationStatus), + }) +} + +function formatMigrationOperationStatus(operation: MigrationOperation): string { + return `${operation.id}: ${operation.status} (${operation.results.edges.length}/${operation.total} settled)` +} diff --git a/packages/app/src/cli/services/subscription-migrations/get-operations.test.ts b/packages/app/src/cli/services/subscription-migrations/get-operations.test.ts new file mode 100644 index 00000000000..60cd114947b --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/get-operations.test.ts @@ -0,0 +1,37 @@ +import {getMigrationOperations} from './get-operations.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {describe, expect, test, vi} from 'vitest' +import type {MigrationOperation} from '../../models/subscription-migrations.js' + +function operation(id: string): MigrationOperation { + return {id, status: 'RUNNING', total: 1, results: {edges: []}} +} + +describe('getMigrationOperations', () => { + test('fetches every ID once and returns operations in input order', async () => { + const getOperation = vi.fn(({operationId}: {operationId: string}) => Promise.resolve(operation(operationId))) + + const operations = await getMigrationOperations({ + clientId: 'client-id', + operationIds: ['two', 'one'], + getOperation, + }) + + expect(operations.map(({id}) => id)).toEqual(['two', 'one']) + expect(getOperation).toHaveBeenNthCalledWith(1, {clientId: 'client-id', operationId: 'two'}) + expect(getOperation).toHaveBeenNthCalledWith(2, {clientId: 'client-id', operationId: 'one'}) + }) + + test('throws an exact AbortError when a fetched operation is missing', async () => { + const getOperation = vi.fn().mockResolvedValue(null) + + const promise = getMigrationOperations({ + clientId: 'client-id', + operationIds: ['missing'], + getOperation, + }) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toThrow('Migration operation not found: missing') + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/get-operations.ts b/packages/app/src/cli/services/subscription-migrations/get-operations.ts new file mode 100644 index 00000000000..bf5ed7ff7b1 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/get-operations.ts @@ -0,0 +1,23 @@ +import {getMigrationOperation} from './partners-api.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import type {MigrationOperation} from '../../models/subscription-migrations.js' + +interface GetMigrationOperationsOptions { + clientId: string + operationIds: string[] + getOperation?: typeof getMigrationOperation +} + +export async function getMigrationOperations({ + clientId, + operationIds, + getOperation = getMigrationOperation, +}: GetMigrationOperationsOptions): Promise { + return Promise.all( + operationIds.map(async (operationId) => { + const operation = await getOperation({clientId, operationId}) + if (!operation) throw new AbortError(`Migration operation not found: ${operationId}`) + return operation + }), + ) +} diff --git a/packages/app/src/cli/services/subscription-migrations/input/load-csv-input.test.ts b/packages/app/src/cli/services/subscription-migrations/input/load-csv-input.test.ts new file mode 100644 index 00000000000..690606acf33 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/input/load-csv-input.test.ts @@ -0,0 +1,69 @@ +import {loadCsvInput} from './load-csv-input.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {describe, expect, test, vi} from 'vitest' + +describe('loadCsvInput', () => { + test('reads a CSV file from the real filesystem', async () => { + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'migrations.csv') + await writeFile(path, 'shop_id\n123\n') + + await expect(loadCsvInput(path)).resolves.toBe('shop_id\n123\n') + }) + }) + + test('throws an AbortError with the exact path when the CSV file does not exist', async () => { + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'missing.csv') + + const promise = loadCsvInput(path) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toMatchObject({message: `CSV file not found: ${path}`}) + }) + }) + + test('wraps CSV file read failures in an actionable AbortError', async () => { + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'unreadable.csv') + const fileExists = vi.fn().mockResolvedValue(true) + const readFile = vi.fn().mockRejectedValue(new Error('Permission denied')) + + const promise = loadCsvInput(path, {fileExists, readFile}) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toMatchObject({ + message: `Couldn't read CSV file ${path}: Permission denied`, + }) + expect(fileExists).toHaveBeenCalledWith(path) + expect(readFile).toHaveBeenCalledWith(path) + }) + }) + + test('does not replace an existing AbortError from the file reader', async () => { + const existingError = new AbortError('File reader already explained the failure.') + const fileExists = vi.fn().mockResolvedValue(true) + const readFile = vi.fn().mockRejectedValue(existingError) + + await expect(loadCsvInput('migrations.csv', {fileExists, readFile})).rejects.toBe(existingError) + }) + + test('reads injected stdin when input is a dash', async () => { + const readStdin = vi.fn().mockResolvedValue('shop_id\n123') + + await expect(loadCsvInput('-', {readStdin})).resolves.toBe('shop_id\n123') + }) + + test('throws an AbortError with the exact message when stdin has no data', async () => { + const readStdin = vi.fn().mockResolvedValue(undefined) + + const promise = loadCsvInput('-', {readStdin}) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toMatchObject({ + message: 'Provide --input or pipe CSV data to stdin.', + }) + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/input/load-csv-input.ts b/packages/app/src/cli/services/subscription-migrations/input/load-csv-input.ts new file mode 100644 index 00000000000..b159507e1aa --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/input/load-csv-input.ts @@ -0,0 +1,42 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {fileExists, readFile} from '@shopify/cli-kit/node/fs' +import {readStdinString} from '@shopify/cli-kit/node/system' + +export interface LoadCsvInputDependencies { + fileExists?: typeof fileExists + readFile?: (path: string) => Promise + readStdin?: typeof readStdinString +} + +const defaultDependencies = { + fileExists, + readFile, + readStdin: readStdinString, +} + +export async function loadCsvInput( + input: string, + dependencies: LoadCsvInputDependencies = defaultDependencies, +): Promise { + const inputDependencies = {...defaultDependencies, ...dependencies} + if (input !== '-') { + if (!(await inputDependencies.fileExists(input))) { + throw new AbortError(`CSV file not found: ${input}`) + } + + try { + return await inputDependencies.readFile(input) + } catch (error) { + if (error instanceof AbortError) throw error + + const message = error instanceof Error ? error.message : String(error) + throw new AbortError(`Couldn't read CSV file ${input}: ${message}`) + } + } + + const content = await inputDependencies.readStdin() + if (content === undefined || content === '') { + throw new AbortError('Provide --input or pipe CSV data to stdin.') + } + return content +} 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 new file mode 100644 index 00000000000..31b188a222b --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/partners-api.test.ts @@ -0,0 +1,206 @@ +import { + cancelMigrationOperation, + createMigrationOperation, + getMigrationOperation, + type MigrationApiInput, +} from './partners-api.js' +import { + AppSubscriptionMigrationOperationCancelMutation, + AppSubscriptionMigrationOperationCreateMutation, + AppSubscriptionMigrationOperationQuery, +} from '../../api/graphql/subscription_migrations.js' +import {PartnersClient} from '../../utilities/developer-platform-client/partners-client.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('../../utilities/developer-platform-client/partners-client.js') + +const request = vi.fn() + +const operation = { + id: 'gid://shopify/AppSubscriptionMigrationOperation/42', + status: 'RUNNING' as const, + total: 1, + results: { + edges: [ + { + node: { + shopId: 'gid://shopify/Shop/1001', + code: 'SCHEDULED' as const, + }, + }, + ], + }, +} + +const scheduledMigration: MigrationApiInput = { + shopId: 'gid://shopify/Shop/1001', + action: { + scheduleMigration: { + targetPlanHandle: 'pro', + priceBehavior: 'PLAN_PRICE', + notification: 'WHEN_REQUIRED', + }, + }, +} + +const canceledMigration: MigrationApiInput = { + shopId: 'gid://shopify/Shop/1002', + action: {cancelMigration: true}, +} + +describe('Partners migration API', () => { + beforeEach(() => { + request.mockReset() + vi.mocked(PartnersClient.getInstance).mockReset() + vi.mocked(PartnersClient.getInstance).mockReturnValue({request} as unknown as PartnersClient) + }) + + test('creates a migration operation with the exported document and exact variables', async () => { + const payload = { + operation, + userErrors: [{message: 'A warning', field: null}], + } + const response = {appSubscriptionMigrationOperationCreate: payload} + request.mockResolvedValue(response) + + await expect( + createMigrationOperation({ + clientId: 'client-id', + idempotencyKey: 'batch-key', + migrations: [scheduledMigration, canceledMigration], + }), + ).resolves.toEqual(payload) + + expect(request).toHaveBeenCalledWith(AppSubscriptionMigrationOperationCreateMutation, { + input: { + apiKey: 'client-id', + idempotencyKey: 'batch-key', + migrations: [scheduledMigration, canceledMigration], + }, + }) + const variables = request.mock.calls[0]?.[1] + expect(variables.input).not.toHaveProperty('input') + }) + + test('normalizes nullable user errors in a create payload', async () => { + request.mockResolvedValue({appSubscriptionMigrationOperationCreate: {operation, userErrors: null}}) + + await expect( + createMigrationOperation({ + clientId: 'client-id', + idempotencyKey: 'batch-key', + migrations: [scheduledMigration], + }), + ).resolves.toEqual({operation, userErrors: []}) + }) + + test('normalizes nullable results in a created operation', async () => { + request.mockResolvedValue({ + appSubscriptionMigrationOperationCreate: {operation: {...operation, results: null}, userErrors: []}, + }) + + await expect( + createMigrationOperation({ + clientId: 'client-id', + idempotencyKey: 'batch-key', + migrations: [scheduledMigration], + }), + ).resolves.toEqual({operation: {...operation, results: {edges: []}}, userErrors: []}) + }) + + test('gets a migration operation with the exported document and exact variables', async () => { + const response = {appSubscriptionMigrationOperation: operation} + request.mockResolvedValue(response) + + await expect(getMigrationOperation({clientId: 'client-id', operationId: operation.id})).resolves.toEqual(operation) + + expect(request).toHaveBeenCalledWith(AppSubscriptionMigrationOperationQuery, { + apiKey: 'client-id', + id: operation.id, + }) + }) + + test('preserves a nullable fetched operation', async () => { + request.mockResolvedValue({appSubscriptionMigrationOperation: null}) + + await expect(getMigrationOperation({clientId: 'client-id', operationId: operation.id})).resolves.toBeNull() + }) + + test('normalizes nullable results in a fetched operation', async () => { + request.mockResolvedValue({appSubscriptionMigrationOperation: {...operation, results: null}}) + + await expect(getMigrationOperation({clientId: 'client-id', operationId: operation.id})).resolves.toEqual({ + ...operation, + results: {edges: []}, + }) + }) + + test('normalizes nullable result edges', async () => { + request.mockResolvedValue({appSubscriptionMigrationOperation: {...operation, results: {edges: null}}}) + + await expect(getMigrationOperation({clientId: 'client-id', operationId: operation.id})).resolves.toEqual({ + ...operation, + results: {edges: []}, + }) + }) + + test('filters nullable result edge elements', async () => { + request.mockResolvedValue({ + appSubscriptionMigrationOperation: {...operation, results: {edges: [operation.results.edges[0], null]}}, + }) + + await expect(getMigrationOperation({clientId: 'client-id', operationId: operation.id})).resolves.toEqual(operation) + }) + + test('cancels a migration operation with the exported document and exact variables', async () => { + const payload = { + operation: {...operation, status: 'CANCELED' as const}, + userErrors: [], + } + const response = {appSubscriptionMigrationOperationCancel: payload} + request.mockResolvedValue(response) + + await expect(cancelMigrationOperation({clientId: 'client-id', operationId: operation.id})).resolves.toEqual(payload) + + expect(request).toHaveBeenCalledWith(AppSubscriptionMigrationOperationCancelMutation, { + input: {apiKey: 'client-id', id: operation.id}, + }) + }) + + test('normalizes nullable user errors in a cancel payload', async () => { + const canceledOperation = {...operation, status: 'CANCELED' as const} + request.mockResolvedValue({ + appSubscriptionMigrationOperationCancel: {operation: canceledOperation, userErrors: null}, + }) + + await expect(cancelMigrationOperation({clientId: 'client-id', operationId: operation.id})).resolves.toEqual({ + operation: canceledOperation, + userErrors: [], + }) + }) + + test('normalizes nullable results in a canceled operation', async () => { + const canceledOperation = {...operation, status: 'CANCELED' as const, results: null} + request.mockResolvedValue({ + appSubscriptionMigrationOperationCancel: {operation: canceledOperation, userErrors: []}, + }) + + await expect(cancelMigrationOperation({clientId: 'client-id', operationId: operation.id})).resolves.toEqual({ + operation: {...canceledOperation, results: {edges: []}}, + userErrors: [], + }) + }) + + test('returns only the operation payload from the server response', async () => { + const payload = {operation, userErrors: []} + request.mockResolvedValue({appSubscriptionMigrationOperationCreate: payload, serverMetadata: 'not gateway data'}) + + await expect( + createMigrationOperation({ + clientId: 'client-id', + idempotencyKey: 'batch-key', + migrations: [scheduledMigration], + }), + ).resolves.toEqual(payload) + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/partners-api.ts b/packages/app/src/cli/services/subscription-migrations/partners-api.ts new file mode 100644 index 00000000000..0739357cfef --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/partners-api.ts @@ -0,0 +1,130 @@ +import { + type MigrationOperation, + type NotificationKind, + type PriceBehavior, +} from '../../models/subscription-migrations.js' +import { + AppSubscriptionMigrationOperationCancelMutation, + AppSubscriptionMigrationOperationCreateMutation, + AppSubscriptionMigrationOperationQuery, +} from '../../api/graphql/subscription_migrations.js' +import {PartnersClient} from '../../utilities/developer-platform-client/partners-client.js' + +export interface MigrationApiInput { + shopId: string + action: + | { + scheduleMigration: { + targetPlanHandle: string + priceBehavior: PriceBehavior + notification: NotificationKind + } + } + | {cancelMigration: true} +} + +export interface MigrationUserError { + message: string + field: string[] | null +} + +export interface MigrationOperationPayload { + operation: MigrationOperation | null + userErrors: MigrationUserError[] +} + +type MigrationResultEdge = MigrationOperation['results']['edges'][number] + +interface RawMigrationResultConnection { + edges: (MigrationResultEdge | null)[] | null +} + +type RawMigrationOperation = Omit & { + results: RawMigrationResultConnection | null +} + +interface RawMigrationOperationPayload { + operation: RawMigrationOperation | null + userErrors: MigrationUserError[] | null +} + +interface CreateMigrationOperationResponse { + appSubscriptionMigrationOperationCreate: RawMigrationOperationPayload +} + +interface GetMigrationOperationResponse { + appSubscriptionMigrationOperation: RawMigrationOperation | null +} + +interface CancelMigrationOperationResponse { + appSubscriptionMigrationOperationCancel: RawMigrationOperationPayload +} + +interface CreateMigrationOperationOptions { + clientId: string + idempotencyKey: string + migrations: MigrationApiInput[] +} + +interface MigrationOperationOptions { + clientId: string + operationId: string +} + +function normalizeMigrationOperation(operation: RawMigrationOperation | null): MigrationOperation | null { + if (operation === null) return null + + return { + ...operation, + results: { + edges: operation.results?.edges?.filter((edge): edge is MigrationResultEdge => edge !== null) ?? [], + }, + } +} + +function normalizeMigrationOperationPayload(payload: RawMigrationOperationPayload): MigrationOperationPayload { + return { + operation: normalizeMigrationOperation(payload.operation), + userErrors: payload.userErrors ?? [], + } +} + +export async function createMigrationOperation({ + clientId, + idempotencyKey, + migrations, +}: CreateMigrationOperationOptions): Promise { + const response = await PartnersClient.getInstance().request( + AppSubscriptionMigrationOperationCreateMutation, + {input: {apiKey: clientId, idempotencyKey, migrations}}, + ) + + return normalizeMigrationOperationPayload(response.appSubscriptionMigrationOperationCreate) +} + +export async function getMigrationOperation({ + clientId, + operationId, +}: MigrationOperationOptions): Promise { + const response = await PartnersClient.getInstance().request( + AppSubscriptionMigrationOperationQuery, + { + apiKey: clientId, + id: operationId, + }, + ) + + return normalizeMigrationOperation(response.appSubscriptionMigrationOperation) +} + +export async function cancelMigrationOperation({ + clientId, + operationId, +}: MigrationOperationOptions): Promise { + const response = await PartnersClient.getInstance().request( + AppSubscriptionMigrationOperationCancelMutation, + {input: {apiKey: clientId, id: operationId}}, + ) + + return normalizeMigrationOperationPayload(response.appSubscriptionMigrationOperationCancel) +} diff --git a/packages/app/src/cli/services/subscription-migrations/plan/create-migration-plan.test.ts b/packages/app/src/cli/services/subscription-migrations/plan/create-migration-plan.test.ts new file mode 100644 index 00000000000..258a42e5a44 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/plan/create-migration-plan.test.ts @@ -0,0 +1,205 @@ +import {createMigrationPlan} from './create-migration-plan.js' +import {describe, expect, test} from 'vitest' +import type {RawMigrationRow} from '../../../models/subscription-migrations.js' + +function scheduleRow(sourceRow: number, shopId: string): RawMigrationRow { + return { + sourceRow, + shopId, + targetPlanHandle: `plan-${shopId}`, + priceBehavior: 'PLAN_PRICE', + } +} + +describe('createMigrationPlan', () => { + test('normalizes, canonically sorts, defaults, and batches schedule rows', () => { + const result = createMigrationPlan('schedule', [ + { + sourceRow: 2, + shopId: '2', + targetPlanHandle: 'pro', + priceBehavior: 'PLAN_PRICE', + }, + { + sourceRow: 3, + shopId: 'gid://shopify/Shop/10', + targetPlanHandle: 'basic', + priceBehavior: 'HONOR_BILLING_PRICE', + notification: 'OPT_OUT', + }, + ]) + + expect(result).toMatchObject({ + ok: true, + plan: { + rows: [ + { + action: 'schedule', + shopId: 'gid://shopify/Shop/10', + targetPlanHandle: 'basic', + priceBehavior: 'HONOR_BILLING_PRICE', + notification: 'OPT_OUT', + }, + { + action: 'schedule', + shopId: 'gid://shopify/Shop/2', + targetPlanHandle: 'pro', + priceBehavior: 'PLAN_PRICE', + notification: 'WHEN_REQUIRED', + }, + ], + batches: [{index: 0}], + }, + }) + }) + + test('reports zero, malformed, wrong-model, and blank shop IDs', () => { + const invalidIds = ['', '0', '-1', '1.5', 'gid://shopify/Product/1', 'gid://shopify/Shop/abc'] + const result = createMigrationPlan( + 'unschedule', + invalidIds.map((shopId, index) => ({sourceRow: index + 2, shopId})), + ) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.errors).toHaveLength(invalidIds.length) + expect(result.errors.every(({field}) => field === 'shop_id')).toBe(true) + expect('plan' in result).toBe(false) + } + }) + + test('aggregates every invalid field from every row and returns no plan', () => { + const result = createMigrationPlan('schedule', [ + { + sourceRow: 2, + shopId: 'invalid', + targetPlanHandle: ' ', + priceBehavior: 'INVALID', + notification: 'ALSO_INVALID', + }, + { + sourceRow: 3, + shopId: '0', + targetPlanHandle: '', + priceBehavior: '', + notification: 'UNKNOWN', + }, + ]) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.errors).toHaveLength(8) + expect('plan' in result).toBe(false) + } + }) + + test('reports all invalid schedule enums together', () => { + const result = createMigrationPlan('schedule', [ + { + sourceRow: 2, + shopId: '1', + targetPlanHandle: 'basic', + priceBehavior: 'INVALID', + notification: 'ALSO_INVALID', + }, + ]) + + expect(result).toEqual({ + ok: false, + errors: [ + { + row: 2, + field: 'price_behavior', + message: 'Price behavior must be HONOR_BILLING_PRICE or PLAN_PRICE', + }, + { + row: 2, + field: 'notification', + message: 'Notification must be NONE, OPT_OUT, or WHEN_REQUIRED', + }, + ], + }) + }) + + test('unschedule validates only shop_id', () => { + const result = createMigrationPlan('unschedule', [ + { + sourceRow: 2, + shopId: '1', + targetPlanHandle: '', + priceBehavior: 'INVALID', + notification: 'INVALID', + }, + ]) + + expect(result).toMatchObject({ + ok: true, + plan: {rows: [{action: 'unschedule', shopId: 'gid://shopify/Shop/1'}]}, + }) + }) + + test('rejects normalized duplicates and cites the first source row', () => { + const result = createMigrationPlan('unschedule', [ + {sourceRow: 2, shopId: '001'}, + {sourceRow: 3, shopId: 'gid://shopify/Shop/1'}, + ]) + + expect(result).toEqual({ + ok: false, + errors: [ + { + row: 3, + field: 'shop_id', + message: 'Duplicate shop ID; first seen on row 2', + }, + ], + }) + }) + + test('splits 251 rows into deterministic batches of 250 and 1', () => { + const first = createMigrationPlan( + 'schedule', + Array.from({length: 251}, (_, index) => scheduleRow(index + 2, String(index + 1))), + ) + const second = createMigrationPlan( + 'schedule', + Array.from({length: 251}, (_, index) => scheduleRow(index + 2, String(251 - index))), + ) + + expect(first.ok && second.ok).toBe(true) + if (first.ok && second.ok) { + expect(first.plan.batches.map(({rows}) => rows.length)).toEqual([250, 1]) + expect(first.plan.batches.map(({index}) => index)).toEqual([0, 1]) + expect(first.plan.batches).toEqual(second.plan.batches) + } + }) + + test('keeps canonical input and its digest stable across source ordering and ID spelling', () => { + const first = createMigrationPlan('schedule', [ + {...scheduleRow(2, '2'), targetPlanHandle: 'plan-2'}, + {...scheduleRow(3, '001'), targetPlanHandle: 'plan-1'}, + ]) + const second = createMigrationPlan('schedule', [ + {...scheduleRow(40, 'gid://shopify/Shop/1'), targetPlanHandle: 'plan-1'}, + {...scheduleRow(50, 'gid://shopify/Shop/2'), targetPlanHandle: 'plan-2'}, + ]) + + expect(first.ok && second.ok).toBe(true) + if (first.ok && second.ok) { + expect(first.plan.canonicalInput).toBe(second.plan.canonicalInput) + expect(first.plan.inputDigest).toBe(second.plan.inputDigest) + expect(first.plan.batches).toEqual(second.plan.batches) + } + }) + + test('uses action-specific canonical input', () => { + const schedule = createMigrationPlan('schedule', [scheduleRow(2, '1')]) + const unschedule = createMigrationPlan('unschedule', [{sourceRow: 2, shopId: '1'}]) + + expect(schedule.ok && unschedule.ok).toBe(true) + if (schedule.ok && unschedule.ok) { + expect(schedule.plan.canonicalInput).not.toBe(unschedule.plan.canonicalInput) + expect(schedule.plan.inputDigest).not.toBe(unschedule.plan.inputDigest) + } + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/plan/create-migration-plan.ts b/packages/app/src/cli/services/subscription-migrations/plan/create-migration-plan.ts new file mode 100644 index 00000000000..a6cbd2c7b18 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/plan/create-migration-plan.ts @@ -0,0 +1,159 @@ +import { + NOTIFICATION_KINDS, + PRICE_BEHAVIORS, + type MigrationAction, + type MigrationBatch, + type MigrationPlanResult, + type MigrationValidationError, + type NotificationKind, + type PlannedMigrationRow, + type PriceBehavior, + type RawMigrationRow, +} from '../../../models/subscription-migrations.js' +import {sha256} from '@shopify/cli-kit/node/crypto' + +const SHOP_GID_PATTERN = /^gid:\/\/shopify\/Shop\/(\d+)$/ +const NUMERIC_SHOP_ID_PATTERN = /^\d+$/ +const BATCH_SIZE = 250 +const priceBehaviors = new Set(PRICE_BEHAVIORS) +const notificationKinds = new Set(NOTIFICATION_KINDS) + +export function createMigrationPlan(action: MigrationAction, rawRows: RawMigrationRow[]): MigrationPlanResult { + const errors: MigrationValidationError[] = [] + const plannedRows: PlannedMigrationRow[] = [] + const firstSourceRowByShopId = new Map() + + for (const rawRow of rawRows) { + const shopId = normalizeShopId(rawRow.shopId) + let rowIsValid = true + + if (shopId) { + const firstSourceRow = firstSourceRowByShopId.get(shopId) + if (firstSourceRow === undefined) { + firstSourceRowByShopId.set(shopId, rawRow.sourceRow) + } else { + errors.push({ + row: rawRow.sourceRow, + field: 'shop_id', + message: `Duplicate shop ID; first seen on row ${firstSourceRow}`, + }) + rowIsValid = false + } + } else { + errors.push({ + row: rawRow.sourceRow, + field: 'shop_id', + message: 'Shop ID must be a positive numeric ID or gid://shopify/Shop/', + }) + rowIsValid = false + } + + if (action === 'unschedule') { + if (rowIsValid && shopId) plannedRows.push({action, shopId}) + continue + } + + const targetPlanHandle = rawRow.targetPlanHandle?.trim() + const priceBehavior = rawRow.priceBehavior?.trim() + const rawNotification = rawRow.notification?.trim() + const notification = rawNotification?.length ? rawNotification : 'WHEN_REQUIRED' + + if (!targetPlanHandle) { + errors.push({row: rawRow.sourceRow, field: 'target_plan_handle', message: 'Target plan handle is required'}) + rowIsValid = false + } + if (!isPriceBehavior(priceBehavior)) { + errors.push({ + row: rawRow.sourceRow, + field: 'price_behavior', + message: 'Price behavior must be HONOR_BILLING_PRICE or PLAN_PRICE', + }) + rowIsValid = false + } + if (!isNotificationKind(notification)) { + errors.push({ + row: rawRow.sourceRow, + field: 'notification', + message: 'Notification must be NONE, OPT_OUT, or WHEN_REQUIRED', + }) + rowIsValid = false + } + + if ( + rowIsValid && + shopId && + targetPlanHandle && + isPriceBehavior(priceBehavior) && + isNotificationKind(notification) + ) { + plannedRows.push({ + action, + shopId, + targetPlanHandle, + priceBehavior, + notification, + }) + } + } + + if (errors.length > 0) return {ok: false, errors} + + const rows = [...plannedRows].sort((left, right) => { + if (left.shopId < right.shopId) return -1 + if (left.shopId > right.shopId) return 1 + return 0 + }) + const canonicalInput = canonicalize(action, rows) + return { + ok: true, + plan: { + action, + rows, + batches: createBatches(action, rows), + canonicalInput, + inputDigest: digest(canonicalInput), + }, + } +} + +function normalizeShopId(value: string | undefined): string | undefined { + const trimmed = value?.trim() + if (!trimmed) return undefined + + const numericId = NUMERIC_SHOP_ID_PATTERN.test(trimmed) ? trimmed : SHOP_GID_PATTERN.exec(trimmed)?.[1] + if (numericId === undefined) return undefined + + const canonicalId = BigInt(numericId) + return canonicalId > 0 ? `gid://shopify/Shop/${canonicalId}` : undefined +} + +function isPriceBehavior(value: string | undefined): value is PriceBehavior { + return value !== undefined && priceBehaviors.has(value) +} + +function isNotificationKind(value: string): value is NotificationKind { + return notificationKinds.has(value) +} + +function canonicalize(action: MigrationAction, rows: PlannedMigrationRow[]): string { + return JSON.stringify({version: 1, action, rows}) +} + +function createBatches(action: MigrationAction, rows: PlannedMigrationRow[]): MigrationBatch[] { + const batches: MigrationBatch[] = [] + for (let offset = 0; offset < rows.length; offset += BATCH_SIZE) { + const batchRows = rows.slice(offset, offset + BATCH_SIZE) + const canonicalPayload = canonicalize(action, batchRows) + batches.push({ + index: batches.length, + rows: batchRows, + canonicalPayload, + payloadDigest: digest(canonicalPayload), + }) + } + return batches +} + +function digest(value: string): string { + return sha256(value).toString('hex') +} diff --git a/packages/app/src/cli/services/subscription-migrations/plan/idempotency.test.ts b/packages/app/src/cli/services/subscription-migrations/plan/idempotency.test.ts new file mode 100644 index 00000000000..6f7dcbc15f3 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/plan/idempotency.test.ts @@ -0,0 +1,37 @@ +import {deriveBatchIdempotencyKey, generateRootIdempotencyKey} from './idempotency.js' +import {describe, expect, test} from 'vitest' +import type {MigrationAction} from '../../../models/subscription-migrations.js' + +const baseInput = { + appIdentifier: 'client-id', + action: 'schedule' as MigrationAction, + rootKey: 'root-key', + canonicalBatchPayload: '{"version":1,"action":"schedule","rows":[]}', +} + +describe('migration idempotency', () => { + test('generates a UUID root key', () => { + expect(generateRootIdempotencyKey()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ) + }) + + test('derives the same batch key for identical semantic inputs', () => { + expect(deriveBatchIdempotencyKey(baseInput)).toBe(deriveBatchIdempotencyKey(baseInput)) + }) + + test.each([ + ['app identifier', {appIdentifier: 'different-app'}], + ['action', {action: 'unschedule' as MigrationAction}], + ['root key', {rootKey: 'different-root'}], + ['canonical payload', {canonicalBatchPayload: '{"different":true}'}], + ])('changes the batch key when the %s changes', (_description, changedInput) => { + expect(deriveBatchIdempotencyKey({...baseInput, ...changedInput})).not.toBe(deriveBatchIdempotencyKey(baseInput)) + }) + + test('matches the committed v1 derivation vector', () => { + expect(deriveBatchIdempotencyKey(baseInput)).toBe( + '8dc97c009690495df812685c01546c6d0cb9ff504aca8cd98fc4fa50f56c359c', + ) + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/plan/idempotency.ts b/packages/app/src/cli/services/subscription-migrations/plan/idempotency.ts new file mode 100644 index 00000000000..43691dce680 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/plan/idempotency.ts @@ -0,0 +1,31 @@ +import {randomUUID, sha256} from '@shopify/cli-kit/node/crypto' +import type {MigrationAction} from '../../../models/subscription-migrations.js' + +const IDEMPOTENCY_NAMESPACE = 'shopify-subscription-migration:v1' + +interface DeriveBatchIdempotencyKeyOptions { + appIdentifier: string + action: MigrationAction + rootKey: string + canonicalBatchPayload: string +} + +export function generateRootIdempotencyKey(): string { + return randomUUID() +} + +export function deriveBatchIdempotencyKey({ + appIdentifier, + action, + rootKey, + canonicalBatchPayload, +}: DeriveBatchIdempotencyKeyOptions): string { + const canonicalDerivationInput = JSON.stringify({ + namespace: IDEMPOTENCY_NAMESPACE, + appIdentifier, + action, + rootKey, + canonicalBatchPayload, + }) + return sha256(canonicalDerivationInput).toString('hex') +} diff --git a/packages/app/src/cli/services/subscription-migrations/plan/parse-migration-csv.test.ts b/packages/app/src/cli/services/subscription-migrations/plan/parse-migration-csv.test.ts new file mode 100644 index 00000000000..d0a1b3b9320 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/plan/parse-migration-csv.test.ts @@ -0,0 +1,113 @@ +import {parseMigrationCsv} from './parse-migration-csv.js' +import {describe, expect, test} from 'vitest' + +describe('parseMigrationCsv', () => { + test('maps schedule headers and preserves source row numbers', () => { + expect( + parseMigrationCsv( + 'shop_id,target_plan_handle,price_behavior,notification\n123,basic,PLAN_PRICE,WHEN_REQUIRED\n', + 'schedule', + ), + ).toEqual({ + ok: true, + rows: [ + { + sourceRow: 2, + shopId: '123', + targetPlanHandle: 'basic', + priceBehavior: 'PLAN_PRICE', + notification: 'WHEN_REQUIRED', + }, + ], + }) + }) + + test('parses quoted fields, a BOM, whitespace, and empty lines', () => { + const result = parseMigrationCsv( + '\ufeffshop_id,target_plan_handle,price_behavior,notification\n\n "123" , "Basic, monthly" , PLAN_PRICE , WHEN_REQUIRED \n', + 'schedule', + ) + + expect(result).toMatchObject({ + ok: true, + rows: [{shopId: '123', targetPlanHandle: 'Basic, monthly'}], + }) + }) + + test('accepts a full schedule CSV for unschedule and ignores schedule-only columns', () => { + expect( + parseMigrationCsv( + 'shop_id,target_plan_handle,price_behavior,notification\n123,basic,not-validated,not-validated\n', + 'unschedule', + ), + ).toEqual({ok: true, rows: [{sourceRow: 2, shopId: '123'}]}) + }) + + test('accepts a minimal unschedule CSV', () => { + expect(parseMigrationCsv('shop_id\n123\n', 'unschedule')).toEqual({ + ok: true, + rows: [{sourceRow: 2, shopId: '123'}], + }) + }) + + test('rejects every missing required schedule header', () => { + expect(parseMigrationCsv('shop_id\n123\n', 'schedule')).toEqual({ + ok: false, + errors: [ + {field: 'target_plan_handle', message: 'Missing required CSV header: target_plan_handle'}, + {field: 'price_behavior', message: 'Missing required CSV header: price_behavior'}, + ], + }) + }) + + test('rejects an unschedule CSV without shop_id', () => { + expect(parseMigrationCsv('notification\nNONE\n', 'unschedule')).toEqual({ + ok: false, + errors: [{field: 'shop_id', message: 'Missing required CSV header: shop_id'}], + }) + }) + + test('rejects every unknown header', () => { + expect(parseMigrationCsv('shop_id,unexpected,also_bad\n123,value,value\n', 'unschedule')).toEqual({ + ok: false, + errors: [ + {field: 'unexpected', message: 'Unknown CSV header: unexpected'}, + {field: 'also_bad', message: 'Unknown CSV header: also_bad'}, + ], + }) + }) + + test('rejects duplicate and unknown headers together', () => { + expect(parseMigrationCsv('shop_id,shop_id,unexpected\n123,456,value\n', 'unschedule')).toEqual({ + ok: false, + errors: [ + {field: 'shop_id', message: 'Duplicate CSV header: shop_id'}, + {field: 'unexpected', message: 'Unknown CSV header: unexpected'}, + ], + }) + }) + + test('returns a structured error for malformed CSV', () => { + const result = parseMigrationCsv('shop_id\n"unterminated\n', 'unschedule') + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.errors).toHaveLength(1) + expect(result.errors[0]?.message).toContain('Invalid CSV') + } + }) + + test('rejects a header-only CSV', () => { + expect(parseMigrationCsv('shop_id\n', 'unschedule')).toEqual({ + ok: false, + errors: [{message: 'The CSV must contain at least one data row'}], + }) + }) + + test('rejects empty input', () => { + expect(parseMigrationCsv('', 'schedule')).toEqual({ + ok: false, + errors: [{message: 'The CSV is empty'}], + }) + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/plan/parse-migration-csv.ts b/packages/app/src/cli/services/subscription-migrations/plan/parse-migration-csv.ts new file mode 100644 index 00000000000..b2111f0d430 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/plan/parse-migration-csv.ts @@ -0,0 +1,100 @@ +import {CsvError, parse} from 'csv-parse/sync' +import type { + MigrationAction, + MigrationValidationError, + RawMigrationRow, +} from '../../../models/subscription-migrations.js' + +const KNOWN_HEADERS = ['shop_id', 'target_plan_handle', 'price_behavior', 'notification'] as const +const REQUIRED_HEADERS: Record> = { + schedule: ['shop_id', 'target_plan_handle', 'price_behavior'], + unschedule: ['shop_id'], +} + +interface CsvRecord { + shop_id: string + target_plan_handle?: string + price_behavior?: string + notification?: string +} + +interface ParsedRecord { + record: CsvRecord + info: {lines: number} +} + +type ParseMigrationCsvResult = {ok: true; rows: RawMigrationRow[]} | {ok: false; errors: MigrationValidationError[]} + +export function parseMigrationCsv(content: string, action: MigrationAction): ParseMigrationCsvResult { + if (content.trim() === '') { + return {ok: false, errors: [{message: 'The CSV is empty'}]} + } + + let headers: string[] = [] + let records: ParsedRecord[] + try { + records = parse(content, { + bom: true, + columns: (parsedHeaders: string[]) => { + headers = parsedHeaders + return parsedHeaders + }, + info: true, + skip_empty_lines: true, + trim: true, + }) + } catch (error) { + if (!(error instanceof CsvError)) throw error + return {ok: false, errors: [{message: `Invalid CSV: ${error.message}`}]} + } + + const headerErrors = validateHeaders(headers, action) + if (headerErrors.length > 0) return {ok: false, errors: headerErrors} + if (records.length === 0) { + return {ok: false, errors: [{message: 'The CSV must contain at least one data row'}]} + } + + return { + ok: true, + rows: records.map(({record, info}) => mapRecord(record, info.lines, action)), + } +} + +function validateHeaders(headers: string[], action: MigrationAction): MigrationValidationError[] { + const errors: MigrationValidationError[] = [] + const knownHeaders = new Set(KNOWN_HEADERS) + const seenHeaders = new Set() + + for (const header of headers) { + if (seenHeaders.has(header)) { + errors.push({field: header, message: `Duplicate CSV header: ${header}`}) + } else { + seenHeaders.add(header) + } + if (!knownHeaders.has(header)) { + errors.push({field: header, message: `Unknown CSV header: ${header}`}) + } + } + + for (const header of REQUIRED_HEADERS[action]) { + if (!headers.includes(header)) { + errors.push({field: header, message: `Missing required CSV header: ${header}`}) + } + } + + return errors +} + +function mapRecord(record: CsvRecord, sourceRow: number, action: MigrationAction): RawMigrationRow { + if (action === 'unschedule') { + return {sourceRow, shopId: record.shop_id} + } + + return { + sourceRow, + shopId: record.shop_id, + ...(record.target_plan_handle === undefined ? {} : {targetPlanHandle: record.target_plan_handle}), + ...(record.price_behavior === undefined ? {} : {priceBehavior: record.price_behavior}), + ...(record.notification === undefined ? {} : {notification: record.notification}), + } +} diff --git a/packages/app/src/cli/services/subscription-migrations/plan/plan-migration-input.test.ts b/packages/app/src/cli/services/subscription-migrations/plan/plan-migration-input.test.ts new file mode 100644 index 00000000000..3d6e1171d23 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/plan/plan-migration-input.test.ts @@ -0,0 +1,77 @@ +import {planMigrationInput} from './plan-migration-input.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {describe, expect, test, vi} from 'vitest' + +describe('planMigrationInput', () => { + test('plans a schedule migration from a real CSV file', async () => { + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'migrations.csv') + await writeFile( + path, + 'shop_id,target_plan_handle,price_behavior,notification\n2,pro,PLAN_PRICE,\n1,basic,HONOR_BILLING_PRICE,OPT_OUT\n', + ) + + const result = await planMigrationInput('schedule', path) + + expect(result).toMatchObject({ + ok: true, + plan: { + rows: [ + {shopId: 'gid://shopify/Shop/1', notification: 'OPT_OUT'}, + {shopId: 'gid://shopify/Shop/2', notification: 'WHEN_REQUIRED'}, + ], + batches: [{index: 0}], + }, + }) + }) + }) + + test('plans an unschedule migration from injected stdin', async () => { + const readStdin = vi.fn().mockResolvedValue('shop_id\n123\n') + + const result = await planMigrationInput('unschedule', '-', {readStdin}) + + expect(result).toMatchObject({ + ok: true, + plan: {rows: [{action: 'unschedule', shopId: 'gid://shopify/Shop/123'}]}, + }) + }) + + test('throws an actionable AbortError when stdin has no data', async () => { + const readStdin = vi.fn().mockResolvedValue(undefined) + + const promise = planMigrationInput('schedule', '-', {readStdin}) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toMatchObject({ + message: 'Provide --input or pipe CSV data to stdin.', + }) + }) + + test('returns all semantic row errors and no plan', async () => { + const readStdin = vi + .fn() + .mockResolvedValue('shop_id,target_plan_handle,price_behavior\ninvalid,,INVALID\n0,basic,PLAN_PRICE\n') + + const result = await planMigrationInput('schedule', '-', {readStdin}) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.errors).toHaveLength(4) + expect('plan' in result).toBe(false) + } + }) + + test('returns parser errors without semantic planning', async () => { + const readStdin = vi.fn().mockResolvedValue('shop_id\n"unterminated\n') + + const result = await planMigrationInput('unschedule', '-', {readStdin}) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.errors[0]?.message).toContain('Invalid CSV') + } + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/plan/plan-migration-input.ts b/packages/app/src/cli/services/subscription-migrations/plan/plan-migration-input.ts new file mode 100644 index 00000000000..d427d33e114 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/plan/plan-migration-input.ts @@ -0,0 +1,15 @@ +import {createMigrationPlan} from './create-migration-plan.js' +import {parseMigrationCsv} from './parse-migration-csv.js' +import {loadCsvInput, type LoadCsvInputDependencies} from '../input/load-csv-input.js' +import type {MigrationAction, MigrationPlanResult} from '../../../models/subscription-migrations.js' + +export async function planMigrationInput( + action: MigrationAction, + input: string, + dependencies?: LoadCsvInputDependencies, +): Promise { + const content = dependencies ? await loadCsvInput(input, dependencies) : await loadCsvInput(input) + const parsed = parseMigrationCsv(content, action) + if (!parsed.ok) return parsed + return createMigrationPlan(action, parsed.rows) +} diff --git a/packages/app/src/cli/services/subscription-migrations/resolve-client-id.test.ts b/packages/app/src/cli/services/subscription-migrations/resolve-client-id.test.ts new file mode 100644 index 00000000000..8ad1bd00f37 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/resolve-client-id.test.ts @@ -0,0 +1,93 @@ +import {resolveSubscriptionMigrationClientId} from './resolve-client-id.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {describe, expect, test, vi} from 'vitest' +import type {getAppConfigurationContext as GetAppConfigurationContext} from '../../models/app/loader.js' + +type AppConfigurationContext = Awaited> + +function configurationContext({ + clientId, + errors = [], +}: { + clientId?: unknown + errors?: {message: string}[] +}): AppConfigurationContext { + return { + project: {}, + activeConfig: { + file: { + content: {client_id: clientId}, + errors, + }, + }, + } as unknown as AppConfigurationContext +} + +describe('resolveSubscriptionMigrationClientId', () => { + test('returns an explicit Client ID without loading an app project', async () => { + const getAppConfigurationContext = vi.fn() + + await expect( + resolveSubscriptionMigrationClientId( + {clientId: 'override-client-id', directory: '/app', configName: 'staging'}, + {getAppConfigurationContext}, + ), + ).resolves.toBe('override-client-id') + expect(getAppConfigurationContext).not.toHaveBeenCalled() + }) + + test('returns the Client ID from the active app configuration', async () => { + const getAppConfigurationContext = vi + .fn() + .mockResolvedValue(configurationContext({clientId: 'configured-client-id'})) + + await expect( + resolveSubscriptionMigrationClientId( + {clientId: undefined, directory: '/app', configName: undefined}, + {getAppConfigurationContext}, + ), + ).resolves.toBe('configured-client-id') + }) + + test('forwards the selected directory and configuration name', async () => { + const getAppConfigurationContext = vi.fn().mockResolvedValue(configurationContext({clientId: 'client-id'})) + + await resolveSubscriptionMigrationClientId( + {clientId: undefined, directory: '/selected/app', configName: 'production'}, + {getAppConfigurationContext}, + ) + + expect(getAppConfigurationContext).toHaveBeenCalledWith('/selected/app', 'production') + }) + + test('raises an AbortError containing active configuration parse errors', async () => { + const getAppConfigurationContext = vi.fn().mockResolvedValue( + configurationContext({ + errors: [{message: 'Unexpected character at row 2'}, {message: 'Expected a string for client_id'}], + }), + ) + const promise = resolveSubscriptionMigrationClientId( + {clientId: undefined, directory: '/app', configName: undefined}, + {getAppConfigurationContext}, + ) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toThrow('Unexpected character at row 2\nExpected a string for client_id') + }) + + test.each([undefined, '', ' '])( + 'raises an actionable AbortError when the active Client ID is %j', + async (clientId) => { + const getAppConfigurationContext = vi.fn().mockResolvedValue(configurationContext({clientId})) + const promise = resolveSubscriptionMigrationClientId( + {clientId: undefined, directory: '/app', configName: undefined}, + {getAppConfigurationContext}, + ) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toThrow('No Client ID found in the active app configuration.') + await expect(promise).rejects.toThrow('shopify app config link') + await expect(promise).rejects.toThrow('--client-id') + }, + ) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/resolve-client-id.ts b/packages/app/src/cli/services/subscription-migrations/resolve-client-id.ts new file mode 100644 index 00000000000..0c9e6126f5c --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/resolve-client-id.ts @@ -0,0 +1,38 @@ +import {getAppConfigurationContext} from '../../models/app/loader.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' + +interface ResolveSubscriptionMigrationClientIdOptions { + clientId: string | undefined + directory: string + configName: string | undefined +} + +interface ResolveSubscriptionMigrationClientIdDependencies { + getAppConfigurationContext?: typeof getAppConfigurationContext +} + +export async function resolveSubscriptionMigrationClientId( + {clientId, directory, configName}: ResolveSubscriptionMigrationClientIdOptions, + dependencies: ResolveSubscriptionMigrationClientIdDependencies = {}, +): Promise { + if (clientId) return clientId + + const loadAppConfigurationContext = dependencies.getAppConfigurationContext ?? getAppConfigurationContext + const {activeConfig} = await loadAppConfigurationContext(directory, configName) + + if (activeConfig.file.errors.length > 0) { + throw new AbortError(activeConfig.file.errors.map((error) => error.message).join('\n')) + } + + const configuredClientId = activeConfig.file.content.client_id + if (typeof configuredClientId !== 'string' || configuredClientId.trim().length === 0) { + throw new AbortError( + outputContent`No Client ID found in the active app configuration. Run ${outputToken.genericShellCommand( + 'shopify app config link', + )} or pass ${outputToken.yellow('--client-id')}.`, + ) + } + + return configuredClientId +} diff --git a/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts b/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts new file mode 100644 index 00000000000..935ca63409f --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts @@ -0,0 +1,204 @@ +import {planMigrationInput} from './plan/plan-migration-input.js' +import {runSubmissionCommand} from './run-submission-command.js' +import {submitMigrationPlan} from './submit-migration-plan.js' +import {watchMigrationOperations} from './watch-operations.js' +import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' +import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import type {MigrationOperation} from '../../models/subscription-migrations.js' +import type {MigrationSubmission, MigrationSubmissionResult} from './submit-migration-plan.js' + +vi.mock('./plan/plan-migration-input.js') +vi.mock('./submit-migration-plan.js', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, submitMigrationPlan: vi.fn()} +}) +vi.mock('./watch-operations.js') +vi.mock('@shopify/cli-kit/node/ui') + +const plan = { + action: 'schedule' as const, + rows: [ + { + action: 'schedule' as const, + shopId: 'gid://shopify/Shop/1', + targetPlanHandle: 'pro', + priceBehavior: 'PLAN_PRICE' as const, + notification: 'WHEN_REQUIRED' as const, + }, + ], + batches: [], + canonicalInput: '{}', + inputDigest: 'digest', +} + +function operation(id: string, status: MigrationOperation['status'] = 'RUNNING'): MigrationOperation { + return {id, status, total: 1, results: {edges: []}} +} + +function submission(): MigrationSubmission { + return { + clientId: 'client-id', + action: 'schedule', + rootIdempotencyKey: 'root-key', + inputDigest: 'digest', + total: 2, + operations: [ + { + batchIndex: 0, + batchPayloadDigest: 'batch-digest-0', + idempotencyKey: 'batch-key-0', + operation: operation('one'), + }, + { + batchIndex: 1, + batchPayloadDigest: 'batch-digest-1', + idempotencyKey: 'batch-key-1', + operation: operation('two'), + }, + ], + } +} + +function successfulResult(): MigrationSubmissionResult { + return {status: 'success', submission: submission()} +} + +const baseOptions = { + action: 'schedule' as const, + input: 'migrations.csv', + clientId: 'client-id', + skipConfirmation: true, + watch: false, +} + +describe('runSubmissionCommand', () => { + beforeEach(() => { + vi.mocked(planMigrationInput).mockReset() + vi.mocked(submitMigrationPlan).mockReset() + vi.mocked(watchMigrationOperations).mockReset() + vi.mocked(renderConfirmationPrompt).mockReset() + }) + + test('aggregates validation errors before prompting or submitting', async () => { + vi.mocked(planMigrationInput).mockResolvedValue({ + ok: false, + errors: [ + {row: 2, field: 'shop_id', message: 'Invalid shop'}, + {row: 3, field: 'target_plan_handle', message: 'Target plan handle is required'}, + ], + }) + + const promise = runSubmissionCommand({...baseOptions, skipConfirmation: false}) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toThrow( + 'row 2, shop_id: Invalid shop\nrow 3, target_plan_handle: Target plan handle is required', + ) + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(submitMigrationPlan).not.toHaveBeenCalled() + }) + + test('silently aborts when confirmation is refused', async () => { + vi.mocked(planMigrationInput).mockResolvedValue({ok: true, plan}) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(false) + + await expect(runSubmissionCommand({...baseOptions, skipConfirmation: false})).rejects.toBeInstanceOf( + AbortSilentError, + ) + expect(renderConfirmationPrompt).toHaveBeenCalledOnce() + expect(submitMigrationPlan).not.toHaveBeenCalled() + }) + + test('returns an unwatched successful result without presenting or watching', async () => { + const result = successfulResult() + const onSubmissionAccepted = vi.fn() + vi.mocked(planMigrationInput).mockResolvedValue({ok: true, plan}) + vi.mocked(submitMigrationPlan).mockResolvedValue(result) + + await expect( + runSubmissionCommand({...baseOptions, rootIdempotencyKey: 'root-key', onSubmissionAccepted}), + ).resolves.toBe(result) + + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(submitMigrationPlan).toHaveBeenCalledWith({ + clientId: 'client-id', + plan, + rootIdempotencyKey: 'root-key', + }) + expect(watchMigrationOperations).not.toHaveBeenCalled() + expect(onSubmissionAccepted).not.toHaveBeenCalled() + }) + + test('returns a failed result immediately without presenting or watching', async () => { + const submitted = submission() + const result: MigrationSubmissionResult = { + status: 'failed', + submission: submitted, + failedBatchIndex: 2, + userErrors: [{message: 'Rejected', field: ['input']}], + } + const onSubmissionAccepted = vi.fn() + vi.mocked(planMigrationInput).mockResolvedValue({ok: true, plan}) + vi.mocked(submitMigrationPlan).mockResolvedValue(result) + + await expect(runSubmissionCommand({...baseOptions, watch: true, onSubmissionAccepted})).resolves.toBe(result) + + expect(onSubmissionAccepted).not.toHaveBeenCalled() + expect(watchMigrationOperations).not.toHaveBeenCalled() + }) + + test.each(['schedule', 'unschedule'] as const)( + 'reports an accepted %s submission before watching and returns merged terminal operations', + async (action) => { + const submitted = {...submission(), action} + const acceptedResult: MigrationSubmissionResult = {status: 'success', submission: submitted} + const terminalOperations = [operation('one', 'FAILED'), operation('two', 'COMPLETED')] + const onSubmissionAccepted = vi.fn() + vi.mocked(planMigrationInput).mockResolvedValue({ok: true, plan: {...plan, action}}) + vi.mocked(submitMigrationPlan).mockResolvedValue(acceptedResult) + vi.mocked(watchMigrationOperations).mockResolvedValue(terminalOperations) + + const result = await runSubmissionCommand({...baseOptions, action, watch: true, onSubmissionAccepted}) + + expect(onSubmissionAccepted).toHaveBeenCalledWith(submitted) + expect(onSubmissionAccepted).toHaveBeenCalledBefore(vi.mocked(watchMigrationOperations)) + expect(watchMigrationOperations).toHaveBeenCalledWith({ + clientId: 'client-id', + operationIds: ['one', 'two'], + }) + expect(result).toEqual({ + status: 'success', + submission: { + ...submitted, + operations: [ + {...submitted.operations[0], operation: terminalOperations[0]}, + {...submitted.operations[1], operation: terminalOperations[1]}, + ], + }, + }) + }, + ) + + test('merges watched terminal operations by ID rather than response order', async () => { + const acceptedResult = successfulResult() + const completedTwo = operation('two', 'COMPLETED') + const failedOne = operation('one', 'FAILED') + vi.mocked(planMigrationInput).mockResolvedValue({ok: true, plan}) + vi.mocked(submitMigrationPlan).mockResolvedValue(acceptedResult) + vi.mocked(watchMigrationOperations).mockResolvedValue([completedTwo, failedOne]) + + const result = await runSubmissionCommand({...baseOptions, watch: true}) + + expect(result).toEqual({ + status: 'success', + submission: { + ...acceptedResult.submission, + operations: [ + {...acceptedResult.submission.operations[0], operation: failedOne}, + {...acceptedResult.submission.operations[1], operation: completedTwo}, + ], + }, + }) + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts b/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts new file mode 100644 index 00000000000..a02d99811b8 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts @@ -0,0 +1,76 @@ +import {planMigrationInput} from './plan/plan-migration-input.js' +import {submitMigrationPlan, type MigrationSubmission, type MigrationSubmissionResult} from './submit-migration-plan.js' +import {watchMigrationOperations} from './watch-operations.js' +import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' +import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' +import type { + MigrationAction, + MigrationOperation, + MigrationValidationError, +} from '../../models/subscription-migrations.js' + +interface RunSubmissionCommandOptions { + action: MigrationAction + input: string + clientId: string + rootIdempotencyKey?: string + skipConfirmation: boolean + watch: boolean + onSubmissionAccepted?: (submission: MigrationSubmission) => void +} + +export async function runSubmissionCommand(options: RunSubmissionCommandOptions): Promise { + const result = await planMigrationInput(options.action, options.input) + if (!result.ok) throw new AbortError(formatValidationErrors(result.errors)) + + if (!options.skipConfirmation) { + const confirmed = await renderConfirmationPrompt({ + message: `${options.action === 'schedule' ? 'Schedule' : 'Unschedule'} ${result.plan.rows.length} subscriptions?`, + }) + if (!confirmed) throw new AbortSilentError() + } + + const submissionResult = await submitMigrationPlan({ + clientId: options.clientId, + plan: result.plan, + ...(options.rootIdempotencyKey === undefined ? {} : {rootIdempotencyKey: options.rootIdempotencyKey}), + }) + + if (submissionResult.status === 'failed' || !options.watch) return submissionResult + + options.onSubmissionAccepted?.(submissionResult.submission) + const terminalOperations = await watchMigrationOperations({ + clientId: options.clientId, + operationIds: submissionResult.submission.operations.map(({operation}) => operation.id), + }) + + return { + status: 'success', + submission: updateSubmissionOperations(submissionResult.submission, terminalOperations), + } +} + +function updateSubmissionOperations( + submission: MigrationSubmission, + operations: MigrationOperation[], +): MigrationSubmission { + const operationsById = new Map(operations.map((operation) => [operation.id, operation])) + return { + ...submission, + operations: submission.operations.map((submittedOperation) => ({ + ...submittedOperation, + operation: operationsById.get(submittedOperation.operation.id) ?? submittedOperation.operation, + })), + } +} + +function formatValidationErrors(errors: MigrationValidationError[]): string { + return errors + .map((error) => { + const location = [error.row === undefined ? undefined : `row ${error.row}`, error.field] + .filter((value) => value !== undefined) + .join(', ') + return location ? `${location}: ${error.message}` : error.message + }) + .join('\n') +} diff --git a/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.test.ts b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.test.ts new file mode 100644 index 00000000000..c946785f1ec --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.test.ts @@ -0,0 +1,215 @@ +import {createMigrationPlan} from './plan/create-migration-plan.js' +import {deriveBatchIdempotencyKey} from './plan/idempotency.js' +import {MigrationSubmissionProtocolError, submitMigrationPlan} from './submit-migration-plan.js' +import {describe, expect, test, vi} from 'vitest' +import type {MigrationOperationPayload} from './partners-api.js' +import type {MigrationAction} from '../../models/subscription-migrations.js' + +function plan(action: MigrationAction, count = 1) { + const rows = Array.from({length: count}, (_, index) => ({ + sourceRow: index + 2, + shopId: String(index + 1), + ...(action === 'schedule' + ? {targetPlanHandle: 'pro', priceBehavior: 'PLAN_PRICE', notification: 'WHEN_REQUIRED'} + : {}), + })) + const result = createMigrationPlan(action, rows) + if (!result.ok) throw new Error('Expected a valid plan') + return result.plan +} + +function payload(id: number, total = 1): MigrationOperationPayload { + return { + operation: { + id: `gid://shopify/AppSubscriptionMigrationOperation/${id}`, + status: 'RUNNING', + total, + results: {edges: []}, + }, + userErrors: [], + } +} + +describe('submitMigrationPlan', () => { + test('submits schedule batches sequentially in index order', async () => { + let resolveFirst: ((value: MigrationOperationPayload) => void) | undefined + const firstOperation = new Promise((resolve) => { + resolveFirst = resolve + }) + const createOperation = vi.fn().mockReturnValueOnce(firstOperation).mockResolvedValueOnce(payload(2)) + const migrationPlan = plan('schedule', 251) + + const submissionPromise = submitMigrationPlan({ + clientId: 'client-id', + plan: migrationPlan, + rootIdempotencyKey: 'root-key', + createOperation, + }) + + expect(createOperation).toHaveBeenCalledTimes(1) + resolveFirst?.(payload(1, 250)) + const result = await submissionPromise + + expect(createOperation).toHaveBeenCalledTimes(2) + expect(result.status).toBe('success') + expect(result.submission.operations.map(({batchIndex, operation}) => [batchIndex, operation.id])).toEqual([ + [0, 'gid://shopify/AppSubscriptionMigrationOperation/1'], + [1, 'gid://shopify/AppSubscriptionMigrationOperation/2'], + ]) + }) + + test('returns successful submission and batch metadata', async () => { + const migrationPlan = plan('schedule') + const createOperation = vi.fn().mockResolvedValue(payload(1)) + + const result = await submitMigrationPlan({ + clientId: 'client-id', + plan: migrationPlan, + rootIdempotencyKey: 'root-key', + createOperation, + }) + + const batch = migrationPlan.batches[0]! + const expectedBatchKey = deriveBatchIdempotencyKey({ + appIdentifier: 'client-id', + action: 'schedule', + rootKey: 'root-key', + canonicalBatchPayload: batch.canonicalPayload, + }) + expect(result).toEqual({ + status: 'success', + submission: { + clientId: 'client-id', + action: 'schedule', + rootIdempotencyKey: 'root-key', + inputDigest: migrationPlan.inputDigest, + total: 1, + operations: [ + { + batchIndex: 0, + batchPayloadDigest: batch.payloadDigest, + idempotencyKey: expectedBatchKey, + operation: payload(1).operation, + }, + ], + }, + }) + }) + + test('generates a root idempotency key when none is provided', async () => { + const createOperation = vi.fn().mockResolvedValue(payload(1)) + + const result = await submitMigrationPlan({clientId: 'client-id', plan: plan('schedule'), createOperation}) + + expect(result.submission.rootIdempotencyKey).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ) + }) + + test('maps schedule rows to schedule API inputs', async () => { + const createOperation = vi.fn().mockResolvedValue(payload(1)) + + await submitMigrationPlan({ + clientId: 'client-id', + plan: plan('schedule'), + rootIdempotencyKey: 'root-key', + createOperation, + }) + + expect(createOperation).toHaveBeenCalledWith({ + clientId: 'client-id', + idempotencyKey: expect.stringMatching(/^[a-f0-9]{64}$/), + migrations: [ + { + shopId: 'gid://shopify/Shop/1', + action: { + scheduleMigration: { + targetPlanHandle: 'pro', + priceBehavior: 'PLAN_PRICE', + notification: 'WHEN_REQUIRED', + }, + }, + }, + ], + }) + }) + + test('maps unschedule rows to cancel API inputs', async () => { + const createOperation = vi.fn().mockResolvedValue(payload(1)) + + await submitMigrationPlan({ + clientId: 'client-id', + plan: plan('unschedule'), + rootIdempotencyKey: 'root-key', + createOperation, + }) + + expect(createOperation).toHaveBeenCalledWith( + expect.objectContaining({ + migrations: [{shopId: 'gid://shopify/Shop/1', action: {cancelMigration: true}}], + }), + ) + }) + + test('returns failure with every accepted operation when a later batch has user errors', async () => { + const acceptedWithErrors = payload(2) + acceptedWithErrors.userErrors = [{message: 'Rejected remaining shops', field: ['input']}] + const createOperation = vi.fn().mockResolvedValueOnce(payload(1, 250)).mockResolvedValueOnce(acceptedWithErrors) + + const result = await submitMigrationPlan({ + clientId: 'client-id', + plan: plan('schedule', 251), + rootIdempotencyKey: 'root-key', + createOperation, + }) + + expect(result).toMatchObject({ + status: 'failed', + failedBatchIndex: 1, + userErrors: [{message: 'Rejected remaining shops', field: ['input']}], + submission: { + rootIdempotencyKey: 'root-key', + operations: [ + {operation: {id: 'gid://shopify/AppSubscriptionMigrationOperation/1'}}, + {operation: {id: 'gid://shopify/AppSubscriptionMigrationOperation/2'}}, + ], + }, + }) + }) + + test('returns failure without an operation when user errors explain the rejection', async () => { + const createOperation = vi.fn().mockResolvedValue({ + operation: null, + userErrors: [{message: 'Invalid migration', field: ['migrations']}], + }) + + const result = await submitMigrationPlan({ + clientId: 'client-id', + plan: plan('schedule'), + rootIdempotencyKey: 'root-key', + createOperation, + }) + + expect(result).toMatchObject({ + status: 'failed', + failedBatchIndex: 0, + userErrors: [{message: 'Invalid migration', field: ['migrations']}], + submission: {rootIdempotencyKey: 'root-key', operations: []}, + }) + }) + + test('throws a protocol error with batch context for an unexplained empty payload', async () => { + const createOperation = vi.fn().mockResolvedValue({operation: null, userErrors: []}) + + const promise = submitMigrationPlan({ + clientId: 'client-id', + plan: plan('schedule'), + rootIdempotencyKey: 'root-key', + createOperation, + }) + + await expect(promise).rejects.toBeInstanceOf(MigrationSubmissionProtocolError) + await expect(promise).rejects.toMatchObject({batchIndex: 0}) + await expect(promise).rejects.toThrow('Migration submission batch 0 returned neither an operation nor user errors') + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts new file mode 100644 index 00000000000..86464500b0a --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts @@ -0,0 +1,120 @@ +import {createMigrationOperation, type MigrationApiInput, type MigrationUserError} from './partners-api.js' +import {deriveBatchIdempotencyKey, generateRootIdempotencyKey} from './plan/idempotency.js' +import type { + MigrationOperation, + MigrationPlan, + PlannedMigrationRow, + ScheduledMigrationRow, +} from '../../models/subscription-migrations.js' + +export interface SubmittedMigrationOperation { + batchIndex: number + batchPayloadDigest: string + idempotencyKey: string + operation: MigrationOperation +} + +export interface MigrationSubmission { + clientId: string + action: MigrationPlan['action'] + rootIdempotencyKey: string + inputDigest: string + total: number + operations: SubmittedMigrationOperation[] +} + +export type MigrationSubmissionResult = + | {status: 'success'; submission: MigrationSubmission} + | { + status: 'failed' + submission: MigrationSubmission + failedBatchIndex: number + userErrors: MigrationUserError[] + } + +export class MigrationSubmissionProtocolError extends Error { + readonly batchIndex: number + + constructor(batchIndex: number) { + super(`Migration submission batch ${batchIndex} returned neither an operation nor user errors`) + this.name = 'MigrationSubmissionProtocolError' + this.batchIndex = batchIndex + } +} + +interface SubmitMigrationPlanOptions { + clientId: string + plan: MigrationPlan + rootIdempotencyKey?: string + createOperation?: typeof createMigrationOperation +} + +export async function submitMigrationPlan({ + clientId, + plan, + rootIdempotencyKey = generateRootIdempotencyKey(), + createOperation = createMigrationOperation, +}: SubmitMigrationPlanOptions): Promise { + const submission: MigrationSubmission = { + clientId, + action: plan.action, + rootIdempotencyKey, + inputDigest: plan.inputDigest, + total: plan.rows.length, + operations: [], + } + + for (const batch of plan.batches) { + const idempotencyKey = deriveBatchIdempotencyKey({ + appIdentifier: clientId, + action: plan.action, + rootKey: rootIdempotencyKey, + canonicalBatchPayload: batch.canonicalPayload, + }) + // Each accepted batch must be recorded before the next request can fail. + // eslint-disable-next-line no-await-in-loop + const payload = await createOperation({ + clientId, + idempotencyKey, + migrations: batch.rows.map(toMigrationApiInput), + }) + + if (payload.operation) { + submission.operations.push({ + batchIndex: batch.index, + batchPayloadDigest: batch.payloadDigest, + idempotencyKey, + operation: payload.operation, + }) + } + + if (payload.userErrors.length > 0) { + return { + status: 'failed', + submission, + failedBatchIndex: batch.index, + userErrors: payload.userErrors, + } + } + + if (!payload.operation) throw new MigrationSubmissionProtocolError(batch.index) + } + + return {status: 'success', submission} +} + +function toMigrationApiInput(row: PlannedMigrationRow): MigrationApiInput { + if (row.action === 'unschedule') { + return {shopId: row.shopId, action: {cancelMigration: true}} + } + + return {shopId: row.shopId, action: {scheduleMigration: scheduleMigrationInput(row)}} +} + +function scheduleMigrationInput({ + targetPlanHandle, + priceBehavior, + notification, +}: ScheduledMigrationRow): Extract['scheduleMigration'] { + return {targetPlanHandle, priceBehavior, notification} +} diff --git a/packages/app/src/cli/services/subscription-migrations/wait-for-operations.test.ts b/packages/app/src/cli/services/subscription-migrations/wait-for-operations.test.ts new file mode 100644 index 00000000000..fd0c608e5b6 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/wait-for-operations.test.ts @@ -0,0 +1,198 @@ +import {waitForOperations} from './wait-for-operations.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {describe, expect, test, vi} from 'vitest' +import type { + MigrationOperation, + MigrationOperationStatus, + MigrationResultCode, +} from '../../models/subscription-migrations.js' + +function operation( + id: string, + status: MigrationOperationStatus, + total = 1, + resultCodes: MigrationResultCode[] = [], +): MigrationOperation { + return { + id, + status, + total, + results: { + edges: resultCodes.map((code, index) => ({node: {shopId: `gid://shopify/Shop/${index + 1}`, code}})), + }, + } +} + +describe('waitForOperations', () => { + test('returns all terminal operations immediately in input ID order', async () => { + const terminalOperations = new Map([ + ['one', operation('one', 'COMPLETED')], + ['two', operation('two', 'FAILED')], + ['three', operation('three', 'CANCELED')], + ]) + const getOperation = vi.fn(({operationId}: {operationId: string}) => + Promise.resolve(terminalOperations.get(operationId) ?? null), + ) + const sleep = vi.fn() + + await expect( + waitForOperations({clientId: 'client-id', operationIds: ['three', 'one', 'two'], getOperation, sleep}), + ).resolves.toEqual([operation('three', 'CANCELED'), operation('one', 'COMPLETED'), operation('two', 'FAILED')]) + expect(getOperation).toHaveBeenCalledTimes(3) + expect(sleep).not.toHaveBeenCalled() + }) + + test('polls only running operations until every ID is terminal', async () => { + const getOperation = vi + .fn() + .mockResolvedValueOnce(operation('one', 'COMPLETED')) + .mockResolvedValueOnce(operation('two', 'RUNNING')) + .mockResolvedValueOnce(operation('two', 'RUNNING')) + .mockResolvedValueOnce(operation('two', 'COMPLETED')) + const sleep = vi.fn().mockResolvedValue(undefined) + + const result = await waitForOperations({ + clientId: 'client-id', + operationIds: ['one', 'two'], + pollIntervalMs: 25, + getOperation, + sleep, + }) + + expect(result).toEqual([operation('one', 'COMPLETED'), operation('two', 'COMPLETED')]) + expect(getOperation).toHaveBeenCalledTimes(4) + expect(getOperation).toHaveBeenNthCalledWith(3, {clientId: 'client-id', operationId: 'two'}) + expect(sleep).toHaveBeenCalledTimes(2) + expect(sleep).toHaveBeenCalledWith(25) + }) + + test('sends the first fetched snapshot to the update callback', async () => { + const completed = operation('one', 'COMPLETED') + const onUpdate = vi.fn() + + await waitForOperations({ + clientId: 'client-id', + operationIds: ['one'], + getOperation: vi.fn().mockResolvedValue(completed), + sleep: vi.fn(), + onUpdate, + }) + + expect(onUpdate).toHaveBeenCalledOnce() + expect(onUpdate).toHaveBeenCalledWith([completed]) + }) + + test('waits for the update callback before completing', async () => { + let releaseUpdate: (() => void) | undefined + const onUpdate = vi.fn( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ) + let hasCompleted = false + + const waiting = waitForOperations({ + clientId: 'client-id', + operationIds: ['one'], + getOperation: vi.fn().mockResolvedValue(operation('one', 'COMPLETED')), + sleep: vi.fn(), + onUpdate, + }) + const completion = waiting.then(() => { + hasCompleted = true + }) + await vi.waitFor(() => expect(onUpdate).toHaveBeenCalledOnce()) + + expect(hasCompleted).toBe(false) + releaseUpdate?.() + await completion + expect(hasCompleted).toBe(true) + }) + + test('does not send an update for an identical running snapshot', async () => { + const running = operation('one', 'RUNNING') + const completed = operation('one', 'COMPLETED') + const getOperation = vi + .fn() + .mockResolvedValueOnce(running) + .mockResolvedValueOnce(structuredClone(running)) + .mockResolvedValueOnce(completed) + const onUpdate = vi.fn() + + await waitForOperations({ + clientId: 'client-id', + operationIds: ['one'], + getOperation, + sleep: vi.fn(), + onUpdate, + }) + + expect(onUpdate).toHaveBeenCalledTimes(2) + expect(onUpdate).toHaveBeenNthCalledWith(1, [running]) + expect(onUpdate).toHaveBeenNthCalledWith(2, [completed]) + }) + + test('sends an update when total, results, codes, or status changes', async () => { + const snapshots = [ + operation('one', 'RUNNING', 1), + operation('one', 'RUNNING', 2), + operation('one', 'RUNNING', 2, ['SCHEDULED']), + operation('one', 'RUNNING', 2, ['INTERNAL_ERROR']), + operation('one', 'COMPLETED', 2, ['INTERNAL_ERROR']), + ] + const getOperation = vi.fn() + for (const snapshot of snapshots) getOperation.mockResolvedValueOnce(snapshot) + const onUpdate = vi.fn() + + await waitForOperations({ + clientId: 'client-id', + operationIds: ['one'], + getOperation, + sleep: vi.fn(), + onUpdate, + }) + + expect(onUpdate).toHaveBeenCalledTimes(snapshots.length) + snapshots.forEach((snapshot, index) => { + expect(onUpdate).toHaveBeenNthCalledWith(index + 1, [snapshot]) + }) + }) + + test('sends full snapshots in input ID order until every operation is terminal', async () => { + const completedOne = operation('one', 'COMPLETED') + const runningTwo = operation('two', 'RUNNING') + const completedTwo = operation('two', 'COMPLETED') + const getOperation = vi + .fn() + .mockResolvedValueOnce(runningTwo) + .mockResolvedValueOnce(completedOne) + .mockResolvedValueOnce(completedTwo) + const onUpdate = vi.fn() + + await waitForOperations({ + clientId: 'client-id', + operationIds: ['two', 'one'], + getOperation, + sleep: vi.fn(), + onUpdate, + }) + + expect(onUpdate).toHaveBeenNthCalledWith(1, [runningTwo, completedOne]) + expect(onUpdate).toHaveBeenNthCalledWith(2, [completedTwo, completedOne]) + }) + + test('throws an AbortError when an operation cannot be found', async () => { + const getOperation = vi.fn().mockResolvedValue(null) + + const promise = waitForOperations({ + clientId: 'client-id', + operationIds: ['missing'], + getOperation, + sleep: vi.fn(), + }) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toThrow('Migration operation not found: missing') + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/wait-for-operations.ts b/packages/app/src/cli/services/subscription-migrations/wait-for-operations.ts new file mode 100644 index 00000000000..2d819590fc8 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/wait-for-operations.ts @@ -0,0 +1,69 @@ +import {getMigrationOperation} from './partners-api.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import type {MigrationOperation} from '../../models/subscription-migrations.js' + +const DEFAULT_POLL_INTERVAL_MS = 1000 +const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELED']) + +interface WaitForOperationsOptions { + clientId: string + operationIds: string[] + getOperation?: typeof getMigrationOperation + pollIntervalMs?: number + sleep?: (milliseconds: number) => Promise + onUpdate?: (operations: MigrationOperation[]) => void | Promise +} + +export async function waitForOperations({ + clientId, + operationIds, + getOperation = getMigrationOperation, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + sleep = delay, + onUpdate, +}: WaitForOperationsOptions): Promise { + const operationsById = new Map() + const fingerprintsById = new Map() + let isFirstCycle = true + let pendingIds = [...operationIds] + + while (pendingIds.length > 0) { + // Each polling cycle depends on the terminal states observed in the previous cycle. + // eslint-disable-next-line no-await-in-loop + const operations = await Promise.all( + pendingIds.map(async (operationId) => { + const operation = await getOperation({clientId, operationId}) + if (!operation) throw new AbortError(`Migration operation not found: ${operationId}`) + return {operationId, operation} + }), + ) + + const hasChanged = operations.some( + ({operationId, operation}) => fingerprintsById.get(operationId) !== JSON.stringify(operation), + ) + for (const {operationId, operation} of operations) { + operationsById.set(operationId, operation) + fingerprintsById.set(operationId, JSON.stringify(operation)) + } + + if (onUpdate && (isFirstCycle || hasChanged)) { + const latestOperations = operationIds.map((operationId) => operationsById.get(operationId)!) + // eslint-disable-next-line no-await-in-loop + await onUpdate(latestOperations) + } + isFirstCycle = false + + pendingIds = operations + .filter(({operation}) => !TERMINAL_STATUSES.has(operation.status)) + .map(({operationId}) => operationId) + + // eslint-disable-next-line no-await-in-loop + if (pendingIds.length > 0) await sleep(pollIntervalMs) + } + + return operationIds.map((operationId) => operationsById.get(operationId)!) +} + +async function delay(milliseconds: number): Promise { + await new Promise((resolve) => setTimeout(resolve, milliseconds)) +} diff --git a/packages/app/src/cli/services/subscription-migrations/watch-operations.test.ts b/packages/app/src/cli/services/subscription-migrations/watch-operations.test.ts new file mode 100644 index 00000000000..cf725ecd480 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/watch-operations.test.ts @@ -0,0 +1,52 @@ +import {watchMigrationOperations} from './watch-operations.js' +import {outputContent, outputResult} from '@shopify/cli-kit/node/output' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {describe, expect, test, vi} from 'vitest' +import type {MigrationOperation} from '../../models/subscription-migrations.js' + +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, outputResult: vi.fn()} +}) +vi.mock('@shopify/cli-kit/node/ui') + +function operation(id: string, status: MigrationOperation['status']): MigrationOperation { + return {id, status, total: 2, results: {edges: []}} +} + +describe('watchMigrationOperations', () => { + test('renders every polling update on stderr and returns the final operations', async () => { + const running = operation('one', 'RUNNING') + const completed = {...operation('one', 'COMPLETED'), results: {edges: []}} + const updateStatus = vi.fn() + const waitForOperations = vi.fn(async ({onUpdate}: {onUpdate?: (operations: MigrationOperation[]) => void}) => { + onUpdate?.([running]) + onUpdate?.([completed]) + return [completed] + }) + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(updateStatus)) + + await expect( + watchMigrationOperations({ + clientId: 'client-id', + operationIds: ['one'], + waitForOperations, + }), + ).resolves.toEqual([completed]) + + expect(renderSingleTask).toHaveBeenCalledOnce() + expect(renderSingleTask).toHaveBeenCalledWith({ + title: outputContent`Polling subscription migration operations`, + task: expect.any(Function), + renderOptions: {stdout: process.stderr}, + }) + expect(waitForOperations).toHaveBeenCalledWith({ + clientId: 'client-id', + operationIds: ['one'], + onUpdate: expect.any(Function), + }) + expect(updateStatus).toHaveBeenNthCalledWith(1, outputContent`one: RUNNING (0/2 settled)`) + expect(updateStatus).toHaveBeenNthCalledWith(2, outputContent`one: COMPLETED (0/2 settled)`) + expect(outputResult).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/watch-operations.ts b/packages/app/src/cli/services/subscription-migrations/watch-operations.ts new file mode 100644 index 00000000000..9d809821b85 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/watch-operations.ts @@ -0,0 +1,28 @@ +import {formatMigrationOperationsStatus} from './command-output.js' +import {waitForOperations as waitForOperationsDefault} from './wait-for-operations.js' +import {outputContent} from '@shopify/cli-kit/node/output' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import type {MigrationOperation} from '../../models/subscription-migrations.js' + +interface WatchMigrationOperationsOptions { + clientId: string + operationIds: string[] + waitForOperations?: typeof waitForOperationsDefault +} + +export async function watchMigrationOperations({ + clientId, + operationIds, + waitForOperations = waitForOperationsDefault, +}: WatchMigrationOperationsOptions): Promise { + return renderSingleTask({ + title: outputContent`Polling subscription migration operations`, + task: (updateStatus) => + waitForOperations({ + clientId, + operationIds, + onUpdate: (operations) => updateStatus(outputContent`${formatMigrationOperationsStatus(operations)}`), + }), + renderOptions: {stdout: process.stderr}, + }) +} diff --git a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts index ae56863ed44..d23ce542933 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts @@ -22,10 +22,9 @@ import {ensureAuthenticatedPartners, Session} from '@shopify/cli-kit/node/sessio import {RequestModeInput} from '@shopify/cli-kit/node/http' /** - * The Partners-backed client now exists solely to run the legacy extension migrations - * (see {@link MigrationDeveloperPlatformClient}). Every other developer platform operation is - * served by the App Management client, so only the migration mutations and the session/request - * infrastructure they depend on remain here. + * This client is limited to migration operations that are not available through the App Management API: + * legacy extension migrations (see {@link MigrationDeveloperPlatformClient}) and app subscription migrations. + * New developer platform operations should use {@link AppManagementClient} unless they are Partners-only. */ export class PartnersClient implements MigrationDeveloperPlatformClient { private static instance: PartnersClient | undefined diff --git a/packages/cli/README.md b/packages/cli/README.md index 445154f2fa8..ddd887bddd4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -29,6 +29,10 @@ * [`shopify app logs`](#shopify-app-logs) * [`shopify app logs sources`](#shopify-app-logs-sources) * [`shopify app release --version `](#shopify-app-release---version-version) +* [`shopify app subscription-migrations cancel`](#shopify-app-subscription-migrations-cancel) +* [`shopify app subscription-migrations schedule`](#shopify-app-subscription-migrations-schedule) +* [`shopify app subscription-migrations status`](#shopify-app-subscription-migrations-status) +* [`shopify app subscription-migrations unschedule`](#shopify-app-subscription-migrations-unschedule) * [`shopify app versions list`](#shopify-app-versions-list) * [`shopify app webhook trigger`](#shopify-app-webhook-trigger) * [`shopify auth login`](#shopify-auth-login) @@ -1809,6 +1813,333 @@ DESCRIPTION Releases an existing app version. Pass the name of the version that you want to release using the `--version` flag. ``` +## `shopify app subscription-migrations cancel` + +Cancels app subscription migration operations. + +``` +USAGE + $ shopify app subscription-migrations cancel --id ... [--auth-alias ] [--client-id | -c ] [-j] + [--no-color] [--path ] [--verbose] + +FLAGS + -c, --config= + The name of the app configuration. + [env: SHOPIFY_FLAG_APP_CONFIG] + + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + + --auth-alias= + Alias of the Shopify account to use for authentication. + [env: SHOPIFY_FLAG_AUTH_ALIAS] + + --client-id= + The Client ID of your app. + [env: SHOPIFY_FLAG_CLIENT_ID] + + --id=... + (required) The app subscription migration operation ID. Can be specified multiple times. + [env: SHOPIFY_FLAG_ID] + + --no-color + Disable color output. + [env: SHOPIFY_FLAG_NO_COLOR] + + --path= + The path to your app directory. + [env: SHOPIFY_FLAG_PATH] + + --verbose + Increase the verbosity of the output. May include sensitive data. + [env: SHOPIFY_FLAG_VERBOSE] + +DESCRIPTION + Cancels app subscription migration operations. + + Cancels app subscription migration operations. + + Canceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. + Use `unschedule` for reversible schedules. + + Repeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting + operation states and per-shop results as structured JSON. + + By default, the command 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 explicitly override the active configuration; this only + selects the app and does not change Partners authentication. + +EXAMPLES + $ shopify app subscription-migrations cancel --id + + $ shopify app subscription-migrations cancel --path ../my-app --config staging --id --id + + $ shopify app subscription-migrations cancel --client-id --id --json +``` + +## `shopify app subscription-migrations schedule` + +Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing. + +``` +USAGE + $ shopify app subscription-migrations schedule [--auth-alias ] [--client-id | -c ] [-f] [--idempotency-key + ] [-i ] [-j] [--no-color] [--path ] [--verbose] [--watch] + +FLAGS + -c, --config= + The name of the app configuration. + [env: SHOPIFY_FLAG_APP_CONFIG] + + -f, --force + Skip confirmation. Required if non interactive. + [env: SHOPIFY_FLAG_FORCE] + + -i, --input= + Path to the migration CSV. If omitted, standard input is used. + [env: SHOPIFY_FLAG_INPUT] + + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + + --auth-alias= + Alias of the Shopify account to use for authentication. + [env: SHOPIFY_FLAG_AUTH_ALIAS] + + --client-id= + The Client ID of your app. + [env: SHOPIFY_FLAG_CLIENT_ID] + + --idempotency-key= + Reuse an existing root idempotency key for the same action and input. + [env: SHOPIFY_FLAG_IDEMPOTENCY_KEY] + + --no-color + Disable color output. + [env: SHOPIFY_FLAG_NO_COLOR] + + --path= + The path to your app directory. + [env: SHOPIFY_FLAG_PATH] + + --verbose + Increase the verbosity of the output. May include sensitive data. + [env: SHOPIFY_FLAG_VERBOSE] + + --watch + Display the current operation state while polling, then output the final outcome when every operation reaches a + terminal status. + [env: SHOPIFY_FLAG_WATCH] + +DESCRIPTION + Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing. + + Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing. + + When `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input + -` is also supported as an explicit stdin path. + + - Required CSV columns: `shop_id`, `target_plan_handle`, and `price_behavior`. + - Optional CSV column: `notification`. + - Example header: `shop_id,target_plan_handle,price_behavior,notification`. + - Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`. + + `price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `NONE`, `OPT_OUT`, or + `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank. + + Validation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in + batches of 250 shops. Preserve the root idempotency key and every operation GID printed by the command. Reusing the + same root idempotency key with the same client ID, action, and input replays the same submission. + + By default, the command 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 explicitly override the active configuration; this only + selects the app and does not change Partners authentication. + + Use `--force` to skip confirmation and immediately submit every valid row. With `--watch`, human-readable output shows + accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json + --watch`, the command outputs one structured JSON document after every operation reaches a terminal status. + +EXAMPLES + $ shopify app subscription-migrations schedule --input migrations.csv --force + + cat migrations.csv | shopify app subscription-migrations schedule --force + + $ shopify app subscription-migrations schedule --input migrations.csv --path ../my-app --config staging --force --json + + $ shopify app subscription-migrations schedule --input migrations.csv --client-id --idempotency-key --force + + $ shopify app subscription-migrations schedule --input - --force --watch +``` + +## `shopify app subscription-migrations status` + +Checks the status of app subscription migration operations. + +``` +USAGE + $ shopify app subscription-migrations status --id ... [--auth-alias ] [--client-id | -c ] [-j] + [--no-color] [--path ] [--verbose] [--watch] + +FLAGS + -c, --config= + The name of the app configuration. + [env: SHOPIFY_FLAG_APP_CONFIG] + + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + + --auth-alias= + Alias of the Shopify account to use for authentication. + [env: SHOPIFY_FLAG_AUTH_ALIAS] + + --client-id= + The Client ID of your app. + [env: SHOPIFY_FLAG_CLIENT_ID] + + --id=... + (required) The app subscription migration operation ID. Can be specified multiple times. + [env: SHOPIFY_FLAG_ID] + + --no-color + Disable color output. + [env: SHOPIFY_FLAG_NO_COLOR] + + --path= + The path to your app directory. + [env: SHOPIFY_FLAG_PATH] + + --verbose + Increase the verbosity of the output. May include sensitive data. + [env: SHOPIFY_FLAG_VERBOSE] + + --watch + Display the current operation state while polling, then output the final state when every operation reaches a + terminal status. + [env: SHOPIFY_FLAG_WATCH] + +DESCRIPTION + Checks the status of app subscription migration operations. + + Checks app subscription migration operation status. + + Repeat `--id` for every operation GID returned by a multi-batch submission. With `--watch`, the command displays the + current state while polling and outputs the final state after all requested operations reach a terminal status. + + `RUNNING` means an operation is still processing. `COMPLETED` means processing finished, but you must inspect the + per-shop results to confirm each outcome. `FAILED` means the operation failed, and `CANCELED` means cancellation + stopped further processing. + + Use `--json` to output every operation and its per-shop results as structured JSON. + + By default, the command 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 explicitly override the active configuration; this only + selects the app and does not change Partners authentication. + +EXAMPLES + $ shopify app subscription-migrations status --id + + $ shopify app subscription-migrations status --path ../my-app --config staging --id --id --watch + + $ shopify app subscription-migrations status --client-id --id --json +``` + +## `shopify app subscription-migrations unschedule` + +Reverses app subscription migrations that are still scheduled. + +``` +USAGE + $ shopify app subscription-migrations unschedule [--auth-alias ] [--client-id | -c ] [-f] [--idempotency-key + ] [-i ] [-j] [--no-color] [--path ] [--verbose] [--watch] + +FLAGS + -c, --config= + The name of the app configuration. + [env: SHOPIFY_FLAG_APP_CONFIG] + + -f, --force + Skip confirmation. Required if non interactive. + [env: SHOPIFY_FLAG_FORCE] + + -i, --input= + Path to the migration CSV. If omitted, standard input is used. + [env: SHOPIFY_FLAG_INPUT] + + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + + --auth-alias= + Alias of the Shopify account to use for authentication. + [env: SHOPIFY_FLAG_AUTH_ALIAS] + + --client-id= + The Client ID of your app. + [env: SHOPIFY_FLAG_CLIENT_ID] + + --idempotency-key= + Reuse an existing root idempotency key for the same action and input. + [env: SHOPIFY_FLAG_IDEMPOTENCY_KEY] + + --no-color + Disable color output. + [env: SHOPIFY_FLAG_NO_COLOR] + + --path= + The path to your app directory. + [env: SHOPIFY_FLAG_PATH] + + --verbose + Increase the verbosity of the output. May include sensitive data. + [env: SHOPIFY_FLAG_VERBOSE] + + --watch + Display the current operation state while polling, then output the final outcome when every operation reaches a + terminal status. + [env: SHOPIFY_FLAG_WATCH] + +DESCRIPTION + Reverses app subscription migrations that are still scheduled. + + Reverses scheduled app subscription migrations that have not migrated yet. + + When `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input + -` is also supported as an explicit stdin path. + + - Required CSV header: `shop_id`. + - Example row: `123456789`. + + The CSV can contain only the `shop_id` header, or it can reuse the complete CSV supplied to `schedule`; schedule-only + columns are ignored. + + Unscheduling is not a rollback after a subscription has migrated. The command validates the entire CSV before sending + any mutation. Use `--force` to skip confirmation and immediately submit every valid row. + + Operations are submitted in batches of 250 shops. Preserve the root idempotency key and every operation GID printed by + the command. Reusing the same root idempotency key with the same client ID, action, and input replays the same + submission. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays + operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document + after every operation reaches a terminal status. + + By default, the command 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 explicitly override the active configuration; this only + selects the app and does not change Partners authentication. + +EXAMPLES + $ shopify app subscription-migrations unschedule --input migrations.csv --force + + cat migrations.csv | shopify app subscription-migrations unschedule --force + + $ shopify app subscription-migrations unschedule --input migrations.csv --path ../my-app --config staging --force --json + + $ shopify app subscription-migrations unschedule --input migrations.csv --client-id --idempotency-key --force + + $ shopify app subscription-migrations unschedule --input - --force --watch +``` + ## `shopify app versions list` List deployed versions of your app. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 45ea376b354..ebb6a289414 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3235,6 +3235,459 @@ "summary": "Release an app version.", "usage": "app release --version " }, + "app:subscription-migrations:cancel": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and per-shop results as structured JSON.\n\nBy default, the command 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 explicitly override the active configuration; this only selects the app and does not change Partners authentication.", + "descriptionWithMarkdown": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and per-shop results as structured JSON.\n\nBy default, the command 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 explicitly override the active configuration; this only selects the app and does not change Partners authentication.", + "enableJsonFlag": false, + "examples": [ + "<%= config.bin %> <%= command.id %> --id ", + "<%= config.bin %> <%= command.id %> --path ../my-app --config staging --id --id ", + "<%= config.bin %> <%= command.id %> --client-id --id --json" + ], + "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" + }, + "id": { + "description": "The app subscription migration operation ID. Can be specified multiple times.", + "env": "SHOPIFY_FLAG_ID", + "hasDynamicHelp": false, + "multiple": true, + "name": "id", + "required": true, + "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" + }, + "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, + "hiddenAliases": [ + ], + "id": "app:subscription-migrations:cancel", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Cancels app subscription migration operations." + }, + "app:subscription-migrations:schedule": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `NONE`, `OPT_OUT`, or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve the root idempotency key and every operation GID printed by the command. Reusing the same root idempotency key with the same client ID, action, and input replays the same submission.\n\nBy default, the command 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 explicitly override the active configuration; this only selects the app and does not change Partners authentication.\n\nUse `--force` to skip confirmation and immediately submit every valid row. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.", + "descriptionWithMarkdown": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `NONE`, `OPT_OUT`, or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve the root idempotency key and every operation GID printed by the command. Reusing the same root idempotency key with the same client ID, action, and input replays the same submission.\n\nBy default, the command 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 explicitly override the active configuration; this only selects the app and does not change Partners authentication.\n\nUse `--force` to skip confirmation and immediately submit every valid row. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.", + "enableJsonFlag": false, + "examples": [ + "<%= config.bin %> <%= command.id %> --input migrations.csv --force", + "cat migrations.csv | <%= config.bin %> <%= command.id %> --force", + "<%= config.bin %> <%= command.id %> --input migrations.csv --path ../my-app --config staging --force --json", + "<%= config.bin %> <%= command.id %> --input migrations.csv --client-id --idempotency-key --force", + "<%= config.bin %> <%= command.id %> --input - --force --watch" + ], + "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" + }, + "force": { + "allowNo": false, + "char": "f", + "description": "Skip confirmation. Required if non interactive.", + "env": "SHOPIFY_FLAG_FORCE", + "name": "force", + "type": "boolean" + }, + "idempotency-key": { + "description": "Reuse an existing root idempotency key for the same action and input.", + "env": "SHOPIFY_FLAG_IDEMPOTENCY_KEY", + "hasDynamicHelp": false, + "multiple": false, + "name": "idempotency-key", + "type": "option" + }, + "input": { + "char": "i", + "description": "Path to the migration CSV. If omitted, standard input is used.", + "env": "SHOPIFY_FLAG_INPUT", + "hasDynamicHelp": false, + "multiple": false, + "name": "input", + "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" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + }, + "watch": { + "allowNo": false, + "description": "Display the current operation state while polling, then output the final outcome when every operation reaches a terminal status.", + "env": "SHOPIFY_FLAG_WATCH", + "name": "watch", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "app:subscription-migrations:schedule", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing." + }, + "app:subscription-migrations:status": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Checks app subscription migration operation status.\n\nRepeat `--id` for every operation GID returned by a multi-batch submission. With `--watch`, the command displays the current state while polling and outputs the final state after all requested operations reach a terminal status.\n\n`RUNNING` means an operation is still processing. `COMPLETED` means processing finished, but you must inspect the per-shop results to confirm each outcome. `FAILED` means the operation failed, and `CANCELED` means cancellation stopped further processing.\n\nUse `--json` to output every operation and its per-shop results as structured JSON.\n\nBy default, the command 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 explicitly override the active configuration; this only selects the app and does not change Partners authentication.", + "descriptionWithMarkdown": "Checks app subscription migration operation status.\n\nRepeat `--id` for every operation GID returned by a multi-batch submission. With `--watch`, the command displays the current state while polling and outputs the final state after all requested operations reach a terminal status.\n\n`RUNNING` means an operation is still processing. `COMPLETED` means processing finished, but you must inspect the per-shop results to confirm each outcome. `FAILED` means the operation failed, and `CANCELED` means cancellation stopped further processing.\n\nUse `--json` to output every operation and its per-shop results as structured JSON.\n\nBy default, the command 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 explicitly override the active configuration; this only selects the app and does not change Partners authentication.", + "enableJsonFlag": false, + "examples": [ + "<%= config.bin %> <%= command.id %> --id ", + "<%= config.bin %> <%= command.id %> --path ../my-app --config staging --id --id --watch", + "<%= config.bin %> <%= command.id %> --client-id --id --json" + ], + "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" + }, + "id": { + "description": "The app subscription migration operation ID. Can be specified multiple times.", + "env": "SHOPIFY_FLAG_ID", + "hasDynamicHelp": false, + "multiple": true, + "name": "id", + "required": true, + "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" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + }, + "watch": { + "allowNo": false, + "description": "Display the current operation state while polling, then output the final state when every operation reaches a terminal status.", + "env": "SHOPIFY_FLAG_WATCH", + "name": "watch", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "app:subscription-migrations:status", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Checks the status of app subscription migration operations." + }, + "app:subscription-migrations:unschedule": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Reverses scheduled app subscription migrations that have not migrated yet.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV header: `shop_id`.\n- Example row: `123456789`.\n\nThe CSV can contain only the `shop_id` header, or it can reuse the complete CSV supplied to `schedule`; schedule-only columns are ignored.\n\nUnscheduling is not a rollback after a subscription has migrated. The command validates the entire CSV before sending any mutation. Use `--force` to skip confirmation and immediately submit every valid row.\n\nOperations are submitted in batches of 250 shops. Preserve the root idempotency key and every operation GID printed by the command. Reusing the same root idempotency key with the same client ID, action, and input replays the same submission. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.\n\nBy default, the command 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 explicitly override the active configuration; this only selects the app and does not change Partners authentication.", + "descriptionWithMarkdown": "Reverses scheduled app subscription migrations that have not migrated yet.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV header: `shop_id`.\n- Example row: `123456789`.\n\nThe CSV can contain only the `shop_id` header, or it can reuse the complete CSV supplied to `schedule`; schedule-only columns are ignored.\n\nUnscheduling is not a rollback after a subscription has migrated. The command validates the entire CSV before sending any mutation. Use `--force` to skip confirmation and immediately submit every valid row.\n\nOperations are submitted in batches of 250 shops. Preserve the root idempotency key and every operation GID printed by the command. Reusing the same root idempotency key with the same client ID, action, and input replays the same submission. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.\n\nBy default, the command 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 explicitly override the active configuration; this only selects the app and does not change Partners authentication.", + "enableJsonFlag": false, + "examples": [ + "<%= config.bin %> <%= command.id %> --input migrations.csv --force", + "cat migrations.csv | <%= config.bin %> <%= command.id %> --force", + "<%= config.bin %> <%= command.id %> --input migrations.csv --path ../my-app --config staging --force --json", + "<%= config.bin %> <%= command.id %> --input migrations.csv --client-id --idempotency-key --force", + "<%= config.bin %> <%= command.id %> --input - --force --watch" + ], + "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" + }, + "force": { + "allowNo": false, + "char": "f", + "description": "Skip confirmation. Required if non interactive.", + "env": "SHOPIFY_FLAG_FORCE", + "name": "force", + "type": "boolean" + }, + "idempotency-key": { + "description": "Reuse an existing root idempotency key for the same action and input.", + "env": "SHOPIFY_FLAG_IDEMPOTENCY_KEY", + "hasDynamicHelp": false, + "multiple": false, + "name": "idempotency-key", + "type": "option" + }, + "input": { + "char": "i", + "description": "Path to the migration CSV. If omitted, standard input is used.", + "env": "SHOPIFY_FLAG_INPUT", + "hasDynamicHelp": false, + "multiple": false, + "name": "input", + "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" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + }, + "watch": { + "allowNo": false, + "description": "Display the current operation state while polling, then output the final outcome when every operation reaches a terminal status.", + "env": "SHOPIFY_FLAG_WATCH", + "name": "watch", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "app:subscription-migrations:unschedule", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Reverses app subscription migrations that are still scheduled." + }, "app:versions:list": { "aliases": [ ], diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 488d7481cb0..62a6927e217 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -33,6 +33,11 @@ │ ├─ logs │ │ └─ sources │ ├─ release +│ ├─ subscription-migrations +│ │ ├─ cancel +│ │ ├─ schedule +│ │ ├─ status +│ │ └─ unschedule │ ├─ versions │ │ └─ list │ └─ webhook diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac9dc0316e9..003f5d6f779 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -194,6 +194,9 @@ importers: chokidar: specifier: 3.6.0 version: 3.6.0 + csv-parse: + specifier: 7.0.2 + version: 7.0.2 diff: specifier: 5.2.2 version: 5.2.2 @@ -5007,6 +5010,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==, tarball: https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz} + csv-parse@7.0.2: + resolution: {integrity: sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==, tarball: https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==, tarball: https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz} @@ -13985,6 +13991,8 @@ snapshots: csstype@3.2.3: {} + csv-parse@7.0.2: {} + damerau-levenshtein@1.0.8: {} data-uri-to-buffer@2.0.2: {}