Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, use, current, unuse, status (--wait), steps, show, run, close — a thin client of the
# Wizard Service: the flow lives on the server, every answer is one envelope.
# Implementation in src/cli/commands/migrations
segments/ # list, get
access-levels/ # list, get, create, update
asa/ # Apple Search Ads: whoami, connect, orgs, apps, campaigns, ad-groups, keywords,
Expand Down Expand Up @@ -102,6 +105,10 @@ eslint zones in `eslint.config.mjs` fail on a new import into `src/lib`. Layers
error mapping; the Adapty SDK and session belong in `cli/base/adapty/`
- `AdaptyCommand` checks the token on access to `this.session` or `this.adapty`; parse and validate
input first. Auth commands use `openSession()` and `build()` explicitly as needed
- Commands that work on the saved migration extend `MigrationCommand` (same door,
`cli/base/adapty/index.js`) and ask `this.currentMigration`
(`get` / `require` / `set` / `clear` / `clearFor` / `overridden`). `ADAPTY_MIGRATION`, the context file and the
token fingerprint live behind that object, in `cli/context/migration/`, and nowhere else

The following client factories and output helpers belong to the frozen legacy stack:

Expand Down
149 changes: 147 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,23 @@ Other auth commands:
```sh
adapty auth whoami # verify token, show user info
adapty auth status # show local auth state
adapty auth logout # clear stored token (local only)
adapty auth revoke # revoke active token and clear any matching stored session
adapty auth logout # clear stored credentials and migration selection (local only)
adapty auth revoke # revoke active token and clear matching credentials and selection
```

`auth revoke` uses `ADAPTY_TOKEN` when set, otherwise the stored token. A different token in the
session file is preserved. After revoking an environment token, unset `ADAPTY_TOKEN`; a preserved
stored session will then become active again. With no token, revoke succeeds without a request
and returns `{"status":"not_authenticated"}` under `--json`.

`auth logout` removes the saved migration selection even without stored credentials or with a
malformed context. `auth revoke` removes selection only after server success and only when it
belongs to the revoked token. Failed revocation preserves both files. Both cleanup operations
are attempted independently; incomplete cleanup returns exit 1 (`auth_cleanup_failed`). If the
server already revoked the token, the error says so: fix local files without repeating revocation.
Environment variables remain in the parent shell; unset `ADAPTY_TOKEN` and `ADAPTY_MIGRATION`
there when needed.

## Commands

All resource commands require `--app APP_ID` (UUID). Use `adapty apps list` to find your app ID.
Expand Down Expand Up @@ -105,6 +113,142 @@ 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 and saves it as current; the returned JSON contains its ID in `migration.id`.
Use `--no-select` to create without changing the saved selection. If creation succeeds but saving
fails, the command still succeeds and prints a warning with the explicit continuation command.

Commands choose a migration in this order: `-m, --migration`, non-empty `ADAPTY_MIGRATION`,
then the saved selection for the current token. Explicit IDs do not change the saved selection.
Replace `mig_7x2` below with an ID from `create` or `list`; agents should pass `-m` explicitly.

#### Manage a saved selection

```sh
adapty migrations use mig_7x2
adapty migrations current --json
adapty migrations unuse
```

`use` verifies access through the API, then saves the returned ID for the current token.
`current` reads the effective selection locally: `ADAPTY_MIGRATION` takes precedence over the
saved context. `unuse` removes the saved selection without authentication; an environment override
must be unset in your shell. Changing tokens makes the previous token's selection inapplicable.

The selection is shared across terminals and survives restarting the CLI. After `use` or `create`,
you can run `adapty migrations status`, `steps`, `show`, `run` or `close` without `-m`.
An operation keeps its initially selected ID even if another terminal changes the selection.
`run` and `close` print the target on stderr before a mutation that uses saved selection.
Scripts should pass explicit IDs and use `create --no-select` to preserve the shared default.

#### Inspect and run an action

```sh
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
Expand Down Expand Up @@ -429,6 +573,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
Expand Down
85 changes: 78 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Everything here would be the same for any HTTP API.
## sdk/adapty

The Developer API assembled on top of core. `createAdapty(options)` builds one transport and hangs
resources off it (`apps`, `auth`, `accessLevels`).
resources off it (`apps`, `auth`, `accessLevels`, `migrations`).

A resource owns everything about its entity: paths, request/response shapes, and its business
rules as pure functions returning `Issue[]`. Rules return lists instead of throwing, so a table
Expand Down Expand Up @@ -88,22 +88,65 @@ text or JSON.

- `base/base-command.ts` — output channel, `SIGINT` → abort signal, error mapping, `render()`.
It owns no product SDK or session, so another product such as ASA can reuse it directly.
- `base/adapty/index.ts` — the public entry point: commands import `AdaptyCommand`, `build`,
`openSession` and session types from here. Implementation files import each other directly.
- `base/adapty/index.ts` — the public entry point: commands import `AdaptyCommand`,
`MigrationCommand`, `build`, `openSession` and session types from here. Implementation files
import each other directly.
- `base/adapty/openSession.ts` — reads `ADAPTY_TOKEN` and `ADAPTY_API_URL`, picks the config dir
from oclif, and returns where to talk, as whom, and the store to write through. `openSession(config)`
also warns about a non-default API URL.
- `base/adapty/build.ts` — `build(session, context)` assembles the SDK with cancellation,
User-Agent and retry warnings. Both authenticated commands and auth commands use it.
- `base/adapty/adapty-command.ts` — resolves an Adapty session and lazily builds its SDK. "Needs
authorization" is expressed in what a command extends, not re-checked inside `run()` bodies.
- `base/adapty/migration-command.ts` — `AdaptyCommand` plus `this.currentMigration`. "Works on the
saved migration" is the second thing a command says by what it extends.
- `errors.ts` — the single `SdkError` → CLI error mapping. The switch has no default, so a new
error kind fails to compile until it is given a message and an exit code.
- `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.
- `context/migration/` — CLI-owned migration selection in `context.json` beside credentials.
`model.ts` defines and validates the record and token fingerprint; `store.ts` owns file I/O and
storage errors; `resolve.ts` chooses an ID by source priority. `current.ts` binds those three
into the object the door exports: `openCurrentMigration({ configDir, session, env })` answers
`get`, `require`, `set`, `clear`, `clearFor` and `overridden`. A command asks that object a
question; it never assembles a store, a session and an environment variable of its own, and
`ADAPTY_MIGRATION` is read here and nowhere else. Files within the module import each other
directly, and a test may open any of them.
The store replaces records atomically; the resolver checks the effective token fingerprint
and reads the file only when no explicit ID is supplied. API URL is not part of the context.
Its two error codes are stable, but the texts are per operation: a read, a write and a removal
are fixed in three different places, so each says which failed and names the errno. The message
never repeats the underlying one — it may quote the record, and the record carries a token
fingerprint — so the original travels as `cause`.
`migrations use` verifies access before saving; `current` resolves the selection locally;
`unuse` removes it without authentication and without a session. Migration operations resolve
once after input validation, using flag > environment > saved context; polling and mutations
retain that captured ID. `create` saves by default, with `--no-select` to opt out; local save
failures warn without failing creation. The SDK remains unaware of this local selection.
- `views/` — plain functions, value in, string out.
- `commands/` — one class per command.

### 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
Expand All @@ -112,6 +155,7 @@ base/
└── adapty/
├── index.ts
├── adapty-command.ts
├── migration-command.ts
├── build.ts
└── openSession.ts
```
Expand All @@ -126,6 +170,15 @@ Commands that require Adapty authorization extend `AdaptyCommand`. It resolves t
input before that access so input errors take precedence over a missing token. A future ASA adapter
can live in `base/asa/` and extend the same `BaseCommand`.

Commands that work on the saved migration extend `MigrationCommand`, a file in the same adapter that
adds one lazy getter, `this.currentMigration`, over `AdaptyCommand`. Selection is one topic out of
ten, so it stays out of `AdaptyCommand`, where all 75 commands would pay for it.
The two commands that answer without credentials — `current` and `unuse` — stay on `BaseCommand`
and call `openCurrentMigration` themselves, as `auth logout` and `auth revoke` do for their cleanup.

`this.resolvedSession` exposes the same captured session without requiring a token, for local
input resolution before authentication. It does not build the SDK or make a network request.

Commands import the Adapty adapter through its public entry point:

```ts
Expand Down Expand Up @@ -164,14 +217,20 @@ 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/<concern>.ts` |
| New error kind | `sdk/core/errors.ts` + `cli/errors.ts` (the compiler insists) |
| Adapty session environment variables | `cli/base/adapty/openSession.ts` |
| `ADAPTY_MIGRATION`, or anything about the saved selection | `cli/context/migration/current.ts` |

Shared input modules group declarations, private parsers and SDK parameter mapping by concern
(for example, `cli/input/pagination.ts`). Import each module directly; there is no barrel index.
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
most topics. Migrated so far: `apps` and `auth`.
most topics. Migrated so far: `apps`, `auth` and `migrations`.

oclif discovers commands only under `src/commands`, so a migrated command keeps a one-line file
there re-exporting the real class from `src/cli/commands`.
Expand All @@ -184,6 +243,18 @@ command targeted only the token in the file. It removes the stored session only
the revoked one; a different stored token remains usable. With no effective token, it keeps the old
successful no-op and `{ "status": "not_authenticated" }` JSON result.

`auth/logout/` and `auth/revoke/` keep command classes in `command.ts` and local cleanup in
`lib/cleanup.ts`. Logout opens the session store and the migration context directly, so it can
remove malformed credentials and an orphaned selection without resolving a usable session. Revoke
removes the selection through `clearFor(token)` — only when its fingerprint matches the revoked
token, independently of the stored credentials; the comparison itself lives with the record. Both commands
attempt both cleanup operations; failures become exit 1 with `auth_cleanup_failed` and one line per
file that survived, naming why. Redaction is about foreign text, not about diagnostics: an error of
ours contributes its own message, a stranger's contributes its errno alone, and the original travels
as `cause` (`describeCleanupFailures` in `cli/errors.ts`). Revoke errors explicitly distinguish
successful server revocation from failed local cleanup. Credentials stay in the SDK session store;
migration cleanup belongs to the CLI.

The apps adapter runs the SDK's pure validation rules before requiring a token. The SDK also keeps
its own validation so other adapters cannot bypass the rules.

Expand Down
Loading
Loading