From 64339c195531ab9900a7dac861954a2190a8fea2 Mon Sep 17 00:00:00 2001 From: German Shteynardt Date: Mon, 14 Sep 2026 11:05:28 +0300 Subject: [PATCH 1/4] feat: added migration model --- docs/architecture.md | 2 +- ...-09-11-feat-migration-sdk-resource-plan.md | 87 +++++++++++++++++ src/sdk/adapty/errors.ts | 12 +++ src/sdk/adapty/index.ts | 21 +++++ src/sdk/adapty/migrations/index.ts | 23 +++++ src/sdk/adapty/migrations/model.ts | 93 +++++++++++++++++++ src/sdk/adapty/migrations/resource.ts | 17 ++++ test/fixtures/migration-envelope.json | 45 +++++++++ test/sdk/adapty/errors.test.ts | 15 +++ test/sdk/adapty/migrations/resource.test.ts | 55 +++++++++++ 10 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-09-11-feat-migration-sdk-resource-plan.md create mode 100644 src/sdk/adapty/migrations/index.ts create mode 100644 src/sdk/adapty/migrations/model.ts create mode 100644 src/sdk/adapty/migrations/resource.ts create mode 100644 test/fixtures/migration-envelope.json create mode 100644 test/sdk/adapty/migrations/resource.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 0206a15..e6db0b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,7 +46,7 @@ Everything here would be the same for any HTTP API. ## sdk/adapty The Developer API assembled on top of core. `createAdapty(options)` builds one transport and hangs -resources off it (`apps`, `auth`, `accessLevels`). +resources off it (`apps`, `auth`, `accessLevels`, `migrations`). A resource owns everything about its entity: paths, request/response shapes, and its business rules as pure functions returning `Issue[]`. Rules return lists instead of throwing, so a table diff --git a/docs/plans/2026-09-11-feat-migration-sdk-resource-plan.md b/docs/plans/2026-09-11-feat-migration-sdk-resource-plan.md new file mode 100644 index 0000000..570eb52 --- /dev/null +++ b/docs/plans/2026-09-11-feat-migration-sdk-resource-plan.md @@ -0,0 +1,87 @@ +--- +title: "feat: migrations resource in sdk/adapty (PR 1 of the migration topic)" +type: feat +status: planned +date: 2026-09-11 +contract: https://app.notion.com/p/CLI-Wizard-Service-3d51ca4355c3812c9fa3e2f2f8a30f3c +--- + +# Migrations resource in `sdk/adapty` + +First step towards `adapty migration …`. Read-only, sdk only, no command yet. The CLI is a thin +client of the Wizard Service (WS): the whole flow lives on the server and every answer has one +shape, the envelope. This PR teaches the sdk that shape and the three GET endpoints. + +## Scope + +In: + +- contract types from section 5 of the doc +- `list()`, `get(id)`, `resource(id, name)` +- `migrations` hung off `createAdapty`, on the same transport and token as `apps` +- the developer error parser reads the WS error body + +Out (each is its own PR): any command, `--wait`, `create`, `run`, `close`, uploads, docs. + +## Files + +``` +src/sdk/adapty/migrations/ +├── index.ts # the door: re-exports only +├── model.ts # Envelope, Migration, Step, Issue, Action, ResourceRef, MigrationList +└── resource.ts # list / get / resource — the endpoint map +``` + +- `src/sdk/adapty/index.ts` — `migrations: migrations(http)` in `Adapty`, types re-exported. +- `src/sdk/adapty/errors.ts` — `developerErrorParser` also accepts `{ error: { code, message } }` + (section 4.5: one parser for both services). Existing shapes keep working. + +## Model rules + +- Responses pass through in the server's snake_case, as every other resource does: the envelope + is what `--json` will print. +- An optional field is always present and carries `null` when empty (section 4.6), so the types + spell `| null`, never `| undefined`: the views of the next PRs test `=== null`. +- `kind` and `state` are open sets (section 4.6). Known values are literal branches; one more + branch is `{ kind: string; href?: string }` so a newer WS cannot break an older CLI. Same for + `MigrationState`. Every future `switch` over them has a `default`; this is the opposite of + `SdkErrorKind`, whose set we own. +- No validation rules yet: reads take no input worth checking. + +## Endpoints + +| Method | Path | Returns | +| --- | --- | --- | +| `list()` | `GET /migrations` | `MigrationList` — `{ items, available }` | +| `get(id)` | `GET /migrations/{id}` | `Envelope` | +| `resource(id, name)` | `GET /migrations/{id}/resources/{name}` | `Envelope` | + +## Tests + +- `test/sdk/adapty/migrations/resource.test.ts` — `createScriptedFetch`, as `apps/resource.test.ts` + does: each method hits its URL with the bearer token and returns the body untouched. +- `test/sdk/adapty/errors.test.ts` — new case: the WS body yields `code` and `message`; the three + existing bodies still parse the same. +- `test/fixtures/migration-envelope.json` — the `action_required` example from section 1, so the + views of the next PRs render a real envelope. + +## Done when + +- `pnpm build && pnpm test` green; frozen-legacy and eslint zones untouched (nothing in `src/lib`, + nothing hand-written in `src/commands`). +- `docs/architecture.md`: `migrations` listed among the resources of `createAdapty`. + +## Pin with the WS team before merging + +1. **Path.** We use `https://api-admin.adapty.io/api/v1/developer/migrations` — same host and + base as `apps`/`auth`. Risk: section 4.6 says WS owns its own `/v1/` → `/v2/`, independent of + the developer API's, which reads as its own namespace, not nested under `/developer`. Ask WS + for the literal full URL of `GET /v1/migrations`. +2. **Trailing slash.** We send `/migrations/{id}/` (Django style). Ask WS to confirm it's accepted + as is, not 404 or redirected. +3. **409 after `--yes`.** A confirmed action's `confirm` text was shown for a specific `revision`; + if that `revision` moved before the `POST` lands, silently retrying with the new one would act + on consequences the user never saw. Proposed behavior: a confirmed action's 409 is a hard stop + (exit 4, "migration changed — re-run `status`, then `run --yes` again"), no auto-retry. + An action with no `confirm` keeps the plain reread-and-retry of section 4.1. Ask WS to confirm + this reading. diff --git a/src/sdk/adapty/errors.ts b/src/sdk/adapty/errors.ts index 9314333..8680e2e 100644 --- a/src/sdk/adapty/errors.ts +++ b/src/sdk/adapty/errors.ts @@ -13,6 +13,10 @@ type DeveloperErrorBody = { * * Wired once in createAdapty: which shapes a service speaks is product knowledge, and ASA speaks * another — which is why the transport takes the parser as a parameter. + * + * The Wizard Service sits behind the same transport (section 4.5 of the CLI–WS contract) and + * words its rejection as `{ error: { code, message } }` (`WizardError`) — one shape this parser + * now reads too, alongside the three the developer API already sends. */ export const developerErrorParser: ErrorParser = (_status, body) => { if (typeof body !== 'object' || body === null) { @@ -27,6 +31,14 @@ export const developerErrorParser: ErrorParser = (_status, body) => { return { code: errorCode, message: fieldMessages(errors) ?? errorCode }; } + if (typeof error === 'object' && error !== null) { + const { code, message } = error as { code?: unknown; message?: unknown }; + + if (typeof code === 'string' && code !== '') { + return { code, message: typeof message === 'string' && message !== '' ? message : code }; + } + } + if (typeof error === 'string' && error !== '') { return { code: error, message: error }; } diff --git a/src/sdk/adapty/index.ts b/src/sdk/adapty/index.ts index 6e00691..94f9acb 100644 --- a/src/sdk/adapty/index.ts +++ b/src/sdk/adapty/index.ts @@ -4,10 +4,12 @@ import { accessLevels } from './access-levels.js'; import { apps } from './apps/index.js'; import { auth } from './auth/index.js'; import { developerErrorParser } from './errors.js'; +import { migrations } from './migrations/index.js'; import type { AccessLevelsApi } from './access-levels.js'; import type { AppsApi } from './apps/index.js'; import type { AuthApi } from './auth/index.js'; +import type { MigrationApi } from './migrations/index.js'; import type { Clock } from '../core/clock.js'; import type { RetryAttempt } from '../core/http/index.js'; @@ -16,6 +18,23 @@ export { developerErrorParser } from './errors.js'; export type { AccessLevel, AccessLevelList, AccessLevelsApi } from './access-levels.js'; export type { AppDetail, AppsApi, AppSummary, CreateAppInput, UpdateAppInput } from './apps/index.js'; export type { AuthApi, AuthUser, IssuedToken } from './auth/index.js'; +export type { + Action, + ActionKind, + AvailableFlow, + Envelope, + Issue, + JsonSchema, + Migration, + MigrationApi, + MigrationList, + MigrationState, + Progress, + ResourceRef, + Step, + StepStatus, + WizardError, +} from './migrations/index.js'; export type { PageParams, Paginated, Pagination } from './pagination.js'; /** Exported because the adapter compares the resolved URL with it and warns when they differ. */ @@ -42,6 +61,7 @@ export type Adapty = { accessLevels: AccessLevelsApi; apps: AppsApi; auth: AuthApi; + migrations: MigrationApi; }; /** The assembly point of the developer API: one transport, resources on top of it. */ @@ -61,5 +81,6 @@ export const createAdapty = (options: AdaptyOptions = {}): Adapty => { accessLevels: accessLevels(http), apps: apps(http), auth: auth(http), + migrations: migrations(http), }; }; diff --git a/src/sdk/adapty/migrations/index.ts b/src/sdk/adapty/migrations/index.ts new file mode 100644 index 0000000..8cb5d25 --- /dev/null +++ b/src/sdk/adapty/migrations/index.ts @@ -0,0 +1,23 @@ +/** + * 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 { migrations } from './resource.js'; + +export type { MigrationApi } from './resource.js'; +export type { + Action, + ActionKind, + AvailableFlow, + Envelope, + Issue, + JsonSchema, + Migration, + MigrationList, + MigrationState, + Progress, + ResourceRef, + Step, + StepStatus, + WizardError, +} from './model.js'; diff --git a/src/sdk/adapty/migrations/model.ts b/src/sdk/adapty/migrations/model.ts new file mode 100644 index 0000000..d55d3f7 --- /dev/null +++ b/src/sdk/adapty/migrations/model.ts @@ -0,0 +1,93 @@ +export type MigrationState = 'running' | 'action_required' | 'completed' | 'failed' | 'canceled'; +export type ActionKind = 'input' | 'upload' | 'external'; +export type StepStatus = 'locked' | 'active' | 'done'; + +export type JsonSchema = Record; + +export type Envelope = { + migration: Migration; + steps: Step[]; + issues: Issue[]; + next_actions: Action[]; + available_actions: Action[]; + resources: ResourceRef[]; + result: TResult | null; +}; + +export type MigrationList = { + items: Migration[]; + available: AvailableFlow[]; +}; + +export type AvailableFlow = { + flow: string; + title: string; + detail: string | null; + app: { id: string; name: string }; +}; + +export type Migration = { + id: string; + flow: string; + revision: number; + state: MigrationState; + app: { id: string; name: string } | null; + poll_after_seconds: number; + progress: Progress | null; + summary: string; + created_at: string; + updated_at: string; +}; + +export type Progress = { + done: number; + total: number | null; + unit: string; +}; + +export type Step = { + step_id: string; + title: string; + status: StepStatus; + summary: string | null; +}; + +export type Issue = { + code: string; + title: string; + detail: string | null; + step_id: string | null; + action_id: string | null; +}; + +type ActionBase = { + action_id: string; + step_id: string; + title: string; + detail: string | null; + reads: string[]; + confirm: string | null; +}; + +export type Action = ActionBase & ( + | { kind: 'input'; input_schema: JsonSchema | null } + | { kind: 'upload' } + | { kind: 'external'; href: string } + // A newer WS may send a kind this build does not know; href is kept so it can still be shown + | { kind: string; href?: string } +); + +export type ResourceRef = { name: string; title: string }; + +export type WizardError = { + error: { + code: string; + message: string; + detail: string | null; + next_step: string | null; + retryable: boolean; + retry_after_seconds: number | null; + fields: { path: string; message: string }[]; + request_id: string; + }; +}; diff --git a/src/sdk/adapty/migrations/resource.ts b/src/sdk/adapty/migrations/resource.ts new file mode 100644 index 0000000..83a5b01 --- /dev/null +++ b/src/sdk/adapty/migrations/resource.ts @@ -0,0 +1,17 @@ +import type { Envelope, MigrationList } from './model.js'; +import type { Http } from '../../core/http/index.js'; + +/** + * 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. + */ +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}`); + }, +}); + +export type MigrationApi = ReturnType; diff --git a/test/fixtures/migration-envelope.json b/test/fixtures/migration-envelope.json new file mode 100644 index 0000000..c1222f8 --- /dev/null +++ b/test/fixtures/migration-envelope.json @@ -0,0 +1,45 @@ +{ + "migration": { + "id": "mig_01H9Z", + "flow": "main", + "revision": 3, + "state": "action_required", + "app": { "id": "app_1", "name": "Demo" }, + "poll_after_seconds": 5, + "progress": { "done": 2, "total": 5, "unit": "steps" }, + "summary": "Waiting on your confirmation to migrate paywalls", + "created_at": "2026-09-10T12:00:00Z", + "updated_at": "2026-09-11T09:30:00Z" + }, + "steps": [ + { "step_id": "step_apps", "title": "Apps", "status": "done", "summary": "2 apps migrated" }, + { "step_id": "step_paywalls", "title": "Paywalls", "status": "active", "summary": null } + ], + "issues": [], + "next_actions": [ + { + "action_id": "act_confirm_paywalls", + "step_id": "step_paywalls", + "title": "Migrate paywalls", + "detail": "This will replace the paywalls in the target app.", + "reads": ["step_paywalls"], + "confirm": "This cannot be undone. Continue?", + "kind": "input", + "input_schema": null + } + ], + "available_actions": [ + { + "action_id": "act_open_report", + "step_id": "step_paywalls", + "title": "Open migration report", + "detail": null, + "reads": [], + "confirm": null, + "kind": "external", + "href": "https://app.adapty.io/migrations/mig_01H9Z/report" + } + ], + "resources": [{ "name": "report", "title": "Migration report" }], + "result": null +} diff --git a/test/sdk/adapty/errors.test.ts b/test/sdk/adapty/errors.test.ts index 7ef6d54..f00d50d 100644 --- a/test/sdk/adapty/errors.test.ts +++ b/test/sdk/adapty/errors.test.ts @@ -39,6 +39,21 @@ describe('developerErrorParser', () => { }); }); + it('reads the Wizard Service shape', () => { + expect( + developerErrorParser(409, { + error: { code: 'revision_conflict', message: 'the migration moved on since you read it' }, + }), + ).to.deep.equal({ code: 'revision_conflict', message: 'the migration moved on since you read it' }); + }); + + it('falls back to the code when the Wizard Service sends no message', () => { + expect(developerErrorParser(409, { error: { code: 'revision_conflict' } })).to.deep.equal({ + code: 'revision_conflict', + message: 'revision_conflict', + }); + }); + it('says nothing about a body it does not recognise, leaving the status to speak', () => { expect(developerErrorParser(500, 'Bad Gateway')).to.deep.equal({}); expect(developerErrorParser(500, { detail: 'nope' })).to.deep.equal({}); diff --git a/test/sdk/adapty/migrations/resource.test.ts b/test/sdk/adapty/migrations/resource.test.ts new file mode 100644 index 0000000..a6f0c04 --- /dev/null +++ b/test/sdk/adapty/migrations/resource.test.ts @@ -0,0 +1,55 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { expect } from 'chai'; + +import { createAdapty } from '../../../../src/sdk/adapty/index.js'; +import { createScriptedFetch } from '../../../../src/sdk/core/testing.js'; + +const FIXTURE_PATH = fileURLToPath(new URL('../../../fixtures/migration-envelope.json', import.meta.url)); +const ENVELOPE = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as unknown; + +type Script = Parameters[0]; + +const BASE = 'https://api.example.com/v1'; + +const setup = (script: Script) => { + const scripted = createScriptedFetch(script); + const adapty = createAdapty({ baseUrl: BASE, fetch: scripted.fetch, token: 't' }); + + return { calls: scripted.calls, migrations: adapty.migrations }; +}; + +describe('adapty.migrations', () => { + it('lists migrations and passes the body through untouched', async () => { + const list = { available: [], items: [] }; + const { calls, migrations } = setup([{ body: list }]); + + const result = await migrations.list(); + + expect(calls[0]?.method).to.equal('GET'); + expect(calls[0]?.url).to.equal(`${BASE}/migrations/`); + expect(calls[0]?.headers.get('authorization')).to.equal('Bearer t'); + expect(result).to.deep.equal(list); + }); + + it('reads one migration by id', async () => { + const { calls, migrations } = setup([{ body: ENVELOPE }]); + + 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(result).to.deep.equal(ENVELOPE); + }); + + it('reads a named resource of a migration', async () => { + const { calls, migrations } = setup([{ body: ENVELOPE }]); + + 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(result).to.deep.equal(ENVELOPE); + }); +}); From f7215d8f5022c1fbbf1c108bcb7e9d84b173f3f8 Mon Sep 17 00:00:00 2001 From: German Shteynardt Date: Mon, 14 Sep 2026 16:47:39 +0300 Subject: [PATCH 2/4] 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 8a1edc9..cfabcab 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/`]); + }); }); From 86059fb6a5221922be826c139df7788ba385099f Mon Sep 17 00:00:00 2001 From: German Shteynardt Date: Wed, 16 Sep 2026 12:05:16 +0300 Subject: [PATCH 3/4] feat: added migrations commands --- CLAUDE.md | 3 + README.md | 116 ++++ docs/architecture.md | 32 +- ...-09-11-feat-migration-sdk-resource-plan.md | 87 --- ...-feat-persistent-current-migration-plan.md | 267 ++++++++++ package.json | 2 +- skills/adapty-cli/SKILL.md | 22 +- skills/adapty-cli/references/cli-commands.md | 121 ++++- src/cli/base/adapty/adapty-command.ts | 1 + src/cli/base/adapty/build.ts | 2 + src/cli/base/base-command.ts | 16 + src/cli/commands/apps/get.ts | 2 +- src/cli/commands/apps/list.ts | 2 +- src/cli/commands/apps/update.ts | 2 +- src/cli/commands/auth/login.ts | 1 + src/cli/commands/auth/revoke.ts | 1 + src/cli/commands/auth/status/command.ts | 31 ++ src/cli/commands/auth/status/index.ts | 32 +- src/cli/commands/migrations/close/command.ts | 60 +++ src/cli/commands/migrations/close/index.ts | 40 +- src/cli/commands/migrations/create/command.ts | 77 +++ src/cli/commands/migrations/create/index.ts | 57 +- src/cli/commands/migrations/list/command.ts | 35 ++ src/cli/commands/migrations/list/index.ts | 20 +- .../commands/migrations/list/lib/render.ts | 5 +- src/cli/commands/migrations/run/command.ts | 178 +++++++ src/cli/commands/migrations/run/index.ts | 63 +-- .../migrations/run/lib/action-view.ts | 7 + .../commands/migrations/run/lib/actions.ts | 31 ++ src/cli/commands/migrations/run/lib/https.ts | 7 + src/cli/commands/migrations/run/lib/input.ts | 69 +++ .../commands/migrations/run/lib/open-link.ts | 17 + src/cli/commands/migrations/show/command.ts | 54 ++ src/cli/commands/migrations/show/index.ts | 33 +- .../commands/migrations/show/lib/render.ts | 36 ++ src/cli/commands/migrations/status/command.ts | 60 +++ src/cli/commands/migrations/status/index.ts | 35 +- .../commands/migrations/status/lib/flags.ts | 37 ++ .../commands/migrations/status/lib/notice.ts | 15 + src/cli/commands/migrations/steps/command.ts | 38 ++ src/cli/commands/migrations/steps/index.ts | 23 +- .../commands/migrations/steps/lib/render.ts | 40 ++ src/cli/errors.ts | 21 +- src/cli/errors/wizard.ts | 76 +++ src/cli/flags.ts | 50 -- src/cli/input/app.ts | 25 + src/cli/input/migration.ts | 12 + src/cli/input/pagination.ts | 13 + src/cli/views/envelope.ts | 99 ---- .../views/migrations/envelope/action-block.ts | 45 ++ src/cli/views/migrations/envelope/envelope.ts | 55 ++ .../views/migrations/envelope/issue-block.ts | 19 + src/cli/views/migrations/index.ts | 1 + src/sdk/adapty/index.ts | 30 +- src/sdk/adapty/migrations/action.ts | 33 ++ src/sdk/adapty/migrations/close.ts | 26 + src/sdk/adapty/migrations/create.ts | 21 +- src/sdk/adapty/migrations/index.ts | 10 +- src/sdk/adapty/migrations/model.ts | 5 +- src/sdk/adapty/migrations/resource.ts | 69 ++- src/sdk/adapty/migrations/wait.ts | 16 + test/cli/base.test.ts | 55 ++ test/cli/command-layout.test.ts | 26 +- .../commands/migrations/list/render.test.ts | 2 +- .../commands/migrations/show/render.test.ts | 59 +++ .../commands/migrations/status/notice.test.ts | 46 ++ .../commands/migrations/steps/render.test.ts | 64 +++ test/cli/errors.test.ts | 32 ++ .../views/{ => migrations}/envelope.test.ts | 46 +- test/cli/wizard-errors.test.ts | 105 ++++ test/commands/migrations-exit-codes.test.ts | 122 +++++ test/commands/migrations.test.ts | 499 +++++++++++++++++- test/fixtures/action-input.json | 5 + test/sdk/adapty/caller-headers.test.ts | 52 ++ test/sdk/adapty/migrations/resource.test.ts | 144 ++++- 75 files changed, 3050 insertions(+), 610 deletions(-) delete mode 100644 docs/plans/2026-09-11-feat-migration-sdk-resource-plan.md create mode 100644 docs/plans/2026-09-16-feat-persistent-current-migration-plan.md create mode 100644 src/cli/commands/auth/status/command.ts create mode 100644 src/cli/commands/migrations/close/command.ts create mode 100644 src/cli/commands/migrations/create/command.ts create mode 100644 src/cli/commands/migrations/list/command.ts create mode 100644 src/cli/commands/migrations/run/command.ts create mode 100644 src/cli/commands/migrations/run/lib/action-view.ts create mode 100644 src/cli/commands/migrations/run/lib/actions.ts create mode 100644 src/cli/commands/migrations/run/lib/https.ts create mode 100644 src/cli/commands/migrations/run/lib/input.ts create mode 100644 src/cli/commands/migrations/run/lib/open-link.ts create mode 100644 src/cli/commands/migrations/show/command.ts create mode 100644 src/cli/commands/migrations/show/lib/render.ts create mode 100644 src/cli/commands/migrations/status/command.ts create mode 100644 src/cli/commands/migrations/status/lib/flags.ts create mode 100644 src/cli/commands/migrations/status/lib/notice.ts create mode 100644 src/cli/commands/migrations/steps/command.ts create mode 100644 src/cli/commands/migrations/steps/lib/render.ts create mode 100644 src/cli/errors/wizard.ts delete mode 100644 src/cli/flags.ts create mode 100644 src/cli/input/app.ts create mode 100644 src/cli/input/migration.ts create mode 100644 src/cli/input/pagination.ts delete mode 100644 src/cli/views/envelope.ts create mode 100644 src/cli/views/migrations/envelope/action-block.ts create mode 100644 src/cli/views/migrations/envelope/envelope.ts create mode 100644 src/cli/views/migrations/envelope/issue-block.ts create mode 100644 src/cli/views/migrations/index.ts create mode 100644 src/sdk/adapty/migrations/action.ts create mode 100644 src/sdk/adapty/migrations/close.ts create mode 100644 src/sdk/adapty/migrations/wait.ts create mode 100644 test/cli/commands/migrations/show/render.test.ts create mode 100644 test/cli/commands/migrations/status/notice.test.ts create mode 100644 test/cli/commands/migrations/steps/render.test.ts rename test/cli/views/{ => migrations}/envelope.test.ts (62%) create mode 100644 test/cli/wizard-errors.test.ts create mode 100644 test/commands/migrations-exit-codes.test.ts create mode 100644 test/fixtures/action-input.json create mode 100644 test/sdk/adapty/caller-headers.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 397c5f8..f7c4b03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,9 @@ src/ # capture is the caller's job, the CLI only builds the URL; # validate — advisory publishability check, always 200, exits non-zero when invalid); # media/ (upload — multipart image upload, returns CDN url to reference in a config) + migrations/ # create, list, status (--wait), steps, show, run, close — a thin client of the + # Wizard Service: the flow lives on the server, every answer is one envelope. + # Implementation in src/cli/commands/migrations, contract in docs/plans segments/ # list, get access-levels/ # list, get, create, update asa/ # Apple Search Ads: whoami, connect, orgs, apps, campaigns, ad-groups, keywords, diff --git a/README.md b/README.md index faeb5b7..0ae78b7 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,121 @@ adapty access-levels create --app UUID [flags] adapty access-levels update --app UUID ACCESS_LEVEL_ID [flags] ``` +### Migrations + +Manage migrations into Adapty: catalog, transactions and store events. The server provides the +steps, available actions and input schemas; use the current response to choose what to do next. + +#### Start or find a migration + +```sh +adapty migrations list +adapty migrations create --name "Acme Fitness" --json +``` + +`--name` starts a catalog migration into a new Adapty app. For an existing app, choose a flow and +its app from `list` and pass both flags (replace `FLOW` and `APP_ID` with those values): + +```sh +adapty migrations create --flow FLOW --app APP_ID --json +``` + +These are alternative creation modes: `--name` cannot be combined with `--flow` or `--app`. +Creation starts the flow; the returned JSON contains its ID in `migration.id`. + +Commands operating on a migration require `-m, --migration` or `ADAPTY_MIGRATION`. An explicit +flag overrides the environment variable. The CLI does not select a migration automatically. +Replace `mig_7x2` below with an ID from `create` or `list`; agents should pass `-m` explicitly. + +#### Inspect and run an action + +```sh +adapty migrations status -m mig_7x2 --json +adapty migrations steps -m mig_7x2 +adapty migrations show -m mig_7x2 +``` + +`steps` shows the checklist. `show` lists readable resource names; pass one as `RESOURCE` to read +its data. Choose `ACTION_ID` from `next_actions` or `available_actions` in the status response. +For an input action, read its `reads` resources and prepare a JSON object matching `input_schema`: + +```sh +adapty migrations show RESOURCE -m mig_7x2 +adapty migrations run ACTION_ID -m mig_7x2 --input-file ./decisions.json --json +``` + +Alternatively, use `--input-file -` with stdin (`< ./decisions.json`), or `--input '{}'` if the +schema allows an empty object. Omitting input also sends `{}`. Use only one input option. + +Read the action's full `confirm` text in `status` or `status --json` before adding `--yes`. Input actions +requiring confirmation exit **6** without it; there is no interactive prompt. The CLI reads +status before refusing, but does not send the action request. After an action, check status again. +After `revision_conflict`, read status and review the current action, input and confirmation before retrying. + +For an `external` action, complete the step in the browser, then check status: + +```sh +adapty migrations run ACTION_ID -m mig_7x2 --no-browser +``` + +`--no-browser` prints the action details and link. By default, the browser opens in an interactive +terminal; pipes and `--json` require `--open`. `BROWSER=none` disables opening, and only HTTPS +links are supported. External actions do not send an action request; their JSON response reflects +the migration before the browser step. Passing `--input` or `--input-file` to an external action +returns a usage error (exit 2). File uploads are marked unsupported in `status`: use the dashboard or an +offered Cloud Export action. `list --all` is also unsupported. + +#### Wait for a change or close a migration + +```sh +adapty migrations status -m mig_7x2 --wait --timeout 5m --json +``` + +`--wait` returns when the revision changes, the state is no longer `running`, or the polling budget +cannot accommodate another pause. `--timeout` requires `--wait`: default 120s, range 1–600s, +with formats such as `300`, `300s` or `5m`. In-flight requests and retries may take longer. +Exit **0** means the request succeeded, even when the migration is still running or has failed; +inspect `migration.state`. Progress goes to stderr; Ctrl+C exits **130**. + +To permanently mark a migration as completed: + +```sh +adapty migrations close -m mig_7x2 --outcome finish --yes +``` + +To abandon it instead: + +```sh +adapty migrations close -m mig_7x2 --outcome cancel --yes +``` + +Both outcomes require `--yes`; there is no confirmation prompt. + +#### JSON output + +With `--json`, `list` returns `{items, available}`. All other migration commands return the full +migration response (the envelope): + +| Data | JSON field | +| --- | --- | +| Migration ID, state and revision | `migration` | +| Next and optional actions | `next_actions`, `available_actions` | +| Checklist | `steps` | +| Readable resource names | `resources` | +| Data from `show RESOURCE` | `result` (may be `null`) | + +Without `--json`, `show RESOURCE` prints just the resource data as JSON, or a message if empty. +Wizard Service errors preserve `detail`, `fields`, `next_step`, `request_id`, `retryable` and +`retry_after_seconds` under `error` in JSON when supplied. Human output includes details, field +errors, the next step and request ID. Retry metadata in the response does not change automatic retry behavior. + +To extract specific fields from the full response, these examples require the separate `jq` utility: + +```sh +adapty migrations steps -m mig_7x2 --json | jq '.steps' +adapty migrations show RESOURCE -m mig_7x2 --json | jq '.result' +``` + ### Apple Search Ads Apple Search Ads commands live under `adapty asa` and talk to the ASA service rather than the Developer @@ -429,6 +544,7 @@ the flags and the size ceiling. | `ADAPTY_TOKEN` | Override stored auth token | | `ADAPTY_API_URL` | Override Developer API base URL (default: `https://api-admin.adapty.io/api/v1/developer`) | | `ADAPTY_ASA_API_URL` | Override Apple Search Ads base URL (default: `https://api-asa-admin.adapty.io/api/v1/cli`) | +| `ADAPTY_MIGRATION` | Migration id used by `adapty migrations` when `-m` is not given | | `ADAPTY_APP_URL` | Override dashboard base URL (default: `https://app.adapty.io`). Used by `flows config preview` for the fixed `/flow-preview` route, and by `auth login` to keep the verification link on that host | The two API URLs are independent: pointing `ADAPTY_API_URL` at a staging host leaves `adapty asa` on the ASA diff --git a/docs/architecture.md b/docs/architecture.md index e6db0b6..918f829 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,11 +99,32 @@ text or JSON. authorization" is expressed in what a command extends, not re-checked inside `run()` bodies. - `errors.ts` — the single `SdkError` → CLI error mapping. The switch has no default, so a new error kind fails to compile until it is given a message and an exit code. -- `flags.ts` — shared flags and args (app id UUID, pagination) and the one place flag names meet - sdk field names. +- `input/` — shared flags and args (app id UUID, pagination, migration id), one module per concern, + and the one place flag names meet sdk field names. - `views/` — plain functions, value in, string out. - `commands/` — one class per command. +### The shape of a command + +A command is one file for as long as it fits in one: `commands/apps/get.ts`. When it outgrows that, +it becomes a directory of three parts, and `test/cli/command-layout.test.ts` holds them apart: + +```text +commands/migrations/status/ +├── command.ts # the class oclif runs +├── index.ts # the door: `export { default } from './command.js';` and nothing else +└── lib/ # this command's own helpers, private to it +``` + +The door exists because the re-export under `src/commands` names a directory, and Node.js ESM +resolves a directory only through its `index.js`. Keeping the class out of it means one spelling for +"where does this command begin": `command.ts`, whether or not the directory has grown a `lib/`. + +A `lib/` belongs to the command beside it, never to a topic: a helper two commands need is not a +helper any more, and moves to `cli/input` or `cli/views` (adapter) or to `sdk/adapty` (product). +Eslint blocks the import of a stranger's `lib/`; the layout test covers what a specifier pattern +cannot see. + ### Command bases and imports ```text @@ -164,10 +185,15 @@ quietly changing what users parse. | New endpoint | a resource module in `sdk/adapty` | | New rule ("X is required when Y") | next to the operation it constrains, in `sdk/adapty` | | New command | `cli/commands/...` + a re-export in `src/commands/...` | -| New flag | the command, or `cli/flags.ts` if shared | +| New flag or argument | the command (or its `lib/flags.ts` for complex parsing); shared input belongs in `cli/input/.ts` | | New error kind | `sdk/core/errors.ts` + `cli/errors.ts` (the compiler insists) | | Adapty session environment variables | `cli/base/adapty/openSession.ts` | +Shared input modules group declarations, private parsers and SDK parameter mapping by concern +(for example, `cli/input/pagination.ts`). Import each module directly; there is no barrel index. +Keep command-specific input local until another command needs it, and export only what consumers +use. Global flags belong to the base command; shared subsets stay composable objects. + ## Migration state The pre-sdk stack (`src/lib` + the commands written against it) is still there and still serves diff --git a/docs/plans/2026-09-11-feat-migration-sdk-resource-plan.md b/docs/plans/2026-09-11-feat-migration-sdk-resource-plan.md deleted file mode 100644 index 570eb52..0000000 --- a/docs/plans/2026-09-11-feat-migration-sdk-resource-plan.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: "feat: migrations resource in sdk/adapty (PR 1 of the migration topic)" -type: feat -status: planned -date: 2026-09-11 -contract: https://app.notion.com/p/CLI-Wizard-Service-3d51ca4355c3812c9fa3e2f2f8a30f3c ---- - -# Migrations resource in `sdk/adapty` - -First step towards `adapty migration …`. Read-only, sdk only, no command yet. The CLI is a thin -client of the Wizard Service (WS): the whole flow lives on the server and every answer has one -shape, the envelope. This PR teaches the sdk that shape and the three GET endpoints. - -## Scope - -In: - -- contract types from section 5 of the doc -- `list()`, `get(id)`, `resource(id, name)` -- `migrations` hung off `createAdapty`, on the same transport and token as `apps` -- the developer error parser reads the WS error body - -Out (each is its own PR): any command, `--wait`, `create`, `run`, `close`, uploads, docs. - -## Files - -``` -src/sdk/adapty/migrations/ -├── index.ts # the door: re-exports only -├── model.ts # Envelope, Migration, Step, Issue, Action, ResourceRef, MigrationList -└── resource.ts # list / get / resource — the endpoint map -``` - -- `src/sdk/adapty/index.ts` — `migrations: migrations(http)` in `Adapty`, types re-exported. -- `src/sdk/adapty/errors.ts` — `developerErrorParser` also accepts `{ error: { code, message } }` - (section 4.5: one parser for both services). Existing shapes keep working. - -## Model rules - -- Responses pass through in the server's snake_case, as every other resource does: the envelope - is what `--json` will print. -- An optional field is always present and carries `null` when empty (section 4.6), so the types - spell `| null`, never `| undefined`: the views of the next PRs test `=== null`. -- `kind` and `state` are open sets (section 4.6). Known values are literal branches; one more - branch is `{ kind: string; href?: string }` so a newer WS cannot break an older CLI. Same for - `MigrationState`. Every future `switch` over them has a `default`; this is the opposite of - `SdkErrorKind`, whose set we own. -- No validation rules yet: reads take no input worth checking. - -## Endpoints - -| Method | Path | Returns | -| --- | --- | --- | -| `list()` | `GET /migrations` | `MigrationList` — `{ items, available }` | -| `get(id)` | `GET /migrations/{id}` | `Envelope` | -| `resource(id, name)` | `GET /migrations/{id}/resources/{name}` | `Envelope` | - -## Tests - -- `test/sdk/adapty/migrations/resource.test.ts` — `createScriptedFetch`, as `apps/resource.test.ts` - does: each method hits its URL with the bearer token and returns the body untouched. -- `test/sdk/adapty/errors.test.ts` — new case: the WS body yields `code` and `message`; the three - existing bodies still parse the same. -- `test/fixtures/migration-envelope.json` — the `action_required` example from section 1, so the - views of the next PRs render a real envelope. - -## Done when - -- `pnpm build && pnpm test` green; frozen-legacy and eslint zones untouched (nothing in `src/lib`, - nothing hand-written in `src/commands`). -- `docs/architecture.md`: `migrations` listed among the resources of `createAdapty`. - -## Pin with the WS team before merging - -1. **Path.** We use `https://api-admin.adapty.io/api/v1/developer/migrations` — same host and - base as `apps`/`auth`. Risk: section 4.6 says WS owns its own `/v1/` → `/v2/`, independent of - the developer API's, which reads as its own namespace, not nested under `/developer`. Ask WS - for the literal full URL of `GET /v1/migrations`. -2. **Trailing slash.** We send `/migrations/{id}/` (Django style). Ask WS to confirm it's accepted - as is, not 404 or redirected. -3. **409 after `--yes`.** A confirmed action's `confirm` text was shown for a specific `revision`; - if that `revision` moved before the `POST` lands, silently retrying with the new one would act - on consequences the user never saw. Proposed behavior: a confirmed action's 409 is a hard stop - (exit 4, "migration changed — re-run `status`, then `run --yes` again"), no auto-retry. - An action with no `confirm` keeps the plain reread-and-retry of section 4.1. Ask WS to confirm - this reading. diff --git a/docs/plans/2026-09-16-feat-persistent-current-migration-plan.md b/docs/plans/2026-09-16-feat-persistent-current-migration-plan.md new file mode 100644 index 0000000..1c66c62 --- /dev/null +++ b/docs/plans/2026-09-16-feat-persistent-current-migration-plan.md @@ -0,0 +1,267 @@ +--- +title: "feat: persistent currentMigrationId in the CLI" +type: feat +status: planned +date: 2026-09-16 +--- + +# Persistent current migration + +Add a saved migration selection so a person can choose a migration once and continue after +restarting the terminal. Keep explicit IDs available for scripts and concurrent work. The local +selection is a CLI convenience; migration state and allowed actions remain owned by the server. + +This plan describes implementation to be done, not functionality already present. + +## Current implementation + +- The published topic is `adapty migrations` (plural). Keep that spelling. +- `src/cli/input/migration.ts` requires `--migration` / `-m`, with `ADAPTY_MIGRATION` as an alternative. +- `status`, `show`, `steps`, `run` and `close` pass the parsed ID directly to the SDK. +- `create` returns an envelope and prints a continuation command with `-m`; it saves no selection. +- `src/sdk/core/session.ts` stores only credentials in `config.json`. Saving rewrites that file; + clearing removes it. Both CLI stacks depend on that format. +- `auth logout` clears the stored session; `auth revoke` clears it only after successful revocation + of a matching token. `ADAPTY_TOKEN` may override a different stored session. +- Session users currently have `email` and `name`, but no typed, stable account/company ID. + +## User-facing behavior + +```sh +adapty migrations list +adapty migrations use mig_abc123 +adapty migrations current +adapty migrations status +adapty migrations show report +adapty migrations status -m mig_other +adapty migrations unuse +``` + +### `migrations use ` + +1. Parse a required, non-empty ID argument and require authentication. +2. Call `migrations.get(id)` with the effective token and API URL to verify access. +3. Save the returned `migration.id` with the scope described below, replacing the previous selection. +4. Print the selected ID only after persistence succeeds. + +The command neither starts nor modifies a migration. Completed, canceled and failed migrations +can be selected for inspection. A failed GET or local write preserves the previous selection. +There is no extra confirmation prompt. The argument is the selection to save even when +`ADAPTY_MIGRATION` is set; warn on stderr that the environment variable still overrides it. + +JSON result: `{ "currentMigrationId": "mig_abc123" }`. + +### `migrations current` + +Show the ID that commands without `-m` would use, with its source: `ADAPTY_MIGRATION` or the saved +context. This is a local read, with no GET, token validation request or write. An expired token +therefore does not prevent inspecting its saved selection. The command does not accept `-m`. + +JSON result: `{ "currentMigrationId": "mig_abc123", "source": "context" }`, where source is +`"env"`, `"context"` or `null`. With no applicable selection, return +`{ "currentMigrationId": null, "source": null }`, print an instruction to run `migrations use `, +and exit 0. Without an effective token, a stored selection is inapplicable; an explicit environment +ID can still be displayed. Never expose the token or its fingerprint in command output. + +### `migrations unuse` + +Remove the saved selection, without authentication or network access. This is idempotent and +works even if the context is malformed or belongs to a different scope. It does not change the +parent shell environment: if `ADAPTY_MIGRATION` is set, explain that it still supplies an ID and +must be unset in the shell. Return `{ "currentMigrationId": null }`; this describes saved state, +not the effective environment override. + +### `migrations create` + +After successful creation, save the returned ID as current, including with `--json` and in a pipe. +Add `--no-select` to opt out for scripts and concurrent workflows. Do not make persistence depend +on whether a terminal is attached. A failed API request never changes the saved selection. + +Keep the existing envelope as the JSON result. If creation succeeds but saving context fails, +return the successful envelope and warn on stderr with the created ID and the explicit `-m` +continuation command. Do not turn this into an apparent failed creation that invites a duplicate +POST. Do not retry creation to repair a local write failure. Also explain an active +`ADAPTY_MIGRATION` override when saving succeeds. + +## Resolving an ID + +Use one shared resolver for `status`, `show`, `steps`, `run` and `close`: + +1. Explicit `--migration` / `-m`. +2. Non-empty `ADAPTY_MIGRATION`. +3. `currentMigrationId` from a context matching the effective session scope. +4. Usage error (exit 2, code `migration_required`) with instructions for `use`, `list` and `-m`. + +Remove `required: true` from the shared flag. Resolve the environment in the CLI resolver instead +of relying on the flag's `env` fallback, so the source remains explicit and `current` can reuse +exactly the same rules. Inject the environment value into the resolution logic for tests; never +read environment variables in the SDK. Update flag help to advertise all three sources. + +An empty environment value counts as absent; explicitly empty or whitespace-only IDs are usage +errors, not permission to fall through to a different migration. Do not invent a Salesforce-like +ID length/prefix restriction for opaque migration IDs. + +Read the context lazily: explicit flag/env IDs must work even if `context.json` is corrupted or +unreadable. They do not update the saved selection. Never guess an ID from `migrations list`. +Keep parsing and command-specific input validation before resolution and network requests. +When both an ID and credentials are absent, keep the actionable missing-ID usage error; when an +ID is supplied but credentials are absent, keep the existing auth error. + +Resolve once per command invocation. In particular, `run` and `close` must use the same captured +ID for the initial GET and subsequent POST, and `status --wait` must keep that ID for every poll. +A `use` in another terminal must not redirect an operation that has already resolved its target. + +Before a `run` or `close` mutation using saved context, identify the target ID on stderr. Existing +`--yes`, revision checks and error handling stay in force. Existing migration JSON envelopes and +exit codes remain unchanged apart from the new local context errors described below. + +## Storage and scope + +Keep credentials in the existing `config.json`. Put the selection in `context.json` beside it, +using oclif's `config.configDir`; do not hardcode a home path or introduce project-directory lookup. +Do not add `currentMigrationId` to SDK `Session` or `SessionStore`. + +One saved selection is sufficient for this version: + +```ts +type MigrationContext = { + version: 1; + apiUrl: string; + tokenFingerprint: string; + currentMigrationId: string; +}; +``` + +Scope by normalized effective API base URL plus SHA-256 of the effective token. Normalize the URL +consistently with the SDK's base URL handling; retain the base path and distinguish environments. +Never write the raw token into the context. A fingerprint is only a local matching key, not proof +of authorization; the server still authorizes every operation. + +This deliberately conservative scope uses the identity information available today. Do not infer +account identity from email or decode an opaque token. A different token, including a different +`ADAPTY_TOKEN`, cannot silently inherit the saved selection. If the environment contains exactly +the stored token, it is the same scope regardless of its source. + +A scope mismatch makes the saved selection inapplicable, without deleting or overwriting it on a +read. Returning to the same token/API URL makes it available again. `use` or selecting a newly +created migration explicitly replaces the single record. An app ID is not part of the scope: a +new catalog migration can have `app: null`, and the migration ID already identifies the target. + +**First-version limitation:** issuing a new token requires selecting the migration again, even +for the same human account. Preserving selection across reauthentication requires a documented, +stable server account/company identity; it is deferred rather than guessed from the current +`email`/`name` fields. Multiple saved profiles are also outside this change. + +Implement the file store in the CLI layer, with the directory passed in. Requirements: + +- Missing file means no selection. Malformed JSON, invalid shape, unsupported version and I/O + failures are distinct from absence; report a context-specific error with the path and recovery + through `migrations unuse` / `migrations use `. +- Use `CliError` with exit 1 and a stable `migration_context_invalid` or + `migration_context_io` code. Do not reuse the SDK storage mapper that tells users to log in. +- Write through a uniquely named temporary file in the same directory, then rename atomically. + Use mode 0600 for the file and private directory permissions on creation; clean up temporary + files after failures. A failed replacement must leave the previous valid file intact. +- `use` can replace malformed context without first reading it; `unuse` can remove it directly. +- Simultaneous selections are last-successful-write-wins; readers never see partial JSON. + Document that the selection is shared across terminals. Scripts should use `-m` or an + environment ID, and `create --no-select` when they must not change the shared default. + +## Selection lifecycle and authentication + +| Event | Context behavior | +| --- | --- | +| Terminal closes or a CLI process exits | Preserve | +| Successful `use ` | Replace after GET validation | +| Successful `create`, without `--no-select` | Replace with the created ID | +| Explicit `-m` or `ADAPTY_MIGRATION` for an operation | Override for this invocation; do not save | +| `unuse` | Remove saved context, even without a session | +| `completed`, `canceled`, `failed`, or successful `close` | Preserve for status/report inspection | +| Network failure, 401, 403, or 404 | Preserve; do not infer permanent deletion | +| Different effective token or API URL | Ignore mismatching context; do not mutate on read | +| Successful login with a new token | Old context cannot match; require a new selection | +| `auth logout` | Clear stored credentials and the local context, including orphaned context | +| Successful `auth revoke` | Clear context only if its fingerprint matches the revoked token | +| Failed/canceled login or failed revoke | Preserve context | + +`logout` clears context even if `session.store.load()` finds no stored session. Attempt both local +cleanup operations if either fails, and report partial cleanup as a local error. Preserve the +existing warning that a parent-shell `ADAPTY_TOKEN` remains set; likewise, CLI commands cannot +unset `ADAPTY_MIGRATION` in that shell. + +`revoke` remains server-first. After server success, keep the existing matching-token rule for +removing `config.json`, and independently remove context belonging to the revoked token. A +revoked environment token must not clear context belonging to a different stored token. Because +this is one saved record, cleanup can compare fingerprints without assuming that the current API +URL was the URL at selection time. If local cleanup fails, make clear that revocation succeeded; +do not automatically repeat the server operation. + +`login` continues saving only credentials. The scope check makes old context inactive after a +token change, so login need not rewrite a second file or clear another token's selection. The +context remains recoverable with `unuse` or replaceable with `use`. Logging out removes it +explicitly. Existing auth JSON result shapes remain unchanged on success. + +There is no migration-deletion command in scope and no automatic cleanup on a generic 404. If a +future command permanently deletes a migration, clear context only after confirmed success and +only when the saved ID and scope match the deleted migration. + +## Implementation sequence + +1. **CLI context store and resolver.** Add `src/cli/context/migration.ts` for the record, scope, + file operations and shared resolution. Keep the module small; split only if implementation + size justifies it. Keep flag/argument declarations in `src/cli/input/migration.ts`. Expose the + already resolved session to the resolver without adding migration policy to `BaseCommand` or + the SDK; if needed, provide a protected non-auth-enforcing session accessor in `AdaptyCommand` + while preserving its existing authenticated accessor. +2. **Selection commands.** Add `use`, `current` and `unuse` under `src/cli/commands/migrations`. + Simple commands can be single files; directory commands use `command.ts` plus an `index.ts` + re-export. Add only discovery re-exports under `src/commands/migrations`. `use` requires an + authenticated session; `current` and `unuse` extend `BaseCommand` and do local work. +3. **Existing commands.** Integrate the resolver into `status`, `show`, `steps`, `run` and `close`. + Pass the captured ID through the run command's helpers instead of reading an optional flag + again. Integrate selection into `create` and add `--no-select`. Preserve envelope output, + action confirmation, optimistic concurrency and polling behavior. +4. **Auth cleanup.** Extend CLI `logout` and `revoke` orchestration, keeping SDK credential storage + and legacy compatibility unchanged. Cover the env-token override cases explicitly. +5. **Documentation.** Update README migration usage, command help/examples, the migration inventory + in `CLAUDE.md`, and `skills/adapty-cli/references/cli-commands.md`. Update any stateless/explicit-ID + claims in `skills/adapty-cli/SKILL.md`. Describe CLI context ownership in `docs/architecture.md`. + Keep this plan as planned until the implementation and checks are complete. + +No new endpoint, SDK resource method, third-party dependency, singular topic alias, interactive +selection menu, automatic retry, migration upload or migration-state change is required. + +## Verification + +Use isolated temporary config directories and scripted HTTP responses. Update +`test/helpers/isolate-config.ts` to clean both files and reset `ADAPTY_MIGRATION` as well as auth +environment values between tests. Restore any environment values changed by individual tests. + +- Store: absent/valid/malformed/version-mismatched context, permission/I/O errors, replacement + failure preserving the old record, idempotent removal, and scope comparison for API URL/token. +- Resolution: flag > env > matching context; empty and invalid inputs; mismatch; no selection; + malformed context bypassed by an explicit ID; no unexpected GET/list calls. +- Commands: `use` verifies before saving and preserves selection on failure; `current` reports + the effective source without requests; `unuse` works offline and explains env overrides. +- Persistence: select in one process, read/use in a fresh process with the same config directory. +- Operations: all five existing target commands use the resolved ID; changing context between + GET and POST or during polling does not change the in-flight target. +- Creation: default selection, `--no-select`, unchanged JSON envelope, and successful server + creation plus failed local persistence produces a warning without a second POST. +- Lifecycle: closed/failed migrations remain inspectable; network/auth/404 errors preserve the + file; logout removes orphaned context; revoke handles matching and different env tokens; + failed revoke preserves both files; reauthentication cannot reuse another token's selection. +- CLI compatibility: retain flag/env behavior, JSON envelopes and confirmation/exit semantics. + Update the subprocess missing-ID test in `test/commands/migrations-exit-codes.test.ts` to expect + the resolver's exit-2 error instead of oclif's former required-flag wording. Verify local context + errors in both human and JSON modes without leaking fingerprints or tokens. +- Run `pnpm build`, `pnpm test` (includes lint) and `pnpm check:agent-docs` after implementation. + Keep frozen-legacy, command-layout and SDK/CLI import-boundary checks intact. + +## Done when + +A user can create or select a migration, reopen the terminal and run `migrations status` without +remembering the ID. `current` explains the effective selection, explicit flag/env IDs remain +predictable, account/environment changes cannot silently reuse it, and logout/revoke perform +only the intended cleanup. Credentials keep their existing format and the SDK remains unaware +of the CLI's saved selection. diff --git a/package.json b/package.json index cfabcab..2acb67f 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "description": "List segments" }, "migrations": { - "description": "Migrate from RevenueCat: catalog, transactions and store events" + "description": "Manage migrations: 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/skills/adapty-cli/SKILL.md b/skills/adapty-cli/SKILL.md index 7dbdac3..0df91e6 100644 --- a/skills/adapty-cli/SKILL.md +++ b/skills/adapty-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: adapty-cli -description: Use when setting up or managing Adapty in-app subscriptions, paywalls, or placements via CLI. +description: Use when setting up or managing Adapty in-app subscriptions, paywalls, placements, or migrations via CLI. --- # Adapty CLI Skill @@ -148,14 +148,26 @@ After showing any option's guide, **loop back** — ask "What's next?" again wit For users who already have an Adapty app and want to manage entities, see `references/cli-commands.md` for the full command reference. Key notes: -- All resource commands (except `apps`) require `--app ` (UUID) + +- Product, paywall, placement, access-level and segment commands require `--app ` (UUID) - `apps get ` and `apps update ` use a positional arg (no `--app` flag) -- All other `get`/`update` commands use a positional arg for the resource ID **plus** `--app` flag -- All `list` commands support `--page` and `--page-size` +- For products, paywalls, placements and access levels, `get`/`update` use a positional resource ID **plus** `--app` +- Lists of these entities and apps support `--page` and `--page-size`; `migrations list` does not - All commands support `--json` -- Use `--title` (not `--name`) for all entities +- Use `--title` for apps, products, paywalls, placements and access levels; `migrations create` uses `--name` - Use `--apple-bundle-id` / `--google-bundle-id` (not ios/android) +### Migrations + +For migrations into Adapty, read [Migrations in the command reference](references/cli-commands.md#migrations). +The server supplies the available flows, action IDs and input schemas. + +- Create with `--name` for a new app, or `--flow` and `--app` together for an existing app, as offered by `migrations list` +- Pass `-m` explicitly when operating on a migration; the CLI accepts `ADAPTY_MIGRATION` but never selects one automatically +- Use `status --json` to read the current action's `reads`, `input_schema` and `confirm` before running it +- Review `confirm` before passing `--yes`; migration commands do not show interactive confirmation prompts +- Check `migration.state` after each action or wait; exit 0 does not mean the migration completed + --- ## Apple Ads diff --git a/skills/adapty-cli/references/cli-commands.md b/skills/adapty-cli/references/cli-commands.md index 3a52043..25e70cd 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -1,7 +1,8 @@ # CLI Command Reference -All resource commands (except `apps`) require `--app ` (UUID). -All `list` commands support `--page` (default 1) and `--page-size` (default 20, max 100). +Product, paywall, placement, access-level and segment commands require `--app ` (UUID). +Lists of these entities and apps support `--page` (default 1) and `--page-size` (default 20, max 100). +Migrations use their own IDs and have no list pagination; ASA scope and pagination are described below. All commands support `--json` for machine-readable output. ## Auth @@ -94,6 +95,122 @@ Read-only. Response shape: `{id, title, description}`. Filters are not exposed v | `access-levels create` | `--app`, `--sdk-id`, `--title` | | `access-levels update ` | `--app`, `--title` | +## Migrations + +Manage migrations into Adapty: catalog, transactions and store events. The server provides the +available flows, steps, actions and input schemas. Choose values from the current response. + +| Command | Flags | +|---------|-------| +| `migrations create` | `--name ` (main flow), or `--flow --app ` | +| `migrations list` | — | +| `migrations status` | `-m`, `--wait`, `--timeout ` (needs `--wait`, default 120s, range 1–600s) | +| `migrations steps` | `-m` | +| `migrations show []` | `-m`; no argument lists what can be read | +| `migrations run ` | `-m`, `--input ` \| `--input-file `, `--yes`, `--open` \| `--no-browser` | +| `migrations close` | `--outcome finish\|cancel`, `--yes` (required), `-m` | + +**Scope and creation.** `create --name` starts a catalog migration into a new app. For an existing +app, choose a flow and its app from `list.available`, then pass `--flow` and `--app` together. +These modes are mutually exclusive. Creation starts the flow and returns its ID in `migration.id`. +For `status`, `steps`, `show`, `run` and `close`, pass `-m ` explicitly. The CLI also accepts +`ADAPTY_MIGRATION`; the flag takes precedence. There is no automatic migration selection. + +**Inspect before acting.** Use `status --json` to read `next_actions` and `available_actions`. +Select an action relevant to the task; optional actions are not a queue to execute. `steps` is a +checklist, not a source of action IDs. `show` without a resource lists readable names; read the +resources named in the chosen action's `reads` before preparing input. + +**Input actions.** The input must be a JSON object matching the current `input_schema`. Use one +of `--input`, `--input-file PATH`, or `--input-file -` for stdin. Omitting input sends `{}`. +Review the full `confirm` text before adding `--yes`. Without it, an input action requiring +confirmation exits **6** with that text. There is no interactive prompt; the CLI reads status +but does not send the action request. After an action, inspect the returned state and actions again. + +**External actions.** Complete the browser step, then read status again. The CLI reads status +and prints the action details and link without sending an action request. The browser opens by +default in an interactive terminal; pipes and `--json` require `--open`. `--no-browser` suppresses +opening, `BROWSER=none` disables it, and only HTTPS links are supported. External actions reject +`--input` and `--input-file` with exit 2 (`action_input_unsupported`); stdin is rejected before +being read. Upload actions are marked unsupported in `status`; use the dashboard or an offered Cloud Export action. + +**Waiting.** `status --wait` returns when the revision changes, the state is no longer `running`, +or the polling budget cannot accommodate another pause. `--timeout` accepts integer seconds or +minutes (`300`, `300s`, `5m`); it does not interrupt in-flight requests or retries. Progress goes +to stderr; Ctrl+C exits **130**. Exit **0** means a successful request, including when the returned +state is `running` or `failed`. Branch on `migration.state`: + +- `running`: wait again within the task's overall time budget. +- `action_required`: inspect and choose an offered action. +- `completed` or `canceled`: stop. +- `failed`: inspect issues and offered recovery actions. +- Unknown state: the CLI prints an upgrade hint and returns successfully; `--wait` stops polling. + Inspect the response and update the CLI before continuing. + +**Unknown action kinds.** With no `href`, `run` prints the action details and an upgrade hint, +returns exit **0**, and sends no action request, even with `--yes`. It does not read stdin. +With an `href`, the CLI hands over the link using the external-action rules above. +Under `--json`, it returns the original envelope without adding fields or text. +Known `upload` actions remain unsupported and return exit **2**; use the dashboard or Cloud Export. + +**Recovery and closure.** After `revision_conflict`, read status and reconsider the action, input +and confirmation before retrying. Exit **2** can indicate an unavailable action, unsupported upload +or invalid input; inspect the error rather than inferring its cause from the exit code alone. +HTTP **403** returns auth exit **3**, preserving the server's message and diagnostics. +Transport retries reuse an idempotency key within one invocation; a new CLI invocation creates a +new key. After an unclear write result, inspect the migration (or `list` after `create`) before +repeating the write. `close --outcome finish` marks completed; `cancel` abandons the migration. +Both permanently close it and require `--yes`, with no interactive prompt. `list --all` is unsupported. + +Wizard Service JSON errors retain `error.detail`, `error.fields` (each with `path` and `message`), +`error.next_step`, `error.request_id`, `error.retryable` and `error.retry_after_seconds` when supplied. +Use these to diagnose the request and plan recovery. Automatic retries still use HTTP status and +the `Retry-After` header; the body fields do not change that policy. + +### Migration JSON and examples + +| Command with `--json` | Response / relevant fields | +| --- | --- | +| `list` | `{items, available}` | +| `create`, `status`, input `run`, `close` | Full migration response (envelope), including `migration` and actions | +| `steps` | Envelope; checklist in `steps` | +| `show` without a resource | Envelope; readable names in `resources` | +| `show RESOURCE` | Envelope; resource data in `result`, which may be `null` | +| external `run` | Envelope from before the browser step | + +Without `--json`, `show RESOURCE` prints only the resource data as JSON, or a message if empty. +Replace `mig_7x2` with an ID from `create` or `list`, `ACTION_ID` with an offered action ID, +and `RESOURCE` with a name from `reads` or `resources`. + +```sh +adapty migrations list --json +adapty migrations create --name "Acme Fitness" --json +adapty migrations status -m mig_7x2 --json +adapty migrations show RESOURCE -m mig_7x2 --json +``` + +Prepare `decisions.json` from the action's current schema. These are alternative ways to submit +the same input; add `--yes` only after reviewing `confirm`: + +```sh +adapty migrations run ACTION_ID -m mig_7x2 --input-file ./decisions.json --json +adapty migrations run ACTION_ID -m mig_7x2 --input-file - --json < ./decisions.json +``` + +For a selected external action, complete the linked step before waiting or checking status: + +```sh +adapty migrations run ACTION_ID -m mig_7x2 --no-browser +adapty migrations status -m mig_7x2 --wait --timeout 5m --json +``` + +Optional JSON filters require the separate `jq` utility: + +```sh +adapty migrations steps -m mig_7x2 --json | jq '.steps' +adapty migrations show RESOURCE -m mig_7x2 --json | jq '.result' +``` + ## Preview | Command | Required flags | diff --git a/src/cli/base/adapty/adapty-command.ts b/src/cli/base/adapty/adapty-command.ts index 7936320..5a4f36d 100644 --- a/src/cli/base/adapty/adapty-command.ts +++ b/src/cli/base/adapty/adapty-command.ts @@ -27,6 +27,7 @@ export abstract class AdaptyCommand extends BaseCommand { protected get adapty(): Adapty { this.#adapty ??= build(this.session, { config: this.config, + interactive: this.interactive, signal: this.signal, warn: message => this.warn(message), }); diff --git a/src/cli/base/adapty/build.ts b/src/cli/base/adapty/build.ts index e6b6ca4..99f4460 100644 --- a/src/cli/base/adapty/build.ts +++ b/src/cli/base/adapty/build.ts @@ -7,6 +7,7 @@ import type { Config } from '@oclif/core'; type CommandContext = { config: Config; + interactive: boolean; signal: AbortSignal; warn: (message: string) => void; }; @@ -14,6 +15,7 @@ type CommandContext = { /** Shared by authenticated commands and login/revoke, which can run without a token. */ export const build = (session: ResolvedSession, context: CommandContext): Adapty => createAdapty({ baseUrl: session.apiUrl, + interactive: context.interactive, onRetry: ({ attempt, delayMs }) => { context.warn(`Request failed, retrying in ${delayMs / 1000}s (attempt ${attempt + 1})`); }, diff --git a/src/cli/base/base-command.ts b/src/cli/base/base-command.ts index 87e43f4..42382b3 100644 --- a/src/cli/base/base-command.ts +++ b/src/cli/base/base-command.ts @@ -4,6 +4,13 @@ import { CliError, toCliError } from '../errors.js'; import type { ErrorJson } from '../errors.js'; +/** + * On a pipe Node leaves `isTTY` absent, not false, while @types/node promises a boolean. Taken at + * its word, a piped run answers `undefined` — a third answer to a two-answer question. The + * parameter type is the one place to say so. + */ +const attachedToTerminal = (stream: { isTTY?: boolean }): boolean => stream.isTTY === true; + /** * What every command gets and nothing more: the output channel, cancellation, the single place * where sdk errors become CLI errors. Product SDKs and sessions belong to their adapters. @@ -23,6 +30,15 @@ export abstract class BaseCommand extends Command { this.#abort.abort(); }; + /** + * Whether a person is watching this run. --json means a program is reading, even from a + * terminal, and a pipe means one is reading whatever the flags say. The sdk sends the answer + * as `X-Adapty-Interactive`; `migrations run` asks it before opening a browser. + */ + protected get interactive(): boolean { + return attachedToTerminal(process.stdout) && !this.jsonEnabled(); + } + protected get signal(): AbortSignal { return this.#abort.signal; } diff --git a/src/cli/commands/apps/get.ts b/src/cli/commands/apps/get.ts index 997f494..f2e2256 100644 --- a/src/cli/commands/apps/get.ts +++ b/src/cli/commands/apps/get.ts @@ -1,5 +1,5 @@ import { AdaptyCommand } from '../../base/adapty/index.js'; -import { appIdArg } from '../../flags.js'; +import { appIdArg } from '../../input/app.js'; import { renderRecord } from '../../views/record.js'; import type { AppDetail } from '../../../sdk/adapty/index.js'; diff --git a/src/cli/commands/apps/list.ts b/src/cli/commands/apps/list.ts index f91df8b..7cdaee5 100644 --- a/src/cli/commands/apps/list.ts +++ b/src/cli/commands/apps/list.ts @@ -1,5 +1,5 @@ import { AdaptyCommand } from '../../base/adapty/index.js'; -import { pageParams, paginationFlags } from '../../flags.js'; +import { pageParams, paginationFlags } from '../../input/pagination.js'; import { renderPage } from '../../views/list.js'; import type { AppSummary, Paginated } from '../../../sdk/adapty/index.js'; diff --git a/src/cli/commands/apps/update.ts b/src/cli/commands/apps/update.ts index ee1fa8e..b9b2bb2 100644 --- a/src/cli/commands/apps/update.ts +++ b/src/cli/commands/apps/update.ts @@ -3,7 +3,7 @@ import { Flags } from '@oclif/core'; import { validateUpdateApp } from '../../../sdk/adapty/apps/index.js'; import { assertValid } from '../../../sdk/core/validation.js'; import { AdaptyCommand } from '../../base/adapty/index.js'; -import { appIdArg } from '../../flags.js'; +import { appIdArg } from '../../input/app.js'; import { renderRecord } from '../../views/record.js'; import type { AppDetail, UpdateAppInput } from '../../../sdk/adapty/index.js'; diff --git a/src/cli/commands/auth/login.ts b/src/cli/commands/auth/login.ts index 04dd4ee..a11a84b 100644 --- a/src/cli/commands/auth/login.ts +++ b/src/cli/commands/auth/login.ts @@ -25,6 +25,7 @@ export default class AuthLogin extends BaseCommand { // No token yet: the same factory without one, which leaves only auth reachable const adapty = build({ ...session, token: undefined }, { config: this.config, + interactive: this.interactive, signal: this.signal, warn: message => this.warn(message), }); diff --git a/src/cli/commands/auth/revoke.ts b/src/cli/commands/auth/revoke.ts index 5834322..0fa9198 100644 --- a/src/cli/commands/auth/revoke.ts +++ b/src/cli/commands/auth/revoke.ts @@ -24,6 +24,7 @@ export default class AuthRevoke extends BaseCommand { // the server and no local copy to revoke it with — only the dashboard could undo that. const adapty = build(session, { config: this.config, + interactive: this.interactive, signal: this.signal, warn: message => this.warn(message), }); diff --git a/src/cli/commands/auth/status/command.ts b/src/cli/commands/auth/status/command.ts new file mode 100644 index 0000000..041ab8f --- /dev/null +++ b/src/cli/commands/auth/status/command.ts @@ -0,0 +1,31 @@ +import { openSession } from '../../../base/adapty/index.js'; +import { BaseCommand } from '../../../base/base-command.js'; + +import { renderStatus } from './lib/render.js'; + +import type { Result } from './lib/result.js'; + +export default class AuthStatus extends BaseCommand { + static override description = 'Show the local authentication state (no network)'; + static override examples = ['<%= config.bin %> auth status']; + + async run(): Promise { + await this.parse(AuthStatus); + + const session = await openSession(this.config); + + const result: Result = session.token === undefined + ? { authenticated: false, config_path: session.store.path, source: 'none' } + : { + authenticated: true, + config_path: session.store.path, + email: session.user?.email, + source: session.source === 'env' ? 'env' : 'file', + token_prefix: session.token.slice(0, 8), + }; + + this.render(result, renderStatus); + + return result; + } +} diff --git a/src/cli/commands/auth/status/index.ts b/src/cli/commands/auth/status/index.ts index 041ab8f..40b09fa 100644 --- a/src/cli/commands/auth/status/index.ts +++ b/src/cli/commands/auth/status/index.ts @@ -1,31 +1 @@ -import { openSession } from '../../../base/adapty/index.js'; -import { BaseCommand } from '../../../base/base-command.js'; - -import { renderStatus } from './lib/render.js'; - -import type { Result } from './lib/result.js'; - -export default class AuthStatus extends BaseCommand { - static override description = 'Show the local authentication state (no network)'; - static override examples = ['<%= config.bin %> auth status']; - - async run(): Promise { - await this.parse(AuthStatus); - - const session = await openSession(this.config); - - const result: Result = session.token === undefined - ? { authenticated: false, config_path: session.store.path, source: 'none' } - : { - authenticated: true, - config_path: session.store.path, - email: session.user?.email, - source: session.source === 'env' ? 'env' : 'file', - token_prefix: session.token.slice(0, 8), - }; - - this.render(result, renderStatus); - - return result; - } -} +export { default } from './command.js'; diff --git a/src/cli/commands/migrations/close/command.ts b/src/cli/commands/migrations/close/command.ts new file mode 100644 index 0000000..0ee282f --- /dev/null +++ b/src/cli/commands/migrations/close/command.ts @@ -0,0 +1,60 @@ +import { Flags } from '@oclif/core'; + +import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { migrationFlags } from '../../../input/migration.js'; +import { renderEnvelope } from '../../../views/migrations/envelope/envelope.js'; + +import type { Envelope } from '../../../../sdk/adapty/index.js'; + +export default class Close extends AdaptyCommand { + static override summary = 'Permanently finish or cancel a migration'; + static override description = [ + 'Choose finish to mark the migration as completed, or cancel to abandon it.', + 'Both outcomes permanently close the migration and require --yes. No confirmation prompt is shown.', + '', + 'With --json, returns the full migration response after closing.', + ].join('\n'); + + static override examples = [ + { + description: 'Mark the migration as finished:', + command: '<%= config.bin %> migrations close -m mig_7x2 --outcome finish --yes', + }, + { + description: 'Abandon the migration:', + command: '<%= config.bin %> migrations close -m mig_7x2 --outcome cancel --yes', + }, + { + description: 'Finish and return the final state as JSON:', + command: '<%= config.bin %> migrations close -m mig_7x2 --outcome finish --yes --json', + }, + ]; + + static override flags = { + ...migrationFlags, + outcome: Flags.option({ + description: 'finish: mark completed; cancel: abandon the migration', + options: ['finish', 'cancel'] as const, + required: true, + })(), + yes: Flags.boolean({ + char: 'y', + description: 'Confirm permanent closure of this migration', + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(Close); + const { migration } = await this.adapty.migrations.get(flags.migration); + + const envelope = await this.adapty.migrations.close(flags.migration, { + expectedRevision: migration.revision, + outcome: flags.outcome, + }); + + this.render(envelope, renderEnvelope); + + return envelope; + } +} diff --git a/src/cli/commands/migrations/close/index.ts b/src/cli/commands/migrations/close/index.ts index faf87ea..40b09fa 100644 --- a/src/cli/commands/migrations/close/index.ts +++ b/src/cli/commands/migrations/close/index.ts @@ -1,39 +1 @@ -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'); - } -} +export { default } from './command.js'; diff --git a/src/cli/commands/migrations/create/command.ts b/src/cli/commands/migrations/create/command.ts new file mode 100644 index 0000000..2e0d079 --- /dev/null +++ b/src/cli/commands/migrations/create/command.ts @@ -0,0 +1,77 @@ +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/migrations/envelope/envelope.js'; + +import type { CreateMigrationInput, Envelope } from '../../../../sdk/adapty/index.js'; + +export default class Create extends AdaptyCommand { + static override summary = 'Start a migration into Adapty'; + static override description = [ + 'Use --name to migrate a catalog into a new Adapty app.', + 'For an existing app, use --flow and --app together. Choose a flow and its app from `adapty migrations list`.', + '', + 'Creation starts the flow; use `adapty migrations status -m ID` to continue.', + 'With --json, the migration ID is in migration.id.', + ].join('\n'); + + static override usage = [ + 'migrations create --name APP_NAME [--json]', + 'migrations create --flow FLOW --app APP_ID [--json]', + ]; + + static override examples = [ + { + description: 'Start a catalog migration into a new app:', + command: '<%= config.bin %> migrations create --name "Acme Fitness"', + }, + { + description: 'Start transactions for an existing app, if listed as available:', + command: '<%= config.bin %> migrations create --flow transactions --app 3f2ab1c4-0000-4000-8000-000000000000', + }, + { + description: 'Return the new migration as JSON for an agent or script:', + command: '<%= config.bin %> migrations create --name "Acme Fitness" --json', + }, + ]; + + static override flags = { + name: Flags.string({ + description: 'New Adapty app name; cannot be combined with --flow or --app', + exclusive: ['app', 'flow'], + helpValue: 'APP_NAME', + }), + flow: Flags.string({ + dependsOn: ['app'], + description: 'Available flow from `adapty migrations list`; requires --app', + helpValue: 'FLOW', + }), + app: Flags.string({ + dependsOn: ['flow'], + description: 'Existing Adapty app ID (UUID); requires --flow', + helpValue: 'APP_ID', + }), + }; + + 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/create/index.ts b/src/cli/commands/migrations/create/index.ts index e8f0ec6..40b09fa 100644 --- a/src/cli/commands/migrations/create/index.ts +++ b/src/cli/commands/migrations/create/index.ts @@ -1,56 +1 @@ -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; - } -} +export { default } from './command.js'; diff --git a/src/cli/commands/migrations/list/command.ts b/src/cli/commands/migrations/list/command.ts new file mode 100644 index 0000000..247ad42 --- /dev/null +++ b/src/cli/commands/migrations/list/command.ts @@ -0,0 +1,35 @@ +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 summary = 'List migrations and the flows you can start'; + static override description = [ + 'Use a migration ID with `adapty migrations status -m ID` to continue an existing flow.', + 'Start an available flow with `adapty migrations create --flow FLOW --app APP_ID`.', + '', + 'With --json, items contains migrations and available contains flows with their target apps.', + ].join('\n'); + + static override examples = [ + { + description: 'Find migrations and available flows, grouped by app:', + command: '<%= config.bin %> migrations list', + }, + { + description: 'Read migration IDs and available flows as JSON:', + command: '<%= config.bin %> migrations list --json', + }, + ]; + + 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/index.ts b/src/cli/commands/migrations/list/index.ts index 0a3e197..40b09fa 100644 --- a/src/cli/commands/migrations/list/index.ts +++ b/src/cli/commands/migrations/list/index.ts @@ -1,19 +1 @@ -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; - } -} +export { default } from './command.js'; diff --git a/src/cli/commands/migrations/list/lib/render.ts b/src/cli/commands/migrations/list/lib/render.ts index eb44049..519fe38 100644 --- a/src/cli/commands/migrations/list/lib/render.ts +++ b/src/cli/commands/migrations/list/lib/render.ts @@ -2,7 +2,6 @@ import type { AvailableFlow, Migration, MigrationList } from '../../../../../sdk 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[]; @@ -103,10 +102,10 @@ const renderGroup = (group: Group): string => [ ...renderAvailable(group.available), ].join('\n'); -/** Grouped by app and, inside an app, by state. */ +/** Group by app, then by migration 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 'No migrations yet. Start one: `adapty migrations create --name "My app"`'; } return groupByApp(list).map(group => renderGroup(group)).join('\n\n'); diff --git a/src/cli/commands/migrations/run/command.ts b/src/cli/commands/migrations/run/command.ts new file mode 100644 index 0000000..702cba4 --- /dev/null +++ b/src/cli/commands/migrations/run/command.ts @@ -0,0 +1,178 @@ +import { Args, Flags } from '@oclif/core'; + +import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { CliError, exitCode } from '../../../errors.js'; +import { migrationFlags } from '../../../input/migration.js'; +import { renderEnvelope } from '../../../views/migrations/envelope/envelope.js'; + +import { actionView } from './lib/action-view.js'; +import { findAction, unknownActionMessage, unsupportedActionMessage } from './lib/actions.js'; +import { readActionInput } from './lib/input.js'; +import { openLink } from './lib/open-link.js'; + +import type { Action, Envelope } from '../../../../sdk/adapty/index.js'; + +type RunContext = { + action: Action; + envelope: Envelope; + flags: { + 'migration': string; + 'no-browser': boolean; + 'open': boolean; + 'yes': boolean; + }; + /** Set for every action the server hands over as a link, whatever kind it calls itself. */ + href: string | undefined; + input: unknown; +}; + +export default class Run extends AdaptyCommand { + static override summary = 'Run an input action or open an external action link'; + static override description = [ + 'Choose an action ID from next_actions or available_actions in `adapty migrations status -m ID --json`.', + 'Replace ACTION_ID in the examples with an offered action ID.', + 'For input actions, read the resources in reads and prepare a JSON object matching input_schema.', + 'Omitting input sends {}. Review confirm before passing --yes.', + 'Without --yes, actions requiring confirmation exit with code 6. No confirmation prompt is shown.', + '', + 'External actions print a link and open it in an interactive terminal. Complete the browser step, then check status.', + 'External actions reject --input and --input-file, including stdin.', + 'Pipes and --json require --open to launch a browser; BROWSER=none disables it.', + 'File uploads are not supported; use the dashboard or an offered Cloud Export action.', + '', + 'With --json, returns the full migration response (before the browser step for external actions).', + 'After a revision_conflict error, read status and review the action before retrying.', + ].join('\n'); + + static override examples = [ + { + description: 'Read current action IDs and input schemas first:', + command: '<%= config.bin %> migrations status -m mig_7x2 --json', + }, + { + description: 'Submit an empty JSON object, if the action input_schema allows it:', + command: '<%= config.bin %> migrations run ACTION_ID -m mig_7x2 --input \'{}\'', + }, + { + description: 'Submit a file matching input_schema, after reviewing confirm:', + command: '<%= config.bin %> migrations run ACTION_ID -m mig_7x2 --input-file ./decisions.json --yes --json', + }, + { + description: 'Read the same prepared input from stdin:', + command: '<%= config.bin %> migrations run ACTION_ID -m mig_7x2 --input-file - --yes --json < ./decisions.json', + }, + { + description: 'Get an external action link without opening a browser:', + command: '<%= config.bin %> migrations run ACTION_ID -m mig_7x2 --no-browser', + }, + ]; + + static override args = { + action_id: Args.string({ + description: 'Offered action ID from `adapty migrations status`', + required: true, + }), + }; + + static override flags = { + ...migrationFlags, + 'input': Flags.string({ + description: 'Input action data as a JSON object matching input_schema', + exclusive: ['input-file'], + helpValue: 'JSON', + }), + 'input-file': Flags.string({ + description: 'Read an input action\'s JSON object from a file; use - for stdin', + exclusive: ['input'], + helpValue: 'PATH', + }), + 'yes': Flags.boolean({ + char: 'y', + default: false, + description: 'Confirm the input action after reviewing its confirmation text', + }), + 'open': Flags.boolean({ + default: false, + description: 'Open an external action\'s HTTPS link, including with pipes or --json', + exclusive: ['no-browser'], + }), + 'no-browser': Flags.boolean({ + default: false, + description: 'Show the external action without opening a browser', + exclusive: ['open'], + }), + }; + + async run(): Promise { + const context = await this.prepare(); + + if (context.href !== undefined) { + return this.runExternalAction(context, context.href); + } + + return this.runInputAction(context); + } + + private async prepare(): Promise { + const { args, flags } = await this.parse(Run); + // Inline input and files are read before the request; stdin waits for the action kind. + const fromStdin = flags['input-file'] === '-'; + const input = fromStdin ? undefined : await readActionInput(flags); + + const envelope = await this.adapty.migrations.get(flags.migration); + const action = findAction(envelope, args.action_id); + + if (action === undefined) { + throw new CliError(unknownActionMessage(envelope, args.action_id), exitCode.usage, 'action_not_found'); + } + + const href = 'href' in action ? action.href : undefined; + + if (href !== undefined && (flags.input !== undefined || flags['input-file'] !== undefined)) { + throw new CliError( + `Action \`${action.action_id}\` hands over a link and does not accept --input or --input-file. Remove the input option and complete the step in the browser.`, + exitCode.usage, + 'action_input_unsupported', + ); + } + + return { + action, envelope, flags, href, + input: fromStdin && action.kind === 'input' ? await readActionInput(flags) : input, + }; + } + + private async runExternalAction(context: RunContext, href: string): Promise { + this.render({ action: context.action, migrationId: context.envelope.migration.id }, actionView); + await openLink(href, context.flags, this.interactive); + + return context.envelope; + } + + private async runInputAction({ action, envelope, flags, input }: RunContext): Promise { + if (action.kind !== 'input' && action.kind !== 'upload' && action.kind !== 'external') { + this.render({ action, migrationId: envelope.migration.id }, actionView); + + return envelope; + } + + if (action.kind !== 'input') { + throw new CliError(unsupportedActionMessage(action), exitCode.usage, 'action_unsupported'); + } + + if (action.confirm !== null && !flags.yes) { + const message = `${action.confirm}\n\nRe-run with --yes to do it.`; + + throw new CliError(message, exitCode.confirmRequired, 'confirm_required'); + } + + const result = await this.adapty.migrations.runAction(flags.migration, action.action_id, { + expectedRevision: envelope.migration.revision, + input, + }); + + this.render(result, renderEnvelope); + + return result; + } +} diff --git a/src/cli/commands/migrations/run/index.ts b/src/cli/commands/migrations/run/index.ts index 1a3148d..40b09fa 100644 --- a/src/cli/commands/migrations/run/index.ts +++ b/src/cli/commands/migrations/run/index.ts @@ -1,62 +1 @@ -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'); - } -} +export { default } from './command.js'; diff --git a/src/cli/commands/migrations/run/lib/action-view.ts b/src/cli/commands/migrations/run/lib/action-view.ts new file mode 100644 index 0000000..d8d6a15 --- /dev/null +++ b/src/cli/commands/migrations/run/lib/action-view.ts @@ -0,0 +1,7 @@ +import { actionBlock } from '../../../../views/migrations/envelope/action-block.js'; + +import type { Action } from '../../../../../sdk/adapty/index.js'; + +type ActionView = { action: Action; migrationId: string }; + +export const actionView = ({ action, migrationId }: ActionView): string => actionBlock(action, migrationId).join('\n'); diff --git a/src/cli/commands/migrations/run/lib/actions.ts b/src/cli/commands/migrations/run/lib/actions.ts new file mode 100644 index 0000000..6181374 --- /dev/null +++ b/src/cli/commands/migrations/run/lib/actions.ts @@ -0,0 +1,31 @@ +import type { Action, Envelope } from '../../../../../sdk/adapty/index.js'; + +const offered = (envelope: Envelope): Action[] => [...envelope.next_actions, ...envelope.available_actions]; + +export const findAction = (envelope: Envelope, actionId: string): Action | undefined => { + return offered(envelope).find(action => action.action_id === actionId); +}; + +const line = (action: Action): string => ` ${action.action_id} (${action.kind}) ${action.title}`; + +export const unknownActionMessage = (envelope: Envelope, actionId: string): string => { + const actions = offered(envelope); + + if (actions.length === 0) { + const { state, summary } = envelope.migration; + + return `No action \`${actionId}\` here: this migration offers none right now (state: ${state}).\n${summary}`; + } + + return [`No action \`${actionId}\` here. Available now:`, ...actions.map(line)].join('\n'); +}; + +export const unsupportedActionMessage = (action: Action): string => { + const detail = action.detail === null ? '' : `\n${action.detail}`; + + if (action.kind === 'upload') { + return `\`${action.action_id}\` (${action.title}) uploads a file, which this adapty-cli cannot do yet.${detail}\nUse the dashboard or an offered Cloud Export action.`; + } + + return `\`${action.action_id}\` (${action.title}) is a "${action.kind}" action: it needs a newer adapty-cli.${detail}`; +}; diff --git a/src/cli/commands/migrations/run/lib/https.ts b/src/cli/commands/migrations/run/lib/https.ts new file mode 100644 index 0000000..5b64228 --- /dev/null +++ b/src/cli/commands/migrations/run/lib/https.ts @@ -0,0 +1,7 @@ +export const isHttps = (href: string): boolean => { + try { + return new URL(href).protocol === 'https:'; + } catch { + return false; + } +}; diff --git a/src/cli/commands/migrations/run/lib/input.ts b/src/cli/commands/migrations/run/lib/input.ts new file mode 100644 index 0000000..74acfda --- /dev/null +++ b/src/cli/commands/migrations/run/lib/input.ts @@ -0,0 +1,69 @@ +import { readFileSync } from 'node:fs'; + +import { validateActionInput } from '../../../../../sdk/adapty/migrations/index.js'; +import { ValidationError } from '../../../../../sdk/core/errors.js'; +import { assertValid } from '../../../../../sdk/core/validation.js'; + +type InputFlags = { + 'input'?: string | undefined; + 'input-file'?: string | undefined; +}; + +/** CLI error formatting maps inputFile to --input-file. */ +const invalid = (path: string, message: string): never => { + throw new ValidationError([{ message, path }]); +}; + +const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +const parseJson = (raw: string, path: string): unknown => { + try { + return JSON.parse(raw); + } catch (error) { + return invalid(path, `is not valid JSON: ${reason(error)}`); + } +}; + +const readFile = (path: string): string => { + try { + return readFileSync(path, 'utf8'); + } catch (error) { + return invalid('inputFile', `cannot be read: ${reason(error)}`); + } +}; + +const readStdin = async (): Promise => { + const chunks: Buffer[] = []; + + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + + return Buffer.concat(chunks).toString('utf8'); +}; + +const parseActionInput = async (flags: InputFlags): Promise => { + if (flags.input !== undefined) { + return parseJson(flags.input, 'input'); + } + + const path = flags['input-file']; + + if (path === undefined) { + return undefined; + } + + return parseJson(path === '-' ? await readStdin() : readFile(path), 'inputFile'); +}; + +/** + * Read inline JSON, a file, or stdin, and answer with an input the action request accepts. + * The caller checks the action kind before waiting on stdin. + */ +export const readActionInput = async (flags: InputFlags): Promise => { + const input = await parseActionInput(flags); + + assertValid(validateActionInput(input)); + + return input; +}; diff --git a/src/cli/commands/migrations/run/lib/open-link.ts b/src/cli/commands/migrations/run/lib/open-link.ts new file mode 100644 index 0000000..7a47c88 --- /dev/null +++ b/src/cli/commands/migrations/run/lib/open-link.ts @@ -0,0 +1,17 @@ +import open from 'open'; + +import { isHttps } from './https.js'; + +type BrowserFlags = { 'no-browser': boolean; 'open': boolean }; + +/** Piped and JSON output require --open to launch a browser. */ +export const openLink = async (href: string, flags: BrowserFlags, interactive: boolean): Promise => { + const wanted = flags.open || interactive; + const allowed = !flags['no-browser'] && process.env.BROWSER !== 'none' && isHttps(href); + + if (!wanted || !allowed) { + return; + } + + await open(href).catch(() => undefined); +}; diff --git a/src/cli/commands/migrations/show/command.ts b/src/cli/commands/migrations/show/command.ts new file mode 100644 index 0000000..da0a44a --- /dev/null +++ b/src/cli/commands/migrations/show/command.ts @@ -0,0 +1,54 @@ +import { Args } from '@oclif/core'; + +import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { migrationFlags } from '../../../input/migration.js'; + +import { renderResources, renderResult } from './lib/render.js'; + +import type { Envelope } from '../../../../sdk/adapty/index.js'; + +export default class Show extends AdaptyCommand { + static override summary = 'List migration resources or read one resource'; + static override description = [ + 'Omit RESOURCE to list what can be read now. Use a name from that list, such as apps, mapping, or report.', + '', + 'Reading a resource prints its data as JSON. With --json, returns the full migration response.', + 'Resource data is in result (null if no data is available yet).', + ].join('\n'); + + static override examples = [ + { + description: 'List resource names available now:', + command: '<%= config.bin %> migrations show -m mig_7x2', + }, + { + description: 'Read mapping data, if listed as available:', + command: '<%= config.bin %> migrations show mapping -m mig_7x2', + }, + { + description: 'Extract only the resource data (requires jq):', + command: '<%= config.bin %> migrations show mapping -m mig_7x2 --json | jq \'.result\'', + }, + ]; + + static override args = { + resource: Args.string({ + description: 'Resource name; omit to list what can be read now', + }), + }; + + static override flags = { ...migrationFlags }; + + async run(): Promise { + const { args, flags } = await this.parse(Show); + const { resource } = args; + + const envelope = resource === undefined + ? await this.adapty.migrations.get(flags.migration) + : await this.adapty.migrations.resource(flags.migration, resource); + + this.render(envelope, resource === undefined ? renderResources : renderResult); + + return envelope; + } +} diff --git a/src/cli/commands/migrations/show/index.ts b/src/cli/commands/migrations/show/index.ts index b49eadf..40b09fa 100644 --- a/src/cli/commands/migrations/show/index.ts +++ b/src/cli/commands/migrations/show/index.ts @@ -1,32 +1 @@ -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'); - } -} +export { default } from './command.js'; diff --git a/src/cli/commands/migrations/show/lib/render.ts b/src/cli/commands/migrations/show/lib/render.ts new file mode 100644 index 0000000..e48b403 --- /dev/null +++ b/src/cli/commands/migrations/show/lib/render.ts @@ -0,0 +1,36 @@ +import type { Envelope, ResourceRef } from '../../../../../sdk/adapty/index.js'; + +const maxWidth = (values: readonly string[]): number => { + return values.reduce((max, value) => Math.max(max, value.length), 0); +}; + +const resourceLine = (resource: ResourceRef, width: number): string => { + return ` ${resource.name.padEnd(width)} ${resource.title}`; +}; + +export const renderResources = (envelope: Envelope): string => { + const { resources } = envelope; + const [first] = resources; + + if (first === undefined) { + return `Nothing to read yet: ${envelope.migration.summary}`; + } + + const width = maxWidth(resources.map(resource => resource.name)); + + return [ + 'Readable now:', + ...resources.map(resource => resourceLine(resource, width)), + '', + `Read one: \`adapty migrations show ${first.name} -m ${envelope.migration.id}\``, + ].join('\n'); +}; + +/** Use JSON because resource shapes vary by flow. */ +export const renderResult = (envelope: Envelope): string => { + if (envelope.result === null) { + return `No data in this resource yet: ${envelope.migration.summary}`; + } + + return JSON.stringify(envelope.result, null, 2); +}; diff --git a/src/cli/commands/migrations/status/command.ts b/src/cli/commands/migrations/status/command.ts new file mode 100644 index 0000000..f4950e5 --- /dev/null +++ b/src/cli/commands/migrations/status/command.ts @@ -0,0 +1,60 @@ +import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { migrationFlags } from '../../../input/migration.js'; +import { renderEnvelope } from '../../../views/migrations/envelope/envelope.js'; + +import { waitFlags } from './lib/flags.js'; +import { pollNotice } from './lib/notice.js'; + +import type { Envelope } from '../../../../sdk/adapty/index.js'; + +export default class Status extends AdaptyCommand { + static override summary = 'Show migration state, issues, and available actions'; + static override description = [ + 'Read next_actions for the next steps and available_actions for optional actions.', + 'Use --json to read each action\'s input_schema, reads, and confirmation text.', + '', + '--wait returns when the revision changes, the state leaves running, or the polling budget runs out.', + 'Exit 0 means the request succeeded, even if the migration is still running or has failed.', + 'Check migration.state in JSON output. Progress goes to stderr. Ctrl+C exits with code 130.', + ].join('\n'); + + static override examples = [ + { + description: 'See the current state and what to do next:', + command: '<%= config.bin %> migrations status -m mig_7x2', + }, + { + description: 'Read action IDs, input schemas, and confirmation text as JSON:', + command: '<%= config.bin %> migrations status -m mig_7x2 --json', + }, + { + description: 'Wait for a change with a five-minute polling budget:', + command: '<%= config.bin %> migrations status -m mig_7x2 --wait --timeout 5m --json', + }, + ]; + + static override flags = { ...migrationFlags, ...waitFlags }; + + async run(): Promise { + const { flags } = await this.parse(Status); + + const envelope = flags.wait + ? await this.waitForMigration(flags.migration, flags.timeout) + : await this.adapty.migrations.get(flags.migration); + + this.render(envelope, renderEnvelope); + + return envelope; + } + + /** Timeout returns the last response with exit 0; Ctrl+C cancels with exit 130. */ + private async waitForMigration(id: string, timeoutMs: number | undefined): Promise { + return this.adapty.migrations.waitFor(id, { + onPoll: (envelope, delayMs) => { + process.stderr.write(`${pollNotice(envelope, delayMs)}\n`); + }, + signal: this.signal, + timeoutMs, + }); + } +} diff --git a/src/cli/commands/migrations/status/index.ts b/src/cli/commands/migrations/status/index.ts index cc3baf2..40b09fa 100644 --- a/src/cli/commands/migrations/status/index.ts +++ b/src/cli/commands/migrations/status/index.ts @@ -1,34 +1 @@ -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'); - } -} +export { default } from './command.js'; diff --git a/src/cli/commands/migrations/status/lib/flags.ts b/src/cli/commands/migrations/status/lib/flags.ts new file mode 100644 index 0000000..5f37604 --- /dev/null +++ b/src/cli/commands/migrations/status/lib/flags.ts @@ -0,0 +1,37 @@ +import { Errors, Flags } from '@oclif/core'; + +import { exitCode } from '../../../../errors.js'; + +const DURATION_PATTERN = /^(\d+)(m|s)?$/; + +const MAX_WAIT_SECONDS = 600; + +const durationHint = `Use seconds or minutes, e.g. 300s or 5m, up to ${MAX_WAIT_SECONDS}s.`; + +const parseDuration = (input: string): Promise => { + const match = DURATION_PATTERN.exec(input); + const amount = Number(match?.[1]); + const seconds = match?.[2] === 'm' ? amount * 60 : amount; + + return Number.isFinite(seconds) && seconds >= 1 && seconds <= MAX_WAIT_SECONDS + ? Promise.resolve(seconds * 1000) + : Promise.reject(new Errors.CLIError(`Invalid duration \`${input}\`. ${durationHint}`, { exit: exitCode.usage })); +}; + +/** Parse seconds or minutes into milliseconds for the SDK. */ +const duration = Flags.custom({ parse: async input => parseDuration(input) }); + +/** + * Keep defaults out of these flags so dependsOn can detect a missing --wait. + * The SDK supplies the default timeout. + */ +export const waitFlags = { + timeout: duration({ + dependsOn: ['wait'], + description: 'Polling budget with --wait: 1-600s, default 120s (e.g. 300, 300s, 5m); in-flight requests may take longer', + helpValue: 'DURATION', + }), + wait: Flags.boolean({ + description: 'Poll for a revision or state change, then return the latest response', + }), +}; diff --git a/src/cli/commands/migrations/status/lib/notice.ts b/src/cli/commands/migrations/status/lib/notice.ts new file mode 100644 index 0000000..ca177f9 --- /dev/null +++ b/src/cli/commands/migrations/status/lib/notice.ts @@ -0,0 +1,15 @@ +import type { Envelope, Progress } from '../../../../../sdk/adapty/index.js'; + +const workDone = (progress: Progress): string => { + const done = progress.total === null ? String(progress.done) : `${progress.done} of ${progress.total}`; + + return `${done} ${progress.unit}`; +}; + +/** Format a progress line for stderr, including when progress or its total is unknown. */ +export const pollNotice = (envelope: Envelope, delayMs: number): string => { + const { progress, state } = envelope.migration; + const work = progress === null ? state : `${state} ${workDone(progress)}`; + + return `${work} — checking again in ${Math.round(delayMs / 1000)}s`; +}; diff --git a/src/cli/commands/migrations/steps/command.ts b/src/cli/commands/migrations/steps/command.ts new file mode 100644 index 0000000..c327e83 --- /dev/null +++ b/src/cli/commands/migrations/steps/command.ts @@ -0,0 +1,38 @@ +import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { migrationFlags } from '../../../input/migration.js'; + +import { renderSteps } from './lib/render.js'; + +import type { Envelope } from '../../../../sdk/adapty/index.js'; + +export default class Steps extends AdaptyCommand { + static override summary = 'Show the migration checklist and step statuses'; + static override description = [ + 'Shows done, active, and locked steps in migration order.', + 'To find actions you can run, use `adapty migrations status -m ID`.', + '', + 'With --json, the full migration response is returned; the checklist is in steps.', + ].join('\n'); + + static override examples = [ + { + description: 'View the checklist:', + command: '<%= config.bin %> migrations steps -m mig_7x2', + }, + { + description: 'Extract the checklist as JSON (requires jq):', + command: '<%= config.bin %> migrations steps -m mig_7x2 --json | jq \'.steps\'', + }, + ]; + + static override flags = { ...migrationFlags }; + + async run(): Promise { + const { flags } = await this.parse(Steps); + const envelope = await this.adapty.migrations.get(flags.migration); + + this.render(envelope, renderSteps); + + return envelope; + } +} diff --git a/src/cli/commands/migrations/steps/index.ts b/src/cli/commands/migrations/steps/index.ts index 191801d..40b09fa 100644 --- a/src/cli/commands/migrations/steps/index.ts +++ b/src/cli/commands/migrations/steps/index.ts @@ -1,22 +1 @@ -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'); - } -} +export { default } from './command.js'; diff --git a/src/cli/commands/migrations/steps/lib/render.ts b/src/cli/commands/migrations/steps/lib/render.ts new file mode 100644 index 0000000..1874457 --- /dev/null +++ b/src/cli/commands/migrations/steps/lib/render.ts @@ -0,0 +1,40 @@ +import type { Envelope, Step, StepStatus } from '../../../../../sdk/adapty/index.js'; + +/** Unknown statuses are displayed as text; satisfies checks that all known statuses have a mark. */ +const marks: Record = { + active: '[>]', + done: '[x]', + locked: '[ ]', +} satisfies Record; + +const markOf = (status: string): string => marks[status] ?? `[${status}]`; + +const maxWidth = (values: readonly string[]): number => { + return values.reduce((max, value) => Math.max(max, value.length), 0); +}; + +type Widths = { mark: number; stepId: number }; + +const stepLine = (step: Step, widths: Widths): string => { + const mark = markOf(step.status).padEnd(widths.mark); + const stepId = step.step_id.padEnd(widths.stepId); + const summary = step.summary === null ? '' : ` ${step.summary}`; + + return `${mark} ${stepId} ${step.title}${summary}`; +}; + +/** Keep server order and step IDs so users can match steps to issues and actions. */ +export const renderSteps = (envelope: Envelope): string => { + const { steps } = envelope; + + if (steps.length === 0) { + return `No steps yet: ${envelope.migration.summary}`; + } + + const widths = { + mark: maxWidth(steps.map(step => markOf(step.status))), + stepId: maxWidth(steps.map(step => step.step_id)), + }; + + return steps.map(step => stepLine(step, widths)).join('\n'); +}; diff --git a/src/cli/errors.ts b/src/cli/errors.ts index da0887f..ca2e043 100644 --- a/src/cli/errors.ts +++ b/src/cli/errors.ts @@ -2,6 +2,9 @@ import { Errors } from '@oclif/core'; import { isSdkError } from '../sdk/core/errors.js'; +import { wizardDiagnostics, wizardErrorMessage } from './errors/wizard.js'; + +import type { WizardDiagnostics } from './errors/wizard.js'; import type { Issue } from '../sdk/core/errors.js'; /** @@ -10,18 +13,20 @@ import type { Issue } from '../sdk/core/errors.js'; * 3 auth — no token, an expired one, or a refused authorization * 4 api — the server rejected a well-formed request * 5 network — the server was never reached + * 6 confirm — nothing was done: the action changes production data and wants --yes * 130 cancelled — Ctrl+C, by the shell convention 128 + SIGINT */ export const exitCode = { api: 4, auth: 3, cancelled: 130, + confirmRequired: 6, network: 5, usage: 2, } as const; /** HTTP status is separate from the process exit code. Snake_case fields preserve the old JSON contract. */ -export type ErrorJson = { +export type ErrorJson = WizardDiagnostics & { code?: string | undefined; error_code?: string | undefined; errors?: unknown; @@ -61,6 +66,12 @@ const describeIssue = (issue: Issue): string => */ export const toCliError = (error: unknown): Error => { if (!isSdkError(error)) { + // Parser errors carry only oclif.exit; JSON handling reads exitCode instead. + // Preserve the error instance and its diagnostics, including any explicit non-usage exit. + if (error instanceof Errors.CLIError && typeof error.oclif.exit === 'number' && !('exitCode' in error)) { + return Object.assign(error, { exitCode: error.oclif.exit }); + } + return error instanceof Error ? error : new Error(String(error)); } @@ -75,10 +86,16 @@ export const toCliError = (error: unknown): Error => { ? error.details.errors : undefined; - return cliError(error.message, exitCode.api, code, { + const diagnostics = wizardDiagnostics(error.details); + // A permission denial is an auth failure; retain the server's explanation and diagnostics. + const exit = error.status === 403 ? exitCode.auth : exitCode.api; + + return cliError(wizardErrorMessage(error.message, diagnostics), exit, code, { + ...diagnostics, code: jsonCode, error_code: jsonCode, errors: fields, + message: error.message, status: error.status, status_code: error.status, }); diff --git a/src/cli/errors/wizard.ts b/src/cli/errors/wizard.ts new file mode 100644 index 0000000..0f00060 --- /dev/null +++ b/src/cli/errors/wizard.ts @@ -0,0 +1,76 @@ +import type { WizardError } from '../../sdk/adapty/migrations/index.js'; + +export type WizardDiagnostics = Partial>; + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}; + +/** Copy only known, correctly typed diagnostics from the untrusted response body. */ +export const wizardDiagnostics = (body: unknown): WizardDiagnostics => { + if (!isRecord(body) || (typeof body.error_code === 'string' && body.error_code !== '')) { + return {}; + } + + const error = body.error; + + if (!isRecord(error) || typeof error.code !== 'string' || error.code === '') { + return {}; + } + + const diagnostics: WizardDiagnostics = {}; + + for (const key of ['detail', 'next_step'] as const) { + if (typeof error[key] === 'string' || error[key] === null) { + diagnostics[key] = error[key]; + } + } + + if (typeof error.retryable === 'boolean') { + diagnostics.retryable = error.retryable; + } + + const delay = error.retry_after_seconds; + + if (delay === null || (typeof delay === 'number' && Number.isFinite(delay) && delay >= 0)) { + diagnostics.retry_after_seconds = delay; + } + + if (typeof error.request_id === 'string') { + diagnostics.request_id = error.request_id; + } + + if (Array.isArray(error.fields)) { + diagnostics.fields = []; + + for (const field of error.fields as unknown[]) { + if (isRecord(field) && typeof field.path === 'string' && typeof field.message === 'string') { + diagnostics.fields.push({ path: field.path, message: field.message }); + } + } + } + + return diagnostics; +}; + +export const wizardErrorMessage = (message: string, diagnostics: WizardDiagnostics): string => { + const lines = [message]; + + if (diagnostics.detail && diagnostics.detail !== message) { + lines.push(diagnostics.detail); + } + + if (diagnostics.fields?.length) { + lines.push('', ...diagnostics.fields.map(field => ` ${field.path}: ${field.message}`)); + } + + if (diagnostics.next_step) { + lines.push('', `Next step: ${diagnostics.next_step}`); + } + + if (diagnostics.request_id) { + lines.push(`Request ID: ${diagnostics.request_id}`); + } + + return lines.join('\n'); +}; diff --git a/src/cli/flags.ts b/src/cli/flags.ts deleted file mode 100644 index df5afa4..0000000 --- a/src/cli/flags.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Args, Errors, Flags } from '@oclif/core'; - -import { exitCode } from './errors.js'; - -import type { PageParams } from '../sdk/adapty/index.js'; - -const UUID_PATTERN = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; - -export const isUuid = (value: string): boolean => UUID_PATTERN.test(value); - -/** - * Checking the shape of a value is the parser's job, so a run() never starts with one. oclif turns - * a *flag* parser's failure into exit 2 itself but passes an *arg* parser's error through, hence - * the explicit code. The hint is per resource: `adapty apps list` only helps for an app id. - */ -const uuidParser = (hint: string) => (input: string): Promise => (isUuid(input) - ? Promise.resolve(input) - : Promise.reject(new Errors.CLIError(hint, { exit: exitCode.usage }))); - -/** Spread into a command: `static args = { ...appIdArg }`. Texts kept as published. */ -export const appIdArg = { - app_id: Args.string({ - description: 'App ID (UUID)', - parse: uuidParser('Invalid app ID format. Run `adapty apps list` to find your app ID.'), - required: true, - }), -}; - -/** - * `-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 }), - 'page-size': Flags.integer({ default: 20, description: 'Items per page (max 100)', max: 100, min: 1 }), -}; - -/** The one place where flag names meet sdk field names. */ -export const pageParams = (flags: { 'page': number; 'page-size': number }): PageParams => - ({ page: flags.page, pageSize: flags['page-size'] }); diff --git a/src/cli/input/app.ts b/src/cli/input/app.ts new file mode 100644 index 0000000..96d87cf --- /dev/null +++ b/src/cli/input/app.ts @@ -0,0 +1,25 @@ +import { Args, Errors } from '@oclif/core'; + +import { exitCode } from '../errors.js'; + +const UUID_PATTERN = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; + +const isUuid = (value: string): boolean => UUID_PATTERN.test(value); + +/** + * Checking the shape of a value is the parser's job, so a run() never starts with one. oclif turns + * a *flag* parser's failure into exit 2 itself but passes an *arg* parser's error through, hence + * the explicit code. The hint is per resource: `adapty apps list` only helps for an app id. + */ +const uuidParser = (hint: string) => (input: string): Promise => (isUuid(input) + ? Promise.resolve(input) + : Promise.reject(new Errors.CLIError(hint, { exit: exitCode.usage }))); + +/** Spread into a command: `static args = { ...appIdArg }`. Texts kept as published. */ +export const appIdArg = { + app_id: Args.string({ + description: 'App ID (UUID)', + parse: uuidParser('Invalid app ID format. Run `adapty apps list` to find your app ID.'), + required: true, + }), +}; diff --git a/src/cli/input/migration.ts b/src/cli/input/migration.ts new file mode 100644 index 0000000..b9248a7 --- /dev/null +++ b/src/cli/input/migration.ts @@ -0,0 +1,12 @@ +import { Flags } from '@oclif/core'; + +/** Require an explicit migration ID, supplied by the flag or ADAPTY_MIGRATION. */ +export const migrationFlags = { + migration: Flags.string({ + char: 'm', + description: 'ID from `adapty migrations list`; overrides ADAPTY_MIGRATION. No automatic selection', + env: 'ADAPTY_MIGRATION', + helpValue: 'ID', + required: true, + }), +}; diff --git a/src/cli/input/pagination.ts b/src/cli/input/pagination.ts new file mode 100644 index 0000000..6bfa6a9 --- /dev/null +++ b/src/cli/input/pagination.ts @@ -0,0 +1,13 @@ +import { Flags } from '@oclif/core'; + +import type { PageParams } from '../../sdk/adapty/index.js'; + +/** 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 }), + 'page-size': Flags.integer({ default: 20, description: 'Items per page (max 100)', max: 100, min: 1 }), +}; + +/** The one place where flag names meet sdk field names. */ +export const pageParams = (flags: { 'page': number; 'page-size': number }): PageParams => + ({ page: flags.page, pageSize: flags['page-size'] }); diff --git a/src/cli/views/envelope.ts b/src/cli/views/envelope.ts deleted file mode 100644 index 77a00c6..0000000 --- a/src/cli/views/envelope.ts +++ /dev/null @@ -1,99 +0,0 @@ -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/cli/views/migrations/envelope/action-block.ts b/src/cli/views/migrations/envelope/action-block.ts new file mode 100644 index 0000000..4c868fb --- /dev/null +++ b/src/cli/views/migrations/envelope/action-block.ts @@ -0,0 +1,45 @@ +import type { Action } from '../../../../sdk/adapty/index.js'; + +const knownKinds = new Set(['external', 'input', 'upload']); + +const indent = (text: string, pad: string): string => { + return text.split('\n').map(line => `${pad}${line}`).join('\n'); +}; + +export const actionBlock = (action: Action, migrationId: string): string[] => { + const lines = [` ${action.action_id} (${action.kind}) ${action.title}`]; + 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) { + const commands = action.reads.map(name => `adapty migrations show ${name} -m ${migrationId}`); + + lines.push(` Read first: ${commands.join(', ')}`); + } + + if (action.confirm !== null) { + lines.push(' Confirmation:', indent(action.confirm, ' ')); + + if (action.kind === 'input') { + lines.push(' Review this text before passing --yes.'); + } + } + + if (action.kind === 'upload') { + lines.push(' File uploads are not supported by this CLI.'); + lines.push(' Use the dashboard or an offered Cloud Export action.'); + } + + if (!knownKinds.has(action.kind)) { + lines.push(' This action needs a newer adapty-cli'); + } + + return lines; +}; diff --git a/src/cli/views/migrations/envelope/envelope.ts b/src/cli/views/migrations/envelope/envelope.ts new file mode 100644 index 0000000..b4ef6b3 --- /dev/null +++ b/src/cli/views/migrations/envelope/envelope.ts @@ -0,0 +1,55 @@ +import { actionBlock } from './action-block.js'; +import { issueBlock } from './issue-block.js'; + +import type { Envelope, Migration, MigrationState, Progress } from '../../../../sdk/adapty/index.js'; + +const knownStates = new Set([ + 'running', 'action_required', 'completed', 'failed', 'canceled', +] satisfies MigrationState[]); + +const appLine = (app: Migration['app']): string => { + return 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}`; +}; + +/** Preserve server wording so new steps and actions need no CLI update. */ +export const renderEnvelope = (envelope: Envelope): string => { + const { migration } = envelope; + + const lines = [ + `${migration.id} ${migration.flow} ${migration.state}`, + appLine(migration.app), + migration.summary, + ]; + + if (!knownStates.has(migration.state)) { + lines.push('This migration state needs a newer adapty-cli'); + } + + if (migration.progress !== null) { + lines.push(progressLine(migration.progress)); + } + + if (envelope.issues.length > 0) { + lines.push('', 'Issues:', ...envelope.issues.flatMap(issue => issueBlock(issue, migration.id))); + } + + if (envelope.next_actions.length > 0) { + lines.push('', 'Do next:', ...envelope.next_actions.flatMap(action => actionBlock(action, migration.id))); + } + + if (envelope.available_actions.length > 0) { + lines.push('', 'Also available:', ...envelope.available_actions.flatMap(action => actionBlock(action, migration.id))); + } + + if (envelope.resources.length > 0) { + lines.push('', `Readable now: ${envelope.resources.map(resource => resource.name).join(', ')}`); + } + + return lines.join('\n'); +}; diff --git a/src/cli/views/migrations/envelope/issue-block.ts b/src/cli/views/migrations/envelope/issue-block.ts new file mode 100644 index 0000000..61075ef --- /dev/null +++ b/src/cli/views/migrations/envelope/issue-block.ts @@ -0,0 +1,19 @@ +import type { Issue } from '../../../../sdk/adapty/index.js'; + +const indent = (text: string, pad: string): string => { + return text.split('\n').map(line => `${pad}${line}`).join('\n'); +}; + +export const issueBlock = (issue: Issue, migrationId: string): 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} -m ${migrationId}`); + } + + return lines; +}; diff --git a/src/cli/views/migrations/index.ts b/src/cli/views/migrations/index.ts new file mode 100644 index 0000000..6abd562 --- /dev/null +++ b/src/cli/views/migrations/index.ts @@ -0,0 +1 @@ +export { renderEnvelope } from './envelope/envelope.js'; diff --git a/src/sdk/adapty/index.ts b/src/sdk/adapty/index.ts index c8f08f8..8e8d236 100644 --- a/src/sdk/adapty/index.ts +++ b/src/sdk/adapty/index.ts @@ -1,3 +1,4 @@ +import { systemClock } from '../core/clock.js'; import { createHttp } from '../core/http/index.js'; import { accessLevels } from './access-levels.js'; @@ -34,6 +35,7 @@ export type { ResourceRef, Step, StepStatus, + WaitOptions, WizardError, } from './migrations/index.js'; export type { PageParams, Paginated, Pagination } from './pagination.js'; @@ -50,6 +52,8 @@ export type AdaptyOptions = { baseUrl?: string | undefined; clock?: Clock | undefined; fetch?: typeof globalThis.fetch | undefined; + /** Whether a person is watching the caller, for the server's audit trail. */ + interactive?: boolean | undefined; onRetry?: ((info: RetryAttempt) => void) | undefined; signal?: AbortSignal | undefined; /** Without a token only auth is usable — that is how login builds the sdk. */ @@ -65,13 +69,33 @@ export type Adapty = { migrations: MigrationApi; }; +/** + * Who is calling, on every request of both clients: `User-Agent` says which program, section 3's + * `X-Adapty-Interactive` says whether a person is watching it. Both are claims a client makes about + * itself, so the server may log them and choose a format by them, never grant anything on them. + */ +const callerHeaders = (options: AdaptyOptions): Record | undefined => { + const headers: Record = {}; + + if (options.userAgent !== undefined) { + headers['user-agent'] = options.userAgent; + } + + // Sent as `false` too: "nobody was watching" is the half of the audit trail that matters. + if (options.interactive !== undefined) { + headers['x-adapty-interactive'] = String(options.interactive); + } + + return Object.keys(headers).length === 0 ? undefined : headers; +}; + /** The assembly point of the developer API: one transport, resources on top of it. */ export const createAdapty = (options: AdaptyOptions = {}): Adapty => { const transport = { baseUrl: options.baseUrl ?? DEFAULT_ADAPTY_API_URL, clock: options.clock, fetch: options.fetch, - headers: options.userAgent === undefined ? undefined : { 'user-agent': options.userAgent }, + headers: callerHeaders(options), onRetry: options.onRetry, parseError: developerErrorParser, signal: options.signal, @@ -89,6 +113,8 @@ export const createAdapty = (options: AdaptyOptions = {}): Adapty => { accessLevels: accessLevels(http), apps: apps(http), auth: auth(http), - migrations: migrations(wizard), + // The clock is a dependency here too, not only in the transport: `waitFor` sleeps + // between polls, and a test must be able to do that instantly. + migrations: migrations(wizard, options.clock ?? systemClock), }; }; diff --git a/src/sdk/adapty/migrations/action.ts b/src/sdk/adapty/migrations/action.ts new file mode 100644 index 0000000..b216c75 --- /dev/null +++ b/src/sdk/adapty/migrations/action.ts @@ -0,0 +1,33 @@ +import type { Issue } from '../../core/errors.js'; + +/** Pass the revision you read; the server rejects stale revisions with 409 revision_conflict. */ +export type RunActionInput = { + expectedRevision: number; + input?: unknown; +}; + +type RunActionRequest = { + expected_revision: number; + input: Record; +}; + +const isPlainObject = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}; + +/** Validate the input shape locally; the server validates its fields against the action schema. */ +export const validateActionInput = (input: unknown): Issue[] => { + if (input === undefined || isPlainObject(input)) { + return []; + } + + return [{ message: 'must be a JSON object', path: 'input' }]; +}; + +/** Omitted input is sent as an empty object. */ +export const toRunActionRequest = ({ expectedRevision, input }: RunActionInput): RunActionRequest => { + return { + expected_revision: expectedRevision, + input: isPlainObject(input) ? input : {}, + }; +}; diff --git a/src/sdk/adapty/migrations/close.ts b/src/sdk/adapty/migrations/close.ts new file mode 100644 index 0000000..dcdafd7 --- /dev/null +++ b/src/sdk/adapty/migrations/close.ts @@ -0,0 +1,26 @@ +import type { Issue } from '../../core/errors.js'; + +/** Both outcomes permanently close the migration. */ +export type CloseOutcome = 'cancel' | 'finish'; + +/** Accept strings so invalid outcomes produce a validation error for any caller. */ +export type CloseMigrationInput = { + expectedRevision: number; + outcome: string; +}; + +type CloseMigrationRequest = { + expected_revision: number; + outcome: string; +}; + +const OUTCOMES = new Set(['cancel', 'finish'] satisfies CloseOutcome[]); + +export const validateCloseMigration = ({ outcome }: CloseMigrationInput): Issue[] => + (OUTCOMES.has(outcome) ? [] : [{ message: 'must be `finish` or `cancel`', path: 'outcome' }]); + +/** The server rejects a stale revision with 409. */ +export const toCloseRequest = ({ expectedRevision, outcome }: CloseMigrationInput): CloseMigrationRequest => ({ + expected_revision: expectedRevision, + outcome, +}); diff --git a/src/sdk/adapty/migrations/create.ts b/src/sdk/adapty/migrations/create.ts index 2933910..0048406 100644 --- a/src/sdk/adapty/migrations/create.ts +++ b/src/sdk/adapty/migrations/create.ts @@ -1,16 +1,9 @@ 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. - */ +/** The main flow creates the Adapty app; other flows require an existing app. */ 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. - */ +/** Keep fields optional so incomplete input produces a validation error. */ export type CreateMigrationInput = { appId?: string | undefined; appName?: string | undefined; @@ -23,11 +16,7 @@ type CreateMigrationRequest = { 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. - */ +/** Accept either a new app name or an existing app ID with a flow. Issue paths identify CLI flags. */ export const validateCreateMigration = (input: CreateMigrationInput): Issue[] => { const { appId, appName, flow } = input; @@ -62,7 +51,6 @@ export const validateCreateMigration = (input: CreateMigrationInput): Issue[] => 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; @@ -71,8 +59,7 @@ export const toCreateRequest = (input: CreateMigrationInput): CreateMigrationReq } 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. + // The resource validates first; reaching this branch means a caller skipped validation. throw new Error('createMigration needs either appName, or appId with flow'); } diff --git a/src/sdk/adapty/migrations/index.ts b/src/sdk/adapty/migrations/index.ts index 0c7af63..b983140 100644 --- a/src/sdk/adapty/migrations/index.ts +++ b/src/sdk/adapty/migrations/index.ts @@ -1,12 +1,12 @@ -/** - * 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 { validateActionInput } from './action.js'; +export { validateCloseMigration } from './close.js'; export { validateCreateMigration } from './create.js'; export { migrations } from './resource.js'; +export type { RunActionInput } from './action.js'; +export type { CloseMigrationInput, CloseOutcome } from './close.js'; export type { CreateMigrationInput } from './create.js'; -export type { MigrationApi } from './resource.js'; +export type { MigrationApi, WaitOptions } from './resource.js'; export type { Action, ActionKind, diff --git a/src/sdk/adapty/migrations/model.ts b/src/sdk/adapty/migrations/model.ts index d55d3f7..3184608 100644 --- a/src/sdk/adapty/migrations/model.ts +++ b/src/sdk/adapty/migrations/model.ts @@ -30,7 +30,8 @@ export type Migration = { id: string; flow: string; revision: number; - state: MigrationState; + /** Known values are MigrationState; newer servers may introduce others. */ + state: string; app: { id: string; name: string } | null; poll_after_seconds: number; progress: Progress | null; @@ -73,7 +74,7 @@ export type Action = ActionBase & ( | { kind: 'input'; input_schema: JsonSchema | null } | { kind: 'upload' } | { kind: 'external'; href: string } - // A newer WS may send a kind this build does not know; href is kept so it can still be shown + // Preserve links for action kinds introduced by newer servers. | { kind: string; href?: string } ); diff --git a/src/sdk/adapty/migrations/resource.ts b/src/sdk/adapty/migrations/resource.ts index 56ab343..e1c2be5 100644 --- a/src/sdk/adapty/migrations/resource.ts +++ b/src/sdk/adapty/migrations/resource.ts @@ -2,40 +2,85 @@ import { randomUUID } from 'node:crypto'; import { assertValid } from '../../core/validation.js'; +import { toRunActionRequest, validateActionInput } from './action.js'; +import { toCloseRequest, validateCloseMigration } from './close.js'; import { toCreateRequest, validateCreateMigration } from './create.js'; +import { DEFAULT_TIMEOUT_MS, hasMoved, pollDelayMs } from './wait.js'; +import type { RunActionInput } from './action.js'; +import type { CloseMigrationInput } from './close.js'; import type { CreateMigrationInput } from './create.js'; import type { Envelope, MigrationList } from './model.js'; +import type { Clock } from '../../core/clock.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. - */ - -/** - * 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. - */ +/** Reuse one idempotency key across retries to avoid applying the same write twice. */ const write = (): RequestOptions => ({ headers: { 'idempotency-key': randomUUID() }, idempotent: true, }); -export const migrations = (http: Http) => ({ +export type WaitOptions = { + /** Called before each pause with the latest response and the upcoming delay. */ + onPoll?: ((envelope: Envelope, delayMs: number) => void) | undefined; + signal?: AbortSignal | undefined; + timeoutMs?: number | undefined; +}; + +export const migrations = (http: Http, clock: Clock) => ({ 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()); }, + + /** Closing does not require an offered action. */ + close: async (id: string, input: CloseMigrationInput): Promise => { + assertValid(validateCloseMigration(input)); + + return http.post(`/migrations/${id}/close`, toCloseRequest(input), write()); + }, + + /** + * Poll until the revision changes or the state leaves running. + * Return the last response when the wait budget runs out. + */ + waitFor: async (id: string, options: WaitOptions = {}): Promise => { + const deadline = clock.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + + let envelope = await http.get(`/migrations/${id}`); + const baseline = envelope.migration.revision; + + while (!hasMoved(envelope, baseline)) { + const delayMs = pollDelayMs(envelope); + + // Stop if the next pause would exceed the deadline. + if (clock.now() + delayMs > deadline) { + return envelope; + } + + options.onPoll?.(envelope, delayMs); + await clock.sleep(delayMs, options.signal); + + envelope = await http.get(`/migrations/${id}`); + } + + return envelope; + }, + + runAction: async (id: string, actionId: string, input: RunActionInput): Promise => { + assertValid(validateActionInput(input.input)); + + const path = `/migrations/${id}/actions/${actionId}`; + + return http.post(path, toRunActionRequest(input), write()); + }, }); export type MigrationApi = ReturnType; diff --git a/src/sdk/adapty/migrations/wait.ts b/src/sdk/adapty/migrations/wait.ts new file mode 100644 index 0000000..d0da0ed --- /dev/null +++ b/src/sdk/adapty/migrations/wait.ts @@ -0,0 +1,16 @@ +import type { Envelope } from './model.js'; + +const MIN_POLL_SECONDS = 5; + +export const DEFAULT_TIMEOUT_MS = 120_000; + +/** Use the server's delay, with a five-second minimum to avoid rapid polling. */ +export const pollDelayMs = (envelope: Envelope): number => + Math.max(envelope.migration.poll_after_seconds, MIN_POLL_SECONDS) * 1000; + +/** Unknown states also end the wait. */ +export const hasMoved = (envelope: Envelope, baselineRevision: number): boolean => { + const { revision, state } = envelope.migration; + + return revision !== baselineRevision || state !== 'running'; +}; diff --git a/test/cli/base.test.ts b/test/cli/base.test.ts index fe15d22..6fa3873 100644 --- a/test/cli/base.test.ts +++ b/test/cli/base.test.ts @@ -71,6 +71,15 @@ class ErrorProbe extends BaseCommand { } } +/** The one question two consumers ask: the header the sdk sends, and the browser `run` may open. */ +class InteractiveProbe extends BaseCommand { + async run(): Promise<{ interactive: boolean }> { + await this.parse(InteractiveProbe); + + return { interactive: this.interactive }; + } +} + let viewCalls = 0; class RenderProbe extends BaseCommand { @@ -87,6 +96,25 @@ class RenderProbe extends BaseCommand { } } +/** Node deletes isTTY on a pipe rather than setting it false, so a stub has nothing to replace. */ +const withTty = async (isTTY: boolean, body: () => Promise): Promise => { + const stream = process.stdout as { isTTY?: boolean | undefined }; + const had = Object.hasOwn(stream, 'isTTY'); + const original = stream.isTTY; + + stream.isTTY = isTTY; + + try { + await body(); + } finally { + if (had) { + stream.isTTY = original; + } else { + delete stream.isTTY; + } + } +}; + const requestHeaders = (stub: sinon.SinonStub, callIndex: number): Headers => { const init = stub.getCall(callIndex).args[1] as RequestInit; @@ -206,11 +234,38 @@ describe('cli base commands', () => { expect(headers.get('authorization')).to.equal('Bearer stored-token'); // oclif's own config.userAgent would read `adapty/ darwin-arm64 …` expect(headers.get('user-agent')).to.contain(`adapty-cli/${config.version}`); + // The suite runs piped, which is the answer the server is told: section 3 wants this + // recorded either way, so `false` travels as a value, not as a missing header. + expect(headers.get('x-adapty-interactive')).to.equal('false'); } finally { stub.restore(); } }); + it('calls a run interactive only when a terminal is there and nothing is parsing the output', async () => { + await withTty(true, async () => { + const { result: watched } = await captureOutput<{ interactive: boolean }>( + async () => InteractiveProbe.run([], config), + ); + + const { result: parsed } = await captureOutput<{ interactive: boolean }>( + async () => InteractiveProbe.run(['--json'], config), + ); + + expect(watched?.interactive).to.equal(true); + // A terminal is still there; --json says a program is reading what it prints. + expect(parsed?.interactive).to.equal(false); + }); + }); + + it('answers false, not undefined, on the pipe where Node leaves isTTY unset', async () => { + const { result } = await captureOutput<{ interactive: boolean }>( + async () => InteractiveProbe.run([], config), + ); + + expect(result?.interactive).to.equal(false); + }); + it('turns Ctrl+C into an abort and exit 130 instead of a silent success', async () => { const { error } = await captureOutput(async () => CancelProbe.run([], config)); diff --git a/test/cli/command-layout.test.ts b/test/cli/command-layout.test.ts index 0d51a20..937756e 100644 --- a/test/cli/command-layout.test.ts +++ b/test/cli/command-layout.test.ts @@ -1,5 +1,5 @@ import { readdir, readFile } from 'node:fs/promises'; -import { dirname, join, relative, resolve, sep } from 'node:path'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; import { expect } from 'chai'; @@ -8,9 +8,9 @@ const SRC = join(ROOT, 'src'); const COMMANDS = join(SRC, 'cli', 'commands'); /** - * A command that outgrows one file becomes a directory: `apps/create/index.ts` is the command - * (oclif collapses `index` into the directory's id) and `apps/create/lib/*.ts` is its own - * business, nobody else's. + * A command that outgrows one file becomes a directory: `apps/create/command.ts` declares the class, + * `apps/create/index.ts` is the door the command id resolves to, and `apps/create/lib/*.ts` is its + * own business, nobody else's. * * Eslint blocks the flat spelling of a stranger's helper: the freeze on src/lib re-includes only * `./lib/*`, the lib next to the importer. Left over for here is what a specifier pattern cannot @@ -20,6 +20,9 @@ const COMMANDS = join(SRC, 'cli', 'commands'); */ const SPECIFIER = /(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g; +/** The whole of a door. A directory command is read by opening `command.ts`, never by comparing two. */ +const DOOR = /^export \{ default \} from '\.\/command\.js';$/m; + async function tsFiles(dir: string): Promise { const entries = await readdir(dir, { withFileTypes: true }); const found: string[] = []; @@ -83,23 +86,23 @@ describe('command layout', () => { } } - expect(outsiders, 'move it to cli/views or cli/flags (adapter) or to sdk/adapty (product)').to.deep.equal([]); + expect(outsiders, 'move it to cli/views or cli/input (adapter) or to sdk/adapty (product)').to.deep.equal([]); }); it('lets only a command own a lib/, so no topic grows a shared one', () => { const known = new Set(files); const orphans = [...new Set(files.map(file => ownerOf(file)))] - .filter(owner => owner !== undefined && !known.has(join(owner, 'index.ts'))) + .filter(owner => owner !== undefined && !known.has(join(owner, 'command.ts'))) .map(owner => relative(SRC, owner ?? '')); - expect(orphans, 'a lib/ needs an index.ts next to it').to.deep.equal([]); + expect(orphans, 'a lib/ needs the command.ts it serves next to it').to.deep.equal([]); }); it('keeps a helper out of the command tree itself, where it would become a command', async () => { // The glob exempts `lib/` and nothing else, so `status/result.ts` next to `status/index.ts` - // would ship as the command `auth status result`. A command file is the one that declares - // the class oclif runs. + // would ship as the command `auth status result`. Two files are allowed to sit there: the + // one that declares the class, and the door that points at it. const strays: string[] = []; for (const file of files.filter(candidate => candidate.startsWith(COMMANDS + sep))) { @@ -108,13 +111,14 @@ describe('command layout', () => { } const source = await readFile(file, 'utf8'); + const belongs = basename(file) === 'index.ts' ? DOOR.test(source) : source.includes('export default class'); - if (!source.includes('export default class')) { + if (!belongs) { strays.push(relative(SRC, file)); } } - expect(strays, 'a file that is not a command belongs in that command lib/').to.deep.equal([]); + expect(strays, 'a command declares its class in command.ts, and its index.ts only re-exports it').to.deep.equal([]); }); it('tells oclif to skip lib/ when it looks for commands', async () => { diff --git a/test/cli/commands/migrations/list/render.test.ts b/test/cli/commands/migrations/list/render.test.ts index 922d60f..658838c 100644 --- a/test/cli/commands/migrations/list/render.test.ts +++ b/test/cli/commands/migrations/list/render.test.ts @@ -85,6 +85,6 @@ describe('renderMigrationList', () => { it('shows a start hint for an empty list', () => { expect(renderMigrationList({ available: [], items: [] })) - .to.equal('No migrations yet. Start one: `adapty migration create --name `'); + .to.equal('No migrations yet. Start one: `adapty migrations create --name "My app"`'); }); }); diff --git a/test/cli/commands/migrations/show/render.test.ts b/test/cli/commands/migrations/show/render.test.ts new file mode 100644 index 0000000..e4c66ba --- /dev/null +++ b/test/cli/commands/migrations/show/render.test.ts @@ -0,0 +1,59 @@ +import { expect } from 'chai'; + +import { renderResources, renderResult } from '../../../../../src/cli/commands/migrations/show/lib/render.js'; + +import type { Envelope, ResourceRef } from '../../../../../src/sdk/adapty/index.js'; + +const envelopeWith = (resources: ResourceRef[], result: unknown = null): Envelope => ({ + available_actions: [], + issues: [], + migration: { + app: null, + created_at: '2026-09-14T09:00:00Z', + flow: 'main', + id: 'mig_1', + poll_after_seconds: 5, + progress: null, + revision: 1, + state: 'action_required', + summary: 'Connect RevenueCat first', + updated_at: '2026-09-14T09:00:00Z', + }, + next_actions: [], + resources, + result, + steps: [], +}); + +describe('migrations show views', () => { + it('lists what can be read now and shows how to read the first of them', () => { + const result = renderResources(envelopeWith([ + { name: 'apps', title: 'RevenueCat apps' }, + { name: 'mapping', title: 'RC → Adapty mapping' }, + ])); + + expect(result.split('\n')).to.deep.equal([ + 'Readable now:', + ' apps RevenueCat apps', + ' mapping RC → Adapty mapping', + '', + 'Read one: `adapty migrations show apps -m mig_1`', + ]); + }); + + it('says why there is nothing to read rather than printing an empty list', () => { + expect(renderResources(envelopeWith([]))).to.equal('Nothing to read yet: Connect RevenueCat first'); + }); + + it('prints the resource as JSON, whatever shape the flow gave it', () => { + const rows = [{ rc_id: 'prod_1', status: 'needs_decision' }]; + + expect(renderResult(envelopeWith([], rows))).to.equal(JSON.stringify(rows, null, 2)); + expect(renderResult(envelopeWith([], { markdown: '# Report' }))).to.equal('{\n "markdown": "# Report"\n}'); + }); + + it('tells a resource that is empty apart from a resource that is not there', () => { + // The server answered with the envelope, so the name was right: only the data is missing. + expect(renderResult(envelopeWith([]))).to.equal('No data in this resource yet: Connect RevenueCat first'); + }); +}); diff --git a/test/cli/commands/migrations/status/notice.test.ts b/test/cli/commands/migrations/status/notice.test.ts new file mode 100644 index 0000000..54c0c96 --- /dev/null +++ b/test/cli/commands/migrations/status/notice.test.ts @@ -0,0 +1,46 @@ +import { expect } from 'chai'; + +import { pollNotice } from '../../../../../src/cli/commands/migrations/status/lib/notice.js'; + +import type { Envelope, Progress } from '../../../../../src/sdk/adapty/index.js'; + +const envelopeWith = (progress: Progress | null): Envelope => ({ + available_actions: [], + issues: [], + migration: { + app: null, + created_at: '2026-09-14T09:00:00Z', + flow: 'main', + id: 'mig_1', + poll_after_seconds: 30, + progress, + revision: 1, + state: 'running', + summary: 'Creating the catalog in Adapty', + updated_at: '2026-09-14T09:00:00Z', + }, + next_actions: [], + resources: [], + result: null, + steps: [], +}); + +describe('migrations status poll notice', () => { + it('says what the server is doing and when it will be asked again', () => { + const notice = pollNotice(envelopeWith({ done: 12, total: 47, unit: 'entities' }), 30_000); + + expect(notice).to.equal('running 12 of 47 entities — checking again in 30s'); + }); + + it('counts what is done while the server does not know the total yet', () => { + const notice = pollNotice(envelopeWith({ done: 1200, total: null, unit: 'profiles' }), 5000); + + expect(notice).to.equal('running 1200 profiles — checking again in 5s'); + }); + + it('still reports the state when there is no progress to report', () => { + const notice = pollNotice(envelopeWith(null), 5000); + + expect(notice).to.equal('running — checking again in 5s'); + }); +}); diff --git a/test/cli/commands/migrations/steps/render.test.ts b/test/cli/commands/migrations/steps/render.test.ts new file mode 100644 index 0000000..6155082 --- /dev/null +++ b/test/cli/commands/migrations/steps/render.test.ts @@ -0,0 +1,64 @@ +import { expect } from 'chai'; + +import { renderSteps } from '../../../../../src/cli/commands/migrations/steps/lib/render.js'; + +import type { Envelope, Step } from '../../../../../src/sdk/adapty/index.js'; + +const envelopeWith = (steps: Step[]): Envelope => ({ + available_actions: [], + issues: [], + migration: { + app: null, + created_at: '2026-09-14T09:00:00Z', + flow: 'main', + id: 'mig_1', + poll_after_seconds: 5, + progress: null, + revision: 1, + state: 'action_required', + summary: 'Nothing planned yet', + updated_at: '2026-09-14T09:00:00Z', + }, + next_actions: [], + resources: [], + result: null, + steps, +}); + +const step = (stepId: string, status: Step['status'], summary: string | null = null): Step => ({ + status, + step_id: stepId, + summary, + title: `Title of ${stepId}`, +}); + +describe('renderSteps', () => { + it('marks each step and keeps the order the server sent', () => { + const result = renderSteps(envelopeWith([ + step('step_apps', 'done', '2 apps migrated'), + step('step_paywalls', 'active'), + step('step_products', 'locked'), + ])); + + expect(result.split('\n')).to.deep.equal([ + '[x] step_apps Title of step_apps 2 apps migrated', + '[>] step_paywalls Title of step_paywalls', + '[ ] step_products Title of step_products', + ]); + }); + + it('prints a status it has never heard of instead of guessing a mark', () => { + const future = { ...step('step_new', 'locked'), status: 'skipped' } as unknown as Step; + + const result = renderSteps(envelopeWith([future, step('step_apps', 'done')])); + + expect(result.split('\n')).to.deep.equal([ + '[skipped] step_new Title of step_new', + '[x] step_apps Title of step_apps', + ]); + }); + + it('says why the checklist is empty rather than printing nothing', () => { + expect(renderSteps(envelopeWith([]))).to.equal('No steps yet: Nothing planned yet'); + }); +}); diff --git a/test/cli/errors.test.ts b/test/cli/errors.test.ts index 4fbd747..84d62b3 100644 --- a/test/cli/errors.test.ts +++ b/test/cli/errors.test.ts @@ -1,3 +1,4 @@ +import { Errors } from '@oclif/core'; import { expect } from 'chai'; import { exitCode, toCliError } from '../../src/cli/errors.js'; @@ -18,6 +19,11 @@ type CliError = Error & { code?: string; exitCode?: number; oclif?: { exit?: num const cases: [AnySdkError, number][] = [ [new ApiError({ code: 'validation_error', message: 'title: is required', status: 400 }), exitCode.api], + [new ApiError({ code: 'forbidden', message: 'Access denied', status: 403 }), exitCode.auth], + [new ApiError({ code: 'migration_wizard_no_company', message: 'No company', status: 403 }), exitCode.auth], + [new ApiError({ message: 'Forbidden', status: 403 }), exitCode.auth], + [new ApiError({ code: 'revision_conflict', message: 'Revision changed', status: 409 }), exitCode.api], + [new ApiError({ code: 'validation_failed', message: 'Invalid input', status: 422 }), exitCode.api], [new AuthRequiredError('missing'), exitCode.auth], [new AuthRequiredError('rejected'), exitCode.auth], [new CancelledError(), exitCode.cancelled], @@ -33,6 +39,7 @@ describe('toCliError', () => { const mapped = toCliError(error) as CliError; expect(mapped.exitCode, error.kind).to.equal(exit); + expect(mapped.oclif?.exit, error.kind).to.equal(exit); expect(mapped.message, error.kind).to.not.equal(''); } }); @@ -45,6 +52,30 @@ describe('toCliError', () => { expect(mapped.exitCode).to.equal(130); }); + it('preserves oclif errors and their assigned exits for JSON handling', () => { + for (const exit of [exitCode.usage, exitCode.cancelled]) { + const error = new Errors.CLIError('Invalid flag', { code: 'invalid_flag', exit, suggestions: ['Use --help'] }); + const mapped = toCliError(error) as CliError; + + expect(mapped).to.equal(error); + expect(mapped.exitCode).to.equal(exit); + expect(mapped.oclif?.exit).to.equal(exit); + expect(mapped.message).to.equal('Invalid flag'); + expect(mapped.code).to.equal('invalid_flag'); + expect(error.suggestions).to.deep.equal(['Use --help']); + } + }); + + it('keeps an existing exitCode and does not turn non-exiting errors into usage errors', () => { + const explicit = Object.assign(new Errors.CLIError('Already mapped', { exit: 4 }), { exitCode: 4 }); + const nonExiting = new Errors.CLIError('Handled elsewhere', { exit: false }); + + expect(toCliError(explicit)).to.equal(explicit); + expect(explicit.exitCode).to.equal(4); + expect(toCliError(nonExiting)).to.equal(nonExiting); + expect(nonExiting).to.not.have.property('exitCode'); + }); + it('names the flag the user typed, not the sdk field, for a validation issue', () => { const error = new ValidationError([ { message: 'is required', path: 'appleBundleId' }, @@ -74,6 +105,7 @@ describe('toCliError', () => { const foreign = new TypeError('boom'); expect(toCliError(foreign)).to.equal(foreign); + expect(foreign).to.not.have.property('exitCode'); expect(toCliError('boom').message).to.equal('boom'); }); }); diff --git a/test/cli/views/envelope.test.ts b/test/cli/views/migrations/envelope.test.ts similarity index 62% rename from test/cli/views/envelope.test.ts rename to test/cli/views/migrations/envelope.test.ts index f4c91a4..4dbbe5d 100644 --- a/test/cli/views/envelope.test.ts +++ b/test/cli/views/migrations/envelope.test.ts @@ -3,11 +3,11 @@ import { fileURLToPath } from 'node:url'; import { expect } from 'chai'; -import { renderEnvelope } from '../../../src/cli/views/envelope.js'; +import { renderEnvelope } from '../../../../src/cli/views/migrations/index.js'; -import type { Envelope } from '../../../src/sdk/adapty/index.js'; +import type { Envelope } from '../../../../src/sdk/adapty/index.js'; -const FIXTURE_PATH = fileURLToPath(new URL('../../fixtures/migration-envelope.json', import.meta.url)); +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 => @@ -27,6 +27,20 @@ describe('renderEnvelope', () => { expect(renderEnvelope(withMigration({ app: null }))).to.contain('App: not created yet'); }); + it('preserves an unknown state and explains that the CLI needs updating', () => { + const result = renderEnvelope(withMigration({ state: 'paused_for_review' })); + + expect(result).to.contain('mig_01H9Z main paused_for_review'); + expect(result).to.contain(ENVELOPE.migration.summary); + expect(result).to.contain('This migration state needs a newer adapty-cli'); + }); + + for (const state of ['running', 'action_required', 'completed', 'failed', 'canceled']) { + it(`does not suggest an upgrade for the known state ${state}`, () => { + expect(renderEnvelope(withMigration({ state }))).to.not.contain('needs a newer adapty-cli'); + }); + } + it('leaves out an unknown total rather than printing null', () => { const result = renderEnvelope(withMigration({ progress: { done: 12, total: null, unit: 'profiles' } })); @@ -39,8 +53,30 @@ describe('renderEnvelope', () => { 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'); + expect(result).to.contain(' Read first: adapty migrations show step_paywalls -m mig_01H9Z'); + expect(result).to.contain(' Confirmation:\n This cannot be undone. Continue?'); + expect(result).to.contain(' Review this text before passing --yes.'); + }); + + it('keeps every line of confirmation text and marks uploads as unsupported', () => { + const result = renderEnvelope({ + ...ENVELOPE, + next_actions: [{ + action_id: 'upload_data', + confirm: 'Existing data will be replaced.\nReview the report first.', + detail: null, + kind: 'upload', + reads: [], + step_id: 'import', + title: 'Upload data', + }], + }); + + expect(result).to.contain(' Existing data will be replaced.\n Review the report first.'); + expect(result).to.contain('File uploads are not supported by this CLI.'); + expect(result).to.contain('Use the dashboard or an offered Cloud Export action.'); + expect(result).to.not.contain('--yes'); + expect(result).to.not.contain('needs a newer adapty-cli'); }); it('keeps optional actions apart from the ones that block the migration, and prints their link', () => { diff --git a/test/cli/wizard-errors.test.ts b/test/cli/wizard-errors.test.ts new file mode 100644 index 0000000..f2d006f --- /dev/null +++ b/test/cli/wizard-errors.test.ts @@ -0,0 +1,105 @@ +import { expect } from 'chai'; + +import { toCliError } from '../../src/cli/errors.js'; +import { ApiError } from '../../src/sdk/core/errors.js'; + +import type { CliError } from '../../src/cli/errors.js'; + +const mapError = (details: unknown): CliError => toCliError(new ApiError({ + code: 'validation_error', details, message: 'Invalid input', status: 422, +})) as CliError; + +describe('Wizard Service error diagnostics', () => { + it('preserves null, false, empty fields and zero delay in JSON without rendering empty sections', () => { + for (const delay of [null, 0]) { + const diagnostics = { + detail: null, + fields: [], + next_step: null, + request_id: '', + retry_after_seconds: delay, + retryable: false, + }; + + const mapped = mapError({ error: { code: 'validation_error', ...diagnostics } }); + + expect(mapped.json).to.deep.include(diagnostics); + expect(mapped.message).to.equal('Invalid input'); + } + }); + + it('preserves retry guidance without appending it to the original JSON message', () => { + const mapped = mapError({ error: { + code: 'validation_error', + detail: 'Invalid input', + next_step: 'Check status.', + request_id: 'req_test', + retry_after_seconds: 30, + retryable: true, + } }); + + expect(mapped.json).to.include({ message: 'Invalid input', retry_after_seconds: 30, retryable: true }); + expect(mapped.message).to.equal('Invalid input\n\nNext step: Check status.\nRequest ID: req_test'); + }); + + it('omits missing diagnostics for minimal or non-object responses', () => { + for (const body of [null, 'Bad gateway', {}, { error: null }, { error: { code: 'validation_error' } }]) { + const mapped = mapError(body); + + expect(JSON.parse(JSON.stringify(mapped.json))).to.deep.equal({ + code: 'validation_error', + error_code: 'validation_error', + message: 'Invalid input', + status: 422, + status_code: 422, + }); + + expect(mapped.message).to.equal('Invalid input'); + } + }); + + it('ignores malformed diagnostics and strips unknown fields from the response', () => { + const mapped = mapError({ error: { + code: 'validation_error', + detail: {}, + fields: [null, 'invalid', { path: 'input.name' }, { path: 'input.id', message: 'Required', internal: 'hidden' }], + internal: 'hidden', + next_step: [], + request_id: 42, + retry_after_seconds: -1, + retryable: 'false', + } }); + + expect(JSON.parse(JSON.stringify(mapped.json))).to.deep.equal({ + code: 'validation_error', + error_code: 'validation_error', + fields: [{ path: 'input.id', message: 'Required' }], + message: 'Invalid input', + status: 422, + status_code: 422, + }); + + expect(mapped.message).to.equal('Invalid input\n\n input.id: Required'); + }); + + it('keeps Developer API errors and their precedence when both error formats are present', () => { + const fields = { title: ['must not be blank'] }; + + const mapped = mapError({ + error: { code: 'nested_code', detail: 'Not the selected error', fields: [] }, + error_code: 'validation_error', + errors: fields, + }); + + expect(JSON.parse(JSON.stringify(mapped.json))).to.deep.equal({ + code: 'validation_error', + error_code: 'validation_error', + errors: fields, + message: 'Invalid input', + status: 422, + status_code: 422, + }); + + expect(mapped.message).to.equal('Invalid input'); + }); +}); diff --git a/test/commands/migrations-exit-codes.test.ts b/test/commands/migrations-exit-codes.test.ts new file mode 100644 index 0000000..d59b21d --- /dev/null +++ b/test/commands/migrations-exit-codes.test.ts @@ -0,0 +1,122 @@ +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; + +import { expect } from 'chai'; + +const ROOT = join(import.meta.dirname, '..', '..'); + +const cases = [ + { args: ['status'], message: 'Missing required flag migration', name: 'missing migration ID' }, + { args: ['run', '-m', 'mig_test'], message: 'action_id', name: 'missing action argument' }, + { + args: ['create', '--name', 'My app', '--flow', 'transactions', '--app', 'app_test'], + message: 'cannot also be provided', + name: 'conflicting flags', + }, + { args: ['status', '-m', 'mig_test', '--unknown'], message: 'Nonexistent flag', name: 'unknown flag' }, + { + args: ['status', '-m', 'mig_test', '--wait', '--timeout', '900s'], + message: 'Invalid duration', + name: 'invalid timeout', + }, + { + args: ['status', '-m', 'mig_test', '--timeout', '5m'], + message: '--wait', + name: 'missing dependent flag', + }, + { + args: ['close', '-m', 'mig_test', '--outcome', 'finish'], + message: 'Missing required flag yes', + name: 'missing closure confirmation flag', + }, + { args: ['create'], message: 'Invalid input', name: 'SDK input validation' }, +]; + +describe('migration process exit codes', () => { + it('returns auth exit 3 for HTTP 403 and preserves server diagnostics in human and JSON modes', () => { + const serverError = { + code: 'forbidden', + detail: 'This user cannot access the migration.', + fields: [], + message: 'Access denied', + next_step: 'Ask the company owner to grant access.', + request_id: 'req_forbidden', + retry_after_seconds: null, + retryable: false, + }; + + const script = ` + import { execute } from '@oclif/core'; + const body = JSON.parse(process.env.MIGRATION_TEST_ERROR); + let requests = 0; + globalThis.fetch = async () => { + if (++requests > 1) throw new Error('Unexpected retry of HTTP 403'); + return new Response(JSON.stringify(body), { + status: 403, headers: { 'content-type': 'application/json' }, + }); + }; + await execute({ args: JSON.parse(process.env.MIGRATION_TEST_ARGS), dir: process.cwd() }); + `; + + for (const json of [false, true]) { + const args = ['migrations', 'status', '-m', 'mig_test', ...(json ? ['--json'] : [])]; + + const child = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: ROOT, + encoding: 'utf8', + env: { + ...process.env, + ADAPTY_TOKEN: 'test-token', + MIGRATION_TEST_ARGS: JSON.stringify(args), + MIGRATION_TEST_ERROR: JSON.stringify({ error: serverError }), + }, + timeout: 10_000, + }); + + expect(child.error).to.equal(undefined); + expect(child.status, child.stderr || child.stdout).to.equal(3); + + if (json) { + expect(JSON.parse(child.stdout)).to.deep.equal({ error: { + ...serverError, error_code: 'forbidden', status: 403, status_code: 403, + } }); + } else { + expect(child.stdout).to.equal(''); + expect(child.stderr).to.contain(serverError.message); + expect(child.stderr).to.contain(serverError.detail); + expect(child.stderr).to.contain(serverError.next_step); + expect(child.stderr).to.contain(serverError.request_id); + } + } + }); + + // Each invocation needs its own process: oclif catches JSON errors without rethrowing them. + for (const { args, message, name } of cases) { + it(`returns usage exit 2 in human and JSON modes for ${name}`, () => { + const env = { ...process.env }; + + delete env.ADAPTY_MIGRATION; + delete env.ADAPTY_TOKEN; + delete env.CONTENT_TYPE; + + for (const json of [false, true]) { + const child = spawnSync(process.execPath, [ + join(ROOT, 'bin/run.js'), 'migrations', ...args, ...(json ? ['--json'] : []), + ], { cwd: ROOT, encoding: 'utf8', env, timeout: 10_000 }); + + expect(child.error).to.equal(undefined); + expect(child.status, child.stderr || child.stdout).to.equal(2); + + if (json) { + const output = JSON.parse(child.stdout) as { error: { message: string } }; + + expect(output.error.message).to.contain(message); + expect(output.error).to.have.all.keys('message'); + } else { + expect(child.stdout).to.equal(''); + expect(child.stderr).to.contain(message); + } + } + }); + } +}); diff --git a/test/commands/migrations.test.ts b/test/commands/migrations.test.ts index 41c9526..bd63795 100644 --- a/test/commands/migrations.test.ts +++ b/test/commands/migrations.test.ts @@ -1,21 +1,80 @@ import { readFileSync } from 'node:fs'; +import { Readable } from 'node:stream'; import { fileURLToPath } from 'node:url'; import { runCommand } from '@oclif/test'; import { expect } from 'chai'; +import * as sinon from 'sinon'; import { exitCode } from '../../src/cli/errors.js'; import { assertFetch, mockFetch, + mockFetchFailure, restoreFetch, TEST_APP_ID, } from '../helpers/mock-fetch.js'; -import type sinon from 'sinon'; +import type { Envelope, WizardError } 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 unknown; +const ENVELOPE = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as Envelope; +const REPORT: Envelope = { ...ENVELOPE, result: { rows: [{ adapty: 'app_1', revenuecat: 'app_ios' }] } }; +const INPUT_FILE = fileURLToPath(new URL('../fixtures/action-input.json', import.meta.url)); + +const CONFIRMED = 'act_confirm_paywalls'; + +const WIZARD_ERROR: WizardError = { error: { + code: 'validation_error', + detail: 'A mapping decision is missing.', + fields: [{ path: 'input.decision', message: 'This field is required.' }], + message: 'Invalid action input', + next_step: 'Read status and prepare input using the current schema.', + request_id: 'req_example', + retry_after_seconds: null, + retryable: false, +} }; + +const UPLOADS: Envelope = { + ...ENVELOPE, + next_actions: [{ + action_id: 'upload_file', + confirm: null, + detail: null, + kind: 'upload', + reads: [], + step_id: 'step_import', + title: 'Upload the RevenueCat export', + }], +}; + +/** A kind this version does not know, handed over as a link the way external actions are. */ +const LINKED: Envelope = { + ...ENVELOPE, + next_actions: [{ + action_id: 'act_sign_agreement', + confirm: null, + detail: null, + href: 'https://app.adapty.io/migrations/mig_01H9Z/agreement', + kind: 'sign', + reads: [], + step_id: 'step_import', + title: 'Sign the agreement', + }], +}; + +const FUTURE_ACTION: Envelope = { + ...ENVELOPE, + next_actions: [{ + action_id: 'act_future', + confirm: 'Review the consequences before proceeding.', + detail: 'Follow the instructions from the newer server.', + kind: 'future_kind', + reads: [], + step_id: 'step_import', + title: 'A new migration action', + }], +}; describe('migrations', () => { let fetchStub: sinon.SinonStub; @@ -26,6 +85,7 @@ describe('migrations', () => { afterEach(() => { restoreFetch(fetchStub); + delete process.env.ADAPTY_MIGRATION; delete process.env.ADAPTY_TOKEN; }); @@ -35,6 +95,441 @@ describe('migrations', () => { assertFetch({ callIndex: 0, method: 'GET', path: '/migrations', stub: fetchStub }); }); + it('preserves server diagnostics in JSON through the HTTP and CLI adapters', async () => { + fetchStub = mockFetchFailure(WIZARD_ERROR, { status: 422 }); + + const { stdout } = await runCommand('migrations status -m mig_01H9Z --json'); + + expect(JSON.parse(stdout)).to.deep.equal({ error: { + ...WIZARD_ERROR.error, + error_code: 'validation_error', + status: 422, + status_code: 422, + } }); + + expect(fetchStub.callCount).to.equal(1); + }); + + it('shows server details, field errors and the next step to a human with exit 4', async () => { + fetchStub = mockFetchFailure(WIZARD_ERROR, { status: 422 }); + + const { error, stdout } = await runCommand('migrations status -m mig_01H9Z'); + + expect(stdout).to.equal(''); + expect(error?.oclif?.exit).to.equal(exitCode.api); + + expect(error?.message).to.equal([ + 'Invalid action input', + 'A mapping decision is missing.', + '', + ' input.decision: This field is required.', + '', + 'Next step: Read status and prepare input using the current schema.', + 'Request ID: req_example', + ].join('\n')); + + expect(fetchStub.callCount).to.equal(1); + }); + + it('status reads the migration it was pointed at', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { stdout } = await runCommand('migrations status -m mig_01H9Z'); + + assertFetch({ callIndex: 0, method: 'GET', path: '/migrations/mig_01H9Z', stub: fetchStub }); + expect(fetchStub.callCount).to.equal(1); + expect(stdout).to.contain('mig_01H9Z main action_required'); + expect(stdout).to.contain('Do next:'); + }); + + it('status takes the migration from $ADAPTY_MIGRATION', async () => { + process.env.ADAPTY_MIGRATION = 'mig_env'; + fetchStub = mockFetch([ENVELOPE]); + + await runCommand('migrations status'); + + assertFetch({ callIndex: 0, method: 'GET', path: '/migrations/mig_env', stub: fetchStub }); + }); + + it('status asks for the migration instead of picking one, before any request', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { error } = await runCommand('migrations status'); + + expect(error?.oclif?.exit).to.equal(exitCode.usage); + expect(error?.message).to.contain('migration'); + expect(fetchStub.callCount).to.equal(0); + }); + + it('status --wait stops at once when the migration already wants the user', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { stdout } = await runCommand('migrations status -m mig_01H9Z --wait'); + + assertFetch({ callIndex: 0, method: 'GET', path: '/migrations/mig_01H9Z', stub: fetchStub }); + expect(fetchStub.callCount).to.equal(1); + expect(stdout).to.contain('mig_01H9Z main action_required'); + }); + + it('status refuses a timeout without --wait, rather than quietly not waiting', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { error } = await runCommand('migrations status -m mig_01H9Z --timeout 300s'); + + expect(error?.oclif?.exit).to.equal(exitCode.usage); + expect(error?.message).to.contain('--wait'); + expect(fetchStub.callCount).to.equal(0); + }); + + it('status refuses a timeout it cannot honour, before any request', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { error } = await runCommand('migrations status -m mig_01H9Z --wait --timeout 900s'); + + expect(error?.oclif?.exit).to.equal(exitCode.usage); + expect(error?.message).to.contain('600s'); + expect(fetchStub.callCount).to.equal(0); + }); + + it('status prints the envelope untouched under --json', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { stdout } = await runCommand('migrations status -m mig_01H9Z --json'); + + expect(JSON.parse(stdout)).to.deep.equal(ENVELOPE); + }); + + it('show lists what can be read now, without naming a resource', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { stdout } = await runCommand('migrations show -m mig_01H9Z'); + + assertFetch({ callIndex: 0, method: 'GET', path: '/migrations/mig_01H9Z', stub: fetchStub }); + expect(stdout).to.contain('report'); + expect(stdout).to.contain('Migration report'); + }); + + it('show reads the named resource and prints what came back', async () => { + fetchStub = mockFetch([REPORT]); + + const { stdout } = await runCommand('migrations show report -m mig_01H9Z'); + + assertFetch({ + callIndex: 0, + method: 'GET', + path: '/migrations/mig_01H9Z/resources/report', + stub: fetchStub, + }); + + expect(JSON.parse(stdout)).to.deep.equal(REPORT.result); + }); + + it('show prints the envelope untouched under --json, not only its result', async () => { + fetchStub = mockFetch([REPORT]); + + const { stdout } = await runCommand('migrations show report -m mig_01H9Z --json'); + + expect(JSON.parse(stdout)).to.deep.equal(REPORT); + }); + + it('steps prints the checklist of the migration it was pointed at', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { stdout } = await runCommand('migrations steps -m mig_01H9Z'); + + assertFetch({ callIndex: 0, method: 'GET', path: '/migrations/mig_01H9Z', stub: fetchStub }); + expect(fetchStub.callCount).to.equal(1); + expect(stdout).to.contain('[x] step_apps Apps 2 apps migrated'); + expect(stdout).to.contain('[>] step_paywalls Paywalls'); + expect(stdout).to.not.contain('Do next:'); + }); + + it('steps prints the envelope untouched under --json', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { stdout } = await runCommand('migrations steps -m mig_01H9Z --json'); + + expect(JSON.parse(stdout)).to.deep.equal(ENVELOPE); + }); + + it('run reads the migration, then posts the input against the revision it read', async () => { + fetchStub = mockFetch([ENVELOPE, ENVELOPE]); + + await runCommand(['migrations', 'run', CONFIRMED, '-m', 'mig_01H9Z', '--input', '{"decisions":[]}', '--yes']); + + assertFetch({ callIndex: 0, method: 'GET', path: '/migrations/mig_01H9Z', stub: fetchStub }); + + assertFetch({ + body: { expected_revision: 3, input: { decisions: [] } }, + callIndex: 1, + method: 'POST', + path: `/migrations/mig_01H9Z/actions/${CONFIRMED}`, + stub: fetchStub, + }); + + expect(fetchStub.callCount).to.equal(2); + }); + + it('run takes the input from a file', async () => { + fetchStub = mockFetch([ENVELOPE, ENVELOPE]); + + await runCommand(['migrations', 'run', CONFIRMED, '-m', 'mig_01H9Z', '--input-file', INPUT_FILE, '--yes']); + + assertFetch({ + // assertFetch compares serialized JSON, so key order must match the fixture. + body: { input: { decisions: [{ rc_id: 'prod_monthly', target: 'create', access_level: 'premium' }] } }, + callIndex: 1, + method: 'POST', + path: `/migrations/mig_01H9Z/actions/${CONFIRMED}`, + stub: fetchStub, + }); + }); + + it('run reads and validates stdin for input actions after looking up the action', async () => { + fetchStub = mockFetch([ENVELOPE, ENVELOPE]); + + const stdin = sinon.stub(process.stdin, Symbol.asyncIterator).callsFake(() => { + expect(fetchStub.callCount).to.equal(1); + + return Readable.from([Buffer.from('{"decision":"create"}')])[Symbol.asyncIterator](); + }); + + try { + const { error } = await runCommand([ + 'migrations', 'run', CONFIRMED, '-m', 'mig_01H9Z', '--input-file', '-', '--yes', + ]); + + expect(error).to.equal(undefined); + expect(stdin.calledOnce).to.equal(true); + + assertFetch({ + body: { input: { decision: 'create' } }, + callIndex: 1, + method: 'POST', + path: `/migrations/mig_01H9Z/actions/${CONFIRMED}`, + stub: fetchStub, + }); + } finally { + stdin.restore(); + } + }); + + it('run prints the confirmation and changes nothing without --yes', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { error } = await runCommand(['migrations', 'run', CONFIRMED, '-m', 'mig_01H9Z']); + + expect(error?.oclif?.exit).to.equal(exitCode.confirmRequired); + expect(error?.message).to.contain('This cannot be undone'); + expect(error?.message).to.contain('--yes'); + expect(fetchStub.callCount).to.equal(1); + }); + + it('run refuses an action the migration does not offer, and says what it does', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { error } = await runCommand(['migrations', 'run', 'no_such_action', '-m', 'mig_01H9Z']); + + expect(error?.oclif?.exit).to.equal(exitCode.usage); + expect(error?.message).to.contain(CONFIRMED); + expect(fetchStub.callCount).to.equal(1); + }); + + it('run hands an external action over as a link, asking the server for nothing', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { stdout } = await runCommand(['migrations', 'run', 'act_open_report', '-m', 'mig_01H9Z', '--no-browser']); + + expect(stdout).to.contain('https://app.adapty.io/migrations/mig_01H9Z/report'); + expect(fetchStub.callCount).to.equal(1); + }); + + it('run says a kind it cannot execute needs another way, instead of failing oddly', async () => { + fetchStub = mockFetch([UPLOADS]); + + const { error } = await runCommand(['migrations', 'run', 'upload_file', '-m', 'mig_01H9Z']); + + expect(error?.oclif?.exit).to.equal(exitCode.usage); + expect(error?.message).to.contain('adapty-cli'); + expect(error?.message).to.contain('Use the dashboard or an offered Cloud Export action.'); + expect(fetchStub.callCount).to.equal(1); + }); + + it('run hands over a link from a kind it does not know, instead of calling it unsupported', async () => { + fetchStub = mockFetch([LINKED]); + + const { error, stdout } = await runCommand([ + 'migrations', 'run', 'act_sign_agreement', '-m', 'mig_01H9Z', '--no-browser', + ]); + + expect(error).to.equal(undefined); + expect(stdout).to.contain('https://app.adapty.io/migrations/mig_01H9Z/agreement'); + expect(fetchStub.callCount).to.equal(1); + }); + + for (const json of [false, true]) { + for (const yes of [false, true]) { + it(`run preserves an unknown action without executing it (json=${json}, yes=${yes})`, async () => { + fetchStub = mockFetch([FUTURE_ACTION]); + + const { error, stdout } = await runCommand([ + 'migrations', 'run', 'act_future', '-m', 'mig_01H9Z', + ...(json ? ['--json'] : []), ...(yes ? ['--yes'] : []), + ]); + + expect(error).to.equal(undefined); + + if (json) { + expect(JSON.parse(stdout)).to.deep.equal(FUTURE_ACTION); + } else { + expect(stdout).to.contain('A new migration action'); + expect(stdout).to.contain('Follow the instructions from the newer server.'); + expect(stdout).to.contain('This action needs a newer adapty-cli'); + } + + expect(fetchStub.callCount).to.equal(1); + assertFetch({ callIndex: 0, method: 'GET', path: '/migrations/mig_01H9Z', stub: fetchStub }); + }); + } + + it(`status --wait returns an unknown state without polling (json=${json})`, async () => { + const envelope = { ...ENVELOPE, migration: { ...ENVELOPE.migration, state: 'paused_for_review' } }; + + fetchStub = mockFetch([envelope]); + + const { error, stdout } = await runCommand([ + 'migrations', 'status', '-m', 'mig_01H9Z', '--wait', ...(json ? ['--json'] : []), + ]); + + expect(error).to.equal(undefined); + + if (json) { + expect(JSON.parse(stdout)).to.deep.equal(envelope); + } else { + expect(stdout).to.contain('paused_for_review'); + expect(stdout).to.contain('This migration state needs a newer adapty-cli'); + } + + expect(fetchStub.callCount).to.equal(1); + }); + } + + it('run does not wait for stdin when it cannot execute an unknown action', async () => { + fetchStub = mockFetch([FUTURE_ACTION]); + + const stdin = sinon.stub(process.stdin, Symbol.asyncIterator).throws(new Error('Must not read stdin')); + + try { + const { error, stdout } = await runCommand([ + 'migrations', 'run', 'act_future', '-m', 'mig_01H9Z', '--input-file', '-', + ]); + + expect(error).to.equal(undefined); + expect(stdout).to.contain('This action needs a newer adapty-cli'); + expect(stdin.called).to.equal(false); + expect(fetchStub.callCount).to.equal(1); + } finally { + stdin.restore(); + } + }); + + for (const inputFlags of [['--input', '{}'], ['--input-file', INPUT_FILE], ['--input-file', '-']]) { + it(`run rejects external action input via ${inputFlags.join(' ')} in human and JSON modes`, async () => { + fetchStub = mockFetch([ENVELOPE]); + + for (const json of [false, true]) { + const result = await runCommand([ + 'migrations', 'run', 'act_open_report', '-m', 'mig_01H9Z', + ...inputFlags, '--no-browser', ...(json ? ['--json'] : []), + ]); + + if (json) { + const output = JSON.parse(result.stdout) as { error: { code: string; message: string } }; + + expect(output.error.code).to.equal('action_input_unsupported'); + expect(output.error.message).to.contain('does not accept --input or --input-file'); + } else { + expect(result.stdout).to.equal(''); + expect(result.error?.oclif?.exit).to.equal(exitCode.usage); + expect(result.error?.message).to.contain('does not accept --input or --input-file'); + } + } + + expect(fetchStub.callCount).to.equal(2); + assertFetch({ callIndex: 0, method: 'GET', path: '/migrations/mig_01H9Z', stub: fetchStub }); + assertFetch({ callIndex: 1, method: 'GET', path: '/migrations/mig_01H9Z', stub: fetchStub }); + }); + } + + it('run rejects an input that is not a JSON object, before any request', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { error } = await runCommand(['migrations', 'run', CONFIRMED, '-m', 'mig_01H9Z', '--input', '[]', '--yes']); + + expect(error?.oclif?.exit).to.equal(exitCode.usage); + expect(error?.message).to.contain('--input'); + expect(fetchStub.callCount).to.equal(0); + }); + + it('run prints the envelope the action answered with under --json', async () => { + fetchStub = mockFetch([ENVELOPE, REPORT]); + + const { stdout } = await runCommand(['migrations', 'run', CONFIRMED, '-m', 'mig_01H9Z', '--yes', '--json']); + + expect(JSON.parse(stdout)).to.deep.equal(REPORT); + }); + + it('close reads the migration, then posts the outcome against that revision', async () => { + fetchStub = mockFetch([ENVELOPE, ENVELOPE]); + + await runCommand('migrations close --outcome finish --yes -m mig_01H9Z'); + + assertFetch({ callIndex: 0, method: 'GET', path: '/migrations/mig_01H9Z', stub: fetchStub }); + + assertFetch({ + body: { expected_revision: 3, outcome: 'finish' }, + callIndex: 1, + method: 'POST', + path: '/migrations/mig_01H9Z/close', + stub: fetchStub, + }); + + expect(fetchStub.callCount).to.equal(2); + }); + + it('close cancels a migration the same way', async () => { + fetchStub = mockFetch([ENVELOPE, ENVELOPE]); + + await runCommand('migrations close --outcome cancel --yes -m mig_01H9Z'); + + assertFetch({ + body: { outcome: 'cancel' }, + callIndex: 1, + method: 'POST', + path: '/migrations/mig_01H9Z/close', + stub: fetchStub, + }); + }); + + it('close without --yes does nothing at all, not even a read', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { error } = await runCommand('migrations close --outcome finish -m mig_01H9Z'); + + expect(error?.oclif?.exit).to.equal(exitCode.usage); + expect(fetchStub.callCount).to.equal(0); + }); + + it('close refuses an outcome that is neither finish nor cancel', async () => { + fetchStub = mockFetch([ENVELOPE]); + + const { error } = await runCommand('migrations close --outcome archive --yes -m mig_01H9Z'); + + expect(error?.oclif?.exit).to.equal(exitCode.usage); + expect(error?.message).to.contain('outcome'); + expect(fetchStub.callCount).to.equal(0); + }); + it('create names the new app and lets the server pick the flow', async () => { fetchStub = mockFetch([ENVELOPE]); diff --git a/test/fixtures/action-input.json b/test/fixtures/action-input.json new file mode 100644 index 0000000..ab7dcef --- /dev/null +++ b/test/fixtures/action-input.json @@ -0,0 +1,5 @@ +{ + "decisions": [ + { "rc_id": "prod_monthly", "target": "create", "access_level": "premium" } + ] +} diff --git a/test/sdk/adapty/caller-headers.test.ts b/test/sdk/adapty/caller-headers.test.ts new file mode 100644 index 0000000..747912d --- /dev/null +++ b/test/sdk/adapty/caller-headers.test.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; + +import { createAdapty } from '../../../src/sdk/adapty/index.js'; +import { createScriptedFetch } from '../../../src/sdk/core/testing.js'; + +/** + * Who the caller is, on every request of both clients. The migrations client is the one section 3 + * asks for `X-Adapty-Interactive`, so the assertions read it there; `apps` proves the header is a + * property of the transport, not of one resource. + */ +const setup = (interactive?: boolean) => { + const scripted = createScriptedFetch([{ body: { available: [], items: [] } }, { body: { data: [] } }]); + + const adapty = createAdapty({ + fetch: scripted.fetch, + interactive, + token: 'tok', + userAgent: 'adapty-cli/test', + }); + + return { adapty, calls: scripted.calls }; +}; + +describe('adapty caller headers', () => { + it('says a person is watching, on every client', async () => { + const { adapty, calls } = setup(true); + + await adapty.migrations.list(); + await adapty.apps.list(); + + expect(calls[0]?.headers.get('x-adapty-interactive')).to.equal('true'); + expect(calls[1]?.headers.get('x-adapty-interactive')).to.equal('true'); + }); + + it('says nobody is, which is the half of the audit trail worth having', async () => { + const { adapty, calls } = setup(false); + + await adapty.migrations.list(); + + expect(calls[0]?.headers.get('x-adapty-interactive')).to.equal('false'); + expect(calls[0]?.headers.get('user-agent')).to.equal('adapty-cli/test'); + }); + + it('stays silent when the caller does not claim to know', async () => { + const { adapty, calls } = setup(); + + await adapty.migrations.list(); + + expect(calls[0]?.headers.get('x-adapty-interactive')).to.equal(null); + expect(calls[0]?.headers.get('user-agent')).to.equal('adapty-cli/test'); + }); +}); diff --git a/test/sdk/adapty/migrations/resource.test.ts b/test/sdk/adapty/migrations/resource.test.ts index b45c527..4b907fd 100644 --- a/test/sdk/adapty/migrations/resource.test.ts +++ b/test/sdk/adapty/migrations/resource.test.ts @@ -5,9 +5,11 @@ 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 { createFakeClock, createScriptedFetch } from '../../../../src/sdk/core/testing.js'; import { rejection } from '../../../helpers/rejection.js'; +import type { Envelope, MigrationState } 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 unknown; @@ -22,6 +24,24 @@ const setup = (script: Script) => { return { calls: scripted.calls, migrations: adapty.migrations }; }; +const envelopeAt = (state: MigrationState, revision: number, pollAfterSeconds = 30): Envelope => { + const envelope = ENVELOPE as Envelope; + + return { + ...envelope, + migration: { ...envelope.migration, poll_after_seconds: pollAfterSeconds, revision, state }, + }; +}; + +/** Use a fake clock to test polling without real delays. */ +const setupWait = (script: Script) => { + const scripted = createScriptedFetch(script); + const clock = createFakeClock(); + const adapty = createAdapty({ baseUrl: BASE, clock, fetch: scripted.fetch, token: 't' }); + + return { calls: scripted.calls, clock, migrations: adapty.migrations }; +}; + describe('adapty.migrations', () => { it('lists migrations and passes the body through untouched', async () => { const list = { available: [], items: [] }; @@ -74,6 +94,125 @@ describe('adapty.migrations', () => { expect(JSON.parse(calls[0]?.body ?? '')).to.deep.equal({ app_id: 'app_1', flow: 'transactions' }); }); + it('runs an action against the revision the caller read', async () => { + const { calls, migrations } = setup([{ body: ENVELOPE }]); + + await migrations.runAction('mig_01H9Z', 'resolve_mapping', { + expectedRevision: 12, + input: { decisions: [] }, + }); + + expect(calls[0]?.method).to.equal('POST'); + expect(calls[0]?.url).to.equal(`${BASE}/migrations/mig_01H9Z/actions/resolve_mapping`); + + expect(JSON.parse(calls[0]?.body ?? '')).to.deep.equal({ + expected_revision: 12, + input: { decisions: [] }, + }); + }); + + it('sends an empty input for an action that asks for none', async () => { + const { calls, migrations } = setup([{ body: ENVELOPE }]); + + await migrations.runAction('mig_01H9Z', 'source_discover', { expectedRevision: 1 }); + + expect(JSON.parse(calls[0]?.body ?? '')).to.deep.equal({ expected_revision: 1, input: {} }); + }); + + it('refuses an input that is not an object before reaching the network', async () => { + const { calls, migrations } = setup([]); + + const error = await rejection(migrations.runAction('mig_01H9Z', 'resolve_mapping', { + expectedRevision: 1, + input: [1, 2], + })); + + expect(error).to.be.instanceOf(ValidationError); + expect(calls).to.have.length(0); + }); + + it('closes a migration with the outcome and the revision it was decided on', async () => { + const { calls, migrations } = setup([{ body: ENVELOPE }]); + + await migrations.close('mig_01H9Z', { expectedRevision: 7, outcome: 'finish' }); + + expect(calls[0]?.method).to.equal('POST'); + expect(calls[0]?.url).to.equal(`${BASE}/migrations/mig_01H9Z/close`); + expect(JSON.parse(calls[0]?.body ?? '')).to.deep.equal({ expected_revision: 7, outcome: 'finish' }); + }); + + it('refuses an outcome it does not know before reaching the network', async () => { + const { calls, migrations } = setup([]); + + const error = await rejection(migrations.close('mig_01H9Z', { expectedRevision: 7, outcome: 'archive' })); + + expect(error).to.be.instanceOf(ValidationError); + expect(calls).to.have.length(0); + }); + + it('waits no longer than it has to: a migration that already wants the user answers at once', async () => { + const { calls, clock, migrations } = setupWait([{ body: envelopeAt('action_required', 7) }]); + + const result = await migrations.waitFor('mig_01H9Z'); + + expect(calls).to.have.lengthOf(1); + expect(clock.sleeps).to.deep.equal([]); + expect(result.migration.revision).to.equal(7); + }); + + it('polls at the pace the server asks until the revision moves', async () => { + const { calls, clock, migrations } = setupWait([ + { body: envelopeAt('running', 7) }, + { body: envelopeAt('running', 7) }, + { body: envelopeAt('running', 8) }, + ]); + + const seen: number[] = []; + const result = await migrations.waitFor('mig_01H9Z', { onPoll: (_, delayMs) => seen.push(delayMs) }); + + expect(calls).to.have.lengthOf(3); + expect(clock.sleeps).to.deep.equal([30_000, 30_000]); + expect(seen).to.deep.equal([30_000, 30_000]); + expect(result.migration.revision).to.equal(8); + }); + + it('never asks faster than every five seconds, whatever the server says', async () => { + const { clock, migrations } = setupWait([ + { body: envelopeAt('running', 7, 0) }, + { body: envelopeAt('running', 8, 0) }, + ]); + + await migrations.waitFor('mig_01H9Z'); + + expect(clock.sleeps).to.deep.equal([5000]); + }); + + it('stops as soon as the flow is over, even though the revision did not move', async () => { + const { calls, migrations } = setupWait([ + { body: envelopeAt('running', 7) }, + { body: envelopeAt('completed', 7) }, + ]); + + const result = await migrations.waitFor('mig_01H9Z'); + + expect(calls).to.have.lengthOf(2); + expect(result.migration.state).to.equal('completed'); + }); + + it('gives up at the timeout and answers with what it last read, which is not an error', async () => { + const { calls, clock, migrations } = setupWait([ + { body: envelopeAt('running', 7, 5) }, + { body: envelopeAt('running', 7, 5) }, + { body: envelopeAt('running', 7, 5) }, + ]); + + const result = await migrations.waitFor('mig_01H9Z', { timeoutMs: 12_000 }); + + expect(calls).to.have.lengthOf(3); + expect(clock.sleeps).to.deep.equal([5000, 5000]); + expect(result.migration.state).to.equal('running'); + }); + it('carries an idempotency key, so a retried create cannot start a second migration', async () => { const { calls, migrations } = setup([{ body: ENVELOPE }, { body: ENVELOPE }]); @@ -95,8 +234,7 @@ describe('adapty.migrations', () => { 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. + // Migration endpoints return 404 for trailing slashes. 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' }); From c6f38046bf3b49955bae0a5f15b69161f8629f64 Mon Sep 17 00:00:00 2001 From: German Shteynardt Date: Thu, 17 Sep 2026 14:49:27 +0300 Subject: [PATCH 4/4] feat: persist current migration selection --- CLAUDE.md | 8 +- README.md | 39 +- docs/architecture.md | 51 +- ...at-adapty-cli-developer-api-client-plan.md | 492 ------------------ ...-feat-persistent-current-migration-plan.md | 267 ---------- skills/adapty-cli/SKILL.md | 3 +- skills/adapty-cli/references/cli-commands.md | 29 +- src/cli/base/adapty/adapty-command.ts | 15 +- src/cli/base/adapty/index.ts | 1 + src/cli/base/adapty/migration-command.ts | 23 + .../auth/{logout.ts => logout/command.ts} | 26 +- src/cli/commands/auth/logout/index.ts | 1 + src/cli/commands/auth/logout/lib/cleanup.ts | 36 ++ .../auth/{revoke.ts => revoke/command.ts} | 19 +- src/cli/commands/auth/revoke/index.ts | 1 + src/cli/commands/auth/revoke/lib/cleanup.ts | 46 ++ src/cli/commands/migrations/close/command.ts | 18 +- src/cli/commands/migrations/create/command.ts | 46 +- .../commands/migrations/current/command.ts | 38 ++ src/cli/commands/migrations/current/index.ts | 1 + src/cli/commands/migrations/run/command.ts | 24 +- src/cli/commands/migrations/show/command.ts | 14 +- src/cli/commands/migrations/status/command.ts | 14 +- src/cli/commands/migrations/steps/command.ts | 12 +- src/cli/commands/migrations/unuse/command.ts | 33 ++ src/cli/commands/migrations/unuse/index.ts | 1 + src/cli/commands/migrations/use/command.ts | 33 ++ src/cli/commands/migrations/use/index.ts | 1 + src/cli/context/migration/current.ts | 75 +++ src/cli/context/migration/index.ts | 5 + src/cli/context/migration/model.ts | 51 ++ src/cli/context/migration/resolve.ts | 57 ++ src/cli/context/migration/store.ts | 120 +++++ src/cli/errors.ts | 77 ++- src/cli/input/migration.ts | 18 +- src/cli/views/migrations/index.ts | 1 - src/cli/views/migrations/notices.ts | 10 + src/commands/auth/logout.ts | 2 +- src/commands/auth/revoke.ts | 2 +- src/commands/migrations/current.ts | 1 + src/commands/migrations/unuse.ts | 1 + src/commands/migrations/use.ts | 1 + test/cli/base.test.ts | 16 +- test/cli/context/migration/current.test.ts | 91 ++++ test/cli/context/migration/resolve.test.ts | 91 ++++ test/cli/context/migration/store.test.ts | 167 ++++++ test/cli/views/migrations/envelope.test.ts | 2 +- test/commands/auth/cleanup-exit-codes.test.ts | 72 +++ test/commands/auth/logout-context.test.ts | 110 ++++ test/commands/auth/revoke-context.test.ts | 151 ++++++ .../migrations-context-operations.test.ts | 196 +++++++ .../migrations-create-selection.test.ts | 116 +++++ test/commands/migrations-current.test.ts | 104 ++++ test/commands/migrations-exit-codes.test.ts | 9 +- .../migrations-selection-process.test.ts | 140 +++++ test/commands/migrations-unuse.test.ts | 69 +++ test/commands/migrations-use.test.ts | 140 +++++ test/commands/migrations.test.ts | 2 +- test/helpers/isolate-config.ts | 17 + 59 files changed, 2359 insertions(+), 847 deletions(-) delete mode 100644 docs/plans/2026-02-19-feat-adapty-cli-developer-api-client-plan.md delete mode 100644 docs/plans/2026-09-16-feat-persistent-current-migration-plan.md create mode 100644 src/cli/base/adapty/migration-command.ts rename src/cli/commands/auth/{logout.ts => logout/command.ts} (51%) create mode 100644 src/cli/commands/auth/logout/index.ts create mode 100644 src/cli/commands/auth/logout/lib/cleanup.ts rename src/cli/commands/auth/{revoke.ts => revoke/command.ts} (71%) create mode 100644 src/cli/commands/auth/revoke/index.ts create mode 100644 src/cli/commands/auth/revoke/lib/cleanup.ts create mode 100644 src/cli/commands/migrations/current/command.ts create mode 100644 src/cli/commands/migrations/current/index.ts create mode 100644 src/cli/commands/migrations/unuse/command.ts create mode 100644 src/cli/commands/migrations/unuse/index.ts create mode 100644 src/cli/commands/migrations/use/command.ts create mode 100644 src/cli/commands/migrations/use/index.ts create mode 100644 src/cli/context/migration/current.ts create mode 100644 src/cli/context/migration/index.ts create mode 100644 src/cli/context/migration/model.ts create mode 100644 src/cli/context/migration/resolve.ts create mode 100644 src/cli/context/migration/store.ts delete mode 100644 src/cli/views/migrations/index.ts create mode 100644 src/cli/views/migrations/notices.ts create mode 100644 src/commands/migrations/current.ts create mode 100644 src/commands/migrations/unuse.ts create mode 100644 src/commands/migrations/use.ts create mode 100644 test/cli/context/migration/current.test.ts create mode 100644 test/cli/context/migration/resolve.test.ts create mode 100644 test/cli/context/migration/store.test.ts create mode 100644 test/commands/auth/cleanup-exit-codes.test.ts create mode 100644 test/commands/auth/logout-context.test.ts create mode 100644 test/commands/auth/revoke-context.test.ts create mode 100644 test/commands/migrations-context-operations.test.ts create mode 100644 test/commands/migrations-create-selection.test.ts create mode 100644 test/commands/migrations-current.test.ts create mode 100644 test/commands/migrations-selection-process.test.ts create mode 100644 test/commands/migrations-unuse.test.ts create mode 100644 test/commands/migrations-use.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index f7c4b03..10c55b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,9 +27,9 @@ src/ # capture is the caller's job, the CLI only builds the URL; # validate — advisory publishability check, always 200, exits non-zero when invalid); # media/ (upload — multipart image upload, returns CDN url to reference in a config) - migrations/ # create, list, status (--wait), steps, show, run, close — a thin client of the + migrations/ # create, list, use, current, unuse, status (--wait), steps, show, run, close — a thin client of the # Wizard Service: the flow lives on the server, every answer is one envelope. - # Implementation in src/cli/commands/migrations, contract in docs/plans + # Implementation in src/cli/commands/migrations segments/ # list, get access-levels/ # list, get, create, update asa/ # Apple Search Ads: whoami, connect, orgs, apps, campaigns, ad-groups, keywords, @@ -105,6 +105,10 @@ eslint zones in `eslint.config.mjs` fail on a new import into `src/lib`. Layers error mapping; the Adapty SDK and session belong in `cli/base/adapty/` - `AdaptyCommand` checks the token on access to `this.session` or `this.adapty`; parse and validate input first. Auth commands use `openSession()` and `build()` explicitly as needed +- Commands that work on the saved migration extend `MigrationCommand` (same door, + `cli/base/adapty/index.js`) and ask `this.currentMigration` + (`get` / `require` / `set` / `clear` / `clearFor` / `overridden`). `ADAPTY_MIGRATION`, the context file and the + token fingerprint live behind that object, in `cli/context/migration/`, and nowhere else The following client factories and output helpers belong to the frozen legacy stack: diff --git a/README.md b/README.md index 0ae78b7..5ce68d9 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ Other auth commands: ```sh adapty auth whoami # verify token, show user info adapty auth status # show local auth state -adapty auth logout # clear stored token (local only) -adapty auth revoke # revoke active token and clear any matching stored session +adapty auth logout # clear stored credentials and migration selection (local only) +adapty auth revoke # revoke active token and clear matching credentials and selection ``` `auth revoke` uses `ADAPTY_TOKEN` when set, otherwise the stored token. A different token in the @@ -42,6 +42,14 @@ session file is preserved. After revoking an environment token, unset `ADAPTY_TO stored session will then become active again. With no token, revoke succeeds without a request and returns `{"status":"not_authenticated"}` under `--json`. +`auth logout` removes the saved migration selection even without stored credentials or with a +malformed context. `auth revoke` removes selection only after server success and only when it +belongs to the revoked token. Failed revocation preserves both files. Both cleanup operations +are attempted independently; incomplete cleanup returns exit 1 (`auth_cleanup_failed`). If the +server already revoked the token, the error says so: fix local files without repeating revocation. +Environment variables remain in the parent shell; unset `ADAPTY_TOKEN` and `ADAPTY_MIGRATION` +there when needed. + ## Commands All resource commands require `--app APP_ID` (UUID). Use `adapty apps list` to find your app ID. @@ -125,12 +133,33 @@ adapty migrations create --flow FLOW --app APP_ID --json ``` These are alternative creation modes: `--name` cannot be combined with `--flow` or `--app`. -Creation starts the flow; the returned JSON contains its ID in `migration.id`. +Creation starts the flow and saves it as current; the returned JSON contains its ID in `migration.id`. +Use `--no-select` to create without changing the saved selection. If creation succeeds but saving +fails, the command still succeeds and prints a warning with the explicit continuation command. -Commands operating on a migration require `-m, --migration` or `ADAPTY_MIGRATION`. An explicit -flag overrides the environment variable. The CLI does not select a migration automatically. +Commands choose a migration in this order: `-m, --migration`, non-empty `ADAPTY_MIGRATION`, +then the saved selection for the current token. Explicit IDs do not change the saved selection. Replace `mig_7x2` below with an ID from `create` or `list`; agents should pass `-m` explicitly. +#### Manage a saved selection + +```sh +adapty migrations use mig_7x2 +adapty migrations current --json +adapty migrations unuse +``` + +`use` verifies access through the API, then saves the returned ID for the current token. +`current` reads the effective selection locally: `ADAPTY_MIGRATION` takes precedence over the +saved context. `unuse` removes the saved selection without authentication; an environment override +must be unset in your shell. Changing tokens makes the previous token's selection inapplicable. + +The selection is shared across terminals and survives restarting the CLI. After `use` or `create`, +you can run `adapty migrations status`, `steps`, `show`, `run` or `close` without `-m`. +An operation keeps its initially selected ID even if another terminal changes the selection. +`run` and `close` print the target on stderr before a mutation that uses saved selection. +Scripts should pass explicit IDs and use `create --no-select` to preserve the shared default. + #### Inspect and run an action ```sh diff --git a/docs/architecture.md b/docs/architecture.md index 918f829..4505e9f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -88,8 +88,9 @@ text or JSON. - `base/base-command.ts` — output channel, `SIGINT` → abort signal, error mapping, `render()`. It owns no product SDK or session, so another product such as ASA can reuse it directly. -- `base/adapty/index.ts` — the public entry point: commands import `AdaptyCommand`, `build`, - `openSession` and session types from here. Implementation files import each other directly. +- `base/adapty/index.ts` — the public entry point: commands import `AdaptyCommand`, + `MigrationCommand`, `build`, `openSession` and session types from here. Implementation files + import each other directly. - `base/adapty/openSession.ts` — reads `ADAPTY_TOKEN` and `ADAPTY_API_URL`, picks the config dir from oclif, and returns where to talk, as whom, and the store to write through. `openSession(config)` also warns about a non-default API URL. @@ -97,10 +98,31 @@ text or JSON. User-Agent and retry warnings. Both authenticated commands and auth commands use it. - `base/adapty/adapty-command.ts` — resolves an Adapty session and lazily builds its SDK. "Needs authorization" is expressed in what a command extends, not re-checked inside `run()` bodies. +- `base/adapty/migration-command.ts` — `AdaptyCommand` plus `this.currentMigration`. "Works on the + saved migration" is the second thing a command says by what it extends. - `errors.ts` — the single `SdkError` → CLI error mapping. The switch has no default, so a new error kind fails to compile until it is given a message and an exit code. - `input/` — shared flags and args (app id UUID, pagination, migration id), one module per concern, and the one place flag names meet sdk field names. +- `context/migration/` — CLI-owned migration selection in `context.json` beside credentials. + `model.ts` defines and validates the record and token fingerprint; `store.ts` owns file I/O and + storage errors; `resolve.ts` chooses an ID by source priority. `current.ts` binds those three + into the object the door exports: `openCurrentMigration({ configDir, session, env })` answers + `get`, `require`, `set`, `clear`, `clearFor` and `overridden`. A command asks that object a + question; it never assembles a store, a session and an environment variable of its own, and + `ADAPTY_MIGRATION` is read here and nowhere else. Files within the module import each other + directly, and a test may open any of them. + The store replaces records atomically; the resolver checks the effective token fingerprint + and reads the file only when no explicit ID is supplied. API URL is not part of the context. + Its two error codes are stable, but the texts are per operation: a read, a write and a removal + are fixed in three different places, so each says which failed and names the errno. The message + never repeats the underlying one — it may quote the record, and the record carries a token + fingerprint — so the original travels as `cause`. + `migrations use` verifies access before saving; `current` resolves the selection locally; + `unuse` removes it without authentication and without a session. Migration operations resolve + once after input validation, using flag > environment > saved context; polling and mutations + retain that captured ID. `create` saves by default, with `--no-select` to opt out; local save + failures warn without failing creation. The SDK remains unaware of this local selection. - `views/` — plain functions, value in, string out. - `commands/` — one class per command. @@ -133,6 +155,7 @@ base/ └── adapty/ ├── index.ts ├── adapty-command.ts + ├── migration-command.ts ├── build.ts └── openSession.ts ``` @@ -147,6 +170,15 @@ Commands that require Adapty authorization extend `AdaptyCommand`. It resolves t input before that access so input errors take precedence over a missing token. A future ASA adapter can live in `base/asa/` and extend the same `BaseCommand`. +Commands that work on the saved migration extend `MigrationCommand`, a file in the same adapter that +adds one lazy getter, `this.currentMigration`, over `AdaptyCommand`. Selection is one topic out of +ten, so it stays out of `AdaptyCommand`, where all 75 commands would pay for it. +The two commands that answer without credentials — `current` and `unuse` — stay on `BaseCommand` +and call `openCurrentMigration` themselves, as `auth logout` and `auth revoke` do for their cleanup. + +`this.resolvedSession` exposes the same captured session without requiring a token, for local +input resolution before authentication. It does not build the SDK or make a network request. + Commands import the Adapty adapter through its public entry point: ```ts @@ -188,6 +220,7 @@ quietly changing what users parse. | New flag or argument | the command (or its `lib/flags.ts` for complex parsing); shared input belongs in `cli/input/.ts` | | New error kind | `sdk/core/errors.ts` + `cli/errors.ts` (the compiler insists) | | Adapty session environment variables | `cli/base/adapty/openSession.ts` | +| `ADAPTY_MIGRATION`, or anything about the saved selection | `cli/context/migration/current.ts` | Shared input modules group declarations, private parsers and SDK parameter mapping by concern (for example, `cli/input/pagination.ts`). Import each module directly; there is no barrel index. @@ -197,7 +230,7 @@ use. Global flags belong to the base command; shared subsets stay composable obj ## Migration state The pre-sdk stack (`src/lib` + the commands written against it) is still there and still serves -most topics. Migrated so far: `apps` and `auth`. +most topics. Migrated so far: `apps`, `auth` and `migrations`. oclif discovers commands only under `src/commands`, so a migrated command keeps a one-line file there re-exporting the real class from `src/cli/commands`. @@ -210,6 +243,18 @@ command targeted only the token in the file. It removes the stored session only the revoked one; a different stored token remains usable. With no effective token, it keeps the old successful no-op and `{ "status": "not_authenticated" }` JSON result. +`auth/logout/` and `auth/revoke/` keep command classes in `command.ts` and local cleanup in +`lib/cleanup.ts`. Logout opens the session store and the migration context directly, so it can +remove malformed credentials and an orphaned selection without resolving a usable session. Revoke +removes the selection through `clearFor(token)` — only when its fingerprint matches the revoked +token, independently of the stored credentials; the comparison itself lives with the record. Both commands +attempt both cleanup operations; failures become exit 1 with `auth_cleanup_failed` and one line per +file that survived, naming why. Redaction is about foreign text, not about diagnostics: an error of +ours contributes its own message, a stranger's contributes its errno alone, and the original travels +as `cause` (`describeCleanupFailures` in `cli/errors.ts`). Revoke errors explicitly distinguish +successful server revocation from failed local cleanup. Credentials stay in the SDK session store; +migration cleanup belongs to the CLI. + The apps adapter runs the SDK's pure validation rules before requiring a token. The SDK also keeps its own validation so other adapters cannot bypass the rules. diff --git a/docs/plans/2026-02-19-feat-adapty-cli-developer-api-client-plan.md b/docs/plans/2026-02-19-feat-adapty-cli-developer-api-client-plan.md deleted file mode 100644 index 9e0351b..0000000 --- a/docs/plans/2026-02-19-feat-adapty-cli-developer-api-client-plan.md +++ /dev/null @@ -1,492 +0,0 @@ ---- -title: "feat: Adapty CLI Developer API Client" -type: feat -status: completed -date: 2026-02-19 -brainstorm: docs/brainstorms/2026-02-19-adapty-cli-brainstorm.md -backend_plan: adapty-dashboard-api/docs/plans/2026-02-17-feat-developer-quick-start-api-plan.md ---- - -# Adapty CLI — Developer API Client - -## Overview - -CLI client for Adapty Developer API. Handles device-flow auth, token storage, CRUD for apps/products/access-levels/paywalls/placements. Dual-mode: human-readable default + `--json` for agents. - -Built on oclif v4 scaffold already in place. TypeScript, ESM, native fetch, zero extra runtime deps (except `open` for browser). - -## Architecture - -``` -src/ -├── commands/ -│ ├── auth/ -│ │ ├── login.ts # Device flow auth -│ │ ├── logout.ts # Remove local token -│ │ ├── status.ts # Show auth state (local only) -│ │ └── whoami.ts # GET /me — verify token, show user + companies -│ ├── apps/ -│ │ ├── create.ts # POST /apps -│ │ ├── get.ts # GET /apps/:id -│ │ └── list.ts # GET /apps -│ ├── products/ -│ │ ├── create.ts # POST /apps/:app_id/products -│ │ └── list.ts # GET /apps/:app_id/products -│ ├── access-levels/ -│ │ ├── create.ts # POST /apps/:app_id/access-levels -│ │ └── list.ts # GET /apps/:app_id/access-levels -│ ├── paywalls/ -│ │ ├── create.ts # POST /apps/:app_id/paywalls -│ │ └── list.ts # GET /apps/:app_id/paywalls -│ └── placements/ -│ ├── create.ts # POST /apps/:app_id/placements -│ └── list.ts # GET /apps/:app_id/placements -├── lib/ -│ ├── api-client.ts # fetch wrapper: base URL, auth header, error handling -│ ├── config.ts # Read/write ~/.config/adapty/config.json -│ ├── auth.ts # Token resolution: ADAPTY_TOKEN env > config file -│ ├── errors.ts # Error formatting (human + JSON) -│ └── output.ts # Human output helpers (key:value lines) -└── index.ts # Re-export (existing) -``` - -### Key Design Decisions - -- **No base command class** — use utility imports from `lib/`. Keeps commands flat and simple. -- **`lib/api-client.ts`** — single fetch wrapper. Handles auth header injection, base URL resolution, error normalization, `User-Agent` header. -- **`lib/config.ts`** — reads/writes config JSON. Creates dir if missing. Sets file perms to 0600. -- **`lib/auth.ts`** — resolves token: `ADAPTY_TOKEN` env var > `config.json` access_token. Single function. - -## Technical Approach - -### Phase 1: Foundation (lib/ + auth commands) - -#### 1.1 Config Management — `src/lib/config.ts` - -```typescript -interface AdaptyConfig { - access_token?: string - user?: { email: string; name: string } -} -``` - -- Read: `JSON.parse(readFile(configPath))`, return empty object on missing/corrupt file -- Write: `writeFile(configPath, JSON.stringify(config, null, 2))`, create dir with `mkdir -p`, set file mode `0o600` -- Config path: use oclif's `this.config.configDir` from commands, or `~/.config/adapty` for lib - -#### 1.2 Auth Resolution — `src/lib/auth.ts` - -Token precedence: -1. `ADAPTY_TOKEN` env var (if set and non-empty) -2. `config.json` `access_token` field - -Return token string or `null`. Commands call this; if null, print "Not authenticated. Run `adapty auth login`." and exit with code 4. - -#### 1.3 API Client — `src/lib/api-client.ts` - -```typescript -const DEFAULT_API_URL = 'https://api.adapty.io/api/v1/developer' - -class ApiClient { - constructor(private baseUrl: string, private token: string | null) {} - - async get(path: string, params?: Record): Promise - async post(path: string, body: unknown): Promise -} -``` - -- Base URL: `ADAPTY_API_URL` env var > `DEFAULT_API_URL` -- Headers: `Authorization: Bearer `, `Content-Type: application/json`, `User-Agent: adapty-cli/ node/ /` -- Error handling: parse response, throw typed errors for 4xx/5xx -- No retry logic (YAGNI for MVP) - -**Exit codes:** -| Code | Meaning | -|------|---------| -| 0 | Success | -| 1 | API error (4xx/5xx) | -| 2 | CLI usage error (missing flags, bad input) — oclif default | -| 3 | Network error (fetch failed) | -| 4 | Auth required (no token) | - -#### 1.4 Error Formatting — `src/lib/errors.ts` - -Normalize API errors into consistent shape: - -**Human mode:** -``` -Error: Access level sdk_id already exists -Field errors: - sdk_id: Already exists for this app -``` - -**JSON mode (`--json`):** -```json -{"error_code": "paid_access_level_sdk_id_already_exists_error", "errors": {"sdk_id": ["Already exists for this app"]}, "status_code": 400} -``` - -Normalize three error shapes from API into single CLI envelope: -- Domain errors: `{errors: {field: [msg]}, error_code, status_code}` — pass through as-is -- Device flow errors: `{error: "authorization_pending"}` — map to `{error_code: "authorization_pending", status_code: 400}` -- Network errors: `{error_code: "network_error", errors: {"connection": ["..."]}, status_code: 0}` -- 401 specifically: print "Token expired or invalid. Run `adapty auth login`." and exit code 1 (not 4 — token exists but is invalid) -- 5xx: print "Server error. Try again later." (distinct from network errors) - -#### 1.5 `adapty auth login` — `src/commands/auth/login.ts` - -Device flow implementation: - -``` -1. If already authenticated: warn "Already authenticated as . Re-authenticating..." -2. POST /auth/device {client_id: "adapty-cli"} (no auth header) -3. Print user_code prominently -4. Try open(verification_uri_complete), always print URL as fallback -5. Poll POST /auth/token every `interval` seconds (from step 2 response, typically 5): - - authorization_pending → continue - - slow_down → increase interval by 5s, continue - - expired_token → exit "Code expired. Run `adapty auth login` again." - - access_denied → exit "Authorization denied." - - success → save token + user to config, print "Authenticated as " -6. Handle Ctrl+C: clean exit with message "Login cancelled." -``` - -Request body for token poll: `{grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: ""}` - -Browser open: use `open` npm package (handles macOS/Linux/Windows/WSL/Snap/flatpak). -Wrap in try/catch — suppress errors silently (URL is always printed as fallback). - -No `--json` flag on this command (interactive by nature). - -#### 1.6 `adapty auth logout` — `src/commands/auth/logout.ts` - -- Remove `access_token` and `user` from config file -- Print: "Logged out. Note: token remains valid server-side until expiry." -- If not authenticated: "Not currently authenticated." -- `enableJsonFlag = true` → `--json` returns `{"status": "logged_out"}` - -#### 1.7 `adapty auth whoami` — `src/commands/auth/whoami.ts` - -- Calls `GET /me` (authenticated) — verifies token works server-side -- Returns user email, name, companies -- `enableJsonFlag = true` - -**Human output:** -``` -Email: jane@example.com -Name: Jane Doe -Companies: - My Company (550e8400-...) -``` - -**JSON output:** raw API response from `GET /me`. - -#### 1.8 `adapty auth status` — `src/commands/auth/status.ts` - -- Read local config only (no network call — matches "let 401 happen" decision) -- If authenticated: show email, token prefix (masked: `dev_live_AbCd****.****`), config path -- If not: "Not authenticated. Run `adapty auth login`." -- `enableJsonFlag = true` - -**Human output:** -``` -Email: jane@example.com -Token: dev_live_AbCd****.**** -Config: ~/.config/adapty/config.json -``` - -**JSON output:** -```json -{"email": "jane@example.com", "token_prefix": "dev_live_AbCd", "config_path": "~/.config/adapty/config.json", "authenticated": true} -``` - -### Phase 2: App Commands - -#### 2.1 `adapty apps create` — `src/commands/apps/create.ts` - -`enableJsonFlag = true` - -**Flags:** -| Flag | Type | Required | Notes | -|------|------|----------|-------| -| `--name` | string | yes | App name | -| `--platform` | string (multiple) | yes | Repeatable: `--platform ios --platform android` | -| `--ios-bundle-id` | string | conditional | Required when `ios` in platforms | -| `--android-bundle-id` | string | conditional | Required when `android` in platforms | - -**Validation (client-side):** -- `--platform` values must be `ios` or `android` -- `--ios-bundle-id` required if `--platform ios` -- `--android-bundle-id` required if `--platform android` - -**Request:** `POST /apps` -```json -{ - "app_name": "", - "platforms": ["ios", "android"], - "ios_bundle_id": "", - "android_bundle_id": "" -} -``` - -**API response structure (nested):** -```json -{ - "app": {"id": "uuid", "name": "My App", "sdk_key": "public_live_..."}, - "default_access_level": {"id": "uuid", "sdk_id": "premium"} -} -``` - -**Human output** (extract from nested response): -``` -App created! -ID: 550e8400-... -Name: My App -SDK Key: public_live_xxxxxxxx.yyyyyyyy -Default Access Level ID: 660e8400-... -Default Access Level SDK ID: premium -``` - -**JSON:** return full nested API response as-is. - -#### 2.2 `adapty apps list` — `src/commands/apps/list.ts` - -`enableJsonFlag = true` - -**Flags:** -| Flag | Type | Required | Default | -|------|------|----------|---------| -| `--page` | integer | no | 1 | -| `--page-size` | integer | no | 20 | - -**Validation:** `--page` >= 1, `--page-size` 1-100. - -**Request:** `GET /apps?page[number]=&page[size]=` - -**API response wrapper:** `{data: [...], meta: {pagination: {count, page, pages}}}`. Human output extracts `data` array; `--json` returns full wrapper. - -**Human output:** -``` -ID: 550e8400-... -Name: My App -SDK Key: public_live_... ---- -ID: 660e8400-... -Name: Another App -SDK Key: public_live_... - -Page 1 of 3 (42 total) -``` - -#### 2.3 `adapty apps get` — `src/commands/apps/get.ts` - -`enableJsonFlag = true` - -**Args:** `app_id` (positional, required) — validated as UUID format client-side. - -**Request:** `GET /apps/` - -**Human output:** -``` -ID: 550e8400-... -Name: My App -SDK Key: public_live_... -Secret Key: secret_live_... -Platforms: ios, android -iOS Bundle ID: com.example.myapp -Android Bundle ID: com.example.myapp -``` - -### Phase 3: App-Scoped Resource Commands - -All commands in this phase share a common `--app` flag (UUID, required). Client-side UUID format validation on `--app` — on failure: "Invalid app ID format. Run `adapty apps list` to find your app ID." - -#### 3.1 `adapty access-levels list` + `adapty access-levels create` - -**list flags:** `--app` (required), `--page`, `--page-size` -**create flags:** - -| Flag | Type | Required | -|------|------|----------| -| `--app` | string | yes | -| `--sdk-id` | string | yes | -| `--title` | string | yes | - -**Request:** `POST /apps//access-levels` `{"sdk_id": "", "title": ""}` - -**Human output (create):** -``` -Access level created! -ID: 770e8400-... -SDK ID: vip -Title: VIP Access -``` - -#### 3.2 `adapty products list` + `adapty products create` - -**list flags:** `--app` (required), `--page`, `--page-size` -**create flags:** - -| Flag | Type | Required | Notes | -|------|------|----------|-------| -| `--app` | string | yes | | -| `--name` | string | yes | | -| `--access-level-id` | string | yes | UUID of access level | -| `--period` | option | yes | weekly/monthly/2_months/3_months/6_months/yearly/lifetime | -| `--ios-product-id` | string | conditional | Required if app has iOS | -| `--android-product-id` | string | conditional | Required if app has Android | -| `--android-base-plan-id` | string | conditional | Required with android-product-id | - -**Human output (create):** -``` -Product created! -ID: 880e8400-... -Name: Monthly Premium -iOS Product: com.example.monthly -Android Product: com.example.monthly (base plan: monthly-base) -``` - -Note: CLI cannot know app's platforms client-side. Send what's provided, let API validate. If user passes `--ios-product-id` for an Android-only app, API returns 400. - -#### 3.3 `adapty paywalls list` + `adapty paywalls create` - -**list flags:** `--app` (required), `--page`, `--page-size` -**create flags:** - -| Flag | Type | Required | -|------|------|----------| -| `--app` | string | yes | -| `--name` | string | yes | -| `--product-id` | string (multiple) | yes | Repeatable: `--product-id <uuid> --product-id <uuid>` | - -**Request:** `POST /apps/<app_id>/paywalls` `{"name": "<name>", "product_ids": ["<uuid>", ...]}` - -**Human output (create):** -``` -Paywall created! -ID: 990e8400-... -Name: Default Paywall -``` - -Note: No natural uniqueness — duplicate names create separate paywalls. Acceptable for MVP; agents should `list` before `create`. - -#### 3.4 `adapty placements list` + `adapty placements create` - -**list flags:** `--app` (required), `--page`, `--page-size` -**create flags:** - -| Flag | Type | Required | -|------|------|----------| -| `--app` | string | yes | -| `--name` | string | yes | -| `--developer-id` | string | yes | -| `--paywall-id` | string | yes | - -**Request:** `POST /apps/<app_id>/placements` `{"name": "<name>", "developer_id": "<dev_id>", "paywall_id": "<uuid>"}` - -**Human output (create):** -``` -Placement created! -ID: aa0e8400-... -Developer ID: default -Name: Default Placement -``` - -### Phase 4: Polish - -#### 4.1 package.json Topics - -Update `oclif.topics`: -```json -{ - "auth": {"description": "Authentication commands"}, - "apps": {"description": "Manage Adapty apps"}, - "products": {"description": "Manage products"}, - "access-levels": {"description": "Manage access levels"}, - "paywalls": {"description": "Manage paywalls"}, - "placements": {"description": "Manage placements"} -} -``` - -Remove `hello` topic. Delete `src/commands/hello/` and `test/commands/hello/`. - -#### 4.2 User-Agent Header - -Every request sends: `User-Agent: adapty-cli/<pkg-version> node/<node-version> <platform>/<arch>` - -Read version from oclif's `this.config.version`. - -#### 4.3 Config File Security - -Set `0o600` permissions on `config.json` after write (token is sensitive). Use `fs.chmod()`. - -#### 4.4 Tests - -Mirror command structure under `test/commands/`. Use mocha + chai (project standard). Mock `fetch` by injecting ApiClient into commands or using `sinon`. - -Priority tests: -1. `auth login` — device flow polling states (pending, slow_down, success, expired, denied) -2. `auth status` — authenticated vs not -3. `apps create` — flag validation (missing bundle ID when platform specified) -4. API client — error normalization (4xx, 5xx, network) -5. Config — read/write/corrupt file handling - -## Acceptance Criteria - -### Auth -- [x] `adapty auth login` completes device flow, stores token to config file -- [x] `adapty auth login` auto-opens browser, prints URL as fallback -- [x] `adapty auth login` handles slow_down (increase interval), expired_token, access_denied -- [x] `adapty auth login` when already authenticated: warns then proceeds -- [x] `adapty auth login` Ctrl+C exits cleanly -- [x] `adapty auth logout` removes token from config, prints server-side note -- [x] `adapty auth status` shows email + masked token prefix (no network call) -- [x] `adapty auth whoami` calls GET /me, shows user + companies -- [x] `ADAPTY_TOKEN` env var takes precedence over config file - -### Resource Commands -- [x] All create commands require flags (no prompts), fail with usage help if missing -- [x] All list commands support `--page` and `--page-size` -- [x] All app-scoped commands require `--app` (UUID, validated client-side) -- [x] `apps create` validates platform-conditional bundle IDs -- [x] `products create` supports `--period` enum with all 7 values -- [x] `paywalls create` supports repeated `--product-id` flag -- [x] `placements create` passes all three required fields - -### Dual-Mode Output -- [x] All commands (except `auth login`) support `--json` via oclif `enableJsonFlag` -- [x] Human output: simple key:value lines -- [x] JSON output: raw API response for data, normalized envelope for errors -- [x] Exit codes: 0=success, 1=API error, 2=usage error, 3=network, 4=auth required - -### Infrastructure -- [x] `ADAPTY_API_URL` env var overrides default base URL -- [x] Config file created with 0600 permissions -- [x] `User-Agent` header sent on every request -- [x] Corrupt config file handled gracefully (treat as empty) -- [x] Scaffold cleanup: remove hello commands + tests - -## Dependencies - -| Dependency | Type | Purpose | -|---|---|---| -| `@oclif/core` ^4 | existing | Command framework, flags, args, JSON flag | -| `@oclif/plugin-help` ^6 | existing | Help command | -| `open` ^10 | new runtime | Cross-platform browser open (macOS/Linux/Windows/WSL) | -| Node 18+ `fetch` | built-in | HTTP client | -| `node:fs/promises` | built-in | Config file read/write | - -## Risks - -| Risk | Mitigation | -|---|---| -| `access-levels` hyphenated dir in oclif | Verify oclif topic routing handles hyphens. Test early in Phase 3. | -| Platform-conditional flag validation | Can't know app platforms client-side. Let API validate, surface clear error. | -| Paywall/app duplicate on retry | Known limitation. Document that agents should `list` before `create`. | -| `open` package adds runtime dep | Small, well-maintained, zero transitive deps. Worth it for cross-platform. | -| Token in config file on shared systems | 0600 permissions mitigate. Document in README. | - -## Unresolved Questions - -1. **Strip `@oclif/plugin-plugins`?** Adds `adapty plugins` commands that aren't useful. Minor cleanup, no risk. -2. **UUID validation on all ID flags?** Currently only `--app` is validated client-side. Should `--paywall-id`, `--access-level-id`, `--product-id` also validate? Recommend yes for consistency. -3. **`products create` — `--ios-product-id` and `--android-product-id` conditionality.** CLI can't know app platforms. Two options: (a) make both optional, let API validate; (b) require at least one, let API reject mismatched platform. Leaning (a). diff --git a/docs/plans/2026-09-16-feat-persistent-current-migration-plan.md b/docs/plans/2026-09-16-feat-persistent-current-migration-plan.md deleted file mode 100644 index 1c66c62..0000000 --- a/docs/plans/2026-09-16-feat-persistent-current-migration-plan.md +++ /dev/null @@ -1,267 +0,0 @@ ---- -title: "feat: persistent currentMigrationId in the CLI" -type: feat -status: planned -date: 2026-09-16 ---- - -# Persistent current migration - -Add a saved migration selection so a person can choose a migration once and continue after -restarting the terminal. Keep explicit IDs available for scripts and concurrent work. The local -selection is a CLI convenience; migration state and allowed actions remain owned by the server. - -This plan describes implementation to be done, not functionality already present. - -## Current implementation - -- The published topic is `adapty migrations` (plural). Keep that spelling. -- `src/cli/input/migration.ts` requires `--migration` / `-m`, with `ADAPTY_MIGRATION` as an alternative. -- `status`, `show`, `steps`, `run` and `close` pass the parsed ID directly to the SDK. -- `create` returns an envelope and prints a continuation command with `-m`; it saves no selection. -- `src/sdk/core/session.ts` stores only credentials in `config.json`. Saving rewrites that file; - clearing removes it. Both CLI stacks depend on that format. -- `auth logout` clears the stored session; `auth revoke` clears it only after successful revocation - of a matching token. `ADAPTY_TOKEN` may override a different stored session. -- Session users currently have `email` and `name`, but no typed, stable account/company ID. - -## User-facing behavior - -```sh -adapty migrations list -adapty migrations use mig_abc123 -adapty migrations current -adapty migrations status -adapty migrations show report -adapty migrations status -m mig_other -adapty migrations unuse -``` - -### `migrations use <id>` - -1. Parse a required, non-empty ID argument and require authentication. -2. Call `migrations.get(id)` with the effective token and API URL to verify access. -3. Save the returned `migration.id` with the scope described below, replacing the previous selection. -4. Print the selected ID only after persistence succeeds. - -The command neither starts nor modifies a migration. Completed, canceled and failed migrations -can be selected for inspection. A failed GET or local write preserves the previous selection. -There is no extra confirmation prompt. The argument is the selection to save even when -`ADAPTY_MIGRATION` is set; warn on stderr that the environment variable still overrides it. - -JSON result: `{ "currentMigrationId": "mig_abc123" }`. - -### `migrations current` - -Show the ID that commands without `-m` would use, with its source: `ADAPTY_MIGRATION` or the saved -context. This is a local read, with no GET, token validation request or write. An expired token -therefore does not prevent inspecting its saved selection. The command does not accept `-m`. - -JSON result: `{ "currentMigrationId": "mig_abc123", "source": "context" }`, where source is -`"env"`, `"context"` or `null`. With no applicable selection, return -`{ "currentMigrationId": null, "source": null }`, print an instruction to run `migrations use <id>`, -and exit 0. Without an effective token, a stored selection is inapplicable; an explicit environment -ID can still be displayed. Never expose the token or its fingerprint in command output. - -### `migrations unuse` - -Remove the saved selection, without authentication or network access. This is idempotent and -works even if the context is malformed or belongs to a different scope. It does not change the -parent shell environment: if `ADAPTY_MIGRATION` is set, explain that it still supplies an ID and -must be unset in the shell. Return `{ "currentMigrationId": null }`; this describes saved state, -not the effective environment override. - -### `migrations create` - -After successful creation, save the returned ID as current, including with `--json` and in a pipe. -Add `--no-select` to opt out for scripts and concurrent workflows. Do not make persistence depend -on whether a terminal is attached. A failed API request never changes the saved selection. - -Keep the existing envelope as the JSON result. If creation succeeds but saving context fails, -return the successful envelope and warn on stderr with the created ID and the explicit `-m` -continuation command. Do not turn this into an apparent failed creation that invites a duplicate -POST. Do not retry creation to repair a local write failure. Also explain an active -`ADAPTY_MIGRATION` override when saving succeeds. - -## Resolving an ID - -Use one shared resolver for `status`, `show`, `steps`, `run` and `close`: - -1. Explicit `--migration` / `-m`. -2. Non-empty `ADAPTY_MIGRATION`. -3. `currentMigrationId` from a context matching the effective session scope. -4. Usage error (exit 2, code `migration_required`) with instructions for `use`, `list` and `-m`. - -Remove `required: true` from the shared flag. Resolve the environment in the CLI resolver instead -of relying on the flag's `env` fallback, so the source remains explicit and `current` can reuse -exactly the same rules. Inject the environment value into the resolution logic for tests; never -read environment variables in the SDK. Update flag help to advertise all three sources. - -An empty environment value counts as absent; explicitly empty or whitespace-only IDs are usage -errors, not permission to fall through to a different migration. Do not invent a Salesforce-like -ID length/prefix restriction for opaque migration IDs. - -Read the context lazily: explicit flag/env IDs must work even if `context.json` is corrupted or -unreadable. They do not update the saved selection. Never guess an ID from `migrations list`. -Keep parsing and command-specific input validation before resolution and network requests. -When both an ID and credentials are absent, keep the actionable missing-ID usage error; when an -ID is supplied but credentials are absent, keep the existing auth error. - -Resolve once per command invocation. In particular, `run` and `close` must use the same captured -ID for the initial GET and subsequent POST, and `status --wait` must keep that ID for every poll. -A `use` in another terminal must not redirect an operation that has already resolved its target. - -Before a `run` or `close` mutation using saved context, identify the target ID on stderr. Existing -`--yes`, revision checks and error handling stay in force. Existing migration JSON envelopes and -exit codes remain unchanged apart from the new local context errors described below. - -## Storage and scope - -Keep credentials in the existing `config.json`. Put the selection in `context.json` beside it, -using oclif's `config.configDir`; do not hardcode a home path or introduce project-directory lookup. -Do not add `currentMigrationId` to SDK `Session` or `SessionStore`. - -One saved selection is sufficient for this version: - -```ts -type MigrationContext = { - version: 1; - apiUrl: string; - tokenFingerprint: string; - currentMigrationId: string; -}; -``` - -Scope by normalized effective API base URL plus SHA-256 of the effective token. Normalize the URL -consistently with the SDK's base URL handling; retain the base path and distinguish environments. -Never write the raw token into the context. A fingerprint is only a local matching key, not proof -of authorization; the server still authorizes every operation. - -This deliberately conservative scope uses the identity information available today. Do not infer -account identity from email or decode an opaque token. A different token, including a different -`ADAPTY_TOKEN`, cannot silently inherit the saved selection. If the environment contains exactly -the stored token, it is the same scope regardless of its source. - -A scope mismatch makes the saved selection inapplicable, without deleting or overwriting it on a -read. Returning to the same token/API URL makes it available again. `use` or selecting a newly -created migration explicitly replaces the single record. An app ID is not part of the scope: a -new catalog migration can have `app: null`, and the migration ID already identifies the target. - -**First-version limitation:** issuing a new token requires selecting the migration again, even -for the same human account. Preserving selection across reauthentication requires a documented, -stable server account/company identity; it is deferred rather than guessed from the current -`email`/`name` fields. Multiple saved profiles are also outside this change. - -Implement the file store in the CLI layer, with the directory passed in. Requirements: - -- Missing file means no selection. Malformed JSON, invalid shape, unsupported version and I/O - failures are distinct from absence; report a context-specific error with the path and recovery - through `migrations unuse` / `migrations use <id>`. -- Use `CliError` with exit 1 and a stable `migration_context_invalid` or - `migration_context_io` code. Do not reuse the SDK storage mapper that tells users to log in. -- Write through a uniquely named temporary file in the same directory, then rename atomically. - Use mode 0600 for the file and private directory permissions on creation; clean up temporary - files after failures. A failed replacement must leave the previous valid file intact. -- `use` can replace malformed context without first reading it; `unuse` can remove it directly. -- Simultaneous selections are last-successful-write-wins; readers never see partial JSON. - Document that the selection is shared across terminals. Scripts should use `-m` or an - environment ID, and `create --no-select` when they must not change the shared default. - -## Selection lifecycle and authentication - -| Event | Context behavior | -| --- | --- | -| Terminal closes or a CLI process exits | Preserve | -| Successful `use <id>` | Replace after GET validation | -| Successful `create`, without `--no-select` | Replace with the created ID | -| Explicit `-m` or `ADAPTY_MIGRATION` for an operation | Override for this invocation; do not save | -| `unuse` | Remove saved context, even without a session | -| `completed`, `canceled`, `failed`, or successful `close` | Preserve for status/report inspection | -| Network failure, 401, 403, or 404 | Preserve; do not infer permanent deletion | -| Different effective token or API URL | Ignore mismatching context; do not mutate on read | -| Successful login with a new token | Old context cannot match; require a new selection | -| `auth logout` | Clear stored credentials and the local context, including orphaned context | -| Successful `auth revoke` | Clear context only if its fingerprint matches the revoked token | -| Failed/canceled login or failed revoke | Preserve context | - -`logout` clears context even if `session.store.load()` finds no stored session. Attempt both local -cleanup operations if either fails, and report partial cleanup as a local error. Preserve the -existing warning that a parent-shell `ADAPTY_TOKEN` remains set; likewise, CLI commands cannot -unset `ADAPTY_MIGRATION` in that shell. - -`revoke` remains server-first. After server success, keep the existing matching-token rule for -removing `config.json`, and independently remove context belonging to the revoked token. A -revoked environment token must not clear context belonging to a different stored token. Because -this is one saved record, cleanup can compare fingerprints without assuming that the current API -URL was the URL at selection time. If local cleanup fails, make clear that revocation succeeded; -do not automatically repeat the server operation. - -`login` continues saving only credentials. The scope check makes old context inactive after a -token change, so login need not rewrite a second file or clear another token's selection. The -context remains recoverable with `unuse` or replaceable with `use`. Logging out removes it -explicitly. Existing auth JSON result shapes remain unchanged on success. - -There is no migration-deletion command in scope and no automatic cleanup on a generic 404. If a -future command permanently deletes a migration, clear context only after confirmed success and -only when the saved ID and scope match the deleted migration. - -## Implementation sequence - -1. **CLI context store and resolver.** Add `src/cli/context/migration.ts` for the record, scope, - file operations and shared resolution. Keep the module small; split only if implementation - size justifies it. Keep flag/argument declarations in `src/cli/input/migration.ts`. Expose the - already resolved session to the resolver without adding migration policy to `BaseCommand` or - the SDK; if needed, provide a protected non-auth-enforcing session accessor in `AdaptyCommand` - while preserving its existing authenticated accessor. -2. **Selection commands.** Add `use`, `current` and `unuse` under `src/cli/commands/migrations`. - Simple commands can be single files; directory commands use `command.ts` plus an `index.ts` - re-export. Add only discovery re-exports under `src/commands/migrations`. `use` requires an - authenticated session; `current` and `unuse` extend `BaseCommand` and do local work. -3. **Existing commands.** Integrate the resolver into `status`, `show`, `steps`, `run` and `close`. - Pass the captured ID through the run command's helpers instead of reading an optional flag - again. Integrate selection into `create` and add `--no-select`. Preserve envelope output, - action confirmation, optimistic concurrency and polling behavior. -4. **Auth cleanup.** Extend CLI `logout` and `revoke` orchestration, keeping SDK credential storage - and legacy compatibility unchanged. Cover the env-token override cases explicitly. -5. **Documentation.** Update README migration usage, command help/examples, the migration inventory - in `CLAUDE.md`, and `skills/adapty-cli/references/cli-commands.md`. Update any stateless/explicit-ID - claims in `skills/adapty-cli/SKILL.md`. Describe CLI context ownership in `docs/architecture.md`. - Keep this plan as planned until the implementation and checks are complete. - -No new endpoint, SDK resource method, third-party dependency, singular topic alias, interactive -selection menu, automatic retry, migration upload or migration-state change is required. - -## Verification - -Use isolated temporary config directories and scripted HTTP responses. Update -`test/helpers/isolate-config.ts` to clean both files and reset `ADAPTY_MIGRATION` as well as auth -environment values between tests. Restore any environment values changed by individual tests. - -- Store: absent/valid/malformed/version-mismatched context, permission/I/O errors, replacement - failure preserving the old record, idempotent removal, and scope comparison for API URL/token. -- Resolution: flag > env > matching context; empty and invalid inputs; mismatch; no selection; - malformed context bypassed by an explicit ID; no unexpected GET/list calls. -- Commands: `use` verifies before saving and preserves selection on failure; `current` reports - the effective source without requests; `unuse` works offline and explains env overrides. -- Persistence: select in one process, read/use in a fresh process with the same config directory. -- Operations: all five existing target commands use the resolved ID; changing context between - GET and POST or during polling does not change the in-flight target. -- Creation: default selection, `--no-select`, unchanged JSON envelope, and successful server - creation plus failed local persistence produces a warning without a second POST. -- Lifecycle: closed/failed migrations remain inspectable; network/auth/404 errors preserve the - file; logout removes orphaned context; revoke handles matching and different env tokens; - failed revoke preserves both files; reauthentication cannot reuse another token's selection. -- CLI compatibility: retain flag/env behavior, JSON envelopes and confirmation/exit semantics. - Update the subprocess missing-ID test in `test/commands/migrations-exit-codes.test.ts` to expect - the resolver's exit-2 error instead of oclif's former required-flag wording. Verify local context - errors in both human and JSON modes without leaking fingerprints or tokens. -- Run `pnpm build`, `pnpm test` (includes lint) and `pnpm check:agent-docs` after implementation. - Keep frozen-legacy, command-layout and SDK/CLI import-boundary checks intact. - -## Done when - -A user can create or select a migration, reopen the terminal and run `migrations status` without -remembering the ID. `current` explains the effective selection, explicit flag/env IDs remain -predictable, account/environment changes cannot silently reuse it, and logout/revoke perform -only the intended cleanup. Credentials keep their existing format and the SDK remains unaware -of the CLI's saved selection. diff --git a/skills/adapty-cli/SKILL.md b/skills/adapty-cli/SKILL.md index 0df91e6..ca7a8b7 100644 --- a/skills/adapty-cli/SKILL.md +++ b/skills/adapty-cli/SKILL.md @@ -163,7 +163,8 @@ For migrations into Adapty, read [Migrations in the command reference](reference The server supplies the available flows, action IDs and input schemas. - Create with `--name` for a new app, or `--flow` and `--app` together for an existing app, as offered by `migrations list` -- Pass `-m` explicitly when operating on a migration; the CLI accepts `ADAPTY_MIGRATION` but never selects one automatically +- Pass `-m` explicitly in scripts; otherwise the CLI uses `ADAPTY_MIGRATION`, then the selection saved by `use` or `create` +- Use `create --no-select` to preserve the shared default; `current` shows selection and `unuse` clears saved selection - Use `status --json` to read the current action's `reads`, `input_schema` and `confirm` before running it - Review `confirm` before passing `--yes`; migration commands do not show interactive confirmation prompts - Check `migration.state` after each action or wait; exit 0 does not mean the migration completed diff --git a/skills/adapty-cli/references/cli-commands.md b/skills/adapty-cli/references/cli-commands.md index 25e70cd..b6ba801 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -10,8 +10,8 @@ All commands support `--json` for machine-readable output. | Command | Description | |-----------------------|-----------------------------------| | `auth login` | OAuth device flow (opens browser) | -| `auth logout` | Remove stored token | -| `auth revoke` | Revoke token server-side + logout | +| `auth logout` | Remove stored credentials and migration selection locally | +| `auth revoke` | Revoke effective token, then remove matching credentials and selection | | `auth whoami` | Show authenticated user info | | `auth status` | Show local auth state | @@ -102,8 +102,11 @@ available flows, steps, actions and input schemas. Choose values from the curren | Command | Flags | |---------|-------| -| `migrations create` | `--name <app name>` (main flow), or `--flow <flow> --app <app_id>` | +| `migrations create` | `--name <app name>` (main flow), or `--flow <flow> --app <app_id>`; `--no-select` | | `migrations list` | — | +| `migrations use <id>` | Verify access and save the selection for the current token | +| `migrations current` | Show the effective local selection and source; no network | +| `migrations unuse` | Clear saved selection without authentication; no network | | `migrations status` | `-m`, `--wait`, `--timeout <duration>` (needs `--wait`, default 120s, range 1–600s) | | `migrations steps` | `-m` | | `migrations show [<resource>]` | `-m`; no argument lists what can be read | @@ -112,9 +115,25 @@ available flows, steps, actions and input schemas. Choose values from the curren **Scope and creation.** `create --name` starts a catalog migration into a new app. For an existing app, choose a flow and its app from `list.available`, then pass `--flow` and `--app` together. -These modes are mutually exclusive. Creation starts the flow and returns its ID in `migration.id`. +These modes are mutually exclusive. Creation starts the flow, returns its ID in `migration.id` +and saves it as current unless `--no-select` is supplied. A local save failure warns on stderr +but preserves the successful creation response; do not retry creation to repair local state. For `status`, `steps`, `show`, `run` and `close`, pass `-m <id>` explicitly. The CLI also accepts -`ADAPTY_MIGRATION`; the flag takes precedence. There is no automatic migration selection. +`ADAPTY_MIGRATION` or saved context, with priority `-m` > non-empty environment > saved selection. + +**Saved selection.** `use` verifies access before saving the returned ID. `current --json` returns +`{ "currentMigrationId": "...", "source": "env" }` or source `"context"`; both fields are null +when no selection applies. `unuse` clears saved state even if malformed, but cannot unset +`ADAPTY_MIGRATION` in the parent shell. Selection is shared across terminals, but each operation +captures its target once, including polling and GET/POST pairs. `run` and `close` identify a saved +target on stderr before mutation. Use explicit IDs and `create --no-select` in scripts to avoid +changing or depending on the shared default. + +`auth logout` removes saved context even without credentials. `auth revoke` removes it only after +successful server revocation and only for the revoked token; other tokens' context is preserved. +Failed revocation preserves local state. Incomplete local cleanup returns exit 1 with +`auth_cleanup_failed`; if the error says the token was revoked, repair local state without repeating +the revoke request. Shell environment overrides must be unset separately. **Inspect before acting.** Use `status --json` to read `next_actions` and `available_actions`. Select an action relevant to the task; optional actions are not a queue to execute. `steps` is a diff --git a/src/cli/base/adapty/adapty-command.ts b/src/cli/base/adapty/adapty-command.ts index 5a4f36d..880d464 100644 --- a/src/cli/base/adapty/adapty-command.ts +++ b/src/cli/base/adapty/adapty-command.ts @@ -17,10 +17,6 @@ export type AuthenticatedSession = ResolvedSession & { token: string }; * branch into all 75 commands, and one of them would forget it. */ export abstract class AdaptyCommand extends BaseCommand { - // Keep one own static so oclif's manifest cache walks through this intermediate class and - // includes statics inherited from BaseCommand. - static override enableJsonFlag = true; - #adapty: Adapty | undefined; #resolved: ResolvedSession | undefined; @@ -35,8 +31,8 @@ export abstract class AdaptyCommand extends BaseCommand { return this.#adapty; } - /** Narrowed once here, so nothing downstream re-checks the token. */ - protected get session(): AuthenticatedSession { + /** Effective session without enforcing auth, for local input resolution before SDK access. */ + protected get resolvedSession(): ResolvedSession { const resolved = this.#resolved; if (resolved === undefined) { @@ -45,6 +41,13 @@ export abstract class AdaptyCommand extends BaseCommand { throw new Error('session is available only after init()'); } + return resolved; + } + + /** Narrowed once here, so nothing downstream re-checks the token. */ + protected get session(): AuthenticatedSession { + const resolved = this.resolvedSession; + if (resolved.token === undefined) { throw new AuthRequiredError('missing'); } diff --git a/src/cli/base/adapty/index.ts b/src/cli/base/adapty/index.ts index 1ba1a38..c897c89 100644 --- a/src/cli/base/adapty/index.ts +++ b/src/cli/base/adapty/index.ts @@ -1,5 +1,6 @@ export { AdaptyCommand } from './adapty-command.js'; export { build } from './build.js'; +export { MigrationCommand } from './migration-command.js'; export { openSession } from './openSession.js'; export type { AuthenticatedSession } from './adapty-command.js'; diff --git a/src/cli/base/adapty/migration-command.ts b/src/cli/base/adapty/migration-command.ts new file mode 100644 index 0000000..b621216 --- /dev/null +++ b/src/cli/base/adapty/migration-command.ts @@ -0,0 +1,23 @@ +import { openCurrentMigration } from '../../context/migration/index.js'; + +import { AdaptyCommand } from './adapty-command.js'; + +import type { CurrentMigration } from '../../context/migration/index.js'; + +/** + * `AdaptyCommand` plus the saved migration selection. One topic out of ten stays a base of its own + * rather than a getter on `AdaptyCommand`, where all 75 commands would pay for it. + */ +export abstract class MigrationCommand extends AdaptyCommand { + #current: CurrentMigration | undefined; + + /** A getter, not a field: field initializers run in the constructor, before init() has a session. */ + protected get currentMigration(): CurrentMigration { + this.#current ??= openCurrentMigration({ + configDir: this.config.configDir, + session: this.resolvedSession, + }); + + return this.#current; + } +} diff --git a/src/cli/commands/auth/logout.ts b/src/cli/commands/auth/logout/command.ts similarity index 51% rename from src/cli/commands/auth/logout.ts rename to src/cli/commands/auth/logout/command.ts index 7526197..60b9fda 100644 --- a/src/cli/commands/auth/logout.ts +++ b/src/cli/commands/auth/logout/command.ts @@ -1,5 +1,9 @@ -import { openSession } from '../../base/adapty/index.js'; -import { BaseCommand } from '../../base/base-command.js'; +import { createFileSessionStore } from '../../../../sdk/core/session.js'; +import { BaseCommand } from '../../../base/base-command.js'; +import { openCurrentMigration } from '../../../context/migration/index.js'; +import { envSuppliesMigration } from '../../../views/migrations/notices.js'; + +import { clearLocalSession } from './lib/cleanup.js'; type Result = { /** The env var outlives the file, so "Logged out" alone would be a lie. */ @@ -17,22 +21,26 @@ const ENV_STILL_SET = 'ADAPTY_TOKEN is still set in the environment, so commands stay authenticated. Unset it to finish logging out.'; export default class AuthLogout extends BaseCommand { - static override description = 'Remove the stored session'; + static override description = 'Remove stored credentials and the saved migration selection'; static override examples = ['<%= config.bin %> auth logout']; async run(): Promise<Result> { await this.parse(AuthLogout); - const session = await openSession(this.config); - const stored = await session.store.load(); + const envTokenSet = process.env.ADAPTY_TOKEN !== undefined && process.env.ADAPTY_TOKEN !== ''; + + // No session: an orphaned selection has to go even when the credentials cannot be read. + const current = openCurrentMigration({ configDir: this.config.configDir }); - if (stored !== undefined) { - await session.store.clear(); + if (current.overridden) { + process.stderr.write(envSuppliesMigration); } + const hadSession = await clearLocalSession(createFileSessionStore(this.config.configDir), current); + const result: Result = { - env_token_set: session.source === 'env', - status: stored === undefined ? 'not_authenticated' : 'logged_out', + env_token_set: envTokenSet, + status: hadSession ? 'logged_out' : 'not_authenticated', }; this.render(result, status => (status.env_token_set diff --git a/src/cli/commands/auth/logout/index.ts b/src/cli/commands/auth/logout/index.ts new file mode 100644 index 0000000..40b09fa --- /dev/null +++ b/src/cli/commands/auth/logout/index.ts @@ -0,0 +1 @@ +export { default } from './command.js'; diff --git a/src/cli/commands/auth/logout/lib/cleanup.ts b/src/cli/commands/auth/logout/lib/cleanup.ts new file mode 100644 index 0000000..6dc0886 --- /dev/null +++ b/src/cli/commands/auth/logout/lib/cleanup.ts @@ -0,0 +1,36 @@ +import { CliError, describeCleanupFailures } from '../../../../errors.js'; + +import type { SessionStore } from '../../../../../sdk/core/session.js'; +import type { CurrentMigration } from '../../../../context/migration/index.js'; + +export const clearLocalSession = async ( + session: SessionStore, + context: CurrentMigration, +): Promise<boolean> => { + let hadSession: boolean; + + try { + hadSession = await session.load() !== undefined; + } catch { + // Logout can remove unreadable or malformed credentials without decoding them. + hadSession = true; + } + + const results = await Promise.allSettled([session.clear(), context.clear()]); + const failed = describeCleanupFailures([session.path, context.path], results); + + if (failed.length > 0) { + throw new CliError( + [ + 'Local logout cleanup is incomplete. Could not remove:', + ...failed.map(failure => ` ${failure.file}: ${failure.reason}`), + 'Check file permissions and run `adapty auth logout` again.', + ].join('\n'), + 1, + 'auth_cleanup_failed', + { cause: failed[0]?.cause }, + ); + } + + return hadSession; +}; diff --git a/src/cli/commands/auth/revoke.ts b/src/cli/commands/auth/revoke/command.ts similarity index 71% rename from src/cli/commands/auth/revoke.ts rename to src/cli/commands/auth/revoke/command.ts index 0fa9198..4ac50c6 100644 --- a/src/cli/commands/auth/revoke.ts +++ b/src/cli/commands/auth/revoke/command.ts @@ -1,12 +1,15 @@ -import { build, openSession } from '../../base/adapty/index.js'; -import { BaseCommand } from '../../base/base-command.js'; +import { build, openSession } from '../../../base/adapty/index.js'; +import { BaseCommand } from '../../../base/base-command.js'; +import { openCurrentMigration } from '../../../context/migration/index.js'; + +import { clearRevokedSession } from './lib/cleanup.js'; type Result = | { status: 'not_authenticated' } | { env_token_set: boolean; status: 'revoked' }; export default class AuthRevoke extends BaseCommand { - static override description = 'Revoke the current token on the server and remove any matching stored session'; + static override description = 'Revoke the current token and remove matching stored credentials and migration selection'; static override examples = ['<%= config.bin %> auth revoke']; async run(): Promise<Result> { @@ -32,11 +35,11 @@ export default class AuthRevoke extends BaseCommand { await adapty.auth.revokeToken(session.token); // ADAPTY_TOKEN may override a different, still-valid session in the file. - const stored = await session.store.load(); - - if (stored?.token === session.token) { - await session.store.clear(); - } + await clearRevokedSession( + session.store, + openCurrentMigration({ configDir: this.config.configDir, session }), + session.token, + ); const result: Result = { env_token_set: session.source === 'env', status: 'revoked' }; diff --git a/src/cli/commands/auth/revoke/index.ts b/src/cli/commands/auth/revoke/index.ts new file mode 100644 index 0000000..40b09fa --- /dev/null +++ b/src/cli/commands/auth/revoke/index.ts @@ -0,0 +1 @@ +export { default } from './command.js'; diff --git a/src/cli/commands/auth/revoke/lib/cleanup.ts b/src/cli/commands/auth/revoke/lib/cleanup.ts new file mode 100644 index 0000000..1abb215 --- /dev/null +++ b/src/cli/commands/auth/revoke/lib/cleanup.ts @@ -0,0 +1,46 @@ +import { CliError, describeCleanupFailures } from '../../../../errors.js'; + +import type { SessionStore } from '../../../../../sdk/core/session.js'; +import type { CurrentMigration } from '../../../../context/migration/index.js'; + +/** + * ADAPTY_TOKEN may override a different, still-valid file session, which revocation must leave + * alone. Read-then-remove, not a compare-and-delete: a concurrent `login` can land in between and + * lose its fresh credentials, which asks for another `adapty auth login` and never keeps a revoked + * token readable. See `CurrentMigration.clearFor`, which drops the selection under the same rule. + */ +const clearMatchingSession = async (store: SessionStore, token: string): Promise<void> => { + const stored = await store.load(); + + if (stored?.token === token) { + await store.clear(); + } +}; + +/** Called only after server success; each local cleanup is attempted independently. */ +export const clearRevokedSession = async ( + session: SessionStore, + context: CurrentMigration, + token: string, +): Promise<void> => { + const results = await Promise.allSettled([ + clearMatchingSession(session, token), + context.clearFor(token), + ]); + + const files = [session.path, context.path]; + const failed = describeCleanupFailures(files, results); + + if (failed.length > 0) { + throw new CliError( + [ + 'Token revoked on the server, but local cleanup is incomplete for:', + ...failed.map(failure => ` ${failure.file}: ${failure.reason}`), + 'Check or remove these local files; do not repeat revocation.', + ].join('\n'), + 1, + 'auth_cleanup_failed', + { cause: failed[0]?.cause }, + ); + } +}; diff --git a/src/cli/commands/migrations/close/command.ts b/src/cli/commands/migrations/close/command.ts index 0ee282f..04a38e1 100644 --- a/src/cli/commands/migrations/close/command.ts +++ b/src/cli/commands/migrations/close/command.ts @@ -1,12 +1,12 @@ import { Flags } from '@oclif/core'; -import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { MigrationCommand } from '../../../base/adapty/index.js'; import { migrationFlags } from '../../../input/migration.js'; import { renderEnvelope } from '../../../views/migrations/envelope/envelope.js'; import type { Envelope } from '../../../../sdk/adapty/index.js'; -export default class Close extends AdaptyCommand { +export default class Close extends MigrationCommand { static override summary = 'Permanently finish or cancel a migration'; static override description = [ 'Choose finish to mark the migration as completed, or cancel to abandon it.', @@ -16,6 +16,10 @@ export default class Close extends AdaptyCommand { ].join('\n'); static override examples = [ + { + description: 'Finish the saved migration:', + command: '<%= config.bin %> migrations close --outcome finish --yes', + }, { description: 'Mark the migration as finished:', command: '<%= config.bin %> migrations close -m mig_7x2 --outcome finish --yes', @@ -46,9 +50,15 @@ export default class Close extends AdaptyCommand { async run(): Promise<Envelope> { const { flags } = await this.parse(Close); - const { migration } = await this.adapty.migrations.get(flags.migration); - const envelope = await this.adapty.migrations.close(flags.migration, { + const selection = await this.currentMigration.require(flags.migration); + const { migration } = await this.adapty.migrations.get(selection.currentMigrationId); + + if (selection.source === 'context') { + process.stderr.write(`Using saved migration: ${selection.currentMigrationId}\n`); + } + + const envelope = await this.adapty.migrations.close(selection.currentMigrationId, { expectedRevision: migration.revision, outcome: flags.outcome, }); diff --git a/src/cli/commands/migrations/create/command.ts b/src/cli/commands/migrations/create/command.ts index 2e0d079..0d9669f 100644 --- a/src/cli/commands/migrations/create/command.ts +++ b/src/cli/commands/migrations/create/command.ts @@ -2,24 +2,26 @@ 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 { MigrationCommand } from '../../../base/adapty/index.js'; import { renderEnvelope } from '../../../views/migrations/envelope/envelope.js'; +import { envOverridesSelection } from '../../../views/migrations/notices.js'; import type { CreateMigrationInput, Envelope } from '../../../../sdk/adapty/index.js'; -export default class Create extends AdaptyCommand { +export default class Create extends MigrationCommand { static override summary = 'Start a migration into Adapty'; static override description = [ 'Use --name to migrate a catalog into a new Adapty app.', 'For an existing app, use --flow and --app together. Choose a flow and its app from `adapty migrations list`.', '', - 'Creation starts the flow; use `adapty migrations status -m ID` to continue.', + 'Creation starts the flow and saves it as current; use `adapty migrations status` to continue.', + 'Use --no-select to keep the previous selection. ADAPTY_MIGRATION overrides saved selection.', 'With --json, the migration ID is in migration.id.', ].join('\n'); static override usage = [ - 'migrations create --name APP_NAME [--json]', - 'migrations create --flow FLOW --app APP_ID [--json]', + 'migrations create --name APP_NAME [--no-select] [--json]', + 'migrations create --flow FLOW --app APP_ID [--no-select] [--json]', ]; static override examples = [ @@ -38,17 +40,21 @@ export default class Create extends AdaptyCommand { ]; static override flags = { - name: Flags.string({ + 'no-select': Flags.boolean({ + description: 'Create without changing the saved migration selection', + default: false, + }), + 'name': Flags.string({ description: 'New Adapty app name; cannot be combined with --flow or --app', exclusive: ['app', 'flow'], helpValue: 'APP_NAME', }), - flow: Flags.string({ + 'flow': Flags.string({ dependsOn: ['app'], description: 'Available flow from `adapty migrations list`; requires --app', helpValue: 'FLOW', }), - app: Flags.string({ + 'app': Flags.string({ dependsOn: ['flow'], description: 'Existing Adapty app ID (UUID); requires --flow', helpValue: 'APP_ID', @@ -68,10 +74,32 @@ export default class Create extends AdaptyCommand { const envelope = await this.adapty.migrations.create(input); + const selected = !flags['no-select'] && await this.select(envelope.migration.id); + const target = selected && !this.currentMigration.overridden ? '' : ` -m ${envelope.migration.id}`; + this.log('Migration created.'); this.render(envelope, renderEnvelope); - this.log(`\nContinue with \`${this.config.bin} migrations status -m ${envelope.migration.id}\`.`); + this.log(`\nContinue with \`${this.config.bin} migrations status${target}\`.`); return envelope; } + + /** Creation has already succeeded: a local failure must not invite a duplicate POST. */ + private async select(migrationId: string): Promise<boolean> { + try { + await this.currentMigration.set(migrationId); + } catch { + process.stderr.write( + `Warning: Migration ${migrationId} was created, but its selection could not be saved. Continue with \`${this.config.bin} migrations status -m ${migrationId}\`.\n`, + ); + + return false; + } + + if (this.currentMigration.overridden) { + process.stderr.write(envOverridesSelection); + } + + return true; + } } diff --git a/src/cli/commands/migrations/current/command.ts b/src/cli/commands/migrations/current/command.ts new file mode 100644 index 0000000..36c88e2 --- /dev/null +++ b/src/cli/commands/migrations/current/command.ts @@ -0,0 +1,38 @@ +import { openSession } from '../../../base/adapty/index.js'; +import { BaseCommand } from '../../../base/base-command.js'; +import { openCurrentMigration } from '../../../context/migration/index.js'; + +type Result = { + currentMigrationId: string | null; + source: 'env' | 'context' | null; +}; + +export default class Current extends BaseCommand { + static override summary = 'Show the effective migration selection and its source (no network)'; + static override description = [ + 'ADAPTY_MIGRATION takes precedence over the saved selection for the current token.', + 'With no applicable selection, returns null and exits successfully.', + ].join('\n'); + + static override examples = ['<%= config.bin %> migrations current', '<%= config.bin %> migrations current --json']; + + async run(): Promise<Result> { + await this.parse(Current); + + const selection = await openCurrentMigration({ + configDir: this.config.configDir, + session: await openSession(this.config), + }).get(); + + const result: Result = selection === undefined + ? { currentMigrationId: null, source: null } + : { currentMigrationId: selection.currentMigrationId, source: selection.source === 'env' ? 'env' : 'context' }; + + this.render(result, value => value.currentMigrationId === null + ? `No migration selected. Run \`${this.config.bin} migrations use <id>\`.` + : `Current migration: ${value.currentMigrationId} (${value.source === 'env' ? 'ADAPTY_MIGRATION' : 'context'})`, + ); + + return result; + } +} diff --git a/src/cli/commands/migrations/current/index.ts b/src/cli/commands/migrations/current/index.ts new file mode 100644 index 0000000..40b09fa --- /dev/null +++ b/src/cli/commands/migrations/current/index.ts @@ -0,0 +1 @@ +export { default } from './command.js'; diff --git a/src/cli/commands/migrations/run/command.ts b/src/cli/commands/migrations/run/command.ts index 702cba4..7be96bf 100644 --- a/src/cli/commands/migrations/run/command.ts +++ b/src/cli/commands/migrations/run/command.ts @@ -1,6 +1,6 @@ import { Args, Flags } from '@oclif/core'; -import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { MigrationCommand } from '../../../base/adapty/index.js'; import { CliError, exitCode } from '../../../errors.js'; import { migrationFlags } from '../../../input/migration.js'; import { renderEnvelope } from '../../../views/migrations/envelope/envelope.js'; @@ -11,12 +11,12 @@ import { readActionInput } from './lib/input.js'; import { openLink } from './lib/open-link.js'; import type { Action, Envelope } from '../../../../sdk/adapty/index.js'; +import type { MigrationSelection } from '../../../context/migration/index.js'; type RunContext = { action: Action; envelope: Envelope; flags: { - 'migration': string; 'no-browser': boolean; 'open': boolean; 'yes': boolean; @@ -24,9 +24,10 @@ type RunContext = { /** Set for every action the server hands over as a link, whatever kind it calls itself. */ href: string | undefined; input: unknown; + selection: MigrationSelection; }; -export default class Run extends AdaptyCommand { +export default class Run extends MigrationCommand { static override summary = 'Run an input action or open an external action link'; static override description = [ 'Choose an action ID from next_actions or available_actions in `adapty migrations status -m ID --json`.', @@ -45,6 +46,10 @@ export default class Run extends AdaptyCommand { ].join('\n'); static override examples = [ + { + description: 'Run an offered action on the saved migration after reviewing confirm:', + command: '<%= config.bin %> migrations run ACTION_ID --yes', + }, { description: 'Read current action IDs and input schemas first:', command: '<%= config.bin %> migrations status -m mig_7x2 --json', @@ -119,7 +124,8 @@ export default class Run extends AdaptyCommand { const fromStdin = flags['input-file'] === '-'; const input = fromStdin ? undefined : await readActionInput(flags); - const envelope = await this.adapty.migrations.get(flags.migration); + const selection = await this.currentMigration.require(flags.migration); + const envelope = await this.adapty.migrations.get(selection.currentMigrationId); const action = findAction(envelope, args.action_id); if (action === undefined) { @@ -137,7 +143,7 @@ export default class Run extends AdaptyCommand { } return { - action, envelope, flags, href, + action, envelope, flags, href, selection, input: fromStdin && action.kind === 'input' ? await readActionInput(flags) : input, }; } @@ -149,7 +155,7 @@ export default class Run extends AdaptyCommand { return context.envelope; } - private async runInputAction({ action, envelope, flags, input }: RunContext): Promise<Envelope> { + private async runInputAction({ action, envelope, flags, input, selection }: RunContext): Promise<Envelope> { if (action.kind !== 'input' && action.kind !== 'upload' && action.kind !== 'external') { this.render({ action, migrationId: envelope.migration.id }, actionView); @@ -166,7 +172,11 @@ export default class Run extends AdaptyCommand { throw new CliError(message, exitCode.confirmRequired, 'confirm_required'); } - const result = await this.adapty.migrations.runAction(flags.migration, action.action_id, { + if (selection.source === 'context') { + process.stderr.write(`Using saved migration: ${selection.currentMigrationId}\n`); + } + + const result = await this.adapty.migrations.runAction(selection.currentMigrationId, action.action_id, { expectedRevision: envelope.migration.revision, input, }); diff --git a/src/cli/commands/migrations/show/command.ts b/src/cli/commands/migrations/show/command.ts index da0a44a..a566bd8 100644 --- a/src/cli/commands/migrations/show/command.ts +++ b/src/cli/commands/migrations/show/command.ts @@ -1,13 +1,13 @@ import { Args } from '@oclif/core'; -import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { MigrationCommand } from '../../../base/adapty/index.js'; import { migrationFlags } from '../../../input/migration.js'; import { renderResources, renderResult } from './lib/render.js'; import type { Envelope } from '../../../../sdk/adapty/index.js'; -export default class Show extends AdaptyCommand { +export default class Show extends MigrationCommand { static override summary = 'List migration resources or read one resource'; static override description = [ 'Omit RESOURCE to list what can be read now. Use a name from that list, such as apps, mapping, or report.', @@ -17,6 +17,10 @@ export default class Show extends AdaptyCommand { ].join('\n'); static override examples = [ + { + description: 'Read the saved migration report:', + command: '<%= config.bin %> migrations show report', + }, { description: 'List resource names available now:', command: '<%= config.bin %> migrations show -m mig_7x2', @@ -41,11 +45,13 @@ export default class Show extends AdaptyCommand { async run(): Promise<Envelope> { const { args, flags } = await this.parse(Show); + + const selection = await this.currentMigration.require(flags.migration); const { resource } = args; const envelope = resource === undefined - ? await this.adapty.migrations.get(flags.migration) - : await this.adapty.migrations.resource(flags.migration, resource); + ? await this.adapty.migrations.get(selection.currentMigrationId) + : await this.adapty.migrations.resource(selection.currentMigrationId, resource); this.render(envelope, resource === undefined ? renderResources : renderResult); diff --git a/src/cli/commands/migrations/status/command.ts b/src/cli/commands/migrations/status/command.ts index f4950e5..0223a9d 100644 --- a/src/cli/commands/migrations/status/command.ts +++ b/src/cli/commands/migrations/status/command.ts @@ -1,4 +1,4 @@ -import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { MigrationCommand } from '../../../base/adapty/index.js'; import { migrationFlags } from '../../../input/migration.js'; import { renderEnvelope } from '../../../views/migrations/envelope/envelope.js'; @@ -7,7 +7,7 @@ import { pollNotice } from './lib/notice.js'; import type { Envelope } from '../../../../sdk/adapty/index.js'; -export default class Status extends AdaptyCommand { +export default class Status extends MigrationCommand { static override summary = 'Show migration state, issues, and available actions'; static override description = [ 'Read next_actions for the next steps and available_actions for optional actions.', @@ -19,6 +19,10 @@ export default class Status extends AdaptyCommand { ].join('\n'); static override examples = [ + { + description: 'Inspect the saved migration:', + command: '<%= config.bin %> migrations status', + }, { description: 'See the current state and what to do next:', command: '<%= config.bin %> migrations status -m mig_7x2', @@ -38,9 +42,11 @@ export default class Status extends AdaptyCommand { async run(): Promise<Envelope> { const { flags } = await this.parse(Status); + const selection = await this.currentMigration.require(flags.migration); + const envelope = flags.wait - ? await this.waitForMigration(flags.migration, flags.timeout) - : await this.adapty.migrations.get(flags.migration); + ? await this.waitForMigration(selection.currentMigrationId, flags.timeout) + : await this.adapty.migrations.get(selection.currentMigrationId); this.render(envelope, renderEnvelope); diff --git a/src/cli/commands/migrations/steps/command.ts b/src/cli/commands/migrations/steps/command.ts index c327e83..3d86b70 100644 --- a/src/cli/commands/migrations/steps/command.ts +++ b/src/cli/commands/migrations/steps/command.ts @@ -1,11 +1,11 @@ -import { AdaptyCommand } from '../../../base/adapty/index.js'; +import { MigrationCommand } from '../../../base/adapty/index.js'; import { migrationFlags } from '../../../input/migration.js'; import { renderSteps } from './lib/render.js'; import type { Envelope } from '../../../../sdk/adapty/index.js'; -export default class Steps extends AdaptyCommand { +export default class Steps extends MigrationCommand { static override summary = 'Show the migration checklist and step statuses'; static override description = [ 'Shows done, active, and locked steps in migration order.', @@ -15,6 +15,10 @@ export default class Steps extends AdaptyCommand { ].join('\n'); static override examples = [ + { + description: 'View the saved migration checklist:', + command: '<%= config.bin %> migrations steps', + }, { description: 'View the checklist:', command: '<%= config.bin %> migrations steps -m mig_7x2', @@ -29,7 +33,9 @@ export default class Steps extends AdaptyCommand { async run(): Promise<Envelope> { const { flags } = await this.parse(Steps); - const envelope = await this.adapty.migrations.get(flags.migration); + + const selection = await this.currentMigration.require(flags.migration); + const envelope = await this.adapty.migrations.get(selection.currentMigrationId); this.render(envelope, renderSteps); diff --git a/src/cli/commands/migrations/unuse/command.ts b/src/cli/commands/migrations/unuse/command.ts new file mode 100644 index 0000000..e481886 --- /dev/null +++ b/src/cli/commands/migrations/unuse/command.ts @@ -0,0 +1,33 @@ +import { BaseCommand } from '../../../base/base-command.js'; +import { openCurrentMigration } from '../../../context/migration/index.js'; +import { envSuppliesMigration } from '../../../views/migrations/notices.js'; + +type Result = { currentMigrationId: null }; + +export default class Unuse extends BaseCommand { + static override summary = 'Clear the saved migration selection (no network)'; + static override description = [ + 'Works without authentication, including when the saved context is malformed.', + 'ADAPTY_MIGRATION remains active until you unset it in your shell.', + ].join('\n'); + + static override examples = ['<%= config.bin %> migrations unuse']; + + async run(): Promise<Result> { + await this.parse(Unuse); + + // No session: removing a record does not depend on whose it is, or on being logged in. + const current = openCurrentMigration({ configDir: this.config.configDir }); + + await current.clear(); + + if (current.overridden) { + process.stderr.write(envSuppliesMigration); + } + + const result: Result = { currentMigrationId: null }; + this.render(result, () => 'Saved migration selection cleared.'); + + return result; + } +} diff --git a/src/cli/commands/migrations/unuse/index.ts b/src/cli/commands/migrations/unuse/index.ts new file mode 100644 index 0000000..40b09fa --- /dev/null +++ b/src/cli/commands/migrations/unuse/index.ts @@ -0,0 +1 @@ +export { default } from './command.js'; diff --git a/src/cli/commands/migrations/use/command.ts b/src/cli/commands/migrations/use/command.ts new file mode 100644 index 0000000..7c8cf5d --- /dev/null +++ b/src/cli/commands/migrations/use/command.ts @@ -0,0 +1,33 @@ +import { MigrationCommand } from '../../../base/adapty/index.js'; +import { migrationArgs } from '../../../input/migration.js'; +import { envOverridesSelection } from '../../../views/migrations/notices.js'; + +type Result = { currentMigrationId: string }; + +export default class Use extends MigrationCommand { + static override summary = 'Save the current migration after verifying access'; + static override description = [ + 'Select a migration for the current token. This does not start or modify the migration.', + 'Completed, canceled and failed migrations can also be selected for inspection.', + 'ADAPTY_MIGRATION overrides the saved selection until you unset it in your shell.', + ].join('\n'); + + static override examples = ['<%= config.bin %> migrations use mig_7x2']; + static override args = { ...migrationArgs }; + + async run(): Promise<Result> { + const { args } = await this.parse(Use); + const { migration } = await this.adapty.migrations.get(args.id); + + await this.currentMigration.set(migration.id); + + if (this.currentMigration.overridden) { + process.stderr.write(envOverridesSelection); + } + + const result = { currentMigrationId: migration.id }; + this.render(result, value => `Current migration: ${value.currentMigrationId}`); + + return result; + } +} diff --git a/src/cli/commands/migrations/use/index.ts b/src/cli/commands/migrations/use/index.ts new file mode 100644 index 0000000..40b09fa --- /dev/null +++ b/src/cli/commands/migrations/use/index.ts @@ -0,0 +1 @@ +export { default } from './command.js'; diff --git a/src/cli/context/migration/current.ts b/src/cli/context/migration/current.ts new file mode 100644 index 0000000..e74e376 --- /dev/null +++ b/src/cli/context/migration/current.ts @@ -0,0 +1,75 @@ +import { AuthRequiredError } from '../../../sdk/core/errors.js'; + +import { createMigrationContext, migrationTokenFingerprint } from './model.js'; +import { requireMigrationSelection, resolveMigrationSelection } from './resolve.js'; +import { createMigrationContextStore } from './store.js'; + +import type { MigrationSession } from './model.js'; +import type { MigrationSelection } from './resolve.js'; + +/** + * Everything a command may ask about "which migration am I on", behind verbs. The file, the token + * that scopes it and ADAPTY_MIGRATION are put together here, once, and not in every `run()`. + */ +export type CurrentMigration = { + /** Unconditional: `unuse` and `logout` remove a record they may not even be able to read. */ + clear(): Promise<void>; + /** + * Only if the record belongs to this token: another token's selection stays usable — with + * ADAPTY_TOKEN set, the file may hold a different, still-valid one. The check is not atomic, + * and nothing here can make it so: a concurrent `login` landing between the read and the + * remove loses its fresh record. That costs one more `adapty auth login`, never a selection + * left behind for a dead token, so the file keeps the same last-writer-wins rule as `save`. + */ + clearFor(token: string): Promise<void>; + get(explicit?: string): Promise<MigrationSelection | undefined>; + /** ADAPTY_MIGRATION is set, so neither a save nor a clear changes what the next command uses. */ + readonly overridden: boolean; + readonly path: string; + require(explicit?: string): Promise<MigrationSelection>; + set(id: string): Promise<void>; +}; + +export type CurrentMigrationOptions = { + configDir: string; + /** The one place ADAPTY_MIGRATION is read; a parameter so a test needs no global. */ + env?: NodeJS.ProcessEnv | undefined; + /** Absent for `unuse`, tokenless for `current`: neither needs credentials to answer. */ + session?: MigrationSession | undefined; +}; + +export const openCurrentMigration = (options: CurrentMigrationOptions): CurrentMigration => { + const store = createMigrationContextStore(options.configDir); + const session = options.session ?? {}; + const envMigration = (options.env ?? process.env).ADAPTY_MIGRATION; + const sources = { envMigration, session, store }; + + return { + overridden: envMigration !== undefined && envMigration !== '', + path: store.path, + + clear: () => store.clear(), + + clearFor: async (token) => { + const stored = await store.load(); + + if (stored?.tokenFingerprint === migrationTokenFingerprint(token)) { + await store.clear(); + } + }, + + get: explicit => resolveMigrationSelection({ ...sources, migration: explicit }), + + require: explicit => requireMigrationSelection({ ...sources, migration: explicit }), + + set: async (id) => { + const { token } = session; + + if (token === undefined || token === '') { + throw new AuthRequiredError('missing'); + } + + await store.save(createMigrationContext({ ...session, token }, id)); + }, + }; +}; diff --git a/src/cli/context/migration/index.ts b/src/cli/context/migration/index.ts new file mode 100644 index 0000000..bc103c9 --- /dev/null +++ b/src/cli/context/migration/index.ts @@ -0,0 +1,5 @@ +export { openCurrentMigration } from './current.js'; +export { validateMigrationId } from './model.js'; + +export type { CurrentMigration } from './current.js'; +export type { MigrationSelection } from './resolve.js'; diff --git a/src/cli/context/migration/model.ts b/src/cli/context/migration/model.ts new file mode 100644 index 0000000..b14caba --- /dev/null +++ b/src/cli/context/migration/model.ts @@ -0,0 +1,51 @@ +import { createHash } from 'node:crypto'; + +import { CliError, exitCode } from '../../errors.js'; + +export type MigrationContext = { + version: 1; + tokenFingerprint: string; + currentMigrationId: string; +}; + +export type MigrationSession = { token?: string | undefined }; + +export const migrationTokenFingerprint = (token: string): string => { + return createHash('sha256').update(token).digest('hex'); +}; + +export const validateMigrationId = (id: string): string => { + if (id.trim() === '') { + throw new CliError('Migration ID must not be empty or whitespace.', exitCode.usage, 'migration_required'); + } + + return id; +}; + +export const createMigrationContext = ( + session: MigrationSession & { token: string }, + currentMigrationId: string, +): MigrationContext => ({ + version: 1, + tokenFingerprint: migrationTokenFingerprint(session.token), + currentMigrationId: validateMigrationId(currentMigrationId), +}); + +const isNonBlankString = (value: unknown): value is string => { + return typeof value === 'string' && value.trim() !== ''; +}; + +const SHA256_HEX = /^[a-f0-9]{64}$/; + +export const isMigrationContext = (value: unknown): value is MigrationContext => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + + const record = value as Record<string, unknown>; + + return record.version === 1 + && typeof record.tokenFingerprint === 'string' + && SHA256_HEX.test(record.tokenFingerprint) + && isNonBlankString(record.currentMigrationId); +}; diff --git a/src/cli/context/migration/resolve.ts b/src/cli/context/migration/resolve.ts new file mode 100644 index 0000000..394a6fa --- /dev/null +++ b/src/cli/context/migration/resolve.ts @@ -0,0 +1,57 @@ +import { CliError, exitCode } from '../../errors.js'; + +import { migrationTokenFingerprint, validateMigrationId } from './model.js'; + +import type { MigrationSession } from './model.js'; +import type { MigrationContextStore } from './store.js'; + +export type MigrationSelection = { + currentMigrationId: string; + source: 'flag' | 'env' | 'context'; +}; + +type ResolutionOptions = { + migration?: string | undefined; + envMigration?: string | undefined; + session: MigrationSession; + store: Pick<MigrationContextStore, 'load'>; +}; + +/** Local-only and lazy: an explicit ID never reads context, even if it is broken. */ +export const resolveMigrationSelection = async ( + options: ResolutionOptions, +): Promise<MigrationSelection | undefined> => { + if (options.migration !== undefined) { + return { currentMigrationId: validateMigrationId(options.migration), source: 'flag' }; + } + + if (options.envMigration !== undefined && options.envMigration !== '') { + return { currentMigrationId: validateMigrationId(options.envMigration), source: 'env' }; + } + + if (options.session.token === undefined || options.session.token === '') { + return undefined; + } + + const context = await options.store.load(); + + if (context?.tokenFingerprint !== migrationTokenFingerprint(options.session.token)) { + return undefined; + } + + return { currentMigrationId: context.currentMigrationId, source: 'context' }; +}; + +export const requireMigrationSelection = async (options: ResolutionOptions): Promise<MigrationSelection> => { + const selection = await resolveMigrationSelection(options); + + if (selection === undefined) { + throw new CliError( + 'No migration selected. Run `adapty migrations list`, then `adapty migrations use <id>`, or supply `-m <id>`.', + exitCode.usage, + 'migration_required', + ); + } + + return selection; +}; diff --git a/src/cli/context/migration/store.ts b/src/cli/context/migration/store.ts new file mode 100644 index 0000000..53214ea --- /dev/null +++ b/src/cli/context/migration/store.ts @@ -0,0 +1,120 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { join } from 'node:path'; + +import { CliError, errorCode } from '../../errors.js'; + +import { isMigrationContext } from './model.js'; + +import type { MigrationContext } from './model.js'; + +export type MigrationContextStore = { + readonly path: string; + load(): Promise<MigrationContext | undefined>; + save(context: MigrationContext): Promise<void>; + clear(): Promise<void>; +}; + +const REPLACE = 'Run `adapty migrations unuse` to remove it or `adapty migrations use <id>` to replace it.'; + +type ContextFailure = 'invalid' | 'read' | 'remove' | 'write'; + +/** + * Two stable codes, but not one text: a malformed record, a denied read and a full disk are fixed + * in three different places, and the errno is what says which. The original travels as `cause` + * rather than in the message, because that message may quote the record — and the record carries a + * token fingerprint. + */ +const contextError = (path: string, failure: ContextFailure, cause?: unknown): CliError => { + const errno = errorCode(cause); + const file = errno === undefined ? path : `${path} (${errno})`; + + const messages: Record<ContextFailure, string> = { + invalid: `Invalid migration context: ${path}. ${REPLACE}`, + read: `Could not read migration context: ${file}. Check its permissions and ownership. ${REPLACE}`, + remove: `Could not remove migration context: ${file}. Check its permissions and ownership, or remove the file yourself.`, + write: `Could not save migration context: ${file}. Check the permissions and free space of its directory; the previous selection is unchanged.`, + }; + + return new CliError( + messages[failure], + 1, + failure === 'invalid' ? 'migration_context_invalid' : 'migration_context_io', + { cause }, + ); +}; + +const isMissing = (error: unknown): boolean => { + return error instanceof Error && 'code' in error && error.code === 'ENOENT'; +}; + +/** CLI-owned state, separate from credentials. Concurrent saves are last-successful-rename-wins. */ +export const createMigrationContextStore = (dir: string): MigrationContextStore => { + const path = join(dir, 'context.json'); + + const store: MigrationContextStore = { + path, + async load() { + let raw: string; + + try { + raw = await fs.readFile(path, 'utf8'); + } catch (error) { + if (isMissing(error)) { + return undefined; + } + + throw contextError(path, 'read', error); + } + + let parsed: unknown; + + try { + parsed = JSON.parse(raw) as unknown; + } catch (error) { + throw contextError(path, 'invalid', error); + } + + if (!isMigrationContext(parsed)) { + throw contextError(path, 'invalid'); + } + + return parsed; + }, + async save(context) { + if (!isMigrationContext(context)) { + throw contextError(path, 'invalid'); + } + + const temporary = join(dir, `.context-${randomUUID()}.tmp`); + + try { + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + + await fs.writeFile(temporary, `${JSON.stringify(context, null, 2)}\n`, { + encoding: 'utf8', flag: 'wx', mode: 0o600, + }); + + await fs.rename(temporary, path); + } catch (error) { + // Never remove the destination on failure: it may hold a previous selection. + try { + await fs.rm(temporary, { force: true }); + } catch { + // Preserve the context-specific error if cleanup also fails. + } + + throw contextError(path, 'write', error); + } + }, + async clear() { + try { + await fs.rm(path, { force: true }); + } catch (error) { + throw contextError(path, 'remove', error); + } + }, + }; + + return store; +}; diff --git a/src/cli/errors.ts b/src/cli/errors.ts index ca2e043..2ab502f 100644 --- a/src/cli/errors.ts +++ b/src/cli/errors.ts @@ -35,6 +35,17 @@ export type ErrorJson = WizardDiagnostics & { status_code?: number | undefined; }; +export type CliErrorOptions = { + /** + * The failure this error explains. It is not printed and not serialized: a foreign message may + * quote whatever was handed to the syscall, and these paths handle credentials. It travels so + * that a stack, a debugger or a test can still reach the original. + */ + cause?: unknown; + /** Fields for --json beyond `message` and `code`. */ + json?: Partial<ErrorJson> | undefined; +}; + /** * oclif takes the exit code from two places: `handle()` reads `oclif.exit`, Command.catch under * --json reads `exitCode` and never rethrows. Set one and the other mode exits 1. @@ -43,16 +54,74 @@ export class CliError extends Errors.CLIError { readonly exitCode: number; readonly json: ErrorJson; - constructor(message: string, exit: number, code?: string, data: Partial<ErrorJson> = {}) { + constructor(message: string, exit: number, code?: string, options: CliErrorOptions = {}) { super(message, { exit }); this.exitCode = exit; this.code = code; - this.json = { message, code, ...data }; + this.json = { message, code, ...options.json }; + + // Assigned only when there is one: an own `cause: undefined` would read as "none known" + // where none was ever offered. + if (options.cause !== undefined) { + this.cause = options.cause; + } } } -const cliError = (message: string, exit: number, code?: string, data?: Partial<ErrorJson>): Error => - new CliError(message, exit, code, data); +const cliError = (message: string, exit: number, code?: string, json?: Partial<ErrorJson>): Error => { + return new CliError(message, exit, code, { json }); +}; + +/** + * An errno — ENOSPC, EACCES, EROFS — classifies a failure without repeating it. It is the part of + * a foreign error that is safe to show: a full disk and a denied write are fixed differently, and + * a message that only says "could not access" sends whoever reads it looking in the wrong place. + */ +export const errorCode = (error: unknown): string | undefined => { + return error instanceof Error && 'code' in error && typeof error.code === 'string' ? error.code : undefined; +}; + +/** What a nested failure adds to an error of ours: our own text, or a stranger's errno alone. */ +const describeFailure = (error: unknown): string => { + if (error instanceof CliError || isSdkError(error)) { + return error.message; + } + + return errorCode(error) ?? 'unknown error'; +}; + +export type CleanupFailure = { + cause: unknown; + file: string; + reason: string; +}; + +/** `reason` is `any` on the settled result; naming it `unknown` is what keeps it from spreading. */ +const rejectionOf = (result: PromiseSettledResult<unknown> | undefined): { reason: unknown } | undefined => { + return result?.status === 'rejected' ? result : undefined; +}; + +/** + * Which files a best-effort cleanup could not finish, and why. Files and settled results are + * positional siblings, one per attempt: "could not remove these two" alone leaves the next person + * guessing between a permission, a read-only mount and a directory sitting where a file belongs. + */ +export const describeCleanupFailures = ( + files: readonly string[], + results: readonly PromiseSettledResult<unknown>[], +): CleanupFailure[] => { + const failures: CleanupFailure[] = []; + + for (const [index, file] of files.entries()) { + const rejection = rejectionOf(results[index]); + + if (rejection) { + failures.push({ cause: rejection.reason, file, reason: describeFailure(rejection.reason) }); + } + } + + return failures; +}; /** Issue.path is a camelCase sdk field; the user typed a kebab-case flag. */ const flagName = (path: string): string => `--${path.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`)}`; diff --git a/src/cli/input/migration.ts b/src/cli/input/migration.ts index b9248a7..adbe460 100644 --- a/src/cli/input/migration.ts +++ b/src/cli/input/migration.ts @@ -1,12 +1,20 @@ -import { Flags } from '@oclif/core'; +import { Args, Flags } from '@oclif/core'; -/** Require an explicit migration ID, supplied by the flag or ADAPTY_MIGRATION. */ +import { validateMigrationId } from '../context/migration/index.js'; + +export const migrationArgs = { + id: Args.string({ + description: 'Migration ID from `adapty migrations list`', + required: true, + parse: value => Promise.resolve(validateMigrationId(value)), + }), +}; + +/** Explicit ID wins over the environment and the saved selection, resolved after parsing. */ export const migrationFlags = { migration: Flags.string({ char: 'm', - description: 'ID from `adapty migrations list`; overrides ADAPTY_MIGRATION. No automatic selection', - env: 'ADAPTY_MIGRATION', + description: 'Migration ID; overrides ADAPTY_MIGRATION and the saved selection from migrations use/create', helpValue: 'ID', - required: true, }), }; diff --git a/src/cli/views/migrations/index.ts b/src/cli/views/migrations/index.ts deleted file mode 100644 index 6abd562..0000000 --- a/src/cli/views/migrations/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { renderEnvelope } from './envelope/envelope.js'; diff --git a/src/cli/views/migrations/notices.ts b/src/cli/views/migrations/notices.ts new file mode 100644 index 0000000..5d43a90 --- /dev/null +++ b/src/cli/views/migrations/notices.ts @@ -0,0 +1,10 @@ +/** + * ADAPTY_MIGRATION outlives both a save and a clear, so "saved" or "cleared" alone would be a lie. + * Commands write these to stderr rather than through `warn()`, which oclif silences under --json: + * a selection that is not in effect is exactly what a script has to be told about. + */ +export const envOverridesSelection + = 'Warning: ADAPTY_MIGRATION still overrides the saved selection. Unset it in your shell to use this ID.\n'; + +export const envSuppliesMigration + = 'Warning: ADAPTY_MIGRATION still supplies a migration ID. Unset it in your shell.\n'; diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 90cfdc9..3a7328a 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -1,2 +1,2 @@ // Implementation lives in the new cli layer; oclif discovers commands only under src/commands. -export { default } from '../../cli/commands/auth/logout.js'; +export { default } from '../../cli/commands/auth/logout/index.js'; diff --git a/src/commands/auth/revoke.ts b/src/commands/auth/revoke.ts index 258678c..54c835f 100644 --- a/src/commands/auth/revoke.ts +++ b/src/commands/auth/revoke.ts @@ -1,2 +1,2 @@ // Implementation lives in the new cli layer; oclif discovers commands only under src/commands. -export { default } from '../../cli/commands/auth/revoke.js'; +export { default } from '../../cli/commands/auth/revoke/index.js'; diff --git a/src/commands/migrations/current.ts b/src/commands/migrations/current.ts new file mode 100644 index 0000000..9997568 --- /dev/null +++ b/src/commands/migrations/current.ts @@ -0,0 +1 @@ +export { default } from '../../cli/commands/migrations/current/index.js'; diff --git a/src/commands/migrations/unuse.ts b/src/commands/migrations/unuse.ts new file mode 100644 index 0000000..a42f912 --- /dev/null +++ b/src/commands/migrations/unuse.ts @@ -0,0 +1 @@ +export { default } from '../../cli/commands/migrations/unuse/index.js'; diff --git a/src/commands/migrations/use.ts b/src/commands/migrations/use.ts new file mode 100644 index 0000000..958940e --- /dev/null +++ b/src/commands/migrations/use.ts @@ -0,0 +1 @@ +export { default } from '../../cli/commands/migrations/use/index.js'; diff --git a/test/cli/base.test.ts b/test/cli/base.test.ts index 6fa3873..fda1d86 100644 --- a/test/cli/base.test.ts +++ b/test/cli/base.test.ts @@ -134,8 +134,20 @@ describe('cli base commands', () => { await createFileSessionStore(config.configDir).clear(); }); - it('keeps an own static on the intermediate authenticated base for oclif manifest caching', () => { - expect(Object.hasOwn(AdaptyCommand, 'enableJsonFlag')).to.equal(true); + /** + * `--json` is declared once, on `BaseCommand`, and reaches a command through two intermediate + * bases. Caching is where that could silently break, so the flag assertions are on the cached + * command oclif builds — the same shape `oclif manifest` writes. `enableJsonFlag` itself never + * reaches that shape: oclif copies a class's own enumerable statics, and this one is inherited. + * The loaded class is where the inheritance the cached flag rests on can be seen. + */ + it('inherits --json through the intermediate bases, and leaves login opted out', async () => { + const status = await config.findCommand('migrations:status')?.load(); + + expect(status?.enableJsonFlag).to.equal(true); + expect(config.findCommand('migrations:status')?.flags).to.have.property('json'); + expect(config.findCommand('apps:list')?.flags).to.have.property('json'); + expect(config.findCommand('auth:login')?.flags).not.to.have.property('json'); }); it('turns a missing token into exit 3 and the login hint', async () => { diff --git a/test/cli/context/migration/current.test.ts b/test/cli/context/migration/current.test.ts new file mode 100644 index 0000000..f66fe8d --- /dev/null +++ b/test/cli/context/migration/current.test.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect } from 'chai'; + +import { openCurrentMigration } from '../../../../src/cli/context/migration/index.js'; +import { createMigrationContext } from '../../../../src/cli/context/migration/model.js'; +import { createMigrationContextStore } from '../../../../src/cli/context/migration/store.js'; +import { AuthRequiredError } from '../../../../src/sdk/core/errors.js'; +import { rejection } from '../../../helpers/rejection.js'; + +import type { CliError } from '../../../../src/cli/errors.js'; + +const session = { token: 'facade-token' }; +const other = createMigrationContext({ token: 'another-token' }, 'mig_other'); + +/** + * The seam every migration command goes through, so this is where the wiring is checked: + * priority between the three sources belongs to resolve.test.ts, file behaviour to store.test.ts. + */ +describe('current migration', () => { + let dir: string; + let configDir: string; + + const open = (env: NodeJS.ProcessEnv = {}) => openCurrentMigration({ configDir, env, session }); + + beforeEach(async () => { + dir = await fs.mkdtemp(join(tmpdir(), 'adapty-current-')); + configDir = join(dir, 'config'); + }); + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + it('saves a selection and reads it back for the same token', async () => { + const current = open(); + + expect(await current.get()).to.equal(undefined); + + await current.set('mig_7x2'); + + expect(await current.get()).to.deep.equal({ currentMigrationId: 'mig_7x2', source: 'context' }); + expect(current.path).to.equal(join(configDir, 'context.json')); + }); + + it('reads ADAPTY_MIGRATION from the environment it was opened with, and yields to an explicit ID', async () => { + const current = open({ ADAPTY_MIGRATION: 'mig_env' }); + + await current.set('mig_saved'); + + expect(current.overridden).to.equal(true); + expect(await current.get()).to.deep.equal({ currentMigrationId: 'mig_env', source: 'env' }); + expect(await current.get('mig_flag')).to.deep.equal({ currentMigrationId: 'mig_flag', source: 'flag' }); + expect(open().overridden).to.equal(false); + }); + + it('turns no selection into a usage error only where one is required', async () => { + const current = open(); + + expect(await current.get()).to.equal(undefined); + + const error = await rejection(current.require()) as CliError; + + expect(error.exitCode).to.equal(2); + expect(error.json.code).to.equal('migration_required'); + }); + + it('clears unconditionally, but for a token only when the record is that token\'s', async () => { + const store = createMigrationContextStore(configDir); + + await store.save(other); + await open().clearFor(session.token); + expect(await store.load()).to.deep.equal(other); + + await open().clearFor('another-token'); + expect(await store.load()).to.equal(undefined); + + await store.save(other); + await open().clear(); + expect(await store.load()).to.equal(undefined); + }); + + it('refuses to save without a token instead of writing an unusable record', async () => { + const anonymous = openCurrentMigration({ configDir, env: {} }); + + expect(await rejection(anonymous.set('mig_7x2'))).to.be.instanceOf(AuthRequiredError); + expect(await fs.readdir(dir)).to.deep.equal([]); + }); +}); diff --git a/test/cli/context/migration/resolve.test.ts b/test/cli/context/migration/resolve.test.ts new file mode 100644 index 0000000..9f4d074 --- /dev/null +++ b/test/cli/context/migration/resolve.test.ts @@ -0,0 +1,91 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { createMigrationContext } from '../../../../src/cli/context/migration/model.js'; +import { requireMigrationSelection, resolveMigrationSelection } from '../../../../src/cli/context/migration/resolve.js'; +import { CliError } from '../../../../src/cli/errors.js'; +import { rejection } from '../../../helpers/rejection.js'; + +const session = { apiUrl: 'https://api.example.com/api/v1/developer', token: 'secret-token' }; +const context = createMigrationContext(session, 'opaque/id'); + +describe('migration selection resolution', () => { + const store = { load: () => Promise.resolve(context) }; + + it('uses flag before env and context; env before context, without reading the file', async () => { + const unreadable = { load: sinon.stub().rejects(new Error('must not read context')) }; + + expect(await resolveMigrationSelection({ session, store: unreadable, migration: 'flag', envMigration: 'env' })) + .to.deep.equal({ currentMigrationId: 'flag', source: 'flag' }); + + expect(await resolveMigrationSelection({ session, store: unreadable, envMigration: 'env' })) + .to.deep.equal({ currentMigrationId: 'env', source: 'env' }); + + expect(unreadable.load.called).to.equal(false); + }); + + it('uses matching context, treating an empty environment value as absent', async () => { + expect(await resolveMigrationSelection({ session, store, envMigration: '' })) + .to.deep.equal({ currentMigrationId: 'opaque/id', source: 'context' }); + }); + + it('keeps the selection when only the API URL changes', async () => { + for (const changed of [ + { ...session, apiUrl: 'https://staging.example.com/api/v1/developer' }, + { ...session, apiUrl: 'https://api.example.com/other' }, + ]) { + expect(await resolveMigrationSelection({ session: changed, store })) + .to.deep.equal({ currentMigrationId: 'opaque/id', source: 'context' }); + } + }); + + it('ignores the selection for another token and makes it available when the original token returns', async () => { + const changed = { ...session, token: 'other-token' }; + + expect(await resolveMigrationSelection({ session: changed, store })).to.equal(undefined); + expect(await resolveMigrationSelection({ session, store })).to.have.property('currentMigrationId', 'opaque/id'); + }); + + it('requires no credentials for explicit IDs and ignores saved context without a token', async () => { + const anonymous = { token: undefined }; + const unreadable = { load: sinon.stub().rejects(new Error('must not read context')) }; + + expect(await resolveMigrationSelection({ session: anonymous, store: unreadable })).to.equal(undefined); + + expect(await resolveMigrationSelection({ session: anonymous, store, envMigration: 'env' })) + .to.deep.equal({ currentMigrationId: 'env', source: 'env' }); + + expect(await requireMigrationSelection({ session: anonymous, store, migration: 'flag' })) + .to.deep.equal({ currentMigrationId: 'flag', source: 'flag' }); + + expect(unreadable.load.called).to.equal(false); + }); + + for (const input of [{ migration: '' }, { migration: ' ' }, { envMigration: ' \t' }]) { + it(`rejects explicitly invalid IDs without falling through: ${JSON.stringify(input)}`, async () => { + const error = await rejection(resolveMigrationSelection({ session, store, ...input })) as CliError; + + expect(error.exitCode).to.equal(2); + expect(error.json.code).to.equal('migration_required'); + }); + } + + it('returns no selection for current, but an actionable usage error for a required target', async () => { + const empty = { load: () => Promise.resolve(undefined) }; + + expect(await resolveMigrationSelection({ session, store: empty })).to.equal(undefined); + + const error = await rejection(requireMigrationSelection({ session, store: empty })) as CliError; + + expect(error.exitCode).to.equal(2); + expect(error.json.code).to.equal('migration_required'); + expect(error.message).to.include('migrations list').and.include('migrations use').and.include('-m'); + }); + + it('propagates invalid context when it is needed, without changing the error', async () => { + const error = new CliError('broken', 1, 'migration_context_invalid'); + const broken = { load: sinon.stub().rejects(error) }; + + expect(await rejection(resolveMigrationSelection({ session, store: broken }))).to.equal(error); + }); +}); diff --git a/test/cli/context/migration/store.test.ts b/test/cli/context/migration/store.test.ts new file mode 100644 index 0000000..d96d0f8 --- /dev/null +++ b/test/cli/context/migration/store.test.ts @@ -0,0 +1,167 @@ +import fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { createMigrationContext } from '../../../../src/cli/context/migration/model.js'; +import { createMigrationContextStore } from '../../../../src/cli/context/migration/store.js'; +import { CliError } from '../../../../src/cli/errors.js'; +import { rejection } from '../../../helpers/rejection.js'; + +import type { MigrationContextStore } from '../../../../src/cli/context/migration/store.js'; + +const session = { apiUrl: 'https://api.example.com/api/v1/developer', token: 'secret-token' }; +const context = createMigrationContext(session, 'opaque/id'); +const posix = process.platform === 'win32' ? it.skip : it; + +describe('migration context store', () => { + let dir: string; + let store: MigrationContextStore; + + beforeEach(async () => { + dir = await fs.mkdtemp(join(tmpdir(), 'adapty-context-')); + store = createMigrationContextStore(join(dir, 'config')); + }); + + afterEach(async () => { + sinon.restore(); + await fs.rm(dir, { recursive: true, force: true }); + }); + + /** What holds for every context error; `expected` is what the failing operation adds. */ + const expectContextError = async (operation: Promise<unknown>, code: string, ...expected: string[]) => { + const error = await rejection(operation) as CliError; + + expect(error).to.be.instanceOf(CliError); + expect(error.exitCode).to.equal(1); + expect(error.json.code).to.equal(code); + expect(error.message).to.include(store.path); + + for (const fragment of expected) { + expect(error.message).to.include(fragment); + } + + expect(error.message).not.to.include(session.token).and.not.to.include(context.tokenFingerprint); + expect(JSON.stringify(error.json)).not.to.include(session.token).and.not.to.include(context.tokenFingerprint); + + return error; + }; + + it('treats absence as no selection and clears idempotently', async () => { + expect(await store.load()).to.equal(undefined); + await store.clear(); + await store.save(context); + await store.clear(); + await store.clear(); + expect(await store.load()).to.equal(undefined); + }); + + it('persists a record for a new store instance without storing the raw token', async () => { + await store.save(context); + + expect(await createMigrationContextStore(join(dir, 'config')).load()).to.deep.equal(context); + expect(await fs.readFile(store.path, 'utf8')).not.to.include(session.token); + + expect(JSON.parse(await fs.readFile(store.path, 'utf8'))).to.have.all.keys( + 'version', 'tokenFingerprint', 'currentMigrationId', + ); + + expect(context.tokenFingerprint).to.match(/^[a-f0-9]{64}$/); + }); + + posix('creates a private directory and file, including when replacing a loose file', async () => { + await store.save(context); + expect((await fs.stat(join(dir, 'config'))).mode & 0o777).to.equal(0o700); + expect((await fs.stat(store.path)).mode & 0o777).to.equal(0o600); + await fs.chmod(store.path, 0o644); + await store.save(context); + expect((await fs.stat(store.path)).mode & 0o777).to.equal(0o600); + }); + + for (const raw of [ + '{broken secret-token', 'null', '[]', '{}', + JSON.stringify({ ...context, version: 2 }), + JSON.stringify({ ...context, currentMigrationId: ' ' }), + JSON.stringify({ ...context, tokenFingerprint: 'secret-token' }), + ]) { + it(`rejects invalid context: ${raw}`, async () => { + await store.save(context); + await fs.writeFile(store.path, raw); + + await expectContextError( + store.load(), 'migration_context_invalid', + 'Invalid migration context', 'migrations unuse', 'migrations use', + ); + }); + } + + it('replaces and removes malformed context without reading it first', async () => { + await store.save(context); + await fs.writeFile(store.path, 'broken'); + await store.save(context); + expect(await store.load()).to.deep.equal(context); + await fs.writeFile(store.path, 'broken again'); + await store.clear(); + expect(await store.load()).to.equal(undefined); + }); + + it('reports read and removal I/O errors instead of absence, and says which failed', async () => { + await fs.mkdir(store.path, { recursive: true }); + await expectContextError(store.load(), 'migration_context_io', 'Could not read', 'migrations unuse'); + await expectContextError(store.clear(), 'migration_context_io', 'Could not remove'); + }); + + it('names the errno and keeps the original error, without exposing its contents', async () => { + const underlying = Object.assign(new Error(session.token), { code: 'EACCES' }); + + sinon.stub(fs, 'readFile').rejects(underlying); + + const error = await expectContextError(store.load(), 'migration_context_io', 'Could not read', '(EACCES)'); + + expect(error.cause).to.equal(underlying); + }); + + it('preserves the previous record and removes the temporary file when rename fails', async () => { + await store.save(context); + sinon.stub(fs, 'rename').rejects(Object.assign(new Error('denied'), { code: 'EACCES' })); + + await expectContextError( + store.save(createMigrationContext(session, 'next')), 'migration_context_io', + 'Could not save', '(EACCES)', 'previous selection is unchanged', + ); + + expect(await store.load()).to.deep.equal(context); + expect(await fs.readdir(join(dir, 'config'))).to.deep.equal(['context.json']); + }); + + it('cleans up a partially written temporary file after write failure', async () => { + await store.save(context); + const writeFile = fs.writeFile; + + sinon.stub(fs, 'writeFile').callsFake(async (path, _data, options) => { + await writeFile(path, 'partial', options); + throw new Error('disk full'); + }); + + // An error without an errno leaves nothing safe to repeat: the code names the operation + // and the original stays reachable only through `cause`. + const error = await expectContextError( + store.save(createMigrationContext(session, 'next')), 'migration_context_io', 'Could not save', + ); + + expect(error.message).not.to.include('disk full'); + expect((error.cause as Error).message).to.equal('disk full'); + expect(await store.load()).to.deep.equal(context); + expect(await fs.readdir(join(dir, 'config'))).to.deep.equal(['context.json']); + }); + + it('supports simultaneous writes without partial records or leftover temporary files', async () => { + const records = Array.from({ length: 10 }, (_, index) => createMigrationContext(session, `migration-${index}`)); + + await Promise.all(records.map(record => store.save(record))); + expect(records).to.deep.include(await store.load()); + expect(await fs.readdir(join(dir, 'config'))).to.deep.equal(['context.json']); + }); +}); diff --git a/test/cli/views/migrations/envelope.test.ts b/test/cli/views/migrations/envelope.test.ts index 4dbbe5d..3df26be 100644 --- a/test/cli/views/migrations/envelope.test.ts +++ b/test/cli/views/migrations/envelope.test.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { expect } from 'chai'; -import { renderEnvelope } from '../../../../src/cli/views/migrations/index.js'; +import { renderEnvelope } from '../../../../src/cli/views/migrations/envelope/envelope.js'; import type { Envelope } from '../../../../src/sdk/adapty/index.js'; diff --git a/test/commands/auth/cleanup-exit-codes.test.ts b/test/commands/auth/cleanup-exit-codes.test.ts new file mode 100644 index 0000000..c4cd361 --- /dev/null +++ b/test/commands/auth/cleanup-exit-codes.test.ts @@ -0,0 +1,72 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { join } from 'node:path'; + +import { Config } from '@oclif/core'; +import { expect } from 'chai'; + +import { createMigrationContextStore } from '../../../src/cli/context/migration/store.js'; +import { createFileSessionStore } from '../../../src/sdk/core/session.js'; + +const ROOT = join(import.meta.dirname, '..', '..', '..'); +const TOKEN = 'cleanup-secret-token'; + +const SCRIPT = ` + import { execute } from '@oclif/core'; + let requests = 0; + globalThis.fetch = async () => { + if (process.env.CLEANUP_COMMAND !== 'revoke' || ++requests > 1) { + throw new Error('Unexpected auth request'); + } + return new Response('{}'); + }; + await execute({ args: JSON.parse(process.env.CLEANUP_ARGS), dir: process.cwd() }); +`; + +describe('auth cleanup process errors', () => { + for (const command of ['logout', 'revoke']) { + for (const json of [false, true]) { + it(`${command} returns local exit 1 after attempting both files (json=${json})`, async () => { + const config = await Config.load(ROOT); + const session = createFileSessionStore(config.configDir); + const context = createMigrationContextStore(config.configDir); + await session.save({ token: TOKEN }); + await fs.mkdir(context.path); + + try { + const child = spawnSync(process.execPath, ['--input-type=module', '-e', SCRIPT], { + cwd: ROOT, + encoding: 'utf8', + env: { + ...process.env, + ADAPTY_TOKEN: TOKEN, + CLEANUP_COMMAND: command, + CLEANUP_ARGS: JSON.stringify(['auth', command, ...(json ? ['--json'] : [])]), + }, + timeout: 10_000, + }); + + expect(child.error).to.equal(undefined); + expect(child.status, child.stderr || child.stdout).to.equal(1); + expect(await session.load()).to.equal(undefined); + expect(child.stdout + child.stderr).not.to.contain(TOKEN); + + if (json) { + const output = JSON.parse(child.stdout) as { error: { code: string; message: string } }; + expect(output.error.code).to.equal('auth_cleanup_failed'); + expect(output.error.message).to.contain('cleanup is incomplete'); + } else { + expect(child.stdout).to.equal(''); + expect(child.stderr).to.contain('cleanup is incomplete'); + } + + if (command === 'revoke') { + expect(child.stdout + child.stderr).to.contain('Token revoked on the server'); + } + } finally { + await fs.rm(context.path, { recursive: true, force: true }); + } + }); + } + } +}); diff --git a/test/commands/auth/logout-context.test.ts b/test/commands/auth/logout-context.test.ts new file mode 100644 index 0000000..79a8c65 --- /dev/null +++ b/test/commands/auth/logout-context.test.ts @@ -0,0 +1,110 @@ +import fs from 'node:fs/promises'; +import { join } from 'node:path'; + +import { Config } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { createMigrationContext } from '../../../src/cli/context/migration/model.js'; +import { createMigrationContextStore } from '../../../src/cli/context/migration/store.js'; +import { createFileSessionStore } from '../../../src/sdk/core/session.js'; + +import type { MigrationContextStore } from '../../../src/cli/context/migration/store.js'; +import type { SessionStore } from '../../../src/sdk/core/session.js'; + +describe('auth logout migration cleanup', () => { + let context: MigrationContextStore; + let session: SessionStore; + let fetch: sinon.SinonStub; + + beforeEach(async () => { + const config = await Config.load(join(import.meta.dirname, '..', '..', '..')); + session = createFileSessionStore(config.configDir); + context = createMigrationContextStore(config.configDir); + await context.save(createMigrationContext({ token: 'old-token' }, 'mig_saved')); + fetch = sinon.stub(globalThis, 'fetch').rejects(new Error('Logout must stay offline')); + }); + + afterEach(async () => { + sinon.restore(); + await fs.rm(session.path, { recursive: true, force: true }); + await fs.rm(context.path, { recursive: true, force: true }); + expect(fetch.callCount).to.equal(0); + }); + + it('clears orphaned context without credentials, preserving the JSON result', async () => { + const { stdout, error } = await runCommand('auth logout --json'); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout)).to.deep.equal({ env_token_set: false, status: 'not_authenticated' }); + expect(await context.load()).to.equal(undefined); + }); + + it('removes credentials and context belonging to a different token from the environment', async () => { + await session.save({ token: 'stored-token' }); + process.env.ADAPTY_TOKEN = 'env-token'; + const { stdout, error } = await runCommand('auth logout --json'); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout)).to.deep.equal({ env_token_set: true, status: 'logged_out' }); + expect(await session.load()).to.equal(undefined); + expect(await context.load()).to.equal(undefined); + expect(process.env.ADAPTY_TOKEN).to.equal('env-token'); + }); + + it('can remove malformed credentials and context', async () => { + await fs.writeFile(session.path, '{broken'); + await fs.writeFile(context.path, '{broken'); + const { error } = await runCommand('auth logout'); + + expect(error).to.equal(undefined); + expect(await session.load()).to.equal(undefined); + expect(await context.load()).to.equal(undefined); + }); + + for (const failed of ['session', 'context', 'both']) { + it(`attempts both removals and reports incomplete cleanup when ${failed} cannot be removed`, async () => { + await session.save({ token: 'stored-token' }); + + for (const store of failed === 'both' ? [session, context] : [failed === 'session' ? session : context]) { + await fs.rm(store.path); + await fs.mkdir(store.path); + } + + const { error, stdout } = await runCommand('auth logout'); + + expect(error?.oclif?.exit).to.equal(1); + expect(error?.code).to.equal('auth_cleanup_failed'); + expect(error?.message).to.contain('cleanup is incomplete'); + expect(stdout).to.equal(''); + + // Why each file survived, not only its path: an errno for a failure that is not ours, + // the context store's own explanation for the one that is. + if (failed !== 'context') { + expect(error?.message).to.contain(session.path).and.contain('EISDIR'); + } + + if (failed !== 'session') { + expect(error?.message).to.contain('Could not remove migration context'); + } + + if (failed !== 'both') { + expect(await (failed === 'session' ? context : session).load()).to.equal(undefined); + } + }); + } + + for (const json of [false, true]) { + it(`warns about a surviving migration environment override (json=${json})`, async () => { + process.env.ADAPTY_MIGRATION = 'mig_env'; + const { stderr, error } = await runCommand(`auth logout${json ? ' --json' : ''}`); + + expect(error).to.equal(undefined); + expect(stderr).to.contain('ADAPTY_MIGRATION still supplies'); + expect(stderr).to.contain('Unset it'); + expect(process.env.ADAPTY_MIGRATION).to.equal('mig_env'); + expect(await context.load()).to.equal(undefined); + }); + } +}); diff --git a/test/commands/auth/revoke-context.test.ts b/test/commands/auth/revoke-context.test.ts new file mode 100644 index 0000000..4188091 --- /dev/null +++ b/test/commands/auth/revoke-context.test.ts @@ -0,0 +1,151 @@ +import fs from 'node:fs/promises'; +import { join } from 'node:path'; + +import { Config } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { createMigrationContext } from '../../../src/cli/context/migration/model.js'; +import { createMigrationContextStore } from '../../../src/cli/context/migration/store.js'; +import { createFileSessionStore } from '../../../src/sdk/core/session.js'; +import { mockFetch, mockFetchFailure } from '../../helpers/mock-fetch.js'; + +import type { MigrationContextStore } from '../../../src/cli/context/migration/store.js'; +import type { SessionStore } from '../../../src/sdk/core/session.js'; + +describe('auth revoke migration cleanup', () => { + let context: MigrationContextStore; + let session: SessionStore; + + beforeEach(async () => { + const config = await Config.load(join(import.meta.dirname, '..', '..', '..')); + session = createFileSessionStore(config.configDir); + context = createMigrationContextStore(config.configDir); + }); + + afterEach(async () => { + sinon.restore(); + await fs.rm(session.path, { recursive: true, force: true }); + await fs.rm(context.path, { recursive: true, force: true }); + }); + + it('revokes on the server before clearing either local file', async () => { + const saved = createMigrationContext({ token: 'stored-token' }, 'mig_saved'); + await session.save({ token: 'stored-token' }); + await context.save(saved); + const fetch = mockFetch(); + + fetch.callsFake(async () => { + expect(await session.load()).to.deep.equal({ token: 'stored-token' }); + expect(await context.load()).to.deep.equal(saved); + + return new Response('{}'); + }); + + const { stdout, error } = await runCommand('auth revoke --json'); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout)).to.deep.equal({ env_token_set: false, status: 'revoked' }); + expect(fetch.callCount).to.equal(1); + expect(await session.load()).to.equal(undefined); + expect(await context.load()).to.equal(undefined); + }); + + for (const contextToken of ['env-token', 'stored-token']) { + it(`revokes the env token and only clears context matching it (${contextToken})`, async () => { + process.env.ADAPTY_TOKEN = 'env-token'; + await session.save({ token: 'stored-token' }); + const saved = createMigrationContext({ token: contextToken }, 'mig_saved'); + await context.save(saved); + const fetch = mockFetch(); + const { stdout, error } = await runCommand('auth revoke --json'); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout)).to.deep.equal({ env_token_set: true, status: 'revoked' }); + expect(fetch.callCount).to.equal(1); + expect(await session.load()).to.deep.equal({ token: 'stored-token' }); + expect(await context.load()).to.deep.equal(contextToken === 'env-token' ? undefined : saved); + }); + } + + it('clears a matching stored copy of the env token and its context', async () => { + process.env.ADAPTY_TOKEN = 'same-token'; + await session.save({ token: 'same-token' }); + await context.save(createMigrationContext({ token: 'same-token' }, 'mig_saved')); + mockFetch(); + const { error } = await runCommand('auth revoke'); + + expect(error).to.equal(undefined); + expect(await session.load()).to.equal(undefined); + expect(await context.load()).to.equal(undefined); + }); + + it('does not read or clear orphaned context when there is no effective token', async () => { + await context.save(createMigrationContext({ token: 'old-token' }, 'mig_saved')); + await fs.writeFile(context.path, '{broken'); + const fetch = mockFetch(); + const { stdout, error } = await runCommand('auth revoke --json'); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout)).to.deep.equal({ status: 'not_authenticated' }); + expect(fetch.callCount).to.equal(0); + expect(await fs.readFile(context.path, 'utf8')).to.equal('{broken'); + }); + + it('preserves both files after a rejected revoke', async () => { + await session.save({ token: 'stored-token' }); + const saved = createMigrationContext({ token: 'stored-token' }, 'mig_saved'); + await context.save(saved); + const fetch = mockFetchFailure({ error: { message: 'Denied' } }, { status: 403 }); + const { error } = await runCommand('auth revoke'); + + expect(error?.oclif?.exit).to.equal(3); + expect(fetch.callCount).to.equal(1); + expect(await session.load()).to.deep.equal({ token: 'stored-token' }); + expect(await context.load()).to.deep.equal(saved); + }); + + it('reports a context removal failure after clearing matching credentials', async () => { + await session.save({ token: 'stored-token' }); + const saved = createMigrationContext({ token: 'stored-token' }, 'mig_saved'); + await context.save(saved); + + sinon.stub(fs, 'rm').callThrough().withArgs(context.path, { force: true }) + .rejects(new Error('private filesystem error')); + + const fetch = mockFetch(); + const { error } = await runCommand('auth revoke'); + + expect(error?.oclif?.exit).to.equal(1); + expect(error?.message).to.contain('Token revoked on the server'); + // The store's explanation reaches the user; the error it wrapped does not. + expect(error?.message).to.contain('Could not remove migration context'); + expect(error?.message).not.to.contain('private filesystem error'); + expect(fetch.callCount).to.equal(1); + expect(await session.load()).to.equal(undefined); + expect(await context.load()).to.deep.equal(saved); + }); + + for (const failed of ['session', 'context']) { + it(`still cleans the other file after a ${failed} cleanup failure without repeating revoke`, async () => { + process.env.ADAPTY_TOKEN = 'same-token'; + await session.save({ token: 'same-token' }); + await context.save(createMigrationContext({ token: 'same-token' }, 'mig_saved')); + const broken = failed === 'session' ? session : context; + await fs.writeFile(broken.path, '{private broken data'); + const fetch = mockFetch(); + const { error, stdout } = await runCommand('auth revoke'); + + expect(error?.oclif?.exit).to.equal(1); + expect(error?.code).to.equal('auth_cleanup_failed'); + expect(error?.message).to.contain('Token revoked on the server'); + expect(error?.message).to.contain('do not repeat revocation'); + expect(error?.message).not.to.contain('private broken data'); + expect(stdout).to.equal(''); + expect(fetch.callCount).to.equal(1); + expect(await (failed === 'session' ? context : session).load()).to.equal(undefined); + expect(await fs.readFile(broken.path, 'utf8')).to.equal('{private broken data'); + }); + } +}); diff --git a/test/commands/migrations-context-operations.test.ts b/test/commands/migrations-context-operations.test.ts new file mode 100644 index 0000000..74d7240 --- /dev/null +++ b/test/commands/migrations-context-operations.test.ts @@ -0,0 +1,196 @@ +import fs from 'node:fs/promises'; +import { join } from 'node:path'; + +import { Config } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { createMigrationContext } from '../../src/cli/context/migration/model.js'; +import { createMigrationContextStore } from '../../src/cli/context/migration/store.js'; +import { assertFetch, mockFetch, mockFetchFailure } from '../helpers/mock-fetch.js'; + +import type { MigrationContextStore } from '../../src/cli/context/migration/store.js'; +import type { Envelope } from '../../src/sdk/adapty/index.js'; + +const TOKEN = 'operations-token'; +const SAVED = createMigrationContext({ token: TOKEN }, 'mig_saved'); + +const ENVELOPE = JSON.parse(await fs.readFile( + new URL('../fixtures/migration-envelope.json', import.meta.url), 'utf8', +)) as Envelope; + +const commands = [ + { args: ['status'], suffix: '', write: undefined }, + { args: ['steps'], suffix: '', write: undefined }, + { args: ['show'], suffix: '', write: undefined }, + { args: ['show', 'report'], suffix: '/resources/report', write: undefined }, + { args: ['run', 'act_confirm_paywalls', '--yes'], suffix: '', write: '/actions/act_confirm_paywalls' }, + { args: ['close', '--outcome', 'finish', '--yes'], suffix: '', write: '/close' }, +]; + +describe('migration operations with saved context', () => { + let store: MigrationContextStore; + + beforeEach(async () => { + process.env.ADAPTY_TOKEN = TOKEN; + const config = await Config.load(join(import.meta.dirname, '..', '..')); + store = createMigrationContextStore(config.configDir); + await store.save(SAVED); + }); + + afterEach(() => { + sinon.restore(); + }); + + for (const { args, suffix, write } of commands) { + for (const source of ['context', 'env', 'flag']) { + it(`${args.join(' ')} uses ${source} with the expected priority`, async () => { + if (source !== 'context') { + process.env.ADAPTY_MIGRATION = 'mig_env'; + await fs.writeFile(store.path, '{broken'); + } + + const before = await fs.readFile(store.path, 'utf8'); + const fetch = mockFetch([ENVELOPE]); + const id = { flag: 'mig_flag', env: 'mig_env', context: 'mig_saved' }[source] ?? 'mig_saved'; + + const { stdout, stderr, error } = await runCommand([ + 'migrations', ...args, ...(source === 'flag' ? ['-m', id] : []), '--json', + ]); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout)).to.deep.equal(ENVELOPE); + assertFetch({ callIndex: 0, method: 'GET', path: `/migrations/${id}${suffix}`, stub: fetch }); + expect(fetch.callCount).to.equal(write === undefined ? 1 : 2); + + if (write !== undefined) { + assertFetch({ + callIndex: 1, method: 'POST', path: `/migrations/${id}${write}`, stub: fetch, + body: { expected_revision: ENVELOPE.migration.revision }, + }); + + expect(stderr.includes(`Using saved migration: ${id}`)).to.equal(source === 'context'); + } + + expect(await fs.readFile(store.path, 'utf8')).to.equal(before); + }); + } + } + + for (const { args, write } of commands.filter(command => command.write !== undefined)) { + it(`${args[0]} keeps its target when another terminal replaces context after GET`, async () => { + const fetch = mockFetch([ENVELOPE]); + + fetch.onFirstCall().callsFake(async () => { + await store.save(createMigrationContext({ token: TOKEN }, 'mig_other')); + + return new Response(JSON.stringify(ENVELOPE)); + }); + + const { error, stderr } = await runCommand(['migrations', ...args]); + + expect(error).to.equal(undefined); + expect(stderr).to.contain('Using saved migration: mig_saved'); + assertFetch({ callIndex: 1, method: 'POST', path: `/migrations/mig_saved${write}`, stub: fetch }); + expect((await store.load())?.currentMigrationId).to.equal('mig_other'); + }); + } + + it('keeps the captured ID through status polling when context changes', async () => { + const fetch = mockFetch([ENVELOPE]); + + fetch.onFirstCall().callsFake(async () => { + await store.save(createMigrationContext({ token: TOKEN }, 'mig_other')); + + return new Response(JSON.stringify({ ...ENVELOPE, migration: { ...ENVELOPE.migration, state: 'running' } })); + }); + + const { stdout, error } = await runCommand('migrations status --wait --timeout 10s --json'); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout)).to.deep.equal(ENVELOPE); + expect(fetch.callCount).to.equal(2); + + for (const callIndex of [0, 1]) { + assertFetch({ callIndex, method: 'GET', path: '/migrations/mig_saved', stub: fetch }); + } + }); + + for (const args of [['status', '-m', '""'], ['status', '-m', '" "']]) { + it(`rejects a blank explicit ID ${JSON.stringify(args)} instead of falling back`, async () => { + const fetch = mockFetch(); + const { error } = await runCommand(['migrations', ...args]); + + expect(error?.oclif?.exit).to.equal(2); + expect(error?.code).to.equal('migration_required'); + expect(fetch.callCount).to.equal(0); + }); + } + + it('uses saved context for an empty environment ID', async () => { + process.env.ADAPTY_MIGRATION = ''; + const fetch = mockFetch([ENVELOPE]); + const { error } = await runCommand('migrations status'); + + expect(error).to.equal(undefined); + assertFetch({ callIndex: 0, method: 'GET', path: '/migrations/mig_saved', stub: fetch }); + }); + + it('rejects a whitespace environment ID', async () => { + process.env.ADAPTY_MIGRATION = ' '; + const fetch = mockFetch(); + const { error } = await runCommand('migrations status'); + + expect(error?.code).to.equal('migration_required'); + expect(fetch.callCount).to.equal(0); + }); + + for (const token of ['', 'another-token']) { + it(`rejects inapplicable context for token ${JSON.stringify(token)} without requests`, async () => { + process.env.ADAPTY_TOKEN = token; + const fetch = mockFetch(); + const { error } = await runCommand('migrations status'); + + expect(error?.oclif?.exit).to.equal(2); + expect(error?.code).to.equal('migration_required'); + expect(fetch.callCount).to.equal(0); + expect(await store.load()).to.deep.equal(SAVED); + }); + } + + it('requires auth when an explicit ID is supplied without a token', async () => { + delete process.env.ADAPTY_TOKEN; + const fetch = mockFetch(); + const { error } = await runCommand('migrations status -m mig_explicit'); + + expect(error?.oclif?.exit).to.equal(3); + expect(fetch.callCount).to.equal(0); + }); + + it('validates action input before reading corrupted context', async () => { + await fs.writeFile(store.path, '{broken'); + const fetch = mockFetch(); + const { error } = await runCommand(['migrations', 'run', 'act_confirm_paywalls', '--input', '[]']); + + expect(error?.oclif?.exit).to.equal(2); + expect(error?.message).to.contain('input'); + expect(fetch.callCount).to.equal(0); + }); + + it('still requires action confirmation for a saved migration', async () => { + const fetch = mockFetch([ENVELOPE]); + const { error } = await runCommand('migrations run act_confirm_paywalls'); + + expect(error?.oclif?.exit).to.equal(6); + expect(fetch.callCount).to.equal(1); + }); + + it('preserves the saved selection when the server returns 404', async () => { + mockFetchFailure({ error: { message: 'Not found' } }, { status: 404 }); + const { error } = await runCommand('migrations status'); + + expect(error?.oclif?.exit).to.equal(4); + expect(await store.load()).to.deep.equal(SAVED); + }); +}); diff --git a/test/commands/migrations-create-selection.test.ts b/test/commands/migrations-create-selection.test.ts new file mode 100644 index 0000000..dd81373 --- /dev/null +++ b/test/commands/migrations-create-selection.test.ts @@ -0,0 +1,116 @@ +import fs from 'node:fs/promises'; +import { join } from 'node:path'; + +import { Config } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { createMigrationContext } from '../../src/cli/context/migration/model.js'; +import { createMigrationContextStore } from '../../src/cli/context/migration/store.js'; +import { mockFetch, mockFetchFailure } from '../helpers/mock-fetch.js'; + +import type { MigrationContextStore } from '../../src/cli/context/migration/store.js'; +import type { Envelope } from '../../src/sdk/adapty/index.js'; + +const TOKEN = 'creation-token'; +const PREVIOUS = createMigrationContext({ token: TOKEN }, 'mig_previous'); + +const ENVELOPE = JSON.parse(await fs.readFile( + new URL('../fixtures/migration-envelope.json', import.meta.url), 'utf8', +)) as Envelope; + +describe('migration creation selection', () => { + let store: MigrationContextStore; + + beforeEach(async () => { + process.env.ADAPTY_TOKEN = TOKEN; + const config = await Config.load(join(import.meta.dirname, '..', '..')); + store = createMigrationContextStore(config.configDir); + await store.save(PREVIOUS); + }); + + afterEach(() => { + sinon.restore(); + }); + + for (const json of [false, true]) { + it(`saves the created ID (json=${json})`, async () => { + const fetch = mockFetch([ENVELOPE]); + + const { stdout, error } = await runCommand([ + 'migrations', 'create', '--name', 'App', ...(json ? ['--json'] : []), + ]); + + expect(error).to.equal(undefined); + expect(fetch.callCount).to.equal(1); + expect(await store.load()).to.deep.equal(createMigrationContext({ token: TOKEN }, ENVELOPE.migration.id)); + + if (json) { + expect(JSON.parse(stdout)).to.deep.equal(ENVELOPE); + } else { + expect(stdout).to.contain('Continue with `adapty migrations status`.'); + } + }); + + it(`warns on save failure while returning success and never retrying creation (json=${json})`, async () => { + const fetch = mockFetch([ENVELOPE]); + sinon.stub(fs, 'rename').rejects(new Error(`private ${TOKEN}`)); + + const { stdout, stderr, error } = await runCommand([ + 'migrations', 'create', '--name', 'App', ...(json ? ['--json'] : []), + ]); + + expect(error).to.equal(undefined); + expect(fetch.callCount).to.equal(1); + expect(await store.load()).to.deep.equal(PREVIOUS); + expect(stderr).to.contain(`Migration ${ENVELOPE.migration.id} was created`); + expect(stderr).to.contain(`adapty migrations status -m ${ENVELOPE.migration.id}`); + expect(stderr).not.to.contain(TOKEN); + + if (json) { + expect(JSON.parse(stdout)).to.deep.equal(ENVELOPE); + } else { + expect(stdout).to.contain('Migration created.'); + } + }); + } + + it('leaves the previous selection untouched with --no-select', async () => { + mockFetch([ENVELOPE]); + const { stdout, error } = await runCommand('migrations create --name App --no-select --json'); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout)).to.deep.equal(ENVELOPE); + expect(await store.load()).to.deep.equal(PREVIOUS); + }); + + it('does not access even malformed context with --no-select', async () => { + await fs.writeFile(store.path, '{broken'); + mockFetch([ENVELOPE]); + const { stdout, stderr, error } = await runCommand('migrations create --name App --no-select'); + + expect(error).to.equal(undefined); + expect(stderr).to.equal(''); + expect(stdout).to.contain(`adapty migrations status -m ${ENVELOPE.migration.id}`); + expect(await fs.readFile(store.path, 'utf8')).to.equal('{broken'); + }); + + it('saves the created ID and warns about the surviving environment override', async () => { + process.env.ADAPTY_MIGRATION = 'mig_env'; + mockFetch([ENVELOPE]); + const { stdout, stderr } = await runCommand('migrations create --name App --json'); + + expect(JSON.parse(stdout)).to.deep.equal(ENVELOPE); + expect(stderr).to.contain('ADAPTY_MIGRATION still overrides'); + expect((await store.load())?.currentMigrationId).to.equal(ENVELOPE.migration.id); + }); + + it('preserves the previous selection on an API failure', async () => { + mockFetchFailure({ error: { message: 'Invalid app' } }, { status: 422 }); + const { error } = await runCommand('migrations create --name App'); + + expect(error?.oclif?.exit).to.equal(4); + expect(await store.load()).to.deep.equal(PREVIOUS); + }); +}); diff --git a/test/commands/migrations-current.test.ts b/test/commands/migrations-current.test.ts new file mode 100644 index 0000000..fbf104f --- /dev/null +++ b/test/commands/migrations-current.test.ts @@ -0,0 +1,104 @@ +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +import { Config } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { createMigrationContext } from '../../src/cli/context/migration/model.js'; +import { createMigrationContextStore } from '../../src/cli/context/migration/store.js'; +import { createFileSessionStore } from '../../src/sdk/core/session.js'; + +import type { MigrationContextStore } from '../../src/cli/context/migration/store.js'; + +const TOKEN = 'selection-test-token'; + +describe('migrations current', () => { + let store: MigrationContextStore; + let fetch: sinon.SinonStub; + + beforeEach(async () => { + process.env.ADAPTY_TOKEN = TOKEN; + const config = await Config.load(fileURLToPath(new URL('../../', import.meta.url))); + store = createMigrationContextStore(config.configDir); + await store.save(createMigrationContext({ token: TOKEN }, 'mig_saved')); + fetch = sinon.stub(globalThis, 'fetch').rejects(new Error('Unexpected network request')); + }); + + afterEach(() => { + expect(fetch.callCount).to.equal(0); + sinon.restore(); + }); + + it('shows saved context locally without validating the token or changing the file', async () => { + const before = await fs.readFile(store.path, 'utf8'); + const { stdout, error } = await runCommand('migrations current --json'); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout)).to.deep.equal({ currentMigrationId: 'mig_saved', source: 'context' }); + expect(await fs.readFile(store.path, 'utf8')).to.equal(before); + const human = await runCommand('migrations current'); + expect(human.stdout).to.contain('mig_saved (context)'); + }); + + it('uses the token from stored credentials', async () => { + delete process.env.ADAPTY_TOKEN; + const config = await Config.load(fileURLToPath(new URL('../../', import.meta.url))); + await createFileSessionStore(config.configDir).save({ token: TOKEN }); + const { stdout } = await runCommand('migrations current --json'); + + expect(JSON.parse(stdout)).to.deep.equal({ currentMigrationId: 'mig_saved', source: 'context' }); + }); + + for (const token of ['', 'different-token']) { + it(`has no applicable context with token ${JSON.stringify(token)}`, async () => { + process.env.ADAPTY_TOKEN = token; + const { stdout, error } = await runCommand('migrations current --json'); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout)).to.deep.equal({ currentMigrationId: null, source: null }); + expect((await store.load())?.currentMigrationId).to.equal('mig_saved'); + }); + } + + it('explains how to select a migration when none is saved', async () => { + await store.clear(); + const { stdout, error } = await runCommand('migrations current'); + + expect(error).to.equal(undefined); + expect(stdout).to.contain('adapty migrations use <id>'); + }); + + it('shows an environment override without auth even when context is malformed', async () => { + delete process.env.ADAPTY_TOKEN; + process.env.ADAPTY_MIGRATION = 'mig_env'; + await fs.writeFile(store.path, '{broken'); + const { stdout } = await runCommand('migrations current --json'); + + expect(JSON.parse(stdout)).to.deep.equal({ currentMigrationId: 'mig_env', source: 'env' }); + const human = await runCommand('migrations current'); + expect(human.stdout).to.contain('mig_env (ADAPTY_MIGRATION)'); + }); + + it('ignores an empty environment value', async () => { + process.env.ADAPTY_MIGRATION = ''; + const { stdout } = await runCommand('migrations current --json'); + + expect(JSON.parse(stdout)).to.deep.equal({ currentMigrationId: 'mig_saved', source: 'context' }); + }); + + it('rejects a whitespace environment ID instead of falling back', async () => { + process.env.ADAPTY_MIGRATION = ' '; + const { error } = await runCommand('migrations current'); + + expect(error?.oclif?.exit).to.equal(2); + expect(error?.code).to.equal('migration_required'); + }); + + it('does not accept a migration flag', async () => { + const { error } = await runCommand('migrations current -m mig_other'); + + expect(error?.oclif?.exit).to.equal(2); + }); +}); diff --git a/test/commands/migrations-exit-codes.test.ts b/test/commands/migrations-exit-codes.test.ts index d59b21d..1bd7dc1 100644 --- a/test/commands/migrations-exit-codes.test.ts +++ b/test/commands/migrations-exit-codes.test.ts @@ -6,7 +6,7 @@ import { expect } from 'chai'; const ROOT = join(import.meta.dirname, '..', '..'); const cases = [ - { args: ['status'], message: 'Missing required flag migration', name: 'missing migration ID' }, + { args: ['status'], message: 'No migration selected', name: 'missing migration ID' }, { args: ['run', '-m', 'mig_test'], message: 'action_id', name: 'missing action argument' }, { args: ['create', '--name', 'My app', '--flow', 'transactions', '--app', 'app_test'], @@ -111,7 +111,12 @@ describe('migration process exit codes', () => { const output = JSON.parse(child.stdout) as { error: { message: string } }; expect(output.error.message).to.contain(message); - expect(output.error).to.have.all.keys('message'); + + if (name === 'missing migration ID') { + expect(output.error).to.include({ code: 'migration_required' }); + } else { + expect(output.error).to.have.all.keys('message'); + } } else { expect(child.stdout).to.equal(''); expect(child.stderr).to.contain(message); diff --git a/test/commands/migrations-selection-process.test.ts b/test/commands/migrations-selection-process.test.ts new file mode 100644 index 0000000..7895008 --- /dev/null +++ b/test/commands/migrations-selection-process.test.ts @@ -0,0 +1,140 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { join } from 'node:path'; + +import { Config } from '@oclif/core'; +import { expect } from 'chai'; + +import { createMigrationContext } from '../../src/cli/context/migration/model.js'; +import { createMigrationContextStore } from '../../src/cli/context/migration/store.js'; + +const ROOT = join(import.meta.dirname, '..', '..'); +const TOKEN = 'selection-process-token'; + +const ENVELOPE = JSON.parse(await fs.readFile( + new URL('../fixtures/migration-envelope.json', import.meta.url), 'utf8', +)) as { migration: { id: string } }; + +const SCRIPT = ` + import { execute } from '@oclif/core'; + globalThis.fetch = async (url) => { + if (!process.env.SELECTION_TEST_RESPONSE) throw new Error('Unexpected network request'); + if (process.env.SELECTION_TEST_PATH && !url.endsWith(process.env.SELECTION_TEST_PATH)) { + throw new Error('Unexpected migration target: ' + url); + } + return new Response(process.env.SELECTION_TEST_RESPONSE, { + headers: { 'content-type': 'application/json' }, + }); + }; + await execute({ args: JSON.parse(process.env.SELECTION_TEST_ARGS), dir: process.cwd() }); +`; + +const run = (args: string[], response?: string, path?: string) => spawnSync(process.execPath, [ + '--input-type=module', '-e', SCRIPT, +], { + cwd: ROOT, + encoding: 'utf8', + env: { + ...process.env, + ADAPTY_TOKEN: TOKEN, + SELECTION_TEST_ARGS: JSON.stringify(['migrations', ...args]), + SELECTION_TEST_RESPONSE: response ?? '', + SELECTION_TEST_PATH: path ?? '', + }, + timeout: 10_000, +}); + +describe('migration selection across processes', () => { + it('persists use, reads current offline in a new process, and clears with unuse', () => { + const selected = run(['use', 'mig_requested', '--json'], JSON.stringify({ migration: { id: 'mig_returned' } })); + + expect(selected.status, selected.stderr).to.equal(0); + expect(JSON.parse(selected.stdout)).to.deep.equal({ currentMigrationId: 'mig_returned' }); + + const current = run(['current', '--json']); + + expect(current.status, current.stderr).to.equal(0); + expect(JSON.parse(current.stdout)).to.deep.equal({ currentMigrationId: 'mig_returned', source: 'context' }); + + const status = run(['status', '--json'], JSON.stringify(ENVELOPE), '/migrations/mig_returned'); + + expect(status.status, status.stderr).to.equal(0); + expect(JSON.parse(status.stdout)).to.deep.equal(ENVELOPE); + + const cleared = run(['unuse', '--json']); + + expect(cleared.status, cleared.stderr).to.equal(0); + expect(JSON.parse(cleared.stdout)).to.deep.equal({ currentMigrationId: null }); + + const after = run(['current', '--json']); + + expect(after.status, after.stderr).to.equal(0); + expect(JSON.parse(after.stdout)).to.deep.equal({ currentMigrationId: null, source: null }); + }); + + it('creates in a pipe and uses that selection in a new process', () => { + const created = run(['create', '--name', 'App', '--json'], JSON.stringify(ENVELOPE), '/migrations'); + + expect(created.status, created.stderr).to.equal(0); + expect(JSON.parse(created.stdout)).to.deep.equal(ENVELOPE); + + const status = run(['status', '--json'], JSON.stringify(ENVELOPE), `/migrations/${ENVELOPE.migration.id}`); + + expect(status.status, status.stderr).to.equal(0); + expect(JSON.parse(status.stdout)).to.deep.equal(ENVELOPE); + }); + + it('exits 0 with the unchanged creation envelope when saving context fails', async () => { + const config = await Config.load(ROOT); + const store = createMigrationContextStore(config.configDir); + await fs.mkdir(store.path, { recursive: true }); + + try { + const child = run(['create', '--name', 'App', '--json'], JSON.stringify(ENVELOPE), '/migrations'); + + expect(child.status, child.stderr).to.equal(0); + expect(JSON.parse(child.stdout)).to.deep.equal(ENVELOPE); + expect(child.stderr).to.contain(`adapty migrations status -m ${ENVELOPE.migration.id}`); + } finally { + await fs.rm(store.path, { recursive: true, force: true }); + } + }); + + for (const json of [false, true]) { + it(`returns local context errors with exit 1 without leaking stored data (json=${json})`, async () => { + const config = await Config.load(ROOT); + const store = createMigrationContextStore(config.configDir); + const context = createMigrationContext({ token: TOKEN }, 'mig_saved'); + await store.save(context); + await fs.writeFile(store.path, `${JSON.stringify(context)} ${TOKEN}`); + + const child = run(['current', ...(json ? ['--json'] : [])]); + + expect(child.status, child.stderr || child.stdout).to.equal(1); + expect(child.stdout + child.stderr).not.to.contain(TOKEN); + expect(child.stdout + child.stderr).not.to.contain(context.tokenFingerprint); + + if (json) { + const output = JSON.parse(child.stdout) as { error: { code: string; message: string } }; + expect(output.error.code).to.equal('migration_context_invalid'); + expect(output.error.message).to.contain('migrations unuse'); + } else { + expect(child.stdout).to.equal(''); + expect(child.stderr).to.contain('Invalid migration context'); + } + }); + + it(`returns usage exit 2 for a blank use argument (json=${json})`, () => { + const child = run(['use', ' ', ...(json ? ['--json'] : [])]); + + expect(child.status, child.stderr || child.stdout).to.equal(2); + + if (json) { + const output = JSON.parse(child.stdout) as { error: { code: string } }; + expect(output.error.code).to.equal('migration_required'); + } else { + expect(child.stderr).to.contain('empty or whitespace'); + } + }); + } +}); diff --git a/test/commands/migrations-unuse.test.ts b/test/commands/migrations-unuse.test.ts new file mode 100644 index 0000000..5ed7c78 --- /dev/null +++ b/test/commands/migrations-unuse.test.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +import { Config } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { createMigrationContext } from '../../src/cli/context/migration/model.js'; +import { createMigrationContextStore } from '../../src/cli/context/migration/store.js'; + +import type { MigrationContextStore } from '../../src/cli/context/migration/store.js'; + +describe('migrations unuse', () => { + let store: MigrationContextStore; + let fetch: sinon.SinonStub; + + beforeEach(async () => { + const config = await Config.load(fileURLToPath(new URL('../../', import.meta.url))); + store = createMigrationContextStore(config.configDir); + fetch = sinon.stub(globalThis, 'fetch').rejects(new Error('Unexpected network request')); + }); + + afterEach(() => { + expect(fetch.callCount).to.equal(0); + sinon.restore(); + }); + + for (const state of ['missing', 'other-token', 'malformed']) { + it(`clears ${state} context without auth and can be repeated`, async () => { + if (state !== 'missing') { + await store.save(createMigrationContext({ token: 'other-token' }, 'mig_saved')); + } + + if (state === 'malformed') { + await fs.writeFile(store.path, '{broken'); + } + + const { stdout, error } = await runCommand('migrations unuse --json'); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout)).to.deep.equal({ currentMigrationId: null }); + expect(await store.load()).to.equal(undefined); + const again = await runCommand('migrations unuse'); + expect(again.error).to.equal(undefined); + expect(again.stdout).to.contain('Saved migration selection cleared'); + }); + } + + for (const json of [false, true]) { + it(`explains the surviving environment override on stderr (json=${json})`, async () => { + process.env.ADAPTY_MIGRATION = 'mig_env'; + + const { stdout, stderr, error } = await runCommand([ + 'migrations', 'unuse', ...(json ? ['--json'] : []), + ]); + + expect(error).to.equal(undefined); + expect(stderr).to.contain('ADAPTY_MIGRATION'); + expect(stderr).to.contain('Unset it'); + expect(stdout).not.to.contain('Warning'); + expect(process.env.ADAPTY_MIGRATION).to.equal('mig_env'); + + if (json) { + expect(JSON.parse(stdout)).to.deep.equal({ currentMigrationId: null }); + } + }); + } +}); diff --git a/test/commands/migrations-use.test.ts b/test/commands/migrations-use.test.ts new file mode 100644 index 0000000..6806479 --- /dev/null +++ b/test/commands/migrations-use.test.ts @@ -0,0 +1,140 @@ +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +import { Config } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { createMigrationContext } from '../../src/cli/context/migration/model.js'; +import { createMigrationContextStore } from '../../src/cli/context/migration/store.js'; +import { assertFetch, mockFetch, mockFetchFailure } from '../helpers/mock-fetch.js'; + +import type { MigrationContextStore } from '../../src/cli/context/migration/store.js'; +import type { Envelope } from '../../src/sdk/adapty/index.js'; + +const TOKEN = 'selection-test-token'; +const PREVIOUS = createMigrationContext({ token: TOKEN }, 'mig_previous'); + +const ENVELOPE = JSON.parse(await fs.readFile( + new URL('../fixtures/migration-envelope.json', import.meta.url), 'utf8', +)) as Envelope; + +describe('migrations use', () => { + let store: MigrationContextStore; + + beforeEach(async () => { + process.env.ADAPTY_TOKEN = TOKEN; + const config = await Config.load(fileURLToPath(new URL('../../', import.meta.url))); + store = createMigrationContextStore(config.configDir); + await store.save(PREVIOUS); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('verifies access with the effective token and saves the returned ID before printing it', async () => { + const fetch = mockFetch([ENVELOPE]); + const { stdout, error } = await runCommand('migrations use mig_requested --json'); + + expect(error).to.equal(undefined); + + assertFetch({ + callIndex: 0, method: 'GET', path: '/migrations/mig_requested', stub: fetch, + }); + + const init = fetch.firstCall.args[1] as RequestInit; + + expect(new Headers(init.headers).get('authorization')).to.equal(`Bearer ${TOKEN}`); + + expect(fetch.callCount).to.equal(1); + expect(JSON.parse(stdout)).to.deep.equal({ currentMigrationId: ENVELOPE.migration.id }); + expect(await store.load()).to.deep.equal(createMigrationContext({ token: TOKEN }, ENVELOPE.migration.id)); + }); + + for (const state of ['completed', 'canceled', 'failed']) { + it(`allows selecting a ${state} migration`, async () => { + mockFetch([{ ...ENVELOPE, migration: { ...ENVELOPE.migration, state } }]); + const { stdout, error } = await runCommand('migrations use mig_requested'); + + expect(error).to.equal(undefined); + expect(stdout).to.contain(ENVELOPE.migration.id); + expect((await store.load())?.currentMigrationId).to.equal(ENVELOPE.migration.id); + }); + } + + for (const status of [401, 403, 404]) { + it(`preserves the previous selection on HTTP ${status}`, async () => { + const fetch = mockFetchFailure({ error: { message: 'Access denied' } }, { status }); + const { error, stdout } = await runCommand('migrations use mig_requested'); + + expect(error?.oclif?.exit).to.equal(status === 404 ? 4 : 3); + expect(stdout).to.equal(''); + expect(fetch.callCount).to.equal(1); + expect(await store.load()).to.deep.equal(PREVIOUS); + }); + } + + it('reports a failed save without printing success or losing the previous selection', async () => { + mockFetch([ENVELOPE]); + sinon.stub(fs, 'rename').rejects(new Error(`private error ${TOKEN}`)); + const { error, stdout } = await runCommand('migrations use mig_requested'); + + expect(error?.oclif?.exit).to.equal(1); + expect(error?.code).to.equal('migration_context_io'); + expect(error?.message).not.to.contain(TOKEN); + expect(stdout).to.equal(''); + expect(await store.load()).to.deep.equal(PREVIOUS); + }); + + it('replaces malformed context after a successful GET', async () => { + await fs.writeFile(store.path, '{broken'); + mockFetch([ENVELOPE]); + const { error } = await runCommand('migrations use mig_requested'); + + expect(error).to.equal(undefined); + expect((await store.load())?.currentMigrationId).to.equal(ENVELOPE.migration.id); + }); + + for (const json of [false, true]) { + it(`saves the argument despite an env override and warns on stderr (json=${json})`, async () => { + process.env.ADAPTY_MIGRATION = 'mig_env'; + const fetch = mockFetch([ENVELOPE]); + + const { stdout, stderr, error } = await runCommand([ + 'migrations', 'use', 'mig_requested', ...(json ? ['--json'] : []), + ]); + + expect(error).to.equal(undefined); + assertFetch({ callIndex: 0, method: 'GET', path: '/migrations/mig_requested', stub: fetch }); + expect(stderr).to.contain('ADAPTY_MIGRATION'); + expect(stderr).to.contain('Unset it'); + expect(stdout).not.to.contain('Warning'); + expect((await store.load())?.currentMigrationId).to.equal(ENVELOPE.migration.id); + expect(process.env.ADAPTY_MIGRATION).to.equal('mig_env'); + }); + } + + for (const args of [[], [''], [' ']]) { + it(`rejects invalid input ${JSON.stringify(args)} before requiring auth`, async () => { + delete process.env.ADAPTY_TOKEN; + const fetch = mockFetch(); + const { error } = await runCommand(['migrations', 'use', ...args]); + + expect(error?.oclif?.exit).to.equal(2); + expect(fetch.callCount).to.equal(0); + expect(await store.load()).to.deep.equal(PREVIOUS); + }); + } + + it('requires authentication for a valid argument', async () => { + delete process.env.ADAPTY_TOKEN; + const fetch = mockFetch(); + const { error } = await runCommand('migrations use mig_requested'); + + expect(error?.oclif?.exit).to.equal(3); + expect(fetch.callCount).to.equal(0); + expect(await store.load()).to.deep.equal(PREVIOUS); + }); +}); diff --git a/test/commands/migrations.test.ts b/test/commands/migrations.test.ts index bd63795..3430831 100644 --- a/test/commands/migrations.test.ts +++ b/test/commands/migrations.test.ts @@ -545,7 +545,7 @@ describe('migrations', () => { 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'); + expect(stdout).to.contain('Continue with `adapty migrations status`.'); }); it('create starts an optional flow for an app that exists', async () => { diff --git a/test/helpers/isolate-config.ts b/test/helpers/isolate-config.ts index d5c81ee..79005c5 100644 --- a/test/helpers/isolate-config.ts +++ b/test/helpers/isolate-config.ts @@ -5,6 +5,9 @@ import { join } from 'node:path'; import { Config } from '@oclif/core'; let sessionFile: string; +let contextFile: string; +const environmentKeys = ['XDG_CONFIG_HOME', 'ADAPTY_TOKEN', 'ADAPTY_API_URL', 'ADAPTY_MIGRATION'] as const; +const originalEnvironment = new Map(environmentKeys.map(key => [key, process.env[key]])); export const mochaHooks = { async beforeAll() { @@ -14,6 +17,7 @@ export const mochaHooks = { const config = await Config.load(join(import.meta.dirname, '..', '..')); sessionFile = join(config.configDir, 'config.json'); + contextFile = join(config.configDir, 'context.json'); }, /** @@ -23,6 +27,19 @@ export const mochaHooks = { */ async beforeEach() { delete process.env.ADAPTY_TOKEN; + delete process.env.ADAPTY_API_URL; + delete process.env.ADAPTY_MIGRATION; await rm(sessionFile, { force: true }); + await rm(contextFile, { force: true }); + }, + + afterAll() { + for (const [key, value] of originalEnvironment) { + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + process.env[key] = value; + } + } }, };