From f95d1e3b3c93a85f62abfe04dc813d6b214d287e Mon Sep 17 00:00:00 2001 From: German Shteynardt Date: Mon, 14 Sep 2026 16:47:39 +0300 Subject: [PATCH] feat: add migration commands Co-authored-by: Cursor --- package.json | 3 + src/cli/commands/migrations/close/index.ts | 39 ++++++ src/cli/commands/migrations/create/index.ts | 56 +++++++++ src/cli/commands/migrations/list/index.ts | 19 +++ .../commands/migrations/list/lib/render.ts | 113 ++++++++++++++++++ src/cli/commands/migrations/run/index.ts | 62 ++++++++++ src/cli/commands/migrations/show/index.ts | 32 +++++ src/cli/commands/migrations/status/index.ts | 34 ++++++ src/cli/commands/migrations/steps/index.ts | 22 ++++ src/cli/flags.ts | 13 ++ src/cli/views/envelope.ts | 99 +++++++++++++++ src/commands/migrations/close.ts | 2 + src/commands/migrations/create.ts | 2 + src/commands/migrations/list.ts | 2 + src/commands/migrations/run.ts | 2 + src/commands/migrations/show.ts | 2 + src/commands/migrations/status.ts | 2 + src/commands/migrations/steps.ts | 2 + src/sdk/adapty/index.ts | 14 ++- src/sdk/adapty/migrations/create.ts | 80 +++++++++++++ src/sdk/adapty/migrations/index.ts | 2 + src/sdk/adapty/migrations/resource.ts | 32 ++++- .../commands/migrations/list/render.test.ts | 90 ++++++++++++++ test/cli/views/envelope.test.ts | 85 +++++++++++++ test/commands/migrations.test.ts | 96 +++++++++++++++ test/sdk/adapty/migrations/create.test.ts | 39 ++++++ test/sdk/adapty/migrations/resource.test.ts | 60 +++++++++- 27 files changed, 994 insertions(+), 10 deletions(-) create mode 100644 src/cli/commands/migrations/close/index.ts create mode 100644 src/cli/commands/migrations/create/index.ts create mode 100644 src/cli/commands/migrations/list/index.ts create mode 100644 src/cli/commands/migrations/list/lib/render.ts create mode 100644 src/cli/commands/migrations/run/index.ts create mode 100644 src/cli/commands/migrations/show/index.ts create mode 100644 src/cli/commands/migrations/status/index.ts create mode 100644 src/cli/commands/migrations/steps/index.ts create mode 100644 src/cli/views/envelope.ts create mode 100644 src/commands/migrations/close.ts create mode 100644 src/commands/migrations/create.ts create mode 100644 src/commands/migrations/list.ts create mode 100644 src/commands/migrations/run.ts create mode 100644 src/commands/migrations/show.ts create mode 100644 src/commands/migrations/status.ts create mode 100644 src/commands/migrations/steps.ts create mode 100644 src/sdk/adapty/migrations/create.ts create mode 100644 test/cli/commands/migrations/list/render.test.ts create mode 100644 test/cli/views/envelope.test.ts create mode 100644 test/commands/migrations.test.ts create mode 100644 test/sdk/adapty/migrations/create.test.ts diff --git a/package.json b/package.json index e8b9a7f..2c9432d 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,9 @@ "segments": { "description": "List segments" }, + "migrations": { + "description": "Migrate from RevenueCat: catalog, transactions and store events" + }, "asa": { "description": "Apple Search Ads: campaigns, keywords, metrics and automations (scoped by the token's company, no --app)" }, diff --git a/src/cli/commands/migrations/close/index.ts b/src/cli/commands/migrations/close/index.ts new file mode 100644 index 0000000..faf87ea --- /dev/null +++ b/src/cli/commands/migrations/close/index.ts @@ -0,0 +1,39 @@ +import { Flags } from '@oclif/core'; + +import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { migrationFlags } from '../../../flags.js'; + +import type { Envelope } from '../../../../sdk/adapty/index.js'; + +export default class Close extends AdaptyCommand { + static override description = 'Finish a migration or cancel it for good'; + + static override examples = [ + '<%= config.bin %> migrations close --outcome finish --yes', + '<%= config.bin %> migrations close --outcome cancel --yes -m mig_7x2', + ]; + + static override flags = { + ...migrationFlags, + outcome: Flags.option({ + description: 'Finish — mark the migration as completed; Cancel — abandon the migration.', + options: ['finish', 'cancel'] as const, + required: true, + })(), + // Closing is final and never appears in next_actions, so there is nothing to preview: + // the agreement is the flag itself, required even on a TTY. + yes: Flags.boolean({ + char: 'y', + description: 'Confirm closing: it is final and never asked for again', + required: true, + }), + }; + + async run(): Promise { + await this.parse(Close); + + // TODO: resolve the migration id, POST close with the outcome and expected_revision from a + // fresh envelope, then render what came back. + throw new Error('`adapty migrations close` is not implemented yet'); + } +} diff --git a/src/cli/commands/migrations/create/index.ts b/src/cli/commands/migrations/create/index.ts new file mode 100644 index 0000000..e8f0ec6 --- /dev/null +++ b/src/cli/commands/migrations/create/index.ts @@ -0,0 +1,56 @@ +import { Flags } from '@oclif/core'; + +import { validateCreateMigration } from '../../../../sdk/adapty/migrations/index.js'; +import { assertValid } from '../../../../sdk/core/validation.js'; +import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { renderEnvelope } from '../../../views/envelope.js'; + +import type { CreateMigrationInput, Envelope } from '../../../../sdk/adapty/index.js'; + +export default class Create extends AdaptyCommand { + static override description = 'Start a migration from RevenueCat'; + + static override examples = [ + '<%= config.bin %> migrations create --name "Acme Fitness"', + '<%= config.bin %> migrations create --flow transactions --app 3f2ab1c4-0000-4000-8000-000000000000', + ]; + + // One endpoint, two shapes: --name starts the main flow and names the Adapty app it will + // create along the way; --flow starts an optional flow for an app main has already created. + // The pairing is input shape, so it is declared here; the rule behind it — exactly one of the + // two — lives in sdk/adapty/migrations/create.ts, where an MCP server obeys it too. + static override flags = { + name: Flags.string({ + description: 'Name of the Adapty app to create (starts the main flow: RevenueCat catalog)', + exclusive: ['app', 'flow'], + }), + flow: Flags.string({ + dependsOn: ['app'], + description: 'Optional flow to start for an existing app, e.g. transactions (see `adapty migrations list`)', + }), + app: Flags.string({ + dependsOn: ['flow'], + description: 'App ID (UUID) the optional flow runs for', + }), + }; + + async run(): Promise { + const { flags } = await this.parse(Create); + + const input: CreateMigrationInput = { + appId: flags.app, + appName: flags.name, + flow: flags.flow, + }; + + assertValid(validateCreateMigration(input)); + + const envelope = await this.adapty.migrations.create(input); + + this.log('Migration created.'); + this.render(envelope, renderEnvelope); + this.log(`\nContinue with \`${this.config.bin} migrations status -m ${envelope.migration.id}\`.`); + + return envelope; + } +} diff --git a/src/cli/commands/migrations/list/index.ts b/src/cli/commands/migrations/list/index.ts new file mode 100644 index 0000000..0a3e197 --- /dev/null +++ b/src/cli/commands/migrations/list/index.ts @@ -0,0 +1,19 @@ +import { AdaptyCommand } from '../../../base/adapty/index.js'; + +import { renderMigrationList } from './lib/render.js'; + +import type { MigrationList } from '../../../../sdk/adapty/index.js'; + +export default class List extends AdaptyCommand { + static override description = 'List migrations and the flows you can start'; + static override examples = ['<%= config.bin %> migrations list']; + + async run(): Promise { + await this.parse(List); + + const list = await this.adapty.migrations.list(); + this.render(list, renderMigrationList); + + return list; + } +} diff --git a/src/cli/commands/migrations/list/lib/render.ts b/src/cli/commands/migrations/list/lib/render.ts new file mode 100644 index 0000000..eb44049 --- /dev/null +++ b/src/cli/commands/migrations/list/lib/render.ts @@ -0,0 +1,113 @@ +import type { AvailableFlow, Migration, MigrationList } from '../../../../../sdk/adapty/index.js'; + +type App = Migration['app']; + +/** One block per Adapty App: its migrations, then the optional flows WS says can start for it. */ +type Group = { + app: App; + available: AvailableFlow[]; + migrations: Migration[]; +}; + +const stateOrder = [ + 'action_required', + 'running', + 'failed', + 'completed', + 'canceled', +] as const satisfies readonly Migration['state'][]; + +const stateRank = (state: string): number => { + const index = stateOrder.findIndex(knownState => knownState === state); + + return index === -1 ? stateOrder.length : index; +}; + +const appLabel = (app: App): string => (app === null ? 'App not created yet' : `${app.name} (${app.id})`); + +const maxWidth = (values: readonly string[]): number => { + return values.reduce((max, value) => Math.max(max, value.length), 0); +}; + +const groupByApp = (list: MigrationList): Group[] => { + const groups = new Map(); + + const groupFor = (app: App): Group => { + const key = app === null ? null : app.id; + const existing = groups.get(key); + + if (existing !== undefined) { + return existing; + } + + const group: Group = { app, available: [], migrations: [] }; + + groups.set(key, group); + + return group; + }; + + for (const migration of list.items) { + groupFor(migration.app).migrations.push(migration); + } + + for (const flow of list.available) { + groupFor(flow.app).available.push(flow); + } + + return [...groups.values()]; +}; + +const renderMigrations = (migrations: readonly Migration[]): string[] => { + const sorted = [...migrations].sort((a, b) => stateRank(a.state) - stateRank(b.state)); + const idWidth = maxWidth(sorted.map(migration => migration.id)); + const flowWidth = maxWidth(sorted.map(migration => migration.flow)); + const lines: string[] = []; + let heading: string | undefined; + + for (const migration of sorted) { + if (migration.state !== heading) { + heading = migration.state; + lines.push(` ${heading}`); + } + + const id = migration.id.padEnd(idWidth); + const flow = migration.flow.padEnd(flowWidth); + + lines.push(` ${id} ${flow} ${migration.updated_at} ${migration.summary}`); + } + + return lines; +}; + +const renderAvailable = (available: readonly AvailableFlow[]): string[] => { + if (available.length === 0) { + return []; + } + + const flowWidth = maxWidth(available.map(flow => flow.flow)); + + return [ + ' Available to start:', + ...available.map((flow) => { + const detail = flow.detail === null ? '' : ` ${flow.detail}`; + + return ` ${flow.flow.padEnd(flowWidth)} ${flow.title}${detail}`; + }), + ]; +}; + +const renderGroup = (group: Group): string => [ + appLabel(group.app), + ...renderMigrations(group.migrations), + ...renderAvailable(group.available), +].join('\n'); + +/** Grouped by app and, inside an app, by state. */ +export const renderMigrationList = (list: MigrationList): string => { + if (list.items.length === 0 && list.available.length === 0) { + return 'No migrations yet. Start one: `adapty migration create --name `'; + } + + return groupByApp(list).map(group => renderGroup(group)).join('\n\n'); +}; diff --git a/src/cli/commands/migrations/run/index.ts b/src/cli/commands/migrations/run/index.ts new file mode 100644 index 0000000..1a3148d --- /dev/null +++ b/src/cli/commands/migrations/run/index.ts @@ -0,0 +1,62 @@ +import { Args, Flags } from '@oclif/core'; + +import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { migrationFlags } from '../../../flags.js'; + +import type { Envelope } from '../../../../sdk/adapty/index.js'; + +export default class Run extends AdaptyCommand { + static override description = 'Do one of the actions the migration offers'; + + static override examples = [ + '<%= config.bin %> migrations run resolve_app_mapping --input \'{"rc_app_ids":["app_ios"]}\'', + '<%= config.bin %> migrations run resolve_mapping --input-file ./decisions.json --yes', + '<%= config.bin %> migrations run upload_file --file ./rc-export.csv.gz', + ]; + + static override args = { + action_id: Args.string({ + description: 'Action id, as listed by `adapty migrations status`', + required: true, + }), + }; + + // One command per action kind: input goes with --input/--input-file, upload with --file, and + // an external action only prints its link. Which one applies is the server's answer, so the + // flags cannot be split into three commands — the checks belong in run(). + static override flags = { + ...migrationFlags, + 'input': Flags.string({ + description: 'Action input as JSON', + exclusive: ['input-file'], + }), + 'input-file': Flags.string({ + description: 'Read the action input from a file, or from stdin with -', + exclusive: ['input'], + }), + 'file': Flags.string({ + description: 'File to upload for an upload action', + }), + 'yes': Flags.boolean({ + char: 'y', + description: 'Agree to an action that changes production data, without the prompt', + }), + 'open': Flags.boolean({ + description: 'Open the link of an external action, even with --json', + exclusive: ['no-browser'], + }), + 'no-browser': Flags.boolean({ + description: 'Never open a browser; print the link only', + exclusive: ['open'], + }), + }; + + async run(): Promise { + await this.parse(Run); + + // TODO: read the envelope, find the action in next_actions ∪ available_actions (exit 2 when + // it is not there), then branch on kind: external prints and opens the href, upload streams + // the file first, input POSTs. A confirm without --yes prints the text and exits 6. + throw new Error('`adapty migrations run` is not implemented yet'); + } +} diff --git a/src/cli/commands/migrations/show/index.ts b/src/cli/commands/migrations/show/index.ts new file mode 100644 index 0000000..b49eadf --- /dev/null +++ b/src/cli/commands/migrations/show/index.ts @@ -0,0 +1,32 @@ +import { Args } from '@oclif/core'; + +import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { migrationFlags } from '../../../flags.js'; + +import type { Envelope } from '../../../../sdk/adapty/index.js'; + +export default class Show extends AdaptyCommand { + static override description = 'Read the data behind a migration: apps, mapping, report'; + + static override examples = [ + '<%= config.bin %> migrations show', + '<%= config.bin %> migrations show mapping', + '<%= config.bin %> migrations show report -m mig_7x2', + ]; + + static override args = { + resource: Args.string({ + description: 'Resource name; omit to list what can be read now', + }), + }; + + static override flags = { ...migrationFlags }; + + async run(): Promise { + await this.parse(Show); + + // TODO: without the arg render resources[] from the envelope; with it GET the resource and + // render result — a table for a collection, text for { markdown }, JSON for anything else. + throw new Error('`adapty migrations show` is not implemented yet'); + } +} diff --git a/src/cli/commands/migrations/status/index.ts b/src/cli/commands/migrations/status/index.ts new file mode 100644 index 0000000..cc3baf2 --- /dev/null +++ b/src/cli/commands/migrations/status/index.ts @@ -0,0 +1,34 @@ +import { Flags } from '@oclif/core'; + +import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { migrationFlags } from '../../../flags.js'; + +import type { Envelope } from '../../../../sdk/adapty/index.js'; + +export default class Status extends AdaptyCommand { + static override description = 'Show where a migration is and what it needs from you'; + + static override examples = [ + '<%= config.bin %> migrations status', + '<%= config.bin %> migrations status -m mig_7x2', + '<%= config.bin %> migrations status --wait 300s', + ]; + + static override flags = { + ...migrationFlags, + // oclif has no optional-value flag, so the contract's bare `--wait` cannot be declared as + // it is written: a string flag always demands a value. Either the duration stays required + // here, or run() reads the default (120s, max 600s) for a bare `--wait` on its own. + wait: Flags.string({ + description: 'Wait until the migration changes, e.g. 300s (default 120s, max 600s)', + }), + }; + + async run(): Promise { + await this.parse(Status); + + // TODO: resolve the migration id, GET the envelope (polling every poll_after_seconds while + // --wait is on, progress to stderr) and render it. A failed migration is still exit 0. + throw new Error('`adapty migrations status` is not implemented yet'); + } +} diff --git a/src/cli/commands/migrations/steps/index.ts b/src/cli/commands/migrations/steps/index.ts new file mode 100644 index 0000000..191801d --- /dev/null +++ b/src/cli/commands/migrations/steps/index.ts @@ -0,0 +1,22 @@ +import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { migrationFlags } from '../../../flags.js'; + +import type { Envelope } from '../../../../sdk/adapty/index.js'; + +export default class Steps extends AdaptyCommand { + static override description = 'Show the migration checklist: done, current and locked steps'; + + static override examples = [ + '<%= config.bin %> migrations steps', + '<%= config.bin %> migrations steps -m mig_7x2', + ]; + + static override flags = { ...migrationFlags }; + + async run(): Promise { + await this.parse(Steps); + + // TODO: resolve the migration id, GET the envelope and render steps[] as a checklist. + throw new Error('`adapty migrations steps` is not implemented yet'); + } +} diff --git a/src/cli/flags.ts b/src/cli/flags.ts index f4bb7fa..df5afa4 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -26,6 +26,19 @@ export const appIdArg = { }), }; +/** + * `-m` is optional in every command of the migrations topic. The CLI stores nothing locally, so + * the id is resolved flag → $ADAPTY_MIGRATION → the only open migration of the account → an error + * listing the candidates; oclif covers the first two steps, the rest belongs to the commands. + */ +export const migrationFlags = { + migration: Flags.string({ + char: 'm', + description: 'Migration ID (default: the only open migration of the account)', + env: 'ADAPTY_MIGRATION', + }), +}; + /** The published defaults, so a migrated `list` asks for the same page as an untouched one. */ export const paginationFlags = { 'page': Flags.integer({ default: 1, description: 'Page number', min: 1 }), diff --git a/src/cli/views/envelope.ts b/src/cli/views/envelope.ts new file mode 100644 index 0000000..77a00c6 --- /dev/null +++ b/src/cli/views/envelope.ts @@ -0,0 +1,99 @@ +import type { Action, Envelope, Issue, Migration, Progress } from '../../sdk/adapty/index.js'; + +/** + * The envelope as a human reads it: where the migration is, what is wrong, what to do next. Every + * command of the topic answers with this same object, so the view is shared rather than owned by + * one command. + * + * The server writes the texts (`summary`, `detail`, `confirm` are CommonMark) and this prints them + * as they came: a new step, action or wording must not need a CLI release. + */ +export const renderEnvelope = (envelope: Envelope): string => { + const { migration } = envelope; + + const lines = [ + `${migration.id} ${migration.flow} ${migration.state}`, + appLine(migration.app), + migration.summary, + ]; + + if (migration.progress !== null) { + lines.push(progressLine(migration.progress)); + } + + if (envelope.issues.length > 0) { + lines.push('', 'Issues:', ...envelope.issues.flatMap(issue => issueBlock(issue))); + } + + if (envelope.next_actions.length > 0) { + lines.push('', 'Do next:', ...envelope.next_actions.flatMap(action => actionBlock(action))); + } + + if (envelope.available_actions.length > 0) { + lines.push('', 'Also available:', ...envelope.available_actions.flatMap(action => actionBlock(action))); + } + + if (envelope.resources.length > 0) { + lines.push('', `Readable now: ${envelope.resources.map(resource => resource.name).join(', ')}`); + } + + return lines.join('\n'); +}; + +/** Section 4.6: a kind this build never heard of is not an error, it is an older CLI. */ +const knownKinds = new Set(['external', 'input', 'upload']); + +const indent = (text: string, pad: string): string => + text.split('\n').map(line => `${pad}${line}`).join('\n'); + +/** Null until the main flow creates it, which is most of a new migration's life. */ +const appLine = (app: Migration['app']): string => + (app === null ? 'App: not created yet' : `App: ${app.name} (${app.id})`); + +const progressLine = (progress: Progress): string => { + const done = progress.total === null ? String(progress.done) : `${progress.done} of ${progress.total}`; + + return `Progress: ${done} ${progress.unit}`; +}; + +const issueBlock = (issue: Issue): string[] => { + const lines = [` ${issue.title} (${issue.code})`]; + + if (issue.detail !== null) { + lines.push(indent(issue.detail, ' ')); + } + + if (issue.action_id !== null) { + lines.push(` Fix with: adapty migrations run ${issue.action_id}`); + } + + return lines; +}; + +const actionBlock = (action: Action): string[] => { + const lines = [` ${action.action_id} (${action.kind}) ${action.title}`]; + // `href` belongs to the external branch only, so the union is asked before it is read + const href = 'href' in action ? action.href : undefined; + + if (action.detail !== null) { + lines.push(indent(action.detail, ' ')); + } + + if (href !== undefined) { + lines.push(` ${href}`); + } + + if (action.reads.length > 0) { + lines.push(` Read first: ${action.reads.map(name => `adapty migrations show ${name}`).join(', ')}`); + } + + if (action.confirm !== null) { + lines.push(' Changes production data: needs --yes'); + } + + if (!knownKinds.has(action.kind)) { + lines.push(' This action needs a newer adapty-cli'); + } + + return lines; +}; diff --git a/src/commands/migrations/close.ts b/src/commands/migrations/close.ts new file mode 100644 index 0000000..31fddc7 --- /dev/null +++ b/src/commands/migrations/close.ts @@ -0,0 +1,2 @@ +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/migrations/close/index.js'; diff --git a/src/commands/migrations/create.ts b/src/commands/migrations/create.ts new file mode 100644 index 0000000..839358b --- /dev/null +++ b/src/commands/migrations/create.ts @@ -0,0 +1,2 @@ +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/migrations/create/index.js'; diff --git a/src/commands/migrations/list.ts b/src/commands/migrations/list.ts new file mode 100644 index 0000000..ee2db53 --- /dev/null +++ b/src/commands/migrations/list.ts @@ -0,0 +1,2 @@ +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/migrations/list/index.js'; diff --git a/src/commands/migrations/run.ts b/src/commands/migrations/run.ts new file mode 100644 index 0000000..ccdf0bf --- /dev/null +++ b/src/commands/migrations/run.ts @@ -0,0 +1,2 @@ +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/migrations/run/index.js'; diff --git a/src/commands/migrations/show.ts b/src/commands/migrations/show.ts new file mode 100644 index 0000000..b3303ea --- /dev/null +++ b/src/commands/migrations/show.ts @@ -0,0 +1,2 @@ +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/migrations/show/index.js'; diff --git a/src/commands/migrations/status.ts b/src/commands/migrations/status.ts new file mode 100644 index 0000000..b0ceb97 --- /dev/null +++ b/src/commands/migrations/status.ts @@ -0,0 +1,2 @@ +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/migrations/status/index.js'; diff --git a/src/commands/migrations/steps.ts b/src/commands/migrations/steps.ts new file mode 100644 index 0000000..0798fd6 --- /dev/null +++ b/src/commands/migrations/steps.ts @@ -0,0 +1,2 @@ +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/migrations/steps/index.js'; diff --git a/src/sdk/adapty/index.ts b/src/sdk/adapty/index.ts index 94f9acb..c8f08f8 100644 --- a/src/sdk/adapty/index.ts +++ b/src/sdk/adapty/index.ts @@ -22,6 +22,7 @@ export type { Action, ActionKind, AvailableFlow, + CreateMigrationInput, Envelope, Issue, JsonSchema, @@ -66,7 +67,7 @@ export type Adapty = { /** The assembly point of the developer API: one transport, resources on top of it. */ export const createAdapty = (options: AdaptyOptions = {}): Adapty => { - const http = createHttp({ + const transport = { baseUrl: options.baseUrl ?? DEFAULT_ADAPTY_API_URL, clock: options.clock, fetch: options.fetch, @@ -75,12 +76,19 @@ export const createAdapty = (options: AdaptyOptions = {}): Adapty => { parseError: developerErrorParser, signal: options.signal, token: options.token, - }); + }; + + const http = createHttp(transport); + + // Same host, same token, another service: /migrations is proxied through to the Wizard + // Service, which is not Django and answers 404 to the trailing slash the rest of this API + // requires. One client per convention, so neither resource has to remember the other's. + const wizard = createHttp({ ...transport, trailingSlash: false }); return { accessLevels: accessLevels(http), apps: apps(http), auth: auth(http), - migrations: migrations(http), + migrations: migrations(wizard), }; }; diff --git a/src/sdk/adapty/migrations/create.ts b/src/sdk/adapty/migrations/create.ts new file mode 100644 index 0000000..2933910 --- /dev/null +++ b/src/sdk/adapty/migrations/create.ts @@ -0,0 +1,80 @@ +import type { Issue } from '../../core/errors.js'; + +/** + * The flow that starts a migration from scratch: it reads the RevenueCat catalog and creates the + * Adapty app along the way. Every other flow (transactions, store events) runs for an app this + * one has already created, which is why only this name is spelled out here. + */ +const MAIN_FLOW = 'main'; + +/** + * Permissive on purpose: the two shapes the server accepts are "name a new app" and "a flow for an + * app that exists", and telling a user which one they half-typed is the rule below, not the type. + */ +export type CreateMigrationInput = { + appId?: string | undefined; + appName?: string | undefined; + flow?: string | undefined; +}; + +type CreateMigrationRequest = { + app_id?: string; + app_name?: string; + flow: string; +}; + +/** + * The rule of `create`: exactly one of the two shapes, never a mix. An Issue path names the flag + * the user typed (src/cli/errors.ts turns `app` into `--app`), and a path is left out when the + * problem is the input as a whole rather than one field. + */ +export const validateCreateMigration = (input: CreateMigrationInput): Issue[] => { + const { appId, appName, flow } = input; + + if (appName === undefined && flow === undefined && appId === undefined) { + return [{ message: 'pass --name to migrate into a new app, or --flow with --app for an existing one' }]; + } + + const issues: Issue[] = []; + + if (appName !== undefined) { + if (appName.trim() === '') { + issues.push({ message: 'must not be empty', path: 'name' }); + } + + if (flow !== undefined || appId !== undefined) { + issues.push({ message: 'starts the main flow and names the app it creates: --flow and --app do not apply', path: 'name' }); + } + + return issues; + } + + if (flow === undefined) { + issues.push({ message: 'required unless --name is given', path: 'flow' }); + } else if (flow.trim() === '') { + issues.push({ message: 'must not be empty', path: 'flow' }); + } + + if (appId === undefined) { + issues.push({ message: 'required with --flow: the app the flow runs for', path: 'app' }); + } + + return issues; +}; + +/** Two bodies, one endpoint. The flow of a new app is not the caller's to choose: it is `main`. */ +export const toCreateRequest = (input: CreateMigrationInput): CreateMigrationRequest => { + const { appId, appName, flow } = input; + + if (appName !== undefined) { + return { app_name: appName, flow: MAIN_FLOW }; + } + + if (appId === undefined || flow === undefined) { + // Unreachable through the resource, which validates first: a caller that skipped the rule + // has a bug, and a bug is not a ValidationError the user could act on. + throw new Error('createMigration needs either appName, or appId with flow'); + } + + return { app_id: appId, flow }; +}; diff --git a/src/sdk/adapty/migrations/index.ts b/src/sdk/adapty/migrations/index.ts index 8cb5d25..0c7af63 100644 --- a/src/sdk/adapty/migrations/index.ts +++ b/src/sdk/adapty/migrations/index.ts @@ -2,8 +2,10 @@ * The door of the migrations resource: re-exports only, no code of its own. Everything outside * the directory imports from here, which is what lets the files behind it be rearranged. */ +export { validateCreateMigration } from './create.js'; export { migrations } from './resource.js'; +export type { CreateMigrationInput } from './create.js'; export type { MigrationApi } from './resource.js'; export type { Action, diff --git a/src/sdk/adapty/migrations/resource.ts b/src/sdk/adapty/migrations/resource.ts index 83a5b01..56ab343 100644 --- a/src/sdk/adapty/migrations/resource.ts +++ b/src/sdk/adapty/migrations/resource.ts @@ -1,17 +1,41 @@ +import { randomUUID } from 'node:crypto'; + +import { assertValid } from '../../core/validation.js'; + +import { toCreateRequest, validateCreateMigration } from './create.js'; + +import type { CreateMigrationInput } from './create.js'; import type { Envelope, MigrationList } from './model.js'; -import type { Http } from '../../core/http/index.js'; +import type { Http, RequestOptions } from '../../core/http/index.js'; + +/** + * Every path of the migrations resource in one place, so the endpoints can be read as a list. + * What an operation needs of its own — input shape, rules, request body — lives in its own file. + */ /** - * Every read path of the migrations resource in one place, as the endpoints can be read as a - * list. Writes — create, run, close, uploads — are their own files, added with the operations - * that need them. + * Section 4.2: every POST carries an Idempotency-Key, one per call and shared by its retries, so a + * request that was applied but never answered comes back as the stored answer instead of acting + * twice. That is also what makes a write safe to retry at all. */ +const write = (): RequestOptions => ({ + headers: { 'idempotency-key': randomUUID() }, + idempotent: true, +}); + export const migrations = (http: Http) => ({ get: (id: string) => http.get(`/migrations/${id}`), list: () => http.get('/migrations'), resource: (id: string, name: string) => { return http.get>(`/migrations/${id}/resources/${name}`); }, + + /** Async like every validating method: a broken rule arrives as a rejection, as a 400 would. */ + create: async (input: CreateMigrationInput): Promise => { + assertValid(validateCreateMigration(input)); + + return http.post('/migrations', toCreateRequest(input), write()); + }, }); export type MigrationApi = ReturnType; diff --git a/test/cli/commands/migrations/list/render.test.ts b/test/cli/commands/migrations/list/render.test.ts new file mode 100644 index 0000000..922d60f --- /dev/null +++ b/test/cli/commands/migrations/list/render.test.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai'; + +import { renderMigrationList } from '../../../../../src/cli/commands/migrations/list/lib/render.js'; + +import type { Migration, MigrationState } from '../../../../../src/sdk/adapty/index.js'; + +type App = NonNullable; + +const UPDATED_AT = '2026-09-14T09:00:00Z'; + +const migration = (id: string, state: MigrationState, app: App): Migration => ({ + app, + created_at: UPDATED_AT, + flow: 'flow', + id, + poll_after_seconds: 0, + progress: null, + revision: 1, + state, + summary: `summary ${id}`, + updated_at: UPDATED_AT, +}); + +describe('renderMigrationList', () => { + it('groups migrations and available flows by app', () => { + const alpha = { id: 'app-a', name: 'Alpha' }; + const beta = { id: 'app-b', name: 'Beta' }; + + const result = renderMigrationList({ + available: [{ + app: alpha, + detail: 'Ready', + flow: 'import', + title: 'Import catalog', + }], + items: [ + migration('m-a1', 'running', alpha), + migration('m-b', 'running', beta), + migration('m-a2', 'running', alpha), + ], + }); + + expect(result).to.equal([ + 'Alpha (app-a)', + ' running', + ` m-a1 flow ${UPDATED_AT} summary m-a1`, + ` m-a2 flow ${UPDATED_AT} summary m-a2`, + ' Available to start:', + ' import Import catalog Ready', + '', + 'Beta (app-b)', + ' running', + ` m-b flow ${UPDATED_AT} summary m-b`, + ].join('\n')); + }); + + it('sorts states by the user-action priority', () => { + const app = { id: 'app-a', name: 'Alpha' }; + + const result = renderMigrationList({ + available: [], + items: [ + migration('c', 'canceled', app), + migration('d', 'completed', app), + migration('f', 'failed', app), + migration('r', 'running', app), + migration('a', 'action_required', app), + ], + }); + + expect(result).to.equal([ + 'Alpha (app-a)', + ' action_required', + ` a flow ${UPDATED_AT} summary a`, + ' running', + ` r flow ${UPDATED_AT} summary r`, + ' failed', + ` f flow ${UPDATED_AT} summary f`, + ' completed', + ` d flow ${UPDATED_AT} summary d`, + ' canceled', + ` c flow ${UPDATED_AT} summary c`, + ].join('\n')); + }); + + it('shows a start hint for an empty list', () => { + expect(renderMigrationList({ available: [], items: [] })) + .to.equal('No migrations yet. Start one: `adapty migration create --name `'); + }); +}); diff --git a/test/cli/views/envelope.test.ts b/test/cli/views/envelope.test.ts new file mode 100644 index 0000000..f4c91a4 --- /dev/null +++ b/test/cli/views/envelope.test.ts @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { expect } from 'chai'; + +import { renderEnvelope } from '../../../src/cli/views/envelope.js'; + +import type { Envelope } from '../../../src/sdk/adapty/index.js'; + +const FIXTURE_PATH = fileURLToPath(new URL('../../fixtures/migration-envelope.json', import.meta.url)); +const ENVELOPE = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as Envelope; + +const withMigration = (patch: Partial): Envelope => + ({ ...ENVELOPE, migration: { ...ENVELOPE.migration, ...patch } }); + +describe('renderEnvelope', () => { + it('opens with where the migration is and what the server says about it', () => { + const lines = renderEnvelope(ENVELOPE).split('\n'); + + expect(lines[0]).to.equal('mig_01H9Z main action_required'); + expect(lines[1]).to.equal('App: Demo (app_1)'); + expect(lines[2]).to.equal('Waiting on your confirmation to migrate paywalls'); + expect(lines[3]).to.equal('Progress: 2 of 5 steps'); + }); + + it('says the app is not there yet instead of printing an empty name', () => { + expect(renderEnvelope(withMigration({ app: null }))).to.contain('App: not created yet'); + }); + + it('leaves out an unknown total rather than printing null', () => { + const result = renderEnvelope(withMigration({ progress: { done: 12, total: null, unit: 'profiles' } })); + + expect(result).to.contain('Progress: 12 profiles'); + }); + + it('lists what to do next, its instruction, what to read first and that it needs --yes', () => { + const result = renderEnvelope(ENVELOPE); + + expect(result).to.contain('Do next:'); + expect(result).to.contain(' act_confirm_paywalls (input) Migrate paywalls'); + expect(result).to.contain(' This will replace the paywalls in the target app.'); + expect(result).to.contain(' Read first: adapty migrations show step_paywalls'); + expect(result).to.contain(' Changes production data: needs --yes'); + }); + + it('keeps optional actions apart from the ones that block the migration, and prints their link', () => { + const result = renderEnvelope(ENVELOPE); + + expect(result).to.contain('Also available:'); + expect(result).to.contain(' act_open_report (external) Open migration report'); + expect(result).to.contain(' https://app.adapty.io/migrations/mig_01H9Z/report'); + expect(result).to.contain('Readable now: report'); + }); + + it('prints an action of a kind it does not know, and admits the CLI is behind', () => { + const unknown = { + ...ENVELOPE, + next_actions: [{ + action_id: 'act_new', + confirm: null, + detail: null, + href: 'https://app.adapty.io/whatever', + kind: 'telepathy', + reads: [], + step_id: 'step_paywalls', + title: 'Something newer', + }], + } satisfies Envelope; + + const result = renderEnvelope(unknown); + + expect(result).to.contain(' act_new (telepathy) Something newer'); + expect(result).to.contain(' https://app.adapty.io/whatever'); + expect(result).to.contain(' This action needs a newer adapty-cli'); + }); + + it('says nothing about sections the envelope left empty', () => { + const quiet = { ...ENVELOPE, available_actions: [], next_actions: [], resources: [] } satisfies Envelope; + const result = renderEnvelope(quiet); + + expect(result).to.not.contain('Do next:'); + expect(result).to.not.contain('Also available:'); + expect(result).to.not.contain('Readable now:'); + }); +}); diff --git a/test/commands/migrations.test.ts b/test/commands/migrations.test.ts new file mode 100644 index 0000000..41c9526 --- /dev/null +++ b/test/commands/migrations.test.ts @@ -0,0 +1,96 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { runCommand } from '@oclif/test'; +import { expect } from 'chai'; + +import { exitCode } from '../../src/cli/errors.js'; +import { + assertFetch, + mockFetch, + restoreFetch, + TEST_APP_ID, +} from '../helpers/mock-fetch.js'; + +import type sinon from 'sinon'; + +const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/migration-envelope.json', import.meta.url)); +const ENVELOPE = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as unknown; + +describe('migrations', () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + process.env.ADAPTY_TOKEN = 'test-token'; + }); + + afterEach(() => { + restoreFetch(fetchStub); + delete process.env.ADAPTY_TOKEN; + }); + + it('list calls GET /migrations', async () => { + fetchStub = mockFetch([{ available: [], items: [] }]); + await runCommand('migrations list'); + assertFetch({ callIndex: 0, method: 'GET', path: '/migrations', stub: fetchStub }); + }); + + it('create names the new app and lets the server pick the flow', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { stdout } = await runCommand('migrations create --name "Acme Fitness"'); + + assertFetch({ + body: { app_name: 'Acme Fitness', flow: 'main' }, + callIndex: 0, + method: 'POST', + path: '/migrations', + stub: fetchStub, + }); + + expect(stdout).to.contain('Migration created.'); + expect(stdout).to.contain('mig_01H9Z main action_required'); + expect(stdout).to.contain('adapty migrations status -m mig_01H9Z'); + }); + + it('create starts an optional flow for an app that exists', async () => { + fetchStub = mockFetch([ENVELOPE]); + + await runCommand(`migrations create --flow transactions --app ${TEST_APP_ID}`); + + assertFetch({ + body: { app_id: TEST_APP_ID, flow: 'transactions' }, + callIndex: 0, + method: 'POST', + path: '/migrations', + stub: fetchStub, + }); + }); + + it('create prints the envelope untouched under --json', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { stdout } = await runCommand('migrations create --name "Acme Fitness" --json'); + + expect(JSON.parse(stdout)).to.deep.equal(ENVELOPE); + }); + + it('create with nothing to go on names the flags, before any request', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { error } = await runCommand('migrations create'); + + expect(error?.oclif?.exit).to.equal(exitCode.usage); + expect(error?.message).to.contain('--name'); + expect(fetchStub.callCount).to.equal(0); + }); + + it('create refuses a flow without the app it runs for', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { error } = await runCommand('migrations create --flow transactions'); + + expect(error?.oclif?.exit).to.equal(exitCode.usage); + expect(fetchStub.callCount).to.equal(0); + }); +}); diff --git a/test/sdk/adapty/migrations/create.test.ts b/test/sdk/adapty/migrations/create.test.ts new file mode 100644 index 0000000..92c8c4f --- /dev/null +++ b/test/sdk/adapty/migrations/create.test.ts @@ -0,0 +1,39 @@ +import { expect } from 'chai'; + +import { toCreateRequest } from '../../../../src/sdk/adapty/migrations/create.js'; +import { validateCreateMigration } from '../../../../src/sdk/adapty/migrations/index.js'; + +import type { CreateMigrationInput } from '../../../../src/sdk/adapty/index.js'; +import type { Issue } from '../../../../src/sdk/core/errors.js'; + +const paths = (issues: readonly Issue[]): (string | undefined)[] => issues.map(issue => issue.path); + +describe('validateCreateMigration', () => { + const cases: { expected: (string | undefined)[]; input: CreateMigrationInput; name: string }[] = [ + { expected: [], input: { appName: 'Acme Fitness' }, name: 'a name for the app the main flow creates' }, + { expected: [], input: { appId: 'app-1', flow: 'transactions' }, name: 'a flow for an app that exists' }, + { expected: [undefined], input: {}, name: 'nothing at all: the input as a whole is wrong' }, + { expected: ['name'], input: { appName: ' ' }, name: 'a name of spaces' }, + { expected: ['name'], input: { appName: 'Acme', flow: 'transactions' }, name: 'a name mixed with a flow' }, + { expected: ['name'], input: { appId: 'app-1', appName: 'Acme' }, name: 'a name mixed with an app' }, + { expected: ['flow'], input: { appId: 'app-1' }, name: 'an app without a flow' }, + { expected: ['flow', 'app'], input: { flow: '' }, name: 'an empty flow and no app' }, + { expected: ['app'], input: { flow: 'transactions' }, name: 'a flow without an app' }, + ]; + + for (const { expected, input, name } of cases) { + it(name, () => { + expect(paths(validateCreateMigration(input))).to.deep.equal(expected); + }); + } +}); + +describe('toCreateRequest', () => { + it('turns a name into the main flow, never letting the caller pick it', () => { + expect(toCreateRequest({ appName: 'Acme Fitness' })).to.deep.equal({ app_name: 'Acme Fitness', flow: 'main' }); + }); + + it('sends an optional flow with the app it runs for', () => { + expect(toCreateRequest({ appId: 'app-1', flow: 'transactions' })).to.deep.equal({ app_id: 'app-1', flow: 'transactions' }); + }); +}); diff --git a/test/sdk/adapty/migrations/resource.test.ts b/test/sdk/adapty/migrations/resource.test.ts index a6f0c04..b45c527 100644 --- a/test/sdk/adapty/migrations/resource.test.ts +++ b/test/sdk/adapty/migrations/resource.test.ts @@ -4,7 +4,9 @@ import { fileURLToPath } from 'node:url'; import { expect } from 'chai'; import { createAdapty } from '../../../../src/sdk/adapty/index.js'; +import { ValidationError } from '../../../../src/sdk/core/errors.js'; import { createScriptedFetch } from '../../../../src/sdk/core/testing.js'; +import { rejection } from '../../../helpers/rejection.js'; const FIXTURE_PATH = fileURLToPath(new URL('../../../fixtures/migration-envelope.json', import.meta.url)); const ENVELOPE = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as unknown; @@ -28,7 +30,7 @@ describe('adapty.migrations', () => { const result = await migrations.list(); expect(calls[0]?.method).to.equal('GET'); - expect(calls[0]?.url).to.equal(`${BASE}/migrations/`); + expect(calls[0]?.url).to.equal(`${BASE}/migrations`); expect(calls[0]?.headers.get('authorization')).to.equal('Bearer t'); expect(result).to.deep.equal(list); }); @@ -39,7 +41,7 @@ describe('adapty.migrations', () => { const result = await migrations.get('mig_01H9Z'); expect(calls[0]?.method).to.equal('GET'); - expect(calls[0]?.url).to.equal(`${BASE}/migrations/mig_01H9Z/`); + expect(calls[0]?.url).to.equal(`${BASE}/migrations/mig_01H9Z`); expect(result).to.deep.equal(ENVELOPE); }); @@ -49,7 +51,59 @@ describe('adapty.migrations', () => { const result = await migrations.resource('mig_01H9Z', 'report'); expect(calls[0]?.method).to.equal('GET'); - expect(calls[0]?.url).to.equal(`${BASE}/migrations/mig_01H9Z/resources/report/`); + expect(calls[0]?.url).to.equal(`${BASE}/migrations/mig_01H9Z/resources/report`); expect(result).to.deep.equal(ENVELOPE); }); + + it('creates a migration for an app that does not exist yet', async () => { + const { calls, migrations } = setup([{ body: ENVELOPE }]); + + const result = await migrations.create({ appName: 'Acme Fitness' }); + + expect(calls[0]?.method).to.equal('POST'); + expect(calls[0]?.url).to.equal(`${BASE}/migrations`); + expect(JSON.parse(calls[0]?.body ?? '')).to.deep.equal({ app_name: 'Acme Fitness', flow: 'main' }); + expect(result).to.deep.equal(ENVELOPE); + }); + + it('creates an optional flow for an app that exists', async () => { + const { calls, migrations } = setup([{ body: ENVELOPE }]); + + await migrations.create({ appId: 'app_1', flow: 'transactions' }); + + expect(JSON.parse(calls[0]?.body ?? '')).to.deep.equal({ app_id: 'app_1', flow: 'transactions' }); + }); + + it('carries an idempotency key, so a retried create cannot start a second migration', async () => { + const { calls, migrations } = setup([{ body: ENVELOPE }, { body: ENVELOPE }]); + + await migrations.create({ appName: 'A' }); + await migrations.create({ appName: 'A' }); + + const [first, second] = calls.map(call => call.headers.get('idempotency-key')); + + expect(first).to.match(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/); + expect(second).to.not.equal(first); + }); + + it('breaks the create rule before reaching the network', async () => { + const { calls, migrations } = setup([]); + + const error = await rejection(migrations.create({})); + + expect(error).to.be.instanceOf(ValidationError); + expect(calls).to.have.length(0); + }); + + // The wizard is another service behind the same host: Core proxies /migrations through to it, + // and a trailing slash on a collection there is a 404 indistinguishable from a wrong path. + it('sends no trailing slash, while the rest of the developer API keeps it', async () => { + const scripted = createScriptedFetch([{ body: { available: [], items: [] } }, { body: { data: [] } }]); + const adapty = createAdapty({ baseUrl: BASE, fetch: scripted.fetch, token: 't' }); + + await adapty.migrations.list(); + await adapty.apps.list(); + + expect(scripted.calls.map(call => call.url)).to.deep.equal([`${BASE}/migrations`, `${BASE}/apps/`]); + }); });