From 9690295376f2b4b2648c06b1e6b30363e40f7a34 Mon Sep 17 00:00:00 2001 From: German Shteynardt Date: Wed, 16 Sep 2026 12:05:16 +0300 Subject: [PATCH] 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 2c9432d..96b6537 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 ad2ea8d..83ebd88 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' });