diff --git a/CLAUDE.md b/CLAUDE.md index 7687f18..397c5f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,22 @@ src/ preview.ts # flow config normalization + render URL / gzip fragment building ``` +## Where new code goes + +The tree above is the pre-sdk stack, and it is **frozen**: no new file in `src/lib`, no new +hand-written command in `src/commands`. New work goes to + +``` +src/ + sdk/ # the API: core/ (transport, errors, session, clock) + adapty/ (resources, rules) + cli/ # the oclif adapter: base commands, session, exit codes, flags, views +``` + +A migrated command keeps a one-line re-export under `src/commands`, because oclif discovers +commands only there. `test/architecture/frozen-legacy.test.ts` fails on any other new file, and the +eslint zones in `eslint.config.mjs` fail on a new import into `src/lib`. Layers and contracts: +`docs/architecture.md`. + ## Conventions - oclif topic separator is space (e.g. `adapty apps list`, not `adapty apps:list`) @@ -57,6 +73,10 @@ src/ token's company (`--app` there is only a list filter) - `list` commands use shared pagination flags (--page, --page-size) - Commands support `--json` flag via oclif's `enableJsonFlag = true` +- Relative imports use explicit `.js` extensions, including `/index.js` for module entry points + (Node.js ESM + TypeScript `nodenext`) +- Import the Adapty command adapter through `cli/base/adapty/index.js`; files inside that module + import each other directly - Auth token stored at `~/.config/adapty/config.json` (mode 0o600) - `ADAPTY_TOKEN` env overrides stored token - `ADAPTY_API_URL` env overrides default API base URL @@ -77,7 +97,14 @@ src/ ## Key Patterns -- Each command: single class extending `Command` in its own file +- Each migrated command is a single class extending `BaseCommand` when authorization is optional, + or `AdaptyCommand` when an Adapty token is required. `BaseCommand` owns output, cancellation and + 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 + +The following client factories and output helpers belong to the frozen legacy stack: + - `createAuthenticatedClient(config)` — factory for token-aware ApiClient - `createAsaClient(config)` — same, against the ASA service; asa writes print the request body and ask for confirmation before sending (`asa-confirm.ts`) diff --git a/README.md b/README.md index c83d3a1..faeb5b7 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,14 @@ Other auth commands: 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 token server-side and clear local +adapty auth revoke # revoke active token and clear any matching stored session ``` +`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`. + ## Commands All resource commands require `--app APP_ID` (UUID). Use `adapty apps list` to find your app ID. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0206a15 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,206 @@ +# Architecture + +Two layers. `src/sdk` knows the Adapty API and nothing else; `src/cli` knows oclif, the terminal +and the machine it runs on. The arrow points one way — the sdk never imports the cli — which is +what lets the same product code run under another adapter (an MCP server) later. + +``` +src/ +├── sdk/ +│ ├── core/ # transport and primitives, no Adapty knowledge +│ └── adapty/ # the Developer API: paths, shapes, rules +└── cli/ # oclif adapter: flags, views, exit codes, env +``` + +## The dependency rule + +| Layer | May import | +| --- | --- | +| `sdk/core` | itself | +| `sdk/adapty` | `sdk/core` | +| `src/cli` | both | + +Plus two more: `sdk/core/http` is a module with a single door (`core/http/index.js`), and inside it +the transport depends only on `core/errors` and `core/clock`. + +`no-restricted-imports` zones in `eslint.config.mjs` enforce all of this, so a wrong import fails +`pnpm lint` rather than review. + +## sdk/core + +Everything here would be the same for any HTTP API. + +- `http/` — the transport: base URL, bearer token, JSON both ways, responses mapped to sdk errors, + retry for idempotent requests. Error parsing (`policies.ts`) and the retry rule are parameters, + because services word rejections differently. +- `errors.ts` — the error taxonomy. Every error has a stable `kind` and carries no user-facing text + and no exit code; assigning both is the adapter's job. +- `session.ts` — `SessionStore` is a port; `createFileSessionStore(dir)` is the file implementation. + The directory comes from the caller: the sdk touches neither `HOME` nor `process.env`. +- `clock.ts` — time as a dependency, so retry and device flow are testable without waiting. +- `auth/device-flow.ts` — RFC 8628 orchestration with every effect outside it: network behind a + port, time behind `Clock`, cancellation behind a signal. +- `validation.ts` — `assertValid(issues)`, the only place a rule's problems become a throw. +- `testing.ts` — the fakes (`createFakeClock`, `createScriptedFetch`) any consumer's tests can use. + +## sdk/adapty + +The Developer API assembled on top of core. `createAdapty(options)` builds one transport and hangs +resources off it (`apps`, `auth`, `accessLevels`). + +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 +test walks them without an environment and the user sees every problem at once. + +A resource with nothing but paths and shared shapes is one file (`access-levels.ts`). One whose +operations carry knowledge of their own — an input shape, a rule, a request body, a protocol +step — becomes a directory, cut by operation rather than by kind of code, so that a change to +"creating an app" stays inside one file: + +``` +apps/ +├── index.ts # the door: re-exports only, and only what a consumer uses +├── model.ts # what the operations share: the entity as the server sends it +├── create.ts # input shape, rules and request body of one operation +├── update.ts +└── resource.ts # the endpoint map: paths, and the rule check before each write +``` + +A `types.ts` or a `lib/` would be the other cut, by kind, and it costs what this one buys: three +files open to read one operation, request shapes exported only to be moved, and a directory named +after nothing. Outside the resource nobody sees either way — imports go through `index.ts`. + +`auth/` is the same shape with one difference worth knowing: a device flow step is its path and +the reading of its answer together, so `device-code.ts` and `poll-token.ts` keep their own paths, +and `resource.ts` holds only what the resource *is* — the `DeviceAuthApi` port it satisfies, plus +`me` and `revokeToken`. The endpoint map is a property of `apps`, not a law. + +Responses pass through in the server's snake_case (that is what `--json` has always printed); +input is camelCase, because that side is our API, not the server's. + +`AdaptyOptions` is deliberately narrower than `HttpOptions`: the transport seams are product +knowledge, and two adapters overriding them would read the same answers differently. + +## src/cli + +The adapter. It resolves the environment, builds the sdk, turns flags into inputs and results into +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/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. +- `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. +- `views/` — plain functions, value in, string out. +- `commands/` — one class per command. + +### Command bases and imports + +```text +base/ +├── base-command.ts +└── adapty/ + ├── index.ts + ├── adapty-command.ts + ├── build.ts + └── openSession.ts +``` + +Commands that can run without authorization extend `BaseCommand`; it neither opens a session nor +requires a token. This includes `auth login`, `auth status`, `auth logout` and `auth revoke`. +They call `openSession()` and `build()` explicitly when needed. `build()` accepts a session without +a token, as required by login. + +Commands that require Adapty authorization extend `AdaptyCommand`. It resolves the session during +`init()`, then checks the token when `this.session` or `this.adapty` is accessed. Parse and validate +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 import the Adapty adapter through its public entry point: + +```ts +import { AdaptyCommand, build, openSession } from '../../base/adapty/index.js'; +``` + +`index.ts` contains explicit re-exports of the public API and session types. Files inside the module +import each other directly to avoid cycles through the entry point. Tests of internal helpers may +also import their implementation files directly. + +The project uses Node.js ESM and TypeScript `nodenext`. Relative imports include the emitted `.js` +extension even in TypeScript source. Directory imports spell out `/index.js`: Node.js does not +resolve `../../base/adapty` to its index automatically. See +[Node.js: mandatory file extensions](https://nodejs.org/api/esm.html#mandatory-file-extensions). + +### Exit codes + +| Code | Meaning | +| --- | --- | +| 2 | usage — bad input (oclif's own parse errors too) | +| 3 | auth — no token, expired, or authorization refused | +| 4 | api — the server rejected a well-formed request | +| 5 | network — the server was never reached | +| 130 | cancelled — Ctrl+C (128 + SIGINT) | + +### The `--json` contract + +`run()` returns the data and `render()` prints it, so the return type *is* the JSON contract, +checked by the compiler: change a shape in the sdk and the command stops compiling instead of +quietly changing what users parse. + +## Where a change goes + +| Change | Place | +| --- | --- | +| 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 error kind | `sdk/core/errors.ts` + `cli/errors.ts` (the compiler insists) | +| Adapty session environment variables | `cli/base/adapty/openSession.ts` | + +## 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`. + +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`. + +Both stacks read and write the same session file — `{ "access_token", "user" }`, mode 0600 — and +`test/cli/legacy-compat.test.ts` holds that contract in both directions for as long as they coexist. + +`auth revoke` now revokes the effective token (`ADAPTY_TOKEN` takes precedence), whereas the old +command targeted only the token in the file. It removes the stored session only if its token matches +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. + +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. + +### The old stack is frozen + +Nothing new goes into `src/lib` or into a hand-written command. Two guards, because they catch +different mistakes: + +- `test/architecture/frozen-legacy.test.ts` inventories both directories against a committed list. + A new file in `src/lib`, or a file under `src/commands` that is neither on the list nor a + re-export, fails the test. The lists are also the migration's remaining scope: a line leaves when + the module is ported, and the test insists on that too, so they cannot drift into fiction. +- The eslint zone for `src/cli` names the only three modules it may still borrow — `output.js`, + `app-url.js`, `client-from-config.js`. A fourth bridge means porting the helper into the sdk, or + editing `eslint.config.mjs` on purpose. (`src/sdk` may not touch `src/lib` at all.) + +## Tests + +`test/` mirrors `src/`. Sdk tests use `sdk/core/testing.ts` and never touch the network or the +clock; cli tests run commands through oclif and assert on output and exit codes. diff --git a/eslint.config.mjs b/eslint.config.mjs index 302091e..282b5fa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -190,6 +190,10 @@ const formatting = tseslint.config( // Patterns match the import string as written, not the resolved path: a relative // specifier has no `sdk` segment and the number of `../` isn't known up front. // The form without `/**` catches a barrel import of the directory. +// import-x/no-restricted-paths would express the same zones over real paths, but it resolves +// the specifier first, and a '.js' specifier pointing at a '.ts' file resolves to nothing here — +// the rule then passes silently. Making it work needs eslint-import-resolver-typescript; until +// that is worth a dependency, patterns are the only boundary that actually fires. const noOclif = { group: ['@oclif/*'], message: 'sdk must not depend on oclif: the framework lives in src/cli', @@ -205,17 +209,86 @@ const noProducts = { message: 'core must not know about products', }; -// src/sdk doesn't exist yet — these rules await the layer split and match nothing today. +// Both layers live in src while the migration runs, so the arrow is spelled out. +const noLegacy = { + group: ['**/lib', '**/lib/**'], + message: 'sdk must not import src/lib: that layer is what sdk replaces', +}; + +// src/lib is frozen (test/architecture/frozen-legacy.test.ts). These three are the whole of what +// the new layer still borrows from it; a fourth one means either porting the helper into sdk, or +// a deliberate edit here. +// +// `**/lib/*` and not `**/lib/**`: the patterns follow gitignore semantics, where a negation cannot +// re-include a file whose parent directory the group already excluded. src/lib is flat, so one +// level is the whole of it. +const legacyBridgesOnly = { + group: [ + '**/lib/*', + '!**/lib/app-url.js', '!**/lib/client-from-config.js', '!**/lib/output.js', + ], + message: 'src/lib is frozen: only app-url, client-from-config and output may still be borrowed', +}; + +// A command that outgrew one file keeps private helpers in its own lib/ (command-layout.test.ts). +// `./lib/*` is the only way to spell "the lib next to me", so the freeze above can re-include +// exactly that — another command's lib is still out of reach, and so is src/lib. +const ownCommandLib = { + group: [...legacyBridgesOnly.group, '!./lib/*'], + message: 'src/lib is frozen to app-url, client-from-config and output; a lib/ inside another command is private to it', +}; + +// The transport stands on core primitives and pulls in no neighbours (testing, session, auth). +// A pattern sees only the specifier, so the allowlist is spelled as negations. +const corePrimitivesOnly = { + group: ['../*', '../*/**', '!../errors.js', '!../clock.js'], + message: 'the transport depends on core primitives only: errors and clock', +}; + +// core/http is a module with one door: its internals can be rearranged without touching consumers. +const httpDoorOnly = { + group: ['**/core/http/*', '!**/core/http/index.js'], + message: 'core/http has one door: import it through core/http/index.js', +}; + +// Live for src/sdk/core; the blocks for products and for src/cli wait for those layers. const architecture = tseslint.config( { files: ['src/sdk/**/*.ts'], - rules: { 'no-restricted-imports': ['error', { patterns: [noOclif, noCli] }] }, + rules: { 'no-restricted-imports': ['error', { patterns: [noOclif, noCli, noLegacy] }] }, }, { // A later block replaces rule options instead of merging, so sdk boundaries repeat here files: ['src/sdk/core/**/*.ts'], - rules: { 'no-restricted-imports': ['error', { patterns: [noOclif, noCli, noProducts] }] }, + rules: { 'no-restricted-imports': ['error', { patterns: [noOclif, noCli, noLegacy, noProducts] }] }, + }, + + { + files: ['src/sdk/core/http/**/*.ts'], + rules: { + 'no-restricted-imports': ['error', { + patterns: [noOclif, noCli, noLegacy, noProducts, corePrimitivesOnly], + }], + }, + }, + + { + // Products see the module, not its parts + files: ['src/sdk/adapty/**/*.ts', 'src/sdk/asa/**/*.ts'], + rules: { 'no-restricted-imports': ['error', { patterns: [noOclif, noCli, noLegacy, httpDoorOnly] }] }, + }, + + { + // The adapter keeps a few named bridges into src/lib until the commands move over + files: ['src/cli/**/*.ts'], + rules: { 'no-restricted-imports': ['error', { patterns: [httpDoorOnly, legacyBridgesOnly] }] }, + }, + + { + // ... plus, for a command, the lib/ it owns + files: ['src/cli/commands/**/*.ts'], + rules: { 'no-restricted-imports': ['error', { patterns: [httpDoorOnly, ownCommandLib] }] }, }, ); diff --git a/package.json b/package.json index 50e3264..e8b9a7f 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,15 @@ "oclif": { "bin": "adapty", "dirname": "adapty", - "commands": "./dist/commands", + "commands": { + "strategy": "pattern", + "target": "./dist/commands", + "globPatterns": [ + "**/*.+(js|cjs|mjs|ts|tsx|mts|cts)", + "!**/*.+(d.ts|test.ts|test.js|spec.ts|spec.js|d.mts|d.cts)?(x)", + "!**/lib/**" + ] + }, "plugins": [ "@oclif/plugin-help" ], diff --git a/src/cli/base/adapty/adapty-command.ts b/src/cli/base/adapty/adapty-command.ts new file mode 100644 index 0000000..7936320 --- /dev/null +++ b/src/cli/base/adapty/adapty-command.ts @@ -0,0 +1,62 @@ +import { AuthRequiredError } from '../../../sdk/core/errors.js'; +import { BaseCommand } from '../base-command.js'; + +import { build } from './build.js'; +import { openSession } from './openSession.js'; + +import type { ResolvedSession } from './openSession.js'; +import type { Adapty } from '../../../sdk/adapty/index.js'; + +export type AuthenticatedSession = ResolvedSession & { token: string }; + +/** + * Commands that require a token; auth commands other than `whoami` also work without one. + * + * "Needs authorization" becomes a fact of the type system, written in what the command extends + * instead of checked inside run(). One lazy class would put a "what if we are not logged in" + * branch into all 75 commands, and one of them would forget it. + */ +export abstract class AdaptyCommand extends BaseCommand { + // Keep one own static so oclif's manifest cache walks through this intermediate class and + // includes statics inherited from BaseCommand. + static override enableJsonFlag = true; + + #adapty: Adapty | undefined; + #resolved: ResolvedSession | undefined; + + protected get adapty(): Adapty { + this.#adapty ??= build(this.session, { + config: this.config, + signal: this.signal, + warn: message => this.warn(message), + }); + + return this.#adapty; + } + + /** Narrowed once here, so nothing downstream re-checks the token. */ + protected get session(): AuthenticatedSession { + const resolved = this.#resolved; + + if (resolved === undefined) { + // A bug, not a user error, and deliberately not an SdkError: it lands as exit 1 with a + // stack instead of passing for a normal scenario. + throw new Error('session is available only after init()'); + } + + if (resolved.token === undefined) { + throw new AuthRequiredError('missing'); + } + + return { ...resolved, token: resolved.token }; + } + + protected override async init(): Promise { + await super.init(); + // The session is read here, the sdk built lazily from run(), because this.parse() runs + // after init(). Demanding the token here would answer `apps list --pge 2` with "Not + // authenticated" instead of "Nonexistent flag": input errors come before state errors. + // Commands also validate business rules before accessing this.adapty or this.session. + this.#resolved = await openSession(this.config); + } +} diff --git a/src/cli/base/adapty/build.ts b/src/cli/base/adapty/build.ts new file mode 100644 index 0000000..e6b6ca4 --- /dev/null +++ b/src/cli/base/adapty/build.ts @@ -0,0 +1,24 @@ +import { buildUserAgent } from '../../../lib/client-from-config.js'; +import { createAdapty } from '../../../sdk/adapty/index.js'; + +import type { ResolvedSession } from './openSession.js'; +import type { Adapty } from '../../../sdk/adapty/index.js'; +import type { Config } from '@oclif/core'; + +type CommandContext = { + config: Config; + signal: AbortSignal; + warn: (message: string) => void; +}; + +/** 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, + onRetry: ({ attempt, delayMs }) => { + context.warn(`Request failed, retrying in ${delayMs / 1000}s (attempt ${attempt + 1})`); + }, + signal: context.signal, + token: session.token, + // Both CLI stacks use the same User-Agent while migration is in progress. + userAgent: buildUserAgent(context.config), +}); diff --git a/src/cli/base/adapty/index.ts b/src/cli/base/adapty/index.ts new file mode 100644 index 0000000..1ba1a38 --- /dev/null +++ b/src/cli/base/adapty/index.ts @@ -0,0 +1,6 @@ +export { AdaptyCommand } from './adapty-command.js'; +export { build } from './build.js'; +export { openSession } from './openSession.js'; + +export type { AuthenticatedSession } from './adapty-command.js'; +export type { ResolvedSession } from './openSession.js'; diff --git a/src/cli/base/adapty/openSession.ts b/src/cli/base/adapty/openSession.ts new file mode 100644 index 0000000..1d5cbb8 --- /dev/null +++ b/src/cli/base/adapty/openSession.ts @@ -0,0 +1,54 @@ +import { DEFAULT_ADAPTY_API_URL } from '../../../sdk/adapty/index.js'; +import { createFileSessionStore } from '../../../sdk/core/session.js'; + +import type { SessionStore, SessionUser } from '../../../sdk/core/session.js'; +import type { Config } from '@oclif/core'; + +/** + * The CLI's world resolved into what the sdk asks for: where to talk, as whom, and where the + * session lives. Env vars are read here and only here — the sdk never touches process.env, which + * is what lets the same product code run under an MCP server. + */ +export type ResolvedSession = { + apiUrl: string; + /** + * Where the token came from. With ADAPTY_TOKEN in play, "no token in the file" and "not + * authenticated" are different answers, and `auth status` has to say which one it means. + */ + source: 'env' | 'file' | 'none'; + /** The store itself, not its path: `auth login`/`logout` write through it. */ + store: SessionStore; + token?: string | undefined; + user?: SessionUser | undefined; +}; + +/** ADAPTY_TOKEN wins over the stored session, as the published CLI already does. */ +export const resolveSession = async (config: Config): Promise => { + const apiUrl = process.env.ADAPTY_API_URL ?? DEFAULT_ADAPTY_API_URL; + // oclif's configDir already knows XDG on unix and LOCALAPPDATA on Windows + const store = createFileSessionStore(config.configDir); + const envToken = process.env.ADAPTY_TOKEN; + + if (envToken !== undefined && envToken !== '') { + return { apiUrl, source: 'env', store, token: envToken }; + } + + const stored = await store.load(); + + if (stored === undefined) { + return { apiUrl, source: 'none', store }; + } + + return { apiUrl, source: 'file', store, token: stored.token, user: stored.user }; +}; + +export const openSession = async (config: Config): Promise => { + const session = await resolveSession(config); + + if (session.apiUrl !== DEFAULT_ADAPTY_API_URL) { + // oclif's warn() is silent under --json; the destination warning must remain visible. + process.stderr.write(`Warning: Using non-default API URL: ${session.apiUrl}\n`); + } + + return session; +}; diff --git a/src/cli/base/base-command.ts b/src/cli/base/base-command.ts new file mode 100644 index 0000000..87e43f4 --- /dev/null +++ b/src/cli/base/base-command.ts @@ -0,0 +1,79 @@ +import { Command } from '@oclif/core'; + +import { CliError, toCliError } from '../errors.js'; + +import type { ErrorJson } from '../errors.js'; + +/** + * 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. + * + * Whatever lives here is paid for by 75 commands, so what stays out matters as much: flags stay + * composable objects, views stay plain functions, and one command's needs stay in that command. + */ +export abstract class BaseCommand extends Command { + /** --json is a property of the CLI, not a per-command opt-in; `auth login` opts back out. */ + static override enableJsonFlag = true; + + readonly #abort = new AbortController(); + + // A field, not an inline closure: removeListener needs this very reference, or listeners pile + // up whenever several commands run in one process — which tests do. + readonly #onSigint = (): void => { + this.#abort.abort(); + }; + + protected get signal(): AbortSignal { + return this.#abort.signal; + } + + protected override async catch(error: Error): Promise { + return super.catch(toCliError(error)); + } + + protected override toErrorJson(error: unknown): { error: ErrorJson } { + if (error instanceof CliError) { + return { error: error.json }; + } + + // Error.message is non-enumerable; serializing the Error itself drops the explanation. + // Only expose public fields, not oclif's parser context or a command's internal state. + const cause = error instanceof Error ? error : new Error(String(error)); + + return { + error: { + code: 'code' in cause && typeof cause.code === 'string' ? cause.code : undefined, + message: cause.message, + }, + }; + } + + protected override async finally(error: Error | undefined): Promise { + process.removeListener('SIGINT', this.#onSigint); + + return super.finally(error); + } + + protected override async init(): Promise { + await super.init(); + // Abort instead of exit: the command unwinds, the sdk raises CancelledError and + // cli/errors.ts turns it into exit 130 — exiting from the handler is how the published + // `auth login` ends a cancelled login with exit 0. `once` leaves a second Ctrl+C to the + // runtime, so an unresponsive command can still be killed the usual way. + process.once('SIGINT', this.#onSigint); + } + + /** + * run() returns the data and render() prints it, so run()'s return type *is* the --json + * contract, checked by the compiler: change a response shape in the sdk and the command stops + * compiling instead of quietly changing what users parse. + * + * The guard is not what keeps --json clean — oclif's `log` is already silent then. It keeps the + * view from being built at all, so a human view may cost whatever it needs. + */ + protected render(value: T, view: (value: T) => string): void { + if (!this.jsonEnabled()) { + this.log(view(value)); + } + } +} diff --git a/src/cli/commands/apps/create.ts b/src/cli/commands/apps/create.ts new file mode 100644 index 0000000..eb228dc --- /dev/null +++ b/src/cli/commands/apps/create.ts @@ -0,0 +1,83 @@ +import { Flags } from '@oclif/core'; + +import { validateCreateApp } from '../../../sdk/adapty/apps/index.js'; +import { platforms } from '../../../sdk/adapty/index.js'; +import { CancelledError } from '../../../sdk/core/errors.js'; +import { assertValid } from '../../../sdk/core/validation.js'; +import { AdaptyCommand } from '../../base/adapty/index.js'; +import { renderRecord } from '../../views/record.js'; + +import type { AppSummary, CreateAppInput } from '../../../sdk/adapty/index.js'; + +export default class AppsCreate extends AdaptyCommand { + static override description = 'Create a new Adapty app'; + + static override examples = [ + '<%= config.bin %> apps create --title "My App" --platform ios --apple-bundle-id com.example.app', + '<%= config.bin %> apps create --title "My App" --platform ios --platform android --apple-bundle-id com.example.app --google-bundle-id com.example.app', + ]; + + // Input shape here, the rule (a bundle id per platform) in sdk/adapty/apps/create.ts — where a + // future MCP server obeys it too. + static override flags = { + 'title': Flags.string({ description: 'App title', required: true }), + 'platform': Flags.option({ + description: 'Platform (ios, android). Repeat for multiple.', + multiple: true, + options: platforms, + required: true, + })(), + 'apple-bundle-id': Flags.string({ description: 'Apple bundle ID (required with --platform ios)' }), + 'google-bundle-id': Flags.string({ description: 'Google bundle ID (required with --platform android)' }), + }; + + async run(): Promise { + const { flags } = await this.parse(AppsCreate); + + const input: CreateAppInput = { + appleBundleId: flags['apple-bundle-id'], + googleBundleId: flags['google-bundle-id'], + platforms: flags.platform, + title: flags.title, + }; + + assertValid(validateCreateApp(input)); + + const app = await this.adapty.apps.create(input); + + this.log('App created!'); + this.render(app, renderRecord); + await this.showDefaultAccessLevel(app.id); + + return app; + } + + /** + * A courtesy for a human reader: the server creates a default access level with the app. Under + * --json the result is the app itself, so the request is skipped — and a failure to read it + * never fails the create, which has already happened. + */ + private async showDefaultAccessLevel(appId: string): Promise { + if (this.jsonEnabled()) { + return; + } + + try { + const { items } = await this.adapty.accessLevels.list(appId); + const [first] = items ?? []; + + if (first) { + this.log('\nDefault access level:'); + this.log(renderRecord(first)); + } + } catch (error) { + // Ctrl+C is not a failed request: swallowing it would warn and carry on as if the + // user had not asked to stop. + if (error instanceof CancelledError) { + throw error; + } + + this.warn('Could not fetch access levels for new app'); + } + } +} diff --git a/src/cli/commands/apps/get.ts b/src/cli/commands/apps/get.ts new file mode 100644 index 0000000..997f494 --- /dev/null +++ b/src/cli/commands/apps/get.ts @@ -0,0 +1,20 @@ +import { AdaptyCommand } from '../../base/adapty/index.js'; +import { appIdArg } from '../../flags.js'; +import { renderRecord } from '../../views/record.js'; + +import type { AppDetail } from '../../../sdk/adapty/index.js'; + +export default class AppsGet extends AdaptyCommand { + static override args = { ...appIdArg }; + static override description = 'Get app details'; + static override examples = ['<%= config.bin %> apps get 550e8400-e29b-41d4-a716-446655440000']; + + async run(): Promise { + const { args } = await this.parse(AppsGet); + const app = await this.adapty.apps.get(args.app_id); + + this.render(app, renderRecord); + + return app; + } +} diff --git a/src/cli/commands/apps/list.ts b/src/cli/commands/apps/list.ts new file mode 100644 index 0000000..f91df8b --- /dev/null +++ b/src/cli/commands/apps/list.ts @@ -0,0 +1,20 @@ +import { AdaptyCommand } from '../../base/adapty/index.js'; +import { pageParams, paginationFlags } from '../../flags.js'; +import { renderPage } from '../../views/list.js'; + +import type { AppSummary, Paginated } from '../../../sdk/adapty/index.js'; + +export default class AppsList extends AdaptyCommand { + static override description = 'List Adapty apps'; + static override examples = ['<%= config.bin %> apps list', '<%= config.bin %> apps list --page 2 --page-size 10']; + static override flags = { ...paginationFlags }; + + async run(): Promise> { + const { flags } = await this.parse(AppsList); + const page = await this.adapty.apps.list(pageParams(flags)); + + this.render(page, renderPage); + + return page; + } +} diff --git a/src/cli/commands/apps/update.ts b/src/cli/commands/apps/update.ts new file mode 100644 index 0000000..ee1fa8e --- /dev/null +++ b/src/cli/commands/apps/update.ts @@ -0,0 +1,41 @@ +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 { renderRecord } from '../../views/record.js'; + +import type { AppDetail, UpdateAppInput } from '../../../sdk/adapty/index.js'; + +export default class AppsUpdate extends AdaptyCommand { + static override args = { ...appIdArg }; + static override description = 'Update an app'; + static override examples = ['<%= config.bin %> apps update 550e8400-... --title "My App"']; + + // The "at least one field" rule lives in the sdk and is checked before requiring a token. + static override flags = { + 'title': Flags.string({ description: 'App title' }), + 'apple-bundle-id': Flags.string({ description: 'Apple bundle ID' }), + 'google-bundle-id': Flags.string({ description: 'Google bundle ID' }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(AppsUpdate); + + const input: UpdateAppInput = { + appleBundleId: flags['apple-bundle-id'], + googleBundleId: flags['google-bundle-id'], + title: flags.title, + }; + + assertValid(validateUpdateApp(input)); + + const app = await this.adapty.apps.update(args.app_id, input); + + this.log('App updated!'); + this.render(app, renderRecord); + + return app; + } +} diff --git a/src/cli/commands/auth/login.ts b/src/cli/commands/auth/login.ts new file mode 100644 index 0000000..04dd4ee --- /dev/null +++ b/src/cli/commands/auth/login.ts @@ -0,0 +1,84 @@ +import open from 'open'; + +import { onAppHost } from '../../../lib/app-url.js'; +import { runDeviceFlow } from '../../../sdk/core/auth/device-flow.js'; +import { systemClock } from '../../../sdk/core/clock.js'; +import { build, openSession } from '../../base/adapty/index.js'; +import { BaseCommand } from '../../base/base-command.js'; +import { exitCode } from '../../errors.js'; + +export default class AuthLogin extends BaseCommand { + static override description = 'Authenticate with Adapty via browser'; + /** The one command that opts out: an interactive flow has no result to serialize. */ + static override enableJsonFlag = false; + static override examples = ['<%= config.bin %> auth login']; + + async run(): Promise { + await this.parse(AuthLogin); + + const session = await openSession(this.config); + + if (session.user !== undefined) { + this.log(`Already authenticated as ${session.user.email}. Re-authenticating...`); + } + + // No token yet: the same factory without one, which leaves only auth reachable + const adapty = build({ ...session, token: undefined }, { + config: this.config, + signal: this.signal, + warn: message => this.warn(message), + }); + + const issued = await runDeviceFlow(adapty.auth, { + clock: systemClock, + + onCode: (code) => { + // The fallback comes before the host rewrite, so the printed link and the browser + // always agree + const link = this.dashboardLink(code.verificationUriComplete ?? code.verificationUri); + + this.log(`\nYour code: ${code.userCode}\n`); + this.log(`If the browser doesn't open, visit: ${link}\n`); + this.openBrowser(link); + this.log('Waiting for authorization... (Ctrl+C to cancel)'); + }, + + onTransientError: (error, consecutive) => { + // One failed poll is normal and stays quiet; three in a row is worth saying, + // because the screen otherwise looks stuck + if (consecutive >= 3) { + const reason = error instanceof Error ? error.message : String(error); + + this.warn(`${consecutive} consecutive errors while polling: ${reason}`); + } + }, + + signal: this.signal, + }); + + await session.store.save({ token: issued.accessToken, user: issued.user }); + + this.log(`\nAuthenticated as ${issued.user.email}`); + this.log(`Session saved to ${session.store.path}`); + } + + /** ADAPTY_APP_URL is the CLI's business: the sdk knows nothing about browsers or dashboards. */ + private dashboardLink(issuedUrl: string): string { + try { + return onAppHost(issuedUrl); + } catch (error) { + // A malformed ADAPTY_APP_URL is bad input, not a failed login + this.error(error instanceof Error ? error.message : String(error), { exit: exitCode.usage }); + } + } + + private openBrowser(url: string): void { + if (!process.stdin.isTTY) { + return; + } + + // onCode is synchronous, so the launch is fired and forgotten: the link is already on + // screen, and a browser that refuses to start must not fail the login + void open(url).catch(() => undefined); + } +} diff --git a/src/cli/commands/auth/logout.ts b/src/cli/commands/auth/logout.ts new file mode 100644 index 0000000..7526197 --- /dev/null +++ b/src/cli/commands/auth/logout.ts @@ -0,0 +1,44 @@ +import { openSession } from '../../base/adapty/index.js'; +import { BaseCommand } from '../../base/base-command.js'; + +type Result = { + /** The env var outlives the file, so "Logged out" alone would be a lie. */ + env_token_set: boolean; + status: 'logged_out' | 'not_authenticated'; +}; + +/** A record rather than an if: a new status cannot be added without a text for it. */ +const messages: Record = { + logged_out: 'Logged out. Note: the token stays valid server-side until it expires — see `adapty auth revoke`.', + not_authenticated: 'Not currently authenticated.', +}; + +const ENV_STILL_SET + = 'ADAPTY_TOKEN is still set in the environment, so commands stay authenticated. Unset it to finish logging out.'; + +export default class AuthLogout extends BaseCommand { + static override description = 'Remove the stored session'; + static override examples = ['<%= config.bin %> auth logout']; + + async run(): Promise { + await this.parse(AuthLogout); + + const session = await openSession(this.config); + const stored = await session.store.load(); + + if (stored !== undefined) { + await session.store.clear(); + } + + const result: Result = { + env_token_set: session.source === 'env', + status: stored === undefined ? 'not_authenticated' : 'logged_out', + }; + + this.render(result, status => (status.env_token_set + ? `${messages[status.status]}\n${ENV_STILL_SET}` + : messages[status.status])); + + return result; + } +} diff --git a/src/cli/commands/auth/revoke.ts b/src/cli/commands/auth/revoke.ts new file mode 100644 index 0000000..5834322 --- /dev/null +++ b/src/cli/commands/auth/revoke.ts @@ -0,0 +1,48 @@ +import { build, openSession } from '../../base/adapty/index.js'; +import { BaseCommand } from '../../base/base-command.js'; + +type Result + = | { status: 'not_authenticated' } + | { env_token_set: boolean; status: 'revoked' }; + +export default class AuthRevoke extends BaseCommand { + static override description = 'Revoke the current token on the server and remove any matching stored session'; + static override examples = ['<%= config.bin %> auth revoke']; + + async run(): Promise { + await this.parse(AuthRevoke); + + const session = await openSession(this.config); + + if (session.token === undefined) { + this.log('Not currently authenticated.'); + + return { status: 'not_authenticated' }; + } + + // Server first, file second. The other order, on a failed request, leaves a live token on + // the server and no local copy to revoke it with — only the dashboard could undo that. + const adapty = build(session, { + config: this.config, + signal: this.signal, + warn: message => this.warn(message), + }); + + await adapty.auth.revokeToken(session.token); + + // ADAPTY_TOKEN may override a different, still-valid session in the file. + const stored = await session.store.load(); + + if (stored?.token === session.token) { + await session.store.clear(); + } + + const result: Result = { env_token_set: session.source === 'env', status: 'revoked' }; + + this.render(result, status => (status.env_token_set + ? 'Token revoked. ADAPTY_TOKEN is still set and now holds a dead token — unset it.' + : 'Token revoked and logged out.')); + + return result; + } +} diff --git a/src/cli/commands/auth/status/index.ts b/src/cli/commands/auth/status/index.ts new file mode 100644 index 0000000..041ab8f --- /dev/null +++ b/src/cli/commands/auth/status/index.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/lib/render.ts b/src/cli/commands/auth/status/lib/render.ts new file mode 100644 index 0000000..c2764fc --- /dev/null +++ b/src/cli/commands/auth/status/lib/render.ts @@ -0,0 +1,20 @@ +import type { Result } from './result.js'; + +/** Private to `auth status`: the moment a second command needs this, it belongs in cli/views. */ +export const renderStatus = (status: Result): string => { + if (!status.authenticated) { + return 'Not authenticated. Run `adapty auth login`.'; + } + + const lines: string[] = []; + + if (status.email !== undefined) { + lines.push(`Email: ${status.email}`); + } + + // A prefix, never the token: this output ends up in logs, screenshots and CI transcripts + lines.push(`Token: ${status.token_prefix}****`); + lines.push(status.source === 'env' ? 'Source: ADAPTY_TOKEN (environment)' : `Config: ${status.config_path}`); + + return lines.join('\n'); +}; diff --git a/src/cli/commands/auth/status/lib/result.ts b/src/cli/commands/auth/status/lib/result.ts new file mode 100644 index 0000000..d253795 --- /dev/null +++ b/src/cli/commands/auth/status/lib/result.ts @@ -0,0 +1,13 @@ +/** + * What `auth status` returns, and what its view renders. It sits in lib/ so that neither imports + * the other, and because a sibling of index.ts would be picked up as the command `auth:status:*`. + */ +export type Result + = | { authenticated: false; config_path: string; source: 'none' } + | { + authenticated: true; + config_path: string; + email: string | undefined; + source: 'env' | 'file'; + token_prefix: string; + }; diff --git a/src/cli/commands/auth/whoami.ts b/src/cli/commands/auth/whoami.ts new file mode 100644 index 0000000..cc1e25f --- /dev/null +++ b/src/cli/commands/auth/whoami.ts @@ -0,0 +1,21 @@ +import { AdaptyCommand } from '../../base/adapty/index.js'; +import { renderRecord } from '../../views/record.js'; + +/** + * The pair to `auth status`: that one answers "what is stored", this one "does it still work". The + * answer is passed through untyped, so a new server field shows up in the output on its own. + */ +export default class AuthWhoami extends AdaptyCommand { + static override description = 'Show the current user from the server (verifies the token)'; + static override examples = ['<%= config.bin %> auth whoami']; + + async run(): Promise> { + await this.parse(AuthWhoami); + + const me = await this.adapty.auth.me(); + + this.render(me, renderRecord); + + return me; + } +} diff --git a/src/cli/errors.ts b/src/cli/errors.ts new file mode 100644 index 0000000..da0887f --- /dev/null +++ b/src/cli/errors.ts @@ -0,0 +1,131 @@ +import { Errors } from '@oclif/core'; + +import { isSdkError } from '../sdk/core/errors.js'; + +import type { Issue } from '../sdk/core/errors.js'; + +/** + * Exit codes are the adapter's business: sdk errors carry none. + * 2 usage — the input is wrong (oclif's own parse errors exit 2 as well) + * 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 + * 130 cancelled — Ctrl+C, by the shell convention 128 + SIGINT + */ +export const exitCode = { + api: 4, + auth: 3, + cancelled: 130, + 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 = { + code?: string | undefined; + error_code?: string | undefined; + errors?: unknown; + message: string; + status?: number | undefined; + status_code?: number | undefined; +}; + +/** + * oclif takes the exit code from two places: `handle()` reads `oclif.exit`, Command.catch under + * --json reads `exitCode` and never rethrows. Set one and the other mode exits 1. + */ +export class CliError extends Errors.CLIError { + readonly exitCode: number; + readonly json: ErrorJson; + + constructor(message: string, exit: number, code?: string, data: Partial = {}) { + super(message, { exit }); + this.exitCode = exit; + this.code = code; + this.json = { message, code, ...data }; + } +} + +const cliError = (message: string, exit: number, code?: string, data?: Partial): Error => + new CliError(message, exit, code, data); + +/** Issue.path is a camelCase sdk field; the user typed a kebab-case flag. */ +const flagName = (path: string): string => `--${path.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`)}`; + +const describeIssue = (issue: Issue): string => + (issue.path === undefined ? issue.message : `${flagName(issue.path)}: ${issue.message}`); + +/** + * The one place where an sdk error gets a human text and an exit code. No default in the switch: + * a new kind fails to compile until it is mapped here. + */ +export const toCliError = (error: unknown): Error => { + if (!isSdkError(error)) { + return error instanceof Error ? error : new Error(String(error)); + } + + switch (error.kind) { + case 'api': { + // `http_` is our own placeholder, not a code the user can look up + const code = error.code === undefined || error.code.startsWith('http_') ? undefined : error.code; + + const jsonCode = error.code ?? `http_${error.status}`; + + const fields = typeof error.details === 'object' && error.details !== null && 'errors' in error.details + ? error.details.errors + : undefined; + + return cliError(error.message, exitCode.api, code, { + code: jsonCode, + error_code: jsonCode, + errors: fields, + status: error.status, + status_code: error.status, + }); + } + + case 'auth_required': { + return cliError( + error.reason === 'missing' + ? 'Not authenticated. Run `adapty auth login`.' + : 'Token expired or invalid. Run `adapty auth login`.', + exitCode.auth, + undefined, + { code: error.kind, status: error.reason === 'rejected' ? 401 : undefined }, + ); + } + + case 'cancelled': { + return cliError('Cancelled.', exitCode.cancelled); + } + + case 'device_flow_denied': { + return cliError('Authorization was denied. Run `adapty auth login` to try again.', exitCode.auth); + } + + case 'device_flow_expired': { + return cliError('The code expired before authorization. Run `adapty auth login` to get a new one.', exitCode.auth); + } + + case 'network': { + return cliError(`Could not reach ${error.url}. Check the connection and try again.`, exitCode.network, undefined, { + code: 'network_error', + error_code: 'network_error', + errors: { connection: [error.cause instanceof Error ? error.cause.message : error.message] }, + status: 0, + status_code: 0, + }); + } + + case 'storage': { + return cliError(`${error.message}\nDelete the file and run \`adapty auth login\` again.`, exitCode.auth); + } + + case 'validation': { + // A header plus one indented line per issue: a rule reports them all at once + const lines = error.issues.map(issue => describeIssue(issue)); + + return cliError(['Invalid input:', ...lines].join('\n '), exitCode.usage); + } + } +}; diff --git a/src/cli/flags.ts b/src/cli/flags.ts new file mode 100644 index 0000000..f4bb7fa --- /dev/null +++ b/src/cli/flags.ts @@ -0,0 +1,37 @@ +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, + }), +}; + +/** 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/list.ts b/src/cli/views/list.ts new file mode 100644 index 0000000..afeea53 --- /dev/null +++ b/src/cli/views/list.ts @@ -0,0 +1,15 @@ +import { printList } from '../../lib/output.js'; + +import type { Paginated } from '../../sdk/adapty/index.js'; + +/** + * A page as the published CLI prints it: every item a labelled block, `---` between them, then a + * blank line and the pagination footer. Wraps src/lib the same way views/record.ts does. + */ +export const renderPage = (page: Paginated): string => { + const lines: string[] = []; + + printList(page.data as Record[], line => lines.push(line), page.meta.pagination); + + return lines.join('\n'); +}; diff --git a/src/cli/views/record.ts b/src/cli/views/record.ts new file mode 100644 index 0000000..9a09df4 --- /dev/null +++ b/src/cli/views/record.ts @@ -0,0 +1,17 @@ +import { printResponse } from '../../lib/output.js'; + +/** + * A server record as the published CLI prints it: snake_case keys as labels, nested objects + * indented, empty values dropped. + * + * The formatter still lives in src/lib and writes through a callback; this wraps it into the shape + * of a view (value in, string out), so commands do not depend on where it lives. The cast is that + * formatter's parameter type — it walks whatever it is given at runtime. + */ +export const renderRecord = (record: object): string => { + const lines: string[] = []; + + printResponse(record as Record, line => lines.push(line)); + + return lines.join('\n'); +}; diff --git a/src/commands/apps/create.ts b/src/commands/apps/create.ts index 722da09..11085ff 100644 --- a/src/commands/apps/create.ts +++ b/src/commands/apps/create.ts @@ -1,78 +1,2 @@ -import { Command, Flags } from '@oclif/core'; - -import { createAuthenticatedClient } from '../../lib/client-from-config.js'; -import { printResponse } from '../../lib/output.js'; - -import type { AccessLevelDTO, AppCreateRequestDTO, AppSummaryDTO } from '../../lib/api-schemas.js'; - -type AccessLevelsResponse = { - items?: AccessLevelDTO[]; -}; - -export default class AppsCreate extends Command { - static override description = 'Create a new Adapty app'; - static override enableJsonFlag = true; - static override examples = [ - '<%= config.bin %> apps create --title "My App" --platform ios --apple-bundle-id com.example.app', - '<%= config.bin %> apps create --title "My App" --platform ios --platform android --apple-bundle-id com.example.app --google-bundle-id com.example.app', - ]; - - static override flags = { - 'apple-bundle-id': Flags.string({ description: 'Apple bundle ID (required with --platform ios)' }), - 'google-bundle-id': Flags.string({ description: 'Google bundle ID (required with --platform android)' }), - 'platform': Flags.string({ - description: 'Platform (ios, android). Repeat for multiple.', - multiple: true, - options: ['ios', 'android'], - required: true, - }), - 'title': Flags.string({ description: 'App title', required: true }), - }; - - async run(): Promise { - const { flags } = await this.parse(AppsCreate); - - if (flags.platform.includes('ios') && !flags['apple-bundle-id']) { - this.error('--apple-bundle-id is required when --platform ios is specified', { exit: 2 }); - } - - if (flags.platform.includes('android') && !flags['google-bundle-id']) { - this.error('--google-bundle-id is required when --platform android is specified', { exit: 2 }); - } - - const client = await createAuthenticatedClient(this.config); - - const body: AppCreateRequestDTO & { platforms: string[] } = { - platforms: flags.platform, - title: flags.title, - }; - - if (flags['apple-bundle-id']) { - body.apple_bundle_id = flags['apple-bundle-id']; - } - - if (flags['google-bundle-id']) { - body.google_bundle_id = flags['google-bundle-id']; - } - - const result = await client.post('/apps', body); - - this.log('App created!'); - printResponse(result, this.log.bind(this)); - - try { - const accessLevels = await client.get(`/apps/${result.id}/access-levels`); - - const [defaultAccessLevel] = accessLevels.items ?? []; - - if (defaultAccessLevel) { - this.log('\nDefault access level:'); - printResponse(defaultAccessLevel, this.log.bind(this)); - } - } catch { - this.warn('Could not fetch access levels for new app'); - } - - return result; - } -} +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/apps/create.js'; diff --git a/src/commands/apps/get.ts b/src/commands/apps/get.ts index a775063..22d4a71 100644 --- a/src/commands/apps/get.ts +++ b/src/commands/apps/get.ts @@ -1,32 +1,2 @@ -import { Args, Command } from '@oclif/core'; - -import { createAuthenticatedClient } from '../../lib/client-from-config.js'; -import { isValidUuid } from '../../lib/flags.js'; -import { printResponse } from '../../lib/output.js'; - -import type { AppDetailDTO } from '../../lib/api-schemas.js'; - -export default class AppsGet extends Command { - static override args = { - app_id: Args.string({ description: 'App ID (UUID)', required: true }), - }; - - static override description = 'Get app details'; - static override enableJsonFlag = true; - static override examples = ['<%= config.bin %> apps get 550e8400-e29b-41d4-a716-446655440000']; - - async run(): Promise { - const { args } = await this.parse(AppsGet); - - if (!isValidUuid(args.app_id)) { - this.error('Invalid app ID format. Run `adapty apps list` to find your app ID.', { exit: 2 }); - } - - const client = await createAuthenticatedClient(this.config); - const result = await client.get(`/apps/${args.app_id}`); - - printResponse(result, this.log.bind(this)); - - return result; - } -} +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/apps/get.js'; diff --git a/src/commands/apps/list.ts b/src/commands/apps/list.ts index efcacd4..53f1d32 100644 --- a/src/commands/apps/list.ts +++ b/src/commands/apps/list.ts @@ -1,27 +1,2 @@ -import { Command } from '@oclif/core'; - -import { createAuthenticatedClient } from '../../lib/client-from-config.js'; -import { paginationFlags, paginationParams } from '../../lib/flags.js'; -import { printList } from '../../lib/output.js'; - -import type { AppSummaryDTO } from '../../lib/api-schemas.js'; -import type { PaginatedResponse } from '../../lib/flags.js'; - -export default class AppsList extends Command { - static override description = 'List Adapty apps'; - static override enableJsonFlag = true; - static override examples = ['<%= config.bin %> apps list', '<%= config.bin %> apps list --page 2 --page-size 10']; - static override flags = { - ...paginationFlags, - }; - - async run(): Promise> { - const { flags } = await this.parse(AppsList); - const client = await createAuthenticatedClient(this.config); - const result = await client.get>('/apps', paginationParams(flags)); - - printList(result.data, this.log.bind(this), result.meta.pagination); - - return result; - } -} +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/apps/list.js'; diff --git a/src/commands/apps/update.ts b/src/commands/apps/update.ts index 88bc47e..23de955 100644 --- a/src/commands/apps/update.ts +++ b/src/commands/apps/update.ts @@ -1,57 +1,2 @@ -import { Args, Command, Flags } from '@oclif/core'; - -import { createAuthenticatedClient } from '../../lib/client-from-config.js'; -import { isValidUuid } from '../../lib/flags.js'; -import { printResponse } from '../../lib/output.js'; - -import type { AppDetailDTO, AppUpdateRequestDTO } from '../../lib/api-schemas.js'; - -export default class AppsUpdate extends Command { - static override args = { - app_id: Args.string({ description: 'App ID (UUID)', required: true }), - }; - - static override description = 'Update an app'; - static override enableJsonFlag = true; - static override examples = ['<%= config.bin %> apps update 550e8400-... --title "My App"']; - static override flags = { - 'apple-bundle-id': Flags.string({ description: 'Apple bundle ID' }), - 'google-bundle-id': Flags.string({ description: 'Google bundle ID' }), - 'title': Flags.string({ description: 'App title' }), - }; - - async run(): Promise { - const { args, flags } = await this.parse(AppsUpdate); - - if (!isValidUuid(args.app_id)) { - this.error('Invalid app ID format. Run `adapty apps list` to find your app ID.', { exit: 2 }); - } - - if (!flags.title && !flags['apple-bundle-id'] && !flags['google-bundle-id']) { - this.error('At least one of --title, --apple-bundle-id, or --google-bundle-id is required', { exit: 2 }); - } - - const client = await createAuthenticatedClient(this.config); - - const body: AppUpdateRequestDTO = {}; - - if (flags.title) { - body.title = flags.title; - } - - if (flags['apple-bundle-id']) { - body.apple_bundle_id = flags['apple-bundle-id']; - } - - if (flags['google-bundle-id']) { - body.google_bundle_id = flags['google-bundle-id']; - } - - const result = await client.put(`/apps/${args.app_id}`, body); - - this.log('App updated!'); - printResponse(result, this.log.bind(this)); - - return result; - } -} +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/apps/update.js'; diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 38b5b8e..b7e1732 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -1,172 +1,2 @@ -import { Command } from '@oclif/core'; -import open from 'open'; - -import { ApiClient } from '../../lib/api-client.js'; -import { onAppHost } from '../../lib/app-url.js'; -import { buildUserAgent } from '../../lib/client-from-config.js'; -import { readConfig, writeConfig } from '../../lib/config.js'; -import { ApiError } from '../../lib/errors.js'; - -type DeviceResponse = { - device_code: string; - expires_in: number; - interval_seconds: number; - user_code: string; - verification_uri: string; - verification_uri_complete: string; -}; - -type TokenSuccessResponse = { - access_token: string; - expires_in: number; - token_type: string; - user: { email: string; name: string }; -}; - -type TokenErrorResponse = { - error: string; -}; - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -export default class AuthLogin extends Command { - static override description = 'Authenticate with Adapty via browser'; - static override examples = ['<%= config.bin %> auth login']; - - async run(): Promise { - await this.parse(AuthLogin); - const config = await readConfig(this.config.configDir); - - if (config.access_token && config.user) { - this.log(`Already authenticated as ${config.user.email}. Re-authenticating...`); - } - - const client = new ApiClient({ - userAgent: buildUserAgent(this.config), - }); - - let device: DeviceResponse; - - try { - device = await client.post('/auth/device', { client_id: 'adapty-cli' }); - } catch (error) { - this.error(error instanceof Error ? error.message : 'Failed to initiate auth flow', { exit: 1 }); - } - - const verificationUrl = this.verificationUrl(device.verification_uri_complete); - - this.log(`\nYour code: ${device.user_code}\n`); - this.log(`If browser doesn't open, visit: ${verificationUrl}\n`); - - if (process.stdin.isTTY) { - try { - await open(verificationUrl); - } catch { - // browser open failed silently — URL already printed - } - } - - this.log('Waiting for authorization...'); - - let interval = Math.max((device.interval_seconds || 5) * 1000, 5000); - const deadline = Date.now() + device.expires_in * 1000; - let consecutiveErrors = 0; - - // Handle Ctrl+C - const onSignal = (): void => { - this.log('\nLogin cancelled.'); - // eslint-disable-next-line n/no-process-exit -- FIXME if you see this - process.exit(0); - }; - - process.on('SIGINT', onSignal); - - try { - while (Date.now() < deadline) { - await sleep(interval); - - let result: TokenErrorResponse | TokenSuccessResponse; - - try { - result = await client.post('/auth/token', { - client_id: 'adapty-cli', - device_code: device.device_code, - grant_type: 'urn:ietf:params:oauth:grant-type:device_code', - }); - } catch (error) { - if (error instanceof ApiError) { - switch (error.errorCode) { - case 'authorization_pending': { - continue; - } - - case 'slow_down': { - interval += 5000; - continue; - } - - case 'access_denied': { - this.error('Authorization denied.', { exit: 1 }); - break; - } - - case 'expired_token': { - this.error('Code expired. Run `adapty auth login` again.', { exit: 1 }); - break; - } - - default: - // unexpected API error — fall through to network error handling - } - } - - consecutiveErrors++; - - if (consecutiveErrors >= 10) { - this.error('Too many consecutive network errors. Check your connection and try again.', { exit: 1 }); - } - - if (consecutiveErrors >= 3) { - process.stderr.write(`Warning: ${consecutiveErrors} consecutive network errors (${error instanceof Error ? error.message : 'unknown'})\n`); - } - - continue; - } - - consecutiveErrors = 0; - - if ('access_token' in result) { - await writeConfig( - { - access_token: result.access_token, - user: result.user, - }, - this.config.configDir, - ); - - this.log(`\nAuthenticated as ${result.user.email}`); - this.log(`Token saved to ${this.config.configDir}/config.json`); - - return; - } - } - - this.error('Code expired. Run `adapty auth login` again.', { exit: 1 }); - } finally { - process.removeListener('SIGINT', onSignal); - } - } - - /** Keeps the browser on the configured dashboard host, when ADAPTY_APP_URL asks for one. */ - private verificationUrl(issuedUrl: string): string { - try { - return onAppHost(issuedUrl); - } catch (error) { - this.error(error instanceof Error ? error.message : String(error), { exit: 2 }); - } - } -} +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/auth/login.js'; diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 4ecb26a..90cfdc9 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -1,25 +1,2 @@ -import { Command } from '@oclif/core'; - -import { readConfig, writeConfig } from '../../lib/config.js'; - -export default class AuthLogout extends Command { - static override description = 'Remove stored authentication token'; - static override enableJsonFlag = true; - static override examples = ['<%= config.bin %> auth logout']; - - async run(): Promise<{ status: string }> { - await this.parse(AuthLogout); - const config = await readConfig(this.config.configDir); - - if (!config.access_token) { - this.log('Not currently authenticated.'); - - return { status: 'not_authenticated' }; - } - - await writeConfig({}, this.config.configDir); - this.log('Logged out. Note: token remains valid server-side until expiry.'); - - return { status: 'logged_out' }; - } -} +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/auth/logout.js'; diff --git a/src/commands/auth/revoke.ts b/src/commands/auth/revoke.ts index 3960b32..258678c 100644 --- a/src/commands/auth/revoke.ts +++ b/src/commands/auth/revoke.ts @@ -1,29 +1,2 @@ -import { Command } from '@oclif/core'; - -import { createAuthenticatedClient } from '../../lib/client-from-config.js'; -import { readConfig, writeConfig } from '../../lib/config.js'; - -export default class AuthRevoke extends Command { - static override description = 'Revoke the current authentication token'; - static override enableJsonFlag = true; - static override examples = ['<%= config.bin %> auth revoke']; - - async run(): Promise<{ status: string }> { - await this.parse(AuthRevoke); - const config = await readConfig(this.config.configDir); - - if (!config.access_token) { - this.log('Not currently authenticated.'); - - return { status: 'not_authenticated' }; - } - - const client = await createAuthenticatedClient(this.config); - await client.post('/auth/tokens/revoke', { token: config.access_token }); - await writeConfig({}, this.config.configDir); - - this.log('Token revoked and logged out.'); - - return { status: 'revoked' }; - } -} +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/auth/revoke.js'; diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 1daae5c..23e9929 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -1,34 +1,2 @@ -import { Command } from '@oclif/core'; - -import { readConfig } from '../../lib/config.js'; - -export default class AuthStatus extends Command { - static override description = 'Show current authentication state (local only)'; - static override enableJsonFlag = true; - static override examples = ['<%= config.bin %> auth status']; - - async run(): Promise> { - await this.parse(AuthStatus); - const config = await readConfig(this.config.configDir); - - if (!config.access_token || !config.user) { - this.log('Not authenticated. Run `adapty auth login`.'); - - return { authenticated: false }; - } - - const masked = config.access_token.slice(0, 8) + '****'; - const configPath = `${this.config.configDir}/config.json`; - - this.log(`Email: ${config.user.email}`); - this.log(`Token: ${masked}`); - this.log(`Config: ${configPath}`); - - return { - authenticated: true, - config_path: configPath, - email: config.user.email, - token_prefix: config.access_token.slice(0, 8), - }; - } -} +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/auth/status/index.js'; diff --git a/src/commands/auth/whoami.ts b/src/commands/auth/whoami.ts index cc9bc81..2fa7cd9 100644 --- a/src/commands/auth/whoami.ts +++ b/src/commands/auth/whoami.ts @@ -1,20 +1,2 @@ -import { Command } from '@oclif/core'; - -import { createAuthenticatedClient } from '../../lib/client-from-config.js'; -import { printResponse } from '../../lib/output.js'; - -export default class AuthWhoami extends Command { - static override description = 'Show current user info from server (verifies token)'; - static override enableJsonFlag = true; - static override examples = ['<%= config.bin %> auth whoami']; - - async run(): Promise> { - await this.parse(AuthWhoami); - const client = await createAuthenticatedClient(this.config); - const me = await client.get>('/me'); - - printResponse(me, this.log.bind(this)); - - return me; - } -} +// Implementation lives in the new cli layer; oclif discovers commands only under src/commands. +export { default } from '../../cli/commands/auth/whoami.js'; diff --git a/src/sdk/adapty/access-levels.ts b/src/sdk/adapty/access-levels.ts new file mode 100644 index 0000000..a5a37a1 --- /dev/null +++ b/src/sdk/adapty/access-levels.ts @@ -0,0 +1,19 @@ +import type { Http } from '../core/http/index.js'; + +export type AccessLevel = { + id: string; + sdk_id: string; + title: null | string; +}; + +/** Not paginated: this endpoint answers with a bare `items` list, and may omit it entirely. */ +export type AccessLevelList = { + items?: AccessLevel[]; +}; + +/** Only the read `apps create` needs; the rest arrives with the access-levels commands. */ +export const accessLevels = (http: Http) => ({ + list: (appId: string) => http.get(`/apps/${appId}/access-levels`), +}); + +export type AccessLevelsApi = ReturnType; diff --git a/src/sdk/adapty/apps/create.ts b/src/sdk/adapty/apps/create.ts new file mode 100644 index 0000000..a6a808a --- /dev/null +++ b/src/sdk/adapty/apps/create.ts @@ -0,0 +1,62 @@ +import type { Platform } from './model.js'; +import type { Issue } from '../../core/errors.js'; + +export type CreateAppInput = { + appleBundleId?: string | undefined; + googleBundleId?: string | undefined; + platforms: readonly Platform[]; + title: string; +}; + +type CreateAppRequest = { + apple_bundle_id?: string; + google_bundle_id?: string; + platforms: Platform[]; + title: string; +}; + +/** + * The business rules of creating an app: a pure function returning a list of problems, knowing + * nothing about flags or the network. An Issue path names an input field (camelCase) and the + * adapter turns it into the flag the user typed (src/cli/errors.ts). + * + * A list instead of a throw, so a table test walks the rules without an environment and the user + * sees every problem at once. + */ +export const validateCreateApp = (input: CreateAppInput): Issue[] => { + const issues: Issue[] = []; + + if (input.title.trim() === '') { + issues.push({ message: 'must not be empty', path: 'title' }); + } + + if (input.platforms.length === 0) { + issues.push({ message: 'at least one platform is required', path: 'platform' }); + } + + // An empty string counts as missing: `--apple-bundle-id ""` is not a bundle id + if (input.platforms.includes('ios') && (input.appleBundleId ?? '') === '') { + issues.push({ message: 'required when platforms include ios', path: 'appleBundleId' }); + } + + if (input.platforms.includes('android') && (input.googleBundleId ?? '') === '') { + issues.push({ message: 'required when platforms include android', path: 'googleBundleId' }); + } + + return issues; +}; + +/** An absent field is left out of the body: the server tells "not given" from "set to empty". */ +export const toCreateRequest = (input: CreateAppInput): CreateAppRequest => { + const body: CreateAppRequest = { platforms: [...input.platforms], title: input.title }; + + if (input.appleBundleId !== undefined) { + body.apple_bundle_id = input.appleBundleId; + } + + if (input.googleBundleId !== undefined) { + body.google_bundle_id = input.googleBundleId; + } + + return body; +}; diff --git a/src/sdk/adapty/apps/index.ts b/src/sdk/adapty/apps/index.ts new file mode 100644 index 0000000..c75bd3d --- /dev/null +++ b/src/sdk/adapty/apps/index.ts @@ -0,0 +1,14 @@ +/** + * The door of the apps 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. Only what a + * consumer uses passes through — the request builders stay private to their operation. + */ +export { platforms } from './model.js'; +export { validateCreateApp } from './create.js'; +export { apps } from './resource.js'; +export { validateUpdateApp } from './update.js'; + +export type { AppDetail, AppSummary } from './model.js'; +export type { CreateAppInput } from './create.js'; +export type { AppsApi } from './resource.js'; +export type { UpdateAppInput } from './update.js'; diff --git a/src/sdk/adapty/apps/model.ts b/src/sdk/adapty/apps/model.ts new file mode 100644 index 0000000..6d204b0 --- /dev/null +++ b/src/sdk/adapty/apps/model.ts @@ -0,0 +1,27 @@ +/** + * What every operation of the resource shares: the entity as the server sends it, and the + * platform vocabulary. Answers pass through exactly as they arrive, in snake_case — that is what + * --json has printed since the first release. + * + * An input shape is not shared and does not belong here: it is camelCase, it is our API rather + * than the server's, and it lives in the file of the operation that takes it. + */ + +/** In the order the published help lists them, so `--platform` keeps reading `(ios|android)`. */ +export const platforms = ['ios', 'android'] as const; +export type Platform = (typeof platforms)[number]; + +export type AppSummary = { + id: string; + sdk_key: null | string; + title: string; +}; + +/** What get and update answer with: the summary plus the fields only the detail view carries. */ +export type AppDetail = AppSummary & { + apple_bundle_id: null | string; + google_bundle_id: null | string; + /** Not the Platform union: the server owns this list and may grow it without asking us. */ + platforms: string[]; + secret_key: null | string; +}; diff --git a/src/sdk/adapty/apps/resource.ts b/src/sdk/adapty/apps/resource.ts new file mode 100644 index 0000000..7775eb8 --- /dev/null +++ b/src/sdk/adapty/apps/resource.ts @@ -0,0 +1,37 @@ +import { assertValid } from '../../core/validation.js'; +import { pageQuery } from '../pagination.js'; + +import { toCreateRequest, validateCreateApp } from './create.js'; +import { toUpdateRequest, validateUpdateApp } from './update.js'; + +import type { CreateAppInput } from './create.js'; +import type { AppDetail, AppSummary } from './model.js'; +import type { UpdateAppInput } from './update.js'; +import type { Http } from '../../core/http/index.js'; +import type { PageParams, Paginated } from '../pagination.js'; + +/** + * Every path of the apps resource in one place, so the endpoints can be read as a list. What each + * operation needs of its own — input shape, rules, request body — lives in its own file. + * + * The validating methods are async on purpose — a broken rule then arrives as a rejected promise, + * like a network failure, and one await covers both. + */ +export const apps = (http: Http) => ({ + get: (appId: string) => http.get(`/apps/${appId}`), + list: (params?: PageParams) => http.get>('/apps', { query: pageQuery(params) }), + + create: async (input: CreateAppInput): Promise => { + assertValid(validateCreateApp(input)); + + return http.post('/apps', toCreateRequest(input)); + }, + + update: async (appId: string, input: UpdateAppInput): Promise => { + assertValid(validateUpdateApp(input)); + + return http.put(`/apps/${appId}`, toUpdateRequest(input)); + }, +}); + +export type AppsApi = ReturnType; diff --git a/src/sdk/adapty/apps/update.ts b/src/sdk/adapty/apps/update.ts new file mode 100644 index 0000000..b04a195 --- /dev/null +++ b/src/sdk/adapty/apps/update.ts @@ -0,0 +1,47 @@ +import type { Issue } from '../../core/errors.js'; + +export type UpdateAppInput = { + appleBundleId?: string | undefined; + googleBundleId?: string | undefined; + title?: string | undefined; +}; + +type UpdateAppRequest = { + apple_bundle_id?: string; + google_bundle_id?: string; + title?: string; +}; + +/** Same contract as the create rule: problems as a list, paths naming input fields. */ +export const validateUpdateApp = (input: UpdateAppInput): Issue[] => { + const fields = [input.title, input.appleBundleId, input.googleBundleId]; + + if (fields.every(value => value === undefined)) { + return [{ message: 'nothing to update: pass a title or a bundle id' }]; + } + + if (input.title?.trim() === '') { + return [{ message: 'must not be empty', path: 'title' }]; + } + + return []; +}; + +/** An absent field is left out of the body: the server tells "not given" from "set to empty". */ +export const toUpdateRequest = (input: UpdateAppInput): UpdateAppRequest => { + const body: UpdateAppRequest = {}; + + if (input.title !== undefined) { + body.title = input.title; + } + + if (input.appleBundleId !== undefined) { + body.apple_bundle_id = input.appleBundleId; + } + + if (input.googleBundleId !== undefined) { + body.google_bundle_id = input.googleBundleId; + } + + return body; +}; diff --git a/src/sdk/adapty/auth/device-code.ts b/src/sdk/adapty/auth/device-code.ts new file mode 100644 index 0000000..9e68dcb --- /dev/null +++ b/src/sdk/adapty/auth/device-code.ts @@ -0,0 +1,33 @@ +import { CLIENT_ID } from './model.js'; + +import type { DeviceCode } from '../../core/auth/device-flow.js'; +import type { Http } from '../../core/http/index.js'; + +type DeviceResponse = { + device_code: string; + expires_in: number; + interval_seconds?: number; + user_code: string; + verification_uri: string; + verification_uri_complete?: string; +}; + +/** + * Step one of the device flow: ask for the code the user will type. The path sits with the + * reading of the answer, because here they are one thing — what core's port receives is this + * server's response renamed, and the two cannot be understood apart. + */ +export const requestDeviceCode = async (http: Http): Promise => { + const response = await http.post('/auth/device', { client_id: CLIENT_ID }); + + return { + deviceCode: response.device_code, + expiresInSec: response.expires_in, + // optional in the protocol; the default is knowledge about this server, so it + // belongs here and not in the orchestration + intervalSec: response.interval_seconds ?? 5, + userCode: response.user_code, + verificationUri: response.verification_uri, + verificationUriComplete: response.verification_uri_complete, + }; +}; diff --git a/src/sdk/adapty/auth/index.ts b/src/sdk/adapty/auth/index.ts new file mode 100644 index 0000000..5647db3 --- /dev/null +++ b/src/sdk/adapty/auth/index.ts @@ -0,0 +1,8 @@ +/** + * The door of the auth resource: re-exports only, no code of its own. The two device flow steps + * are reached through the port `auth()` returns, not imported directly. + */ +export { auth } from './resource.js'; + +export type { AuthUser, IssuedToken } from './model.js'; +export type { AuthApi } from './resource.js'; diff --git a/src/sdk/adapty/auth/model.ts b/src/sdk/adapty/auth/model.ts new file mode 100644 index 0000000..025d9b4 --- /dev/null +++ b/src/sdk/adapty/auth/model.ts @@ -0,0 +1,18 @@ +/** + * What the auth operations share: the shapes a successful login yields, and the name this client + * gives itself. An input shape belongs to its operation, not here. + */ + +export type AuthUser = { + email: string; + name: string; +}; + +export type IssuedToken = { + accessToken: string; + expiresInSec: number; + user: AuthUser; +}; + +/** The device flow identifies the client rather than authenticating it: there is no secret. */ +export const CLIENT_ID = 'adapty-cli'; diff --git a/src/sdk/adapty/auth/poll-token.ts b/src/sdk/adapty/auth/poll-token.ts new file mode 100644 index 0000000..fc3fa7d --- /dev/null +++ b/src/sdk/adapty/auth/poll-token.ts @@ -0,0 +1,80 @@ +import { ApiError } from '../../core/errors.js'; + +import { CLIENT_ID } from './model.js'; + +import type { AuthUser, IssuedToken } from './model.js'; +import type { PollResult } from '../../core/auth/device-flow.js'; +import type { Http } from '../../core/http/index.js'; + +type TokenSuccessResponse = { + access_token: string; + expires_in: number; + token_type: string; + user: AuthUser; +}; + +type TokenResponse = TokenSuccessResponse | { error: string }; + +/** A 400 carrying one of these codes is a state of the protocol, not a failure. */ +const pollResultByCode: Record> = { + access_denied: { status: 'denied' }, + authorization_pending: { status: 'pending' }, + expired_token: { status: 'expired' }, + slow_down: { status: 'slow_down' }, +}; + +/** A protocol code as a state, or a real error when the code is not one of the protocol's. */ +const toPollResult = (code: string, body: unknown): PollResult => { + const result = pollResultByCode[code]; + + if (result === undefined) { + throw new ApiError({ + code, + details: body, + message: `Unexpected device flow response: ${code}`, + status: 200, + }); + } + + return result; +}; + +/** + * Step two, asked over and over until the user is done: the answer is a token, a state of the + * protocol, or a failure — and telling the three apart is the whole of this operation. + */ +export const pollToken = async (http: Http, deviceCode: string): Promise> => { + try { + const response = await http.post('/auth/token', { + client_id: CLIENT_ID, + device_code: deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }); + + // A protocol state normally arrives as a 400 and lands in the catch below, but the + // shipped CLI's types also anticipate a 200 whose body is { error: }, and + // reading only the thrown case would call a pending login authorized. + if (!('access_token' in response)) { + return toPollResult(response.error, response); + } + + return { + status: 'authorized', + token: { + accessToken: response.access_token, + expiresInSec: response.expires_in, + user: response.user, + }, + }; + } catch (error) { + if (error instanceof ApiError && error.code !== undefined) { + const result = pollResultByCode[error.code]; + + if (result) { + return result; + } + } + + throw error; + } +}; diff --git a/src/sdk/adapty/auth/resource.ts b/src/sdk/adapty/auth/resource.ts new file mode 100644 index 0000000..e24057f --- /dev/null +++ b/src/sdk/adapty/auth/resource.ts @@ -0,0 +1,29 @@ +import { requestDeviceCode } from './device-code.js'; +import { pollToken } from './poll-token.js'; + +import type { IssuedToken } from './model.js'; +import type { DeviceAuthApi } from '../../core/auth/device-flow.js'; +import type { Http } from '../../core/http/index.js'; + +/** + * The auth resource: core's DeviceAuthApi port filled in by the two device flow steps, plus the + * endpoints that only need a path. A missing or stale token surfaces as AuthRequiredError. + * + * Unlike apps, not every path is listed here: a device flow step is its path and the reading of + * its answer together, so each keeps its own. What is left in this file is the shape of the + * resource — which port it satisfies, and what else it answers. + */ +export const auth = (http: Http) => { + const deviceAuth: DeviceAuthApi = { + pollToken: (deviceCode: string) => pollToken(http, deviceCode), + requestDeviceCode: () => requestDeviceCode(http), + }; + + return { + ...deviceAuth, + me: () => http.get>('/me'), + revokeToken: (token: string) => http.post('/auth/tokens/revoke', { token }), + }; +}; + +export type AuthApi = ReturnType; diff --git a/src/sdk/adapty/errors.ts b/src/sdk/adapty/errors.ts new file mode 100644 index 0000000..9314333 --- /dev/null +++ b/src/sdk/adapty/errors.ts @@ -0,0 +1,54 @@ +import type { ErrorParser } from '../core/http/index.js'; + +type DeveloperErrorBody = { + error?: unknown; + error_code?: unknown; + errors?: unknown; +}; + +/** + * How this service words a rejection: `{ error_code, errors: { field: [message] } }`, sometimes + * just `{ error: 'code' }`. Ported from src/lib/errors.ts so a migrated command keeps saying + * "apple_bundle_id: already used" instead of "POST /apps failed with HTTP 400". + * + * Wired once in createAdapty: which shapes a service speaks is product knowledge, and ASA speaks + * another — which is why the transport takes the parser as a parameter. + */ +export const developerErrorParser: ErrorParser = (_status, body) => { + if (typeof body !== 'object' || body === null) { + return {}; + } + + const { error, error_code: errorCode, errors } = body as DeveloperErrorBody; + + if (typeof errorCode === 'string' && errorCode !== '') { + // The bare code as the fallback message: what the published CLI prints, and still better + // than an HTTP status when the server sends no field errors + return { code: errorCode, message: fieldMessages(errors) ?? errorCode }; + } + + if (typeof error === 'string' && error !== '') { + return { code: error, message: error }; + } + + return {}; +}; + +/** `non_field_errors` is the server's name for "about the request as a whole": printed bare. */ +const fieldMessages = (errors: unknown): string | undefined => { + if (typeof errors !== 'object' || errors === null) { + return undefined; + } + + const parts: string[] = []; + + for (const [field, messages] of Object.entries(errors as Record)) { + for (const message of Array.isArray(messages) ? (messages as unknown[]) : [messages]) { + if (typeof message === 'string') { + parts.push(field === 'non_field_errors' ? message : `${field}: ${message}`); + } + } + } + + return parts.length > 0 ? parts.join('; ') : undefined; +}; diff --git a/src/sdk/adapty/index.ts b/src/sdk/adapty/index.ts new file mode 100644 index 0000000..6e00691 --- /dev/null +++ b/src/sdk/adapty/index.ts @@ -0,0 +1,65 @@ +import { createHttp } from '../core/http/index.js'; + +import { accessLevels } from './access-levels.js'; +import { apps } from './apps/index.js'; +import { auth } from './auth/index.js'; +import { developerErrorParser } from './errors.js'; + +import type { AccessLevelsApi } from './access-levels.js'; +import type { AppsApi } from './apps/index.js'; +import type { AuthApi } from './auth/index.js'; +import type { Clock } from '../core/clock.js'; +import type { RetryAttempt } from '../core/http/index.js'; + +export { platforms } from './apps/index.js'; +export { developerErrorParser } from './errors.js'; +export type { AccessLevel, AccessLevelList, AccessLevelsApi } from './access-levels.js'; +export type { AppDetail, AppsApi, AppSummary, CreateAppInput, UpdateAppInput } from './apps/index.js'; +export type { AuthApi, AuthUser, IssuedToken } from './auth/index.js'; +export type { PageParams, Paginated, Pagination } from './pagination.js'; + +/** Exported because the adapter compares the resolved URL with it and warns when they differ. */ +export const DEFAULT_ADAPTY_API_URL = 'https://api-admin.adapty.io/api/v1/developer'; + +/** + * Narrower than HttpOptions on purpose: the transport seams (parseError, shouldRetry, the retry + * policy, trailingSlash) are product knowledge, and two adapters overriding them would read the + * same answers differently. What is left is what a consumer really owns. + */ +export type AdaptyOptions = { + baseUrl?: string | undefined; + clock?: Clock | undefined; + fetch?: typeof globalThis.fetch | undefined; + onRetry?: ((info: RetryAttempt) => void) | undefined; + signal?: AbortSignal | undefined; + /** Without a token only auth is usable — that is how login builds the sdk. */ + token?: string | undefined; + /** Comes from the adapter, so server logs can tell the CLI from an MCP server. */ + userAgent?: string | undefined; +}; + +export type Adapty = { + accessLevels: AccessLevelsApi; + apps: AppsApi; + auth: AuthApi; +}; + +/** The assembly point of the developer API: one transport, resources on top of it. */ +export const createAdapty = (options: AdaptyOptions = {}): Adapty => { + const http = createHttp({ + baseUrl: options.baseUrl ?? DEFAULT_ADAPTY_API_URL, + clock: options.clock, + fetch: options.fetch, + headers: options.userAgent === undefined ? undefined : { 'user-agent': options.userAgent }, + onRetry: options.onRetry, + parseError: developerErrorParser, + signal: options.signal, + token: options.token, + }); + + return { + accessLevels: accessLevels(http), + apps: apps(http), + auth: auth(http), + }; +}; diff --git a/src/sdk/adapty/pagination.ts b/src/sdk/adapty/pagination.ts new file mode 100644 index 0000000..aa53b06 --- /dev/null +++ b/src/sdk/adapty/pagination.ts @@ -0,0 +1,28 @@ +import type { QueryParams } from '../core/http/index.js'; + +/** + * The paginated answer, and the query that asks for a page. Product and not core: `meta.pagination` + * and the bracketed `page[...]` names are this API's convention, not the transport's business. ASA + * follows the same convention today, so sdk/asa can import this until the two actually diverge. + */ +export type Pagination = { + count: number; + page: number; + pages: number; +}; + +export type Paginated = { + data: T[]; + meta: { + pagination: Pagination; + }; +}; + +/** Page selection as we take it: camelCase in, the server's names on the wire. */ +export type PageParams = { + page?: number | undefined; + pageSize?: number | undefined; +}; + +export const pageQuery = (params: PageParams | undefined): QueryParams => + ({ 'page[number]': params?.page, 'page[size]': params?.pageSize }); diff --git a/src/sdk/core/auth/device-flow.ts b/src/sdk/core/auth/device-flow.ts new file mode 100644 index 0000000..94f0b1e --- /dev/null +++ b/src/sdk/core/auth/device-flow.ts @@ -0,0 +1,118 @@ +import { throwIfAborted } from '../clock.js'; +import { CancelledError, DeviceFlowDeniedError, DeviceFlowExpiredError } from '../errors.js'; + +import type { Clock } from '../clock.js'; + +export type DeviceCode = { + deviceCode: string; + expiresInSec: number; + intervalSec: number; + userCode: string; + verificationUri: string; + verificationUriComplete: string | undefined; +}; + +export type PollResult + = | { status: 'authorized'; token: TToken } + | { status: 'denied' } + | { status: 'expired' } + | { status: 'pending' } + | { status: 'slow_down' }; + +/** + * The device flow port (RFC 8628) — all the orchestration below knows. What sits inside a token + * (for Adapty, an access_token plus the user) is the port's business. + */ +export type DeviceAuthApi = { + pollToken(deviceCode: string): Promise>; + requestDeviceCode(): Promise; +}; + +/** RFC 8628, 3.5: on slow_down the interval grows by 5 seconds. */ +const SLOW_DOWN_STEP_MS = 5000; +const DEFAULT_MIN_INTERVAL_MS = 5000; +const DEFAULT_MAX_CONSECUTIVE_ERRORS = 10; + +export type DeviceFlowDeps = { + clock: Clock; + maxConsecutiveErrors?: number | undefined; + /** Do not poll faster than this, even if the server allows it. */ + minIntervalMs?: number | undefined; + /** Show the code and the link to the user. Sdk prints nothing itself. */ + onCode: (code: DeviceCode) => void; + /** One poll failed (network, odd answer). The flow goes on up to maxConsecutiveErrors. */ + onTransientError?: ((error: unknown, consecutive: number) => void) | undefined; + signal?: AbortSignal | undefined; +}; + +/** + * Device flow orchestration with every effect kept outside: the network behind the api port, time + * behind clock, cancellation behind signal. That is what makes expiry, slow_down, poll failures + * and Ctrl+C unit-testable. + */ +export const runDeviceFlow = async (api: DeviceAuthApi, deps: DeviceFlowDeps): Promise => { + const { clock, signal } = deps; + const maxConsecutiveErrors = deps.maxConsecutiveErrors ?? DEFAULT_MAX_CONSECUTIVE_ERRORS; + + throwIfAborted(signal); + + const code = await api.requestDeviceCode(); + + deps.onCode(code); + + const deadline = clock.now() + code.expiresInSec * 1000; + let intervalMs = Math.max(code.intervalSec * 1000, deps.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS); + let consecutiveErrors = 0; + + for (;;) { + if (clock.now() >= deadline) { + throw new DeviceFlowExpiredError(); + } + + await clock.sleep(intervalMs, signal); + throwIfAborted(signal); + + let result: PollResult; + + try { + result = await api.pollToken(code.deviceCode); + consecutiveErrors = 0; + } catch (error) { + if (error instanceof CancelledError) { + throw error; + } + + consecutiveErrors += 1; + + if (consecutiveErrors >= maxConsecutiveErrors) { + throw error; + } + + deps.onTransientError?.(error, consecutiveErrors); + continue; + } + + switch (result.status) { + case 'authorized': { + return result.token; + } + + case 'pending': { + break; + } + + case 'slow_down': { + intervalMs += SLOW_DOWN_STEP_MS; + break; + } + + case 'expired': { + throw new DeviceFlowExpiredError(); + } + + case 'denied': { + throw new DeviceFlowDeniedError(); + } + } + } +}; diff --git a/src/sdk/core/clock.ts b/src/sdk/core/clock.ts new file mode 100644 index 0000000..cdebe1b --- /dev/null +++ b/src/sdk/core/clock.ts @@ -0,0 +1,31 @@ +import { setTimeout as delay } from 'node:timers/promises'; + +import { CancelledError, isAbortError } from './errors.js'; + +/** Time as a dependency: device flow and retry take a Clock, so tests never wait for real. */ +export type Clock = { + now(): number; + sleep(ms: number, signal?: AbortSignal): Promise; +}; + +export const systemClock: Clock = { + now: () => Date.now(), + + sleep: async (ms, signal) => { + try { + await delay(ms, undefined, signal ? { signal } : {}); + } catch (error) { + if (isAbortError(error)) { + throw new CancelledError(); + } + + throw error; + } + }, +}; + +export const throwIfAborted = (signal: AbortSignal | undefined): void => { + if (signal?.aborted === true) { + throw new CancelledError(); + } +}; diff --git a/src/sdk/core/errors.ts b/src/sdk/core/errors.ts new file mode 100644 index 0000000..4104f46 --- /dev/null +++ b/src/sdk/core/errors.ts @@ -0,0 +1,149 @@ +/** + * Sdk errors carry no user-facing text and no exit code: the adapter assigns both + * (src/cli/errors.ts, and a future MCP server). + * + * Every error has a stable `kind`, and the adapter switches over it. An instanceof chain would + * let a new error class fall through to the fallback and become exit 1 with no readable text. + */ + +export type SdkErrorKind + = | 'api' + | 'auth_required' + | 'cancelled' + | 'device_flow_denied' + | 'device_flow_expired' + | 'network' + | 'storage' + | 'validation'; + +/** Abstract on purpose: `new SdkError(...)` would be an error without a kind, unrecognisable. */ +export abstract class SdkError extends Error { + abstract readonly kind: SdkErrorKind; + + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = new.target.name; + } +} + +/** No response at all: DNS, refused connection, a dropped socket. */ +export class NetworkError extends SdkError { + readonly kind = 'network'; + readonly url: string; + + constructor(url: string, cause: unknown) { + super(`Request to ${url} failed`, { cause }); + this.url = url; + } +} + +/** The server answered with an error. `code` and `details` follow the service's own format. */ +export class ApiError extends SdkError { + readonly kind = 'api'; + readonly code: string | undefined; + readonly details: unknown; + readonly retryAfterMs: number | undefined; + readonly status: number; + + constructor(init: { + code?: string | undefined; + details?: unknown; + message: string; + retryAfterMs?: number | undefined; + status: number; + }) { + super(init.message); + this.status = init.status; + this.code = init.code; + this.details = init.details; + this.retryAfterMs = init.retryAfterMs; + } +} + +/** missing: no token stored locally. rejected: the server answered 401. */ +export type AuthRequiredReason = 'missing' | 'rejected'; + +export class AuthRequiredError extends SdkError { + readonly kind = 'auth_required'; + readonly reason: AuthRequiredReason; + + constructor(reason: AuthRequiredReason) { + super(reason === 'missing' ? 'Not authenticated' : 'Token expired or invalid'); + this.reason = reason; + } +} + +export class CancelledError extends SdkError { + readonly kind = 'cancelled'; + + constructor() { + super('Operation cancelled'); + } +} + +export type Issue = { + message: string; + /** The input field the problem belongs to. No path: the input as a whole is wrong. */ + path?: string | undefined; +}; + +/** A business rule broken before any network call. */ +export class ValidationError extends SdkError { + readonly kind = 'validation'; + readonly issues: readonly Issue[]; + + constructor(issues: readonly Issue[]) { + super(issues.map(issue => (issue.path === undefined ? issue.message : `${issue.path}: ${issue.message}`)).join('; ')); + this.issues = issues; + } +} + +export class DeviceFlowExpiredError extends SdkError { + readonly kind = 'device_flow_expired'; + + constructor() { + super('Device code expired before authorization'); + } +} + +export class DeviceFlowDeniedError extends SdkError { + readonly kind = 'device_flow_denied'; + + constructor() { + super('Authorization denied'); + } +} + +/** The session file is there but unusable: corrupted JSON, or something else in its place. */ +export class StorageError extends SdkError { + readonly kind = 'storage'; + readonly path: string; + + constructor(message: string, path: string, options?: ErrorOptions) { + super(message, options); + this.path = path; + } +} + +/** + * Every concrete error: a switch over `kind` narrows to the class. instanceof on the abstract + * base gives the base, without .status, .url or .issues. + */ +export type AnySdkError + = | ApiError + | AuthRequiredError + | CancelledError + | DeviceFlowDeniedError + | DeviceFlowExpiredError + | NetworkError + | StorageError + | ValidationError; + +export const isSdkError = (error: unknown): error is AnySdkError => error instanceof SdkError; + +type Assert = T; + +/** Safety net: a kind whose class never made it into AnySdkError breaks the build here. */ +export type AllKindsCovered = Assert; + +export const isAbortError = (error: unknown): boolean => error instanceof Error && error.name === 'AbortError'; diff --git a/src/sdk/core/http/client.ts b/src/sdk/core/http/client.ts new file mode 100644 index 0000000..206b10f --- /dev/null +++ b/src/sdk/core/http/client.ts @@ -0,0 +1,203 @@ +import { systemClock } from '../clock.js'; +import { ApiError, AuthRequiredError, CancelledError, isAbortError, NetworkError } from '../errors.js'; + +import { defaultErrorParser, defaultShouldRetry } from './policies.js'; +import { defaultRetryPolicy, retry } from './retry.js'; +import { buildUrl } from './url.js'; + +import type { Clock } from '../clock.js'; +import type { ErrorParser, ShouldRetry } from './policies.js'; +import type { RetryAttempt, RetryPolicy } from './retry.js'; +import type { QueryParams } from './url.js'; + +export type HttpMethod = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT'; + +export type RequestOptions = { + /** A JSON-serializable value, or FormData for file uploads. */ + body?: unknown; + headers?: Record | undefined; + /** Retry on 429, 5xx and network errors. On by default for GET only. */ + idempotent?: boolean | undefined; + /** Response headers, e.g. an ETag for optimistic locking. */ + onResponse?: ((headers: Headers) => void) | undefined; + query?: QueryParams | undefined; + signal?: AbortSignal | undefined; +}; + +type BodylessOptions = Omit; + +export type Http = { + delete(path: string, options?: BodylessOptions): Promise; + get(path: string, options?: BodylessOptions): Promise; + patch(path: string, body?: unknown, options?: BodylessOptions): Promise; + post(path: string, body?: unknown, options?: BodylessOptions): Promise; + put(path: string, body?: unknown, options?: BodylessOptions): Promise; + request(method: HttpMethod, path: string, options?: RequestOptions): Promise; +}; + +export type HttpOptions = { + baseUrl: string; + clock?: Clock | undefined; + fetch?: typeof globalThis.fetch | undefined; + headers?: Record | undefined; + onRetry?: ((info: RetryAttempt) => void) | undefined; + parseError?: ErrorParser | undefined; + retry?: RetryPolicy | undefined; + /** When to retry an idempotent request. */ + shouldRetry?: ShouldRetry | undefined; + signal?: AbortSignal | undefined; + token?: string | undefined; + /** Trailing slash in paths (Django style). On by default. */ + trailingSlash?: boolean | undefined; +}; + +/** + * The transport: base URL, bearer token, JSON both ways, responses mapped to sdk errors, + * retry for idempotent requests. It knows nothing about resources or about the CLI. + */ +export const createHttp = (options: HttpOptions): Http => { + const fetchImpl = options.fetch ?? globalThis.fetch; + const clock = options.clock ?? systemClock; + const policy = options.retry ?? defaultRetryPolicy; + const shouldRetry = options.shouldRetry ?? defaultShouldRetry; + const parseError = options.parseError ?? defaultErrorParser; + const trailingSlash = options.trailingSlash ?? true; + + const send = async (method: HttpMethod, path: string, req: RequestOptions): Promise => { + const url = buildUrl(options.baseUrl, path, req.query, trailingSlash); + const signal = combineSignals(options.signal, req.signal); + const headers = new Headers({ accept: 'application/json', ...options.headers, ...req.headers }); + + if (options.token !== undefined) { + headers.set('authorization', `Bearer ${options.token}`); + } + + const init: RequestInit = { headers, method }; + + if (req.body instanceof FormData) { + init.body = req.body; + } else if (req.body !== undefined) { + headers.set('content-type', 'application/json'); + init.body = JSON.stringify(req.body); + } + + if (signal) { + init.signal = signal; + } + + let response: Response; + + try { + response = await fetchImpl(url, init); + } catch (error) { + if (signal?.aborted === true || isAbortError(error)) { + throw new CancelledError(); + } + + throw new NetworkError(url, error); + } + + req.onResponse?.(response.headers); + + // fetch resolves at the headers; reading the body can still fail or be cancelled. + // Keep the caller's onResponse callback outside this network-error conversion. + let payload: unknown; + + try { + payload = await readBody(response); + } catch (error) { + if (signal?.aborted === true || isAbortError(error)) { + throw new CancelledError(); + } + + throw new NetworkError(url, error); + } + + if (response.ok) { + return payload as T; + } + + if (response.status === 401) { + throw new AuthRequiredError('rejected'); + } + + const parsed = parseError(response.status, payload); + + throw new ApiError({ + code: parsed.code ?? `http_${response.status}`, + details: payload, + message: parsed.message ?? `${method} ${path} failed with HTTP ${response.status}`, + retryAfterMs: parseRetryAfter(response.headers.get('retry-after'), clock), + status: response.status, + }); + }; + + const request = (method: HttpMethod, path: string, req: RequestOptions = {}): Promise => { + const idempotent = req.idempotent ?? method === 'GET'; + + if (!idempotent) { + return send(method, path, req); + } + + return retry(() => send(method, path, req), { + clock, + onRetry: options.onRetry, + policy, + shouldRetry, + signal: combineSignals(options.signal, req.signal), + }); + }; + + return { + request, + delete: (path: string, req?: BodylessOptions) => request('DELETE', path, req), + get: (path: string, req?: BodylessOptions) => request('GET', path, req), + patch: (path: string, body?: unknown, req?: BodylessOptions) => request('PATCH', path, { ...req, body }), + post: (path: string, body?: unknown, req?: BodylessOptions) => request('POST', path, { ...req, body }), + put: (path: string, body?: unknown, req?: BodylessOptions) => request('PUT', path, { ...req, body }), + }; +}; + +const combineSignals = (...signals: (AbortSignal | undefined)[]): AbortSignal | undefined => { + const present = signals.filter((signal): signal is AbortSignal => signal !== undefined); + + if (present.length <= 1) { + return present[0]; + } + + return AbortSignal.any(present); +}; + +const readBody = async (response: Response): Promise => { + if (response.status === 204) { + return undefined; + } + + const text = await response.text(); + + if (text.length === 0) { + return undefined; + } + + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +}; + +const parseRetryAfter = (header: null | string, clock: Clock): number | undefined => { + if (header === null) { + return undefined; + } + + const seconds = Number(header); + + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000); + } + + const date = Date.parse(header); + + return Number.isNaN(date) ? undefined : Math.max(0, date - clock.now()); +}; diff --git a/src/sdk/core/http/index.ts b/src/sdk/core/http/index.ts new file mode 100644 index 0000000..aaaf203 --- /dev/null +++ b/src/sdk/core/http/index.ts @@ -0,0 +1,12 @@ +/** + * The transport as a module: one door out. An eslint zone fences off the internals (client, + * policies, retry, url), so they can be rearranged without touching products. + */ +export { createHttp } from './client.js'; +export type { Http, HttpMethod, HttpOptions, RequestOptions } from './client.js'; +export { defaultErrorParser, defaultShouldRetry } from './policies.js'; +export type { ErrorParser, ShouldRetry } from './policies.js'; +export { defaultRetryPolicy, retry } from './retry.js'; +export type { RetryAttempt, RetryDecision, RetryOptions, RetryPolicy } from './retry.js'; +// No pageQuery here: how a page is asked for is the product's convention — sdk/adapty/pagination.ts +export type { QueryParams, QueryValue } from './url.js'; diff --git a/src/sdk/core/http/policies.ts b/src/sdk/core/http/policies.ts new file mode 100644 index 0000000..8c2a530 --- /dev/null +++ b/src/sdk/core/http/policies.ts @@ -0,0 +1,48 @@ +import { ApiError, NetworkError } from '../errors.js'; + +import type { RetryDecision } from './retry.js'; + +/** How to pull code and message out of an error body. Developer API and ASA differ, so it is a parameter. */ +export type ErrorParser = (status: number, body: unknown) => { + code?: string | undefined; + message?: string | undefined; +}; + +export type ShouldRetry = (error: unknown, attempt: number) => RetryDecision; + +/** Default: network errors with backoff, 429 and 5xx after the delay the server asked for, if any. */ +export const defaultShouldRetry: ShouldRetry = (error) => { + if (error instanceof NetworkError) { + return {}; + } + + if (error instanceof ApiError && (error.status === 429 || error.status >= 500)) { + return { delayMs: error.retryAfterMs }; + } + + return false; +}; + +/** + * The default error format, tolerant of the three common body shapes: + * { error: 'code', error_description }, { error: { code, message } }, { code, message | detail }. + */ +export const defaultErrorParser: ErrorParser = (_status, body) => { + if (!isRecord(body)) { + return {}; + } + + if (typeof body.error === 'string') { + return { code: body.error, message: asString(body.error_description ?? body.message) }; + } + + if (isRecord(body.error)) { + return { code: asString(body.error.code), message: asString(body.error.message) }; + } + + return { code: asString(body.code), message: asString(body.message ?? body.detail) }; +}; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + +const asString = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined); diff --git a/src/sdk/core/http/retry.ts b/src/sdk/core/http/retry.ts new file mode 100644 index 0000000..c59f89c --- /dev/null +++ b/src/sdk/core/http/retry.ts @@ -0,0 +1,58 @@ +import { throwIfAborted } from '../clock.js'; + +import type { Clock } from '../clock.js'; + +export type RetryPolicy = { + /** Total attempts, the first one included. */ + attempts: number; + baseDelayMs: number; + maxDelayMs: number; +}; + +export const defaultRetryPolicy: RetryPolicy = { attempts: 3, baseDelayMs: 500, maxDelayMs: 8000 }; + +/** false: do not retry. An object: retry, optionally after the delay the server suggested. */ +export type RetryDecision = false | { delayMs?: number | undefined }; + +export type RetryAttempt = { + /** Number of the failed attempt, starting at 1. */ + attempt: number; + delayMs: number; + error: unknown; +}; + +export type RetryOptions = { + clock: Clock; + /** Called before the pause. The sdk never prints: cli warns, MCP stays quiet. */ + onRetry?: ((info: RetryAttempt) => void) | undefined; + policy: RetryPolicy; + shouldRetry: (error: unknown, attempt: number) => RetryDecision; + signal?: AbortSignal | undefined; +}; + +export const retry = async (fn: () => Promise, options: RetryOptions): Promise => { + const { clock, policy, signal } = options; + + for (let attempt = 1; ; attempt += 1) { + throwIfAborted(signal); + + try { + return await fn(); + } catch (error) { + const decision = attempt < policy.attempts ? options.shouldRetry(error, attempt) : false; + + if (decision === false) { + throw error; + } + + const delayMs = decision.delayMs ?? backoffDelay(policy, attempt); + + options.onRetry?.({ attempt, delayMs, error }); + await clock.sleep(delayMs, signal); + } + } +}; + +/** Exponential backoff without jitter: deterministic tests are worth more than spread here. */ +const backoffDelay = (policy: RetryPolicy, attempt: number): number => + Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1)); diff --git a/src/sdk/core/http/url.ts b/src/sdk/core/http/url.ts new file mode 100644 index 0000000..88a53fe --- /dev/null +++ b/src/sdk/core/http/url.ts @@ -0,0 +1,33 @@ +export type QueryValue = boolean | number | readonly string[] | string | undefined; +export type QueryParams = Record; + +export const buildUrl = ( + baseUrl: string, + path: string, + query: QueryParams | undefined, + trailingSlash: boolean, +): string => { + // A relative path against a base ending in a slash keeps a prefix like /api/v1/developer + const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + let relative = path.replace(/^\/+/, ''); + + if (trailingSlash && relative !== '' && !relative.endsWith('/')) { + relative += '/'; + } + + const url = new URL(relative, base); + + for (const [key, value] of Object.entries(query ?? {})) { + if (value === undefined) { + continue; + } + + const items = typeof value === 'object' ? value : [value]; + + for (const item of items) { + url.searchParams.append(key, String(item)); + } + } + + return url.toString(); +}; diff --git a/src/sdk/core/session.ts b/src/sdk/core/session.ts new file mode 100644 index 0000000..428d2c1 --- /dev/null +++ b/src/sdk/core/session.ts @@ -0,0 +1,117 @@ +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { StorageError } from './errors.js'; + +export type SessionUser = { + email: string; + name: string; +}; + +export type Session = { + token: string; + user?: SessionUser | undefined; +}; + +/** + * Where the session lives is a port, not a path: a keychain, a CI secret store or an in-memory + * store for an MCP server plug into this same type without touching a command. + */ +export type SessionStore = { + clear(): Promise; + load(): Promise; + readonly path: string; + save(session: Session): Promise; +}; + +/** + * The file and field names the published CLI already writes. A rename would log every existing + * user out, so the on-disk contract stays `{ "access_token", "user": { "email", "name" } }`. + */ +const FILE_NAME = 'config.json'; + +type StoredSession = { + access_token: string; + user?: SessionUser | undefined; +}; + +/** A file under a directory the adapter picks: the sdk touches neither HOME nor process.env. */ +export const createFileSessionStore = (dir: string): SessionStore => { + const path = join(dir, FILE_NAME); + + return { + path, + + clear: async () => { + await rm(path, { force: true }); + }, + + load: async () => { + let raw: string; + + try { + raw = await readFile(path, 'utf8'); + } catch (error) { + if (isErrno(error, 'ENOENT')) { + return undefined; + } + + // No permission, a broken disk: not ours to paper over as "not logged in" + throw error; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(raw) as unknown; + } catch (error) { + throw new StorageError(`Session file is corrupted: ${path}`, path, { cause: error }); + } + + // An unknown shape is most likely a file from another version: asking for a fresh + // login beats failing every command. + return toSession(parsed); + }, + + save: async (session) => { + await mkdir(dir, { recursive: true, mode: 0o700 }); + await writeFile(path, `${JSON.stringify(toStored(session), null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + // `mode` above applies only when the file is created; an existing 0644 one (a hand + // edit, a restored backup) would leave the token readable for everyone on the machine. + await chmod(path, 0o600); + }, + }; +}; + +const toStored = (session: Session): StoredSession => (session.user === undefined + ? { access_token: session.token } + : { access_token: session.token, user: session.user }); + +/** Not a full schema check: only the minimum the rest of the code leans on. */ +const toSession = (parsed: unknown): Session | undefined => { + const record = asRecord(parsed); + + if (record === undefined || typeof record.access_token !== 'string' || record.access_token === '') { + return undefined; + } + + const user = toUser(record.user); + + return user === undefined ? { token: record.access_token } : { token: record.access_token, user }; +}; + +const toUser = (value: unknown): SessionUser | undefined => { + const record = asRecord(value); + + if (record === undefined || typeof record.email !== 'string' || typeof record.name !== 'string') { + return undefined; + } + + return { email: record.email, name: record.name }; +}; + +const asRecord = (value: unknown): Record | undefined => + (typeof value === 'object' && value !== null ? (value as Record) : undefined); + +const isErrno = (error: unknown, code: string): boolean => + error instanceof Error && 'code' in error && error.code === code; diff --git a/src/sdk/core/testing.ts b/src/sdk/core/testing.ts new file mode 100644 index 0000000..41e3c41 --- /dev/null +++ b/src/sdk/core/testing.ts @@ -0,0 +1,86 @@ +/** Fakes for the sdk and its consumers: cli, MCP and any other adapter test with the same tools. */ +import { throwIfAborted } from './clock.js'; + +import type { Clock } from './clock.js'; + +export type FakeClock = { + advance(ms: number): void; + /** Durations of every sleep, in order. */ + readonly sleeps: number[]; +} & Clock; + +/** A clock whose sleep moves time forward instantly. */ +export const createFakeClock = (start = 0): FakeClock => { + let time = start; + const sleeps: number[] = []; + + return { + sleeps, + now: () => time, + + advance: (ms) => { + time += ms; + }, + + sleep: (ms, signal) => { + throwIfAborted(signal); + sleeps.push(ms); + time += ms; + + return Promise.resolve(); + }, + }; +}; + +export type ScriptedResponse = { + body?: unknown; + headers?: Record; + status?: number; +}; + +export type RecordedCall = { + body: string | undefined; + headers: Headers; + method: string; + url: string; +}; + +export type ScriptedFetch = { + calls: RecordedCall[]; + fetch: typeof globalThis.fetch; +}; + +/** Answers with the scripted responses in order; an Error in the script is thrown, as fetch would. */ +export const createScriptedFetch = (script: readonly (Error | ScriptedResponse)[]): ScriptedFetch => { + const queue = [...script]; + const calls: RecordedCall[] = []; + + const fetch: typeof globalThis.fetch = (input, init) => { + calls.push({ + body: typeof init?.body === 'string' ? init.body : undefined, + headers: new Headers(init?.headers), + method: init?.method ?? 'GET', + url: String(input instanceof Request ? input.url : input), + }); + + const next = queue.shift(); + + if (next === undefined) { + return Promise.reject(new Error(`scripted fetch: no response for call #${calls.length}`)); + } + + if (next instanceof Error) { + return Promise.reject(next); + } + + // ResponseInit.headers is readonly in @types/node, so the init is built in one go + const status = next.status ?? 200; + const responseInit: ResponseInit = next.headers ? { headers: next.headers, status } : { status }; + + const body = next.body === undefined ? null : JSON.stringify(next.body); + + return Promise.resolve(new Response(body, responseInit)); + }; + + return { calls, fetch }; +}; diff --git a/src/sdk/core/validation.ts b/src/sdk/core/validation.ts new file mode 100644 index 0000000..4512172 --- /dev/null +++ b/src/sdk/core/validation.ts @@ -0,0 +1,17 @@ +import { ValidationError } from './errors.js'; + +import type { Issue } from './errors.js'; + +/** + * Turns a rule's problems into a throw — the only place that does. Rules return lists instead of + * throwing, so a table test walks them without an environment and the user sees every problem at + * once. + * + * Call it from an `async` method: a broken rule then arrives as a rejected promise, like a network + * failure, instead of a throw the caller has to handle separately. + */ +export const assertValid = (issues: readonly Issue[]): void => { + if (issues.length > 0) { + throw new ValidationError(issues); + } +}; diff --git a/test/architecture/frozen-legacy.test.ts b/test/architecture/frozen-legacy.test.ts new file mode 100644 index 0000000..9ef5b2b --- /dev/null +++ b/test/architecture/frozen-legacy.test.ts @@ -0,0 +1,145 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join, sep } from 'node:path'; + +import { expect } from 'chai'; + +const SRC = join(import.meta.dirname, '..', '..', 'src'); + +/** + * The pre-sdk stack is frozen: new work goes to src/sdk (the API) and src/cli (the adapter). + * Eslint guards the imports between layers, but nothing stops a file from simply appearing here — + * these lists do. + * + * They are also the migration's remaining scope: a line leaves when the module is ported, and + * nothing is ever added. + */ +const FROZEN_LIB = [ + 'api-client.ts', + 'api-schemas.ts', + 'app-url.ts', + 'asa-client.ts', + 'asa-flags.ts', + 'asa-keyword-action.ts', + 'asa-schemas.ts', + 'auth.ts', + 'client-from-config.ts', + 'config.ts', + 'confirm.ts', + 'errors.ts', + 'flags.ts', + 'flow-help.ts', + 'output.ts', + 'placement-audiences.ts', + 'preview.ts', +]; + +/** The commands still written against src/lib. A ported one turns into a re-export and drops out. */ +const FROZEN_COMMANDS = [ + 'access-levels/create.ts', + 'access-levels/get.ts', + 'access-levels/list.ts', + 'access-levels/update.ts', + 'asa/ad-groups/create.ts', + 'asa/ad-groups/get.ts', + 'asa/ad-groups/list.ts', + 'asa/ad-groups/update.ts', + 'asa/ads/create.ts', + 'asa/ads/get.ts', + 'asa/ads/list.ts', + 'asa/ads/update.ts', + 'asa/apps/list.ts', + 'asa/automations/create.ts', + 'asa/automations/get.ts', + 'asa/automations/list.ts', + 'asa/automations/run.ts', + 'asa/automations/runs.ts', + 'asa/automations/update.ts', + 'asa/campaigns/bulk-create.ts', + 'asa/campaigns/bulk-list.ts', + 'asa/campaigns/bulk-status.ts', + 'asa/campaigns/create.ts', + 'asa/campaigns/get.ts', + 'asa/campaigns/list.ts', + 'asa/campaigns/update.ts', + 'asa/competitors/summary.ts', + 'asa/connect.ts', + 'asa/creatives/list.ts', + 'asa/keywords/add.ts', + 'asa/keywords/list.ts', + 'asa/keywords/update.ts', + 'asa/metrics/index.ts', + 'asa/metrics/overview.ts', + 'asa/negative-keywords/add.ts', + 'asa/negative-keywords/list.ts', + 'asa/orgs/list.ts', + 'asa/product-pages/list.ts', + 'asa/product-pages/sync.ts', + 'asa/search-terms/list.ts', + 'asa/whoami.ts', + 'flows/config/get.ts', + 'flows/config/preview.ts', + 'flows/config/update.ts', + 'flows/config/validate.ts', + 'flows/create.ts', + 'flows/get.ts', + 'flows/list.ts', + 'flows/media/upload.ts', + 'flows/publish.ts', + 'flows/update.ts', + 'paywalls/create.ts', + 'paywalls/get.ts', + 'paywalls/list.ts', + 'paywalls/placements.ts', + 'paywalls/update.ts', + 'placements/create.ts', + 'placements/get.ts', + 'placements/list.ts', + 'placements/update.ts', + 'products/create.ts', + 'products/get.ts', + 'products/list.ts', + 'products/update.ts', + 'segments/get.ts', + 'segments/list.ts', +]; + +/** oclif discovers commands only under src/commands, so a ported one keeps a one-line file here. */ +const SHIM = /^export \{ default \} from '(?:\.\.\/)+cli\/commands\/[\w./-]+\.js';$/; + +const tsFiles = async (dir: string): Promise => { + const entries = await readdir(dir, { recursive: true, withFileTypes: true }); + + return entries + .filter(entry => entry.isFile() && entry.name.endsWith('.ts')) + .map(entry => join(entry.parentPath, entry.name).slice(dir.length + 1).split(sep).join('/')) + .sort(); +}; + +const isShim = async (file: string): Promise => { + const code = await readFile(join(SRC, 'commands', file), 'utf8'); + const lines = code.split('\n').map(line => line.trim()).filter(line => line !== '' && !line.startsWith('//')); + + return lines.length === 1 && SHIM.test(lines[0] ?? ''); +}; + +/** Both directions: an addition is what this catches, a stale line would make the list a lie. */ +const expectFrozen = (actual: readonly string[], frozen: readonly string[], place: string): void => { + const added = actual.filter(file => !frozen.includes(file)); + const gone = frozen.filter(file => !actual.includes(file)); + + expect(added, `${place} is frozen: new code belongs in src/sdk and src/cli`).to.deep.equal([]); + expect(gone, `no longer in ${place}: drop these lines, the list tracks what is left to port`).to.deep.equal([]); +}; + +describe('the pre-sdk stack is frozen', () => { + it('takes no new module in src/lib', async () => { + expectFrozen(await tsFiles(join(SRC, 'lib')), FROZEN_LIB, 'src/lib'); + }); + + it('takes no new hand-written command in src/commands', async () => { + const files = await tsFiles(join(SRC, 'commands')); + const shims = await Promise.all(files.map(file => isShim(file))); + + expectFrozen(files.filter((_, index) => shims[index] !== true), FROZEN_COMMANDS, 'src/commands'); + }); +}); diff --git a/test/cli/base.test.ts b/test/cli/base.test.ts new file mode 100644 index 0000000..fe15d22 --- /dev/null +++ b/test/cli/base.test.ts @@ -0,0 +1,318 @@ +import { join } from 'node:path'; + +import { Config, Flags } from '@oclif/core'; +import { captureOutput } from '@oclif/test'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { AdaptyCommand } from '../../src/cli/base/adapty/index.js'; +import { BaseCommand } from '../../src/cli/base/base-command.js'; +import { exitCode } from '../../src/cli/errors.js'; +import { throwIfAborted } from '../../src/sdk/core/clock.js'; +import { AuthRequiredError, CancelledError, isSdkError, NetworkError, ValidationError } from '../../src/sdk/core/errors.js'; +import { createFileSessionStore } from '../../src/sdk/core/session.js'; + +import type { AuthenticatedSession } from '../../src/cli/base/adapty/index.js'; + +const ROOT = join(import.meta.dirname, '..', '..'); + +/** Stands in for a resource command: parses flags first, then talks to the sdk. */ +class TokenProbe extends AdaptyCommand { + static override flags = { page: Flags.integer() }; + + async run(): Promise<{ token: string }> { + await this.parse(TokenProbe); + + return { token: this.session.token }; + } +} + +class MeProbe extends AdaptyCommand { + async run(): Promise> { + await this.parse(MeProbe); + + return this.adapty.auth.me(); + } +} + +/** Reaches for the session without the lifecycle, the way a refactor eventually will. */ +class PeekProbe extends AdaptyCommand { + peek(): AuthenticatedSession { + return this.session; + } + + async run(): Promise { + await this.parse(PeekProbe); + } +} + +class CancelProbe extends BaseCommand { + async run(): Promise { + await this.parse(CancelProbe); + process.emit('SIGINT', 'SIGINT'); + throwIfAborted(this.signal); + } +} + +class ListenerProbe extends BaseCommand { + async run(): Promise { + await this.parse(ListenerProbe); + + return process.listenerCount('SIGINT'); + } +} + +class ErrorProbe extends BaseCommand { + static failure: Error; + + async run(): Promise { + await this.parse(ErrorProbe); + throw ErrorProbe.failure; + } +} + +let viewCalls = 0; + +class RenderProbe extends BaseCommand { + async run(): Promise<{ ok: boolean }> { + await this.parse(RenderProbe); + + this.render({ ok: true }, (value) => { + viewCalls += 1; + + return `rendered ok=${String(value.ok)}`; + }); + + return { ok: true }; + } +} + +const requestHeaders = (stub: sinon.SinonStub, callIndex: number): Headers => { + const init = stub.getCall(callIndex).args[1] as RequestInit; + + return init.headers as Headers; +}; + +describe('cli base commands', () => { + let config: Config; + + before(async () => { + config = await Config.load(ROOT); + }); + + beforeEach(async () => { + delete process.env.ADAPTY_TOKEN; + delete process.env.ADAPTY_API_URL; + await createFileSessionStore(config.configDir).clear(); + }); + + it('keeps an own static on the intermediate authenticated base for oclif manifest caching', () => { + expect(Object.hasOwn(AdaptyCommand, 'enableJsonFlag')).to.equal(true); + }); + + it('turns a missing token into exit 3 and the login hint', async () => { + const { error } = await captureOutput(async () => TokenProbe.run([], config)); + + expect(error?.message).to.contain('adapty auth login'); + expect(error?.oclif?.exit).to.equal(exitCode.auth); + }); + + it('includes the login hint in JSON when there is no token', async () => { + const { stdout } = await captureOutput(async () => TokenProbe.run(['--json'], config)); + + expect(JSON.parse(stdout)).to.deep.equal({ + error: { code: 'auth_required', message: 'Not authenticated. Run `adapty auth login`.' }, + }); + }); + + it('serializes parser errors as a message without the parser context', async () => { + const { stdout } = await captureOutput(async () => TokenProbe.run(['--pge', '2', '--json'], config)); + const result = JSON.parse(stdout) as { error: { message: string } }; + + expect(result.error.message).to.contain('Nonexistent flag'); + expect(result.error).to.have.all.keys('message'); + }); + + it('keeps SDK and unexpected error explanations in JSON', async () => { + const cases = [ + { + error: new ValidationError([{ message: 'is required', path: 'appleBundleId' }]), + expected: { message: 'Invalid input:\n --apple-bundle-id: is required' }, + }, + { + error: new AuthRequiredError('rejected'), + expected: { + code: 'auth_required', + message: 'Token expired or invalid. Run `adapty auth login`.', + status: 401, + }, + }, + { + error: new NetworkError('https://api.example.com/me/', new TypeError('fetch failed')), + expected: { + code: 'network_error', + error_code: 'network_error', + errors: { connection: ['fetch failed'] }, + message: 'Could not reach https://api.example.com/me/. Check the connection and try again.', + status: 0, + status_code: 0, + }, + }, + { error: new CancelledError(), expected: { message: 'Cancelled.' } }, + { error: new TypeError('Unexpected failure'), expected: { message: 'Unexpected failure' } }, + ]; + + for (const { error, expected } of cases) { + ErrorProbe.failure = error; + + const { stdout } = await captureOutput(async () => ErrorProbe.run(['--json'], config)); + + expect(JSON.parse(stdout), error.constructor.name).to.deep.equal({ error: expected }); + } + }); + + it('reports a wrong flag before a missing token: input errors come before state errors', async () => { + const { error } = await captureOutput(async () => TokenProbe.run(['--pge', '2'], config)); + + expect(error?.message).to.contain('Nonexistent flag'); + expect(error?.message).to.not.contain('auth login'); + }); + + it('fails a broken invariant as a bare Error, with no kind and no friendly exit code', () => { + const probe = new PeekProbe([], config); + + expect(() => probe.peek()).to.throw('only after init'); + + try { + probe.peek(); + } catch (error) { + expect(isSdkError(error)).to.equal(false); + } + }); + + it('carries the resolved token into the request under our own User-Agent', async () => { + await createFileSessionStore(config.configDir).save({ token: 'stored-token' }); + + const stub = sinon.stub(globalThis, 'fetch').resolves( + new Response('{"email":"dev@example.com"}', { headers: { 'content-type': 'application/json' }, status: 200 }), + ); + + try { + const { result } = await captureOutput>(async () => MeProbe.run([], config)); + const headers = requestHeaders(stub, 0); + + expect(result).to.deep.equal({ email: 'dev@example.com' }); + expect(stub.getCall(0).args[0]).to.equal(`https://api-admin.adapty.io/api/v1/developer/me/`); + 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}`); + } finally { + stub.restore(); + } + }); + + it('turns Ctrl+C into an abort and exit 130 instead of a silent success', async () => { + const { error } = await captureOutput(async () => CancelProbe.run([], config)); + + expect(error?.message).to.contain('Cancelled'); + expect(error?.oclif?.exit).to.equal(exitCode.cancelled); + }); + + it('adds exactly one SIGINT listener and takes it back off', async () => { + const before = process.listenerCount('SIGINT'); + const { result } = await captureOutput(async () => ListenerProbe.run([], config)); + + expect(result).to.equal(before + 1); + expect(process.listenerCount('SIGINT')).to.equal(before); + }); + + it('renders for humans and skips the view under --json, where oclif prints the return value', async () => { + viewCalls = 0; + + const human = await captureOutput(async () => RenderProbe.run([], config)); + + expect(human.stdout).to.contain('rendered ok=true'); + expect(viewCalls).to.equal(1); + + const json = await captureOutput(async () => RenderProbe.run(['--json'], config)); + + // this.log is already silent under --json, so the guard is checked where it shows: the + // view is never built, and only the returned value is printed + expect(viewCalls).to.equal(1); + expect(json.stdout).to.not.contain('rendered'); + expect(JSON.parse(json.stdout)).to.deep.equal({ ok: true }); + }); + + it('shows an sdk retry as a warning naming the attempt about to be made', async () => { + process.env.ADAPTY_TOKEN = 'env-token'; + + const stub = sinon.stub(globalThis, 'fetch'); + stub.onFirstCall().rejects(new TypeError('fetch failed')); + stub.onSecondCall().resolves(new Response('{}', { headers: { 'content-type': 'application/json' }, status: 200 })); + + try { + const { stderr } = await captureOutput(async () => MeProbe.run([], config)); + + expect(stderr).to.contain('retrying in 0.5s (attempt 2)'); + expect(stub.callCount).to.equal(2); + } finally { + stub.restore(); + } + }); + + it('warns once about a non-default API URL, and says nothing about the default one', async () => { + process.env.ADAPTY_TOKEN = 'env-token'; + process.env.ADAPTY_API_URL = 'https://staging.example.com/api'; + + const redirected = await captureOutput(async () => TokenProbe.run([], config)); + + expect(redirected.stderr).to.equal('Warning: Using non-default API URL: https://staging.example.com/api\n'); + + delete process.env.ADAPTY_API_URL; + + const plain = await captureOutput(async () => TokenProbe.run([], config)); + + expect(plain.stderr).to.not.contain('non-default API URL'); + }); + + it('warns about a non-default API URL under --json without polluting stdout', async () => { + process.env.ADAPTY_TOKEN = 'env-token'; + process.env.ADAPTY_API_URL = 'https://staging.example.com/api'; + + const redirected = await captureOutput(async () => TokenProbe.run(['--json'], config)); + + expect(redirected.stderr).to.equal('Warning: Using non-default API URL: https://staging.example.com/api\n'); + expect(JSON.parse(redirected.stdout)).to.deep.equal({ token: 'env-token' }); + + delete process.env.ADAPTY_API_URL; + + const plain = await captureOutput(async () => TokenProbe.run(['--json'], config)); + + expect(plain.stderr).to.equal(''); + expect(JSON.parse(plain.stdout)).to.deep.equal({ token: 'env-token' }); + }); + + it('turns Ctrl+C while reading a response body into exit 130', async () => { + process.env.ADAPTY_TOKEN = 'env-token'; + + const stub = sinon.stub(globalThis, 'fetch').callsFake(() => Promise.resolve(new Response( + // eslint-disable-next-line n/no-unsupported-features/node-builtins -- Web streams exist in Node 22; only their stability label changed later. + new ReadableStream({ + pull(controller) { + process.emit('SIGINT', 'SIGINT'); + controller.error(new DOMException('aborted', 'AbortError')); + }, + }), + ))); + + try { + const { error } = await captureOutput(async () => MeProbe.run([], config)); + + expect(error?.oclif?.exit).to.equal(exitCode.cancelled); + expect(error?.message).to.equal('Cancelled.'); + expect(stub.callCount).to.equal(1); + } finally { + stub.restore(); + } + }); +}); diff --git a/test/cli/command-layout.test.ts b/test/cli/command-layout.test.ts new file mode 100644 index 0000000..0d51a20 --- /dev/null +++ b/test/cli/command-layout.test.ts @@ -0,0 +1,133 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve, sep } from 'node:path'; + +import { expect } from 'chai'; + +const ROOT = join(import.meta.dirname, '..', '..'); +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. + * + * 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 + * see — a nested `lib/sub/x.js`, a `lib/` owned by a topic rather than by a command, and whether + * oclif is still told to skip lib/ while looking for commands. The scan covers src only: a test is + * free to reach into a command's lib to exercise it directly. + */ +const SPECIFIER = /(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g; + +async function tsFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const found: string[] = []; + + for (const entry of entries) { + const path = join(dir, entry.name); + + if (entry.isDirectory()) { + found.push(...await tsFiles(path)); + } else if (entry.name.endsWith('.ts')) { + found.push(path); + } + } + + return found; +} + +/** The imported file, with the `.js` specifier mapped back to the source it names. */ +function importedFile(file: string, specifier: string): string | undefined { + if (!specifier.startsWith('.')) { + return undefined; + } + + const target = resolve(dirname(file), specifier); + + return target.endsWith('.js') ? `${target.slice(0, -3)}.ts` : target; +} + +/** `commands///lib/render.ts` → `commands//`, the command that owns it. */ +function ownerOf(file: string): string | undefined { + if (!file.startsWith(COMMANDS + sep)) { + return undefined; + } + + const segments = file.slice(COMMANDS.length + 1).split(sep); + const index = segments.indexOf('lib'); + + return index === -1 ? undefined : join(COMMANDS, ...segments.slice(0, index)); +} + +describe('command layout', () => { + let files: string[]; + + before(async () => { + files = await tsFiles(SRC); + }); + + it('keeps the lib/ of a command private to that command', async () => { + const outsiders: string[] = []; + + for (const file of files) { + const source = await readFile(file, 'utf8'); + + for (const [, specifier] of source.matchAll(SPECIFIER)) { + const imported = importedFile(file, specifier ?? ''); + const owner = imported === undefined ? undefined : ownerOf(imported); + + if (owner !== undefined && !file.startsWith(owner + sep)) { + outsiders.push(`${relative(SRC, file)} imports ${specifier}`); + } + } + } + + expect(outsiders, 'move it to cli/views or cli/flags (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'))) + .map(owner => relative(SRC, owner ?? '')); + + expect(orphans, 'a lib/ needs an index.ts 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. + const strays: string[] = []; + + for (const file of files.filter(candidate => candidate.startsWith(COMMANDS + sep))) { + if (file.split(sep).includes('lib')) { + continue; + } + + const source = await readFile(file, 'utf8'); + + if (!source.includes('export default class')) { + strays.push(relative(SRC, file)); + } + } + + expect(strays, 'a file that is not a command belongs in that command lib/').to.deep.equal([]); + }); + + it('tells oclif to skip lib/ when it looks for commands', async () => { + // The reason cannot live in package.json, which has no comments: every file under the + // command root becomes a command id, so `/lib/render.js` would show up as the command + // `::lib:render` and break `oclif manifest`. + const pjson = JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf8')) as { + oclif: { commands: { globPatterns?: string[]; strategy?: string } | string }; + }; + + const { commands } = pjson.oclif; + + expect(commands, 'the string form takes oclif defaults, which see no exclusions').to.be.an('object'); + expect(typeof commands === 'string' ? undefined : commands.globPatterns).to.include('!**/lib/**'); + }); +}); diff --git a/test/cli/commands/auth/status/render.test.ts b/test/cli/commands/auth/status/render.test.ts new file mode 100644 index 0000000..fff17c1 --- /dev/null +++ b/test/cli/commands/auth/status/render.test.ts @@ -0,0 +1,46 @@ +import { expect } from 'chai'; + +import { renderStatus } from '../../../../../src/cli/commands/auth/status/lib/render.js'; + +const CONFIG_PATH = '/home/dev/.config/adapty/config.json'; + +/** + * The view is now reachable on its own, so its four branches are asserted byte for byte instead of + * through a command run. `test/commands/auth/status.test.ts` still covers what the command puts in. + */ +describe('renderStatus', () => { + it('says how to log in when there is no token', () => { + expect(renderStatus({ authenticated: false, config_path: CONFIG_PATH, source: 'none' })) + .to.equal('Not authenticated. Run `adapty auth login`.'); + }); + + it('names the environment as the source instead of a file it never read', () => { + expect(renderStatus({ + authenticated: true, + config_path: CONFIG_PATH, + email: undefined, + source: 'env', + token_prefix: 'env-toke', + })).to.equal('Token: env-toke****\nSource: ADAPTY_TOKEN (environment)'); + }); + + it('prints the email, the masked token and the file it came from', () => { + expect(renderStatus({ + authenticated: true, + config_path: CONFIG_PATH, + email: 'dev@example.com', + source: 'file', + token_prefix: 'stored-t', + })).to.equal(`Email: dev@example.com\nToken: stored-t****\nConfig: ${CONFIG_PATH}`); + }); + + it('drops the email line when the stored session carries no user', () => { + expect(renderStatus({ + authenticated: true, + config_path: CONFIG_PATH, + email: undefined, + source: 'file', + token_prefix: 'stored-t', + })).to.equal(`Token: stored-t****\nConfig: ${CONFIG_PATH}`); + }); +}); diff --git a/test/cli/errors.test.ts b/test/cli/errors.test.ts new file mode 100644 index 0000000..4fbd747 --- /dev/null +++ b/test/cli/errors.test.ts @@ -0,0 +1,79 @@ +import { expect } from 'chai'; + +import { exitCode, toCliError } from '../../src/cli/errors.js'; +import { + ApiError, + AuthRequiredError, + CancelledError, + DeviceFlowDeniedError, + DeviceFlowExpiredError, + NetworkError, + ValidationError, +} from '../../src/sdk/core/errors.js'; + +import type { AnySdkError } from '../../src/sdk/core/errors.js'; + +/** What oclif reads off a thrown error, in the two places it looks. */ +type CliError = Error & { code?: string; exitCode?: number; oclif?: { exit?: number } }; + +const cases: [AnySdkError, number][] = [ + [new ApiError({ code: 'validation_error', message: 'title: is required', status: 400 }), exitCode.api], + [new AuthRequiredError('missing'), exitCode.auth], + [new AuthRequiredError('rejected'), exitCode.auth], + [new CancelledError(), exitCode.cancelled], + [new DeviceFlowDeniedError(), exitCode.auth], + [new DeviceFlowExpiredError(), exitCode.auth], + [new NetworkError('https://api.example.com/apps/', new TypeError('fetch failed')), exitCode.network], + [new ValidationError([{ message: 'is required', path: 'appleBundleId' }]), exitCode.usage], +]; + +describe('toCliError', () => { + it('gives every error kind an exit code and a non-empty message', () => { + for (const [error, exit] of cases) { + const mapped = toCliError(error) as CliError; + + expect(mapped.exitCode, error.kind).to.equal(exit); + expect(mapped.message, error.kind).to.not.equal(''); + } + }); + + it('sets both places oclif takes the exit code from', () => { + // handle() reads oclif.exit; Command.catch under --json reads exitCode and never rethrows + const mapped = toCliError(new CancelledError()) as CliError; + + expect(mapped.oclif?.exit).to.equal(130); + expect(mapped.exitCode).to.equal(130); + }); + + it('names the flag the user typed, not the sdk field, for a validation issue', () => { + const error = new ValidationError([ + { message: 'is required', path: 'appleBundleId' }, + { message: 'at least one store binding is required' }, + ]); + + expect(toCliError(error).message).to.equal( + 'Invalid input:\n --apple-bundle-id: is required\n at least one store binding is required', + ); + }); + + it('keeps a real server code and hides the synthetic http_ one', () => { + const real = toCliError(new ApiError({ code: 'validation_error', message: 'title: is required', status: 400 })); + const synthetic = toCliError(new ApiError({ code: 'http_500', message: 'Server error', status: 500 })); + + expect((real as CliError).code).to.equal('validation_error'); + expect((synthetic as CliError).code).to.equal(undefined); + }); + + it('tells which host could not be reached', () => { + const error = new NetworkError('https://api.example.com/apps/', new TypeError('fetch failed')); + + expect(toCliError(error).message).to.contain('https://api.example.com/apps/'); + }); + + it('passes a foreign error through untouched and wraps a thrown non-error', () => { + const foreign = new TypeError('boom'); + + expect(toCliError(foreign)).to.equal(foreign); + expect(toCliError('boom').message).to.equal('boom'); + }); +}); diff --git a/test/cli/legacy-compat.test.ts b/test/cli/legacy-compat.test.ts new file mode 100644 index 0000000..3c13b20 --- /dev/null +++ b/test/cli/legacy-compat.test.ts @@ -0,0 +1,80 @@ +import { stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { Config } from '@oclif/core'; +import { expect } from 'chai'; + +import { resolveSession } from '../../src/cli/base/adapty/openSession.js'; +import { resolveToken } from '../../src/lib/auth.js'; +import { readConfig, writeConfig } from '../../src/lib/config.js'; +import { createFileSessionStore } from '../../src/sdk/core/session.js'; + +const ROOT = join(import.meta.dirname, '..', '..'); +const posix = process.platform === 'win32' ? it.skip : it; + +/** + * The session file is the only thing the migrated commands and the untouched ones share. These + * tests are that contract, in both directions, for as long as src/lib and src/cli coexist. + */ +describe('session file, shared by both stacks', () => { + let config: Config; + + before(async () => { + config = await Config.load(ROOT); + }); + + beforeEach(async () => { + delete process.env.ADAPTY_TOKEN; + delete process.env.ADAPTY_API_URL; + await createFileSessionStore(config.configDir).clear(); + }); + + it('lets a legacy command use a token the new store saved', async () => { + await createFileSessionStore(config.configDir).save({ + token: 'new-stack-token', + user: { email: 'dev@example.com', name: 'Dev' }, + }); + + expect(await resolveToken(config.configDir)).to.equal('new-stack-token'); + + // `auth status` calls itself authenticated only when both fields are there + const stored = await readConfig(config.configDir); + expect(stored.access_token).to.equal('new-stack-token'); + expect(stored.user).to.deep.equal({ email: 'dev@example.com', name: 'Dev' }); + }); + + it('lets a new command use a token a legacy login saved', async () => { + await writeConfig({ access_token: 'legacy-token', user: { email: 'dev@example.com', name: 'Dev' } }, config.configDir); + + const session = await resolveSession(config); + + expect(session.token).to.equal('legacy-token'); + expect(session.user).to.deep.equal({ email: 'dev@example.com', name: 'Dev' }); + }); + + it('reads a legacy logout, which empties the file instead of removing it', async () => { + await writeConfig({ access_token: 'legacy-token' }, config.configDir); + await writeConfig({}, config.configDir); + + const session = await resolveSession(config); + + expect(session.token).to.equal(undefined); + }); + + it('survives a new logout, which removes the file the legacy code expects', async () => { + await createFileSessionStore(config.configDir).save({ token: 'new-stack-token' }); + await createFileSessionStore(config.configDir).clear(); + + expect(await resolveToken(config.configDir)).to.equal(null); + expect(await readConfig(config.configDir)).to.deep.equal({}); + }); + + posix('keeps the file private after either side rewrites it', async () => { + const path = join(config.configDir, 'config.json'); + + await writeConfig({ access_token: 'legacy-token' }, config.configDir); + await createFileSessionStore(config.configDir).save({ token: 'new-stack-token' }); + + expect((await stat(path)).mode & 0o777).to.equal(0o600); + }); +}); diff --git a/test/cli/session.test.ts b/test/cli/session.test.ts new file mode 100644 index 0000000..d2b5fb9 --- /dev/null +++ b/test/cli/session.test.ts @@ -0,0 +1,81 @@ +import { join } from 'node:path'; + +import { Config } from '@oclif/core'; +import { expect } from 'chai'; + +import { resolveSession } from '../../src/cli/base/adapty/openSession.js'; +import { DEFAULT_ADAPTY_API_URL } from '../../src/sdk/adapty/index.js'; +import { createFileSessionStore } from '../../src/sdk/core/session.js'; + +const ROOT = join(import.meta.dirname, '..', '..'); + +describe('resolveSession', () => { + let config: Config; + + before(async () => { + config = await Config.load(ROOT); + }); + + beforeEach(async () => { + delete process.env.ADAPTY_TOKEN; + delete process.env.ADAPTY_API_URL; + await createFileSessionStore(config.configDir).clear(); + }); + + it('defaults to the developer API and finds no token on a clean machine', async () => { + const session = await resolveSession(config); + + expect(session.apiUrl).to.equal(DEFAULT_ADAPTY_API_URL); + expect(session.token).to.equal(undefined); + expect(session.user).to.equal(undefined); + }); + + it('takes the API URL from ADAPTY_API_URL', async () => { + process.env.ADAPTY_API_URL = 'https://staging.example.com/api/v1/developer'; + + const session = await resolveSession(config); + + expect(session.apiUrl).to.equal('https://staging.example.com/api/v1/developer'); + }); + + it('reads the stored session, user included', async () => { + await createFileSessionStore(config.configDir).save({ + token: 'stored-token', + user: { email: 'dev@example.com', name: 'Dev' }, + }); + + const session = await resolveSession(config); + + expect(session.token).to.equal('stored-token'); + expect(session.user).to.deep.equal({ email: 'dev@example.com', name: 'Dev' }); + }); + + it('lets ADAPTY_TOKEN win over the stored session, and carries no user with it', async () => { + await createFileSessionStore(config.configDir).save({ + token: 'stored-token', + user: { email: 'dev@example.com', name: 'Dev' }, + }); + + process.env.ADAPTY_TOKEN = 'env-token'; + + const session = await resolveSession(config); + + expect(session.token).to.equal('env-token'); + expect(session.user).to.equal(undefined); + }); + + it('ignores an empty ADAPTY_TOKEN, as the published CLI does', async () => { + await createFileSessionStore(config.configDir).save({ token: 'stored-token' }); + process.env.ADAPTY_TOKEN = ''; + + const session = await resolveSession(config); + + expect(session.token).to.equal('stored-token'); + }); + + it('points the store at config.json inside oclif config dir', async () => { + const session = await resolveSession(config); + + expect(session.store.path).to.equal(join(config.configDir, 'config.json')); + }); +}); diff --git a/test/commands/apps.test.ts b/test/commands/apps.test.ts index 4be887e..a4fd03b 100644 --- a/test/commands/apps.test.ts +++ b/test/commands/apps.test.ts @@ -1,54 +1,278 @@ import { runCommand } from '@oclif/test'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { exitCode } from '../../src/cli/errors.js'; import { assertFetch, EMPTY_LIST_RESPONSE, mockFetch, restoreFetch, TEST_APP_ID, + TEST_RESOURCE_ID, } from '../helpers/mock-fetch.js'; -import type sinon from 'sinon'; +type Step = { + body: unknown; + status?: number; +}; + +/** Answers each call with its own status; mockFetch's canned responses are all 200. */ +const mockFetchSteps = (steps: Step[]): sinon.SinonStub => { + let index = 0; + + return sinon.stub(globalThis, 'fetch').callsFake(() => { + const step = steps[index] ?? steps.at(-1); + index += 1; + + return Promise.resolve(new Response(JSON.stringify(step?.body), { status: step?.status ?? 200 })); + }); +}; + +const CREATE_IOS = 'apps create --title "My App" --platform ios --apple-bundle-id com.example.app'; describe('apps', () => { let fetchStub: sinon.SinonStub; + beforeEach(() => { + process.env.ADAPTY_TOKEN = 'test-token'; + }); + afterEach(() => { restoreFetch(fetchStub); delete process.env.ADAPTY_TOKEN; }); it('list calls GET /apps', async () => { - process.env.ADAPTY_TOKEN = 'test-token'; fetchStub = mockFetch([EMPTY_LIST_RESPONSE]); await runCommand('apps list'); assertFetch({ callIndex: 0, method: 'GET', path: '/apps/', stub: fetchStub }); }); it('get calls GET /apps/{id}', async () => { - process.env.ADAPTY_TOKEN = 'test-token'; fetchStub = mockFetch([{ id: TEST_APP_ID, platforms: [], sdk_key: 'sdk_key', secret_key: 'secret', title: 'My App' }]); await runCommand(`apps get ${TEST_APP_ID}`); assertFetch({ callIndex: 0, method: 'GET', path: `/apps/${TEST_APP_ID}/`, stub: fetchStub }); }); it('create calls POST /apps then GET access-levels', async () => { - process.env.ADAPTY_TOKEN = 'test-token'; - fetchStub = mockFetch([ { id: TEST_APP_ID, sdk_key: 'sdk_key', title: 'My App' }, { items: [{ id: 'al-id', sdk_id: 'premium', title: 'Premium' }] }, ]); - await runCommand('apps create --title "My App" --platform ios --apple-bundle-id com.example.app'); + await runCommand(CREATE_IOS); assertFetch({ body: { apple_bundle_id: 'com.example.app', platforms: ['ios'], title: 'My App' }, callIndex: 0, method: 'POST', path: '/apps/', stub: fetchStub }); assertFetch({ callIndex: 1, method: 'GET', path: `/apps/${TEST_APP_ID}/access-levels/`, stub: fetchStub }); }); it('update calls PUT /apps/{id}', async () => { - process.env.ADAPTY_TOKEN = 'test-token'; fetchStub = mockFetch([{ id: TEST_APP_ID, title: 'Updated' }]); await runCommand(`apps update ${TEST_APP_ID} --title "Updated"`); assertFetch({ body: { title: 'Updated' }, callIndex: 0, method: 'PUT', path: `/apps/${TEST_APP_ID}/`, stub: fetchStub }); }); + + /** The published defaults are part of the request, not only of --help. */ + it('list asks for the same page as before, and passes the flags on', async () => { + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]); + + await runCommand('apps list'); + assertFetch({ callIndex: 0, method: 'GET', path: '/apps/', query: { 'page[number]': '1', 'page[size]': '20' }, stub: fetchStub }); + + await runCommand('apps list --page 2 --page-size 10'); + assertFetch({ callIndex: 1, method: 'GET', path: '/apps/', query: { 'page[number]': '2', 'page[size]': '10' }, stub: fetchStub }); + }); + + /** Byte for byte the published output: labelled blocks, `---`, a dropped null, then the footer. */ + it('prints a page the way the published CLI prints it, and the raw answer under --json', async () => { + const body = { + data: [ + { id: TEST_APP_ID, sdk_key: 'sdk_key', title: 'My App' }, + { id: TEST_RESOURCE_ID, sdk_key: null, title: 'Other App' }, + ], + meta: { pagination: { count: 2, page: 1, pages: 1 } }, + }; + + fetchStub = mockFetch([body]); + + const { stdout } = await runCommand('apps list'); + + expect(stdout).to.equal([ + `ID: ${TEST_APP_ID}`, + 'SDK Key: sdk_key', + 'Title: My App', + '---', + `ID: ${TEST_RESOURCE_ID}`, + 'Title: Other App', + '', + 'Page 1 of 1 (2 total)', + '', + ].join('\n')); + + const json = await runCommand('apps list --json'); + + expect(JSON.parse(json.stdout)).to.deep.equal(body); + }); + + it('rejects an app id that is not a uuid, with the published wording and before any request', async () => { + fetchStub = mockFetch([{}]); + + const { error } = await runCommand('apps get not-a-uuid'); + + expect(error?.oclif?.exit).to.equal(exitCode.usage); + expect(error?.message).to.contain('Invalid app ID format'); + expect(fetchStub.callCount).to.equal(0); + }); + + it('will not create an ios app without a bundle id, and names the flag', async () => { + fetchStub = mockFetch([{}]); + + const { error } = await runCommand('apps create --title "My App" --platform ios'); + + expect(error?.oclif?.exit).to.equal(exitCode.usage); + expect(error?.message).to.contain('--apple-bundle-id'); + expect(fetchStub.callCount).to.equal(0); + }); + + it('will not send an update with nothing in it', async () => { + fetchStub = mockFetch([{}]); + + const { error } = await runCommand(`apps update ${TEST_APP_ID}`); + + expect(error?.oclif?.exit).to.equal(exitCode.usage); + expect(error?.message).to.contain('nothing to update'); + expect(fetchStub.callCount).to.equal(0); + }); + + it('validates create and update input before requiring a token', async () => { + delete process.env.ADAPTY_TOKEN; + fetchStub = mockFetch([{}]); + + const cases = [ + { command: `apps update ${TEST_APP_ID}`, message: 'nothing to update' }, + { command: 'apps create --title "My App" --platform ios', message: '--apple-bundle-id' }, + { command: 'apps create --title "My App" --platform android', message: '--google-bundle-id' }, + ]; + + for (const { command, message } of cases) { + const human = await runCommand(command); + + expect(human.error?.oclif?.exit, command).to.equal(exitCode.usage); + expect(human.error?.message).to.contain(message); + + const json = await runCommand(`${command} --json`); + const result = JSON.parse(json.stdout) as { error: { message: string } }; + + expect(result.error.message).to.contain(message); + expect(result.error.message).to.not.contain('Not authenticated'); + } + + expect(fetchStub.callCount).to.equal(0); + }); + + it('still requires a token for valid create and update input', async () => { + delete process.env.ADAPTY_TOKEN; + fetchStub = mockFetch([{}]); + + for (const command of [CREATE_IOS, `apps update ${TEST_APP_ID} --title Updated`]) { + const { error } = await runCommand(command); + + expect(error?.oclif?.exit, command).to.equal(exitCode.auth); + expect(error?.message).to.contain('Not authenticated'); + } + + expect(fetchStub.callCount).to.equal(0); + }); + + /** Under --json the result is the app itself, so the courtesy request is skipped. */ + it('skips the access-level lookup under --json', async () => { + const app = { id: TEST_APP_ID, sdk_key: 'sdk_key', title: 'My App' }; + + fetchStub = mockFetch([app]); + + const { stdout } = await runCommand(`${CREATE_IOS} --json`); + + expect(JSON.parse(stdout)).to.deep.equal(app); + expect(fetchStub.callCount).to.equal(1); + }); + + it('reports the created app even when the access levels cannot be read', async () => { + fetchStub = mockFetchSteps([ + { body: { id: TEST_APP_ID, sdk_key: 'sdk_key', title: 'My App' } }, + { body: { error_code: 'forbidden' }, status: 403 }, + ]); + + const { stderr, stdout } = await runCommand(CREATE_IOS); + + expect(stdout).to.contain('App created!'); + expect(stdout).to.contain('Title: My App'); + expect(stderr).to.contain('Could not fetch access levels for new app'); + }); + + /** The server's own words, not "POST /apps failed with HTTP 400". */ + it('reports a rejected create the way the server worded it', async () => { + fetchStub = mockFetchSteps([{ + body: { error_code: 'validation_error', errors: { apple_bundle_id: ['already used'] } }, + status: 400, + }]); + + const { error } = await runCommand(CREATE_IOS); + + expect(error?.oclif?.exit).to.equal(exitCode.api); + expect(error?.message).to.equal('apple_bundle_id: already used'); + expect(error?.code).to.equal('validation_error'); + }); + + it('preserves API error details in JSON along with the message, code and HTTP status', async () => { + fetchStub = mockFetchSteps([{ + body: { error_code: 'validation_error', errors: { apple_bundle_id: ['already used'] } }, + status: 400, + }]); + + const { stdout } = await runCommand(`${CREATE_IOS} --json`); + + expect(JSON.parse(stdout)).to.deep.equal({ + error: { + code: 'validation_error', + error_code: 'validation_error', + errors: { apple_bundle_id: ['already used'] }, + message: 'apple_bundle_id: already used', + status: 400, + status_code: 400, + }, + }); + + expect(fetchStub.callCount).to.equal(1); + }); + + it('includes a fallback code and HTTP status in JSON when the server sends no error code', async () => { + fetchStub = mockFetchSteps([{ body: {}, status: 403 }]); + + const { stdout } = await runCommand(`${CREATE_IOS} --json`); + + expect(JSON.parse(stdout)).to.deep.equal({ + error: { + code: 'http_403', + error_code: 'http_403', + message: 'POST /apps failed with HTTP 403', + status: 403, + status_code: 403, + }, + }); + }); + + /** The published command catches everything here, so Ctrl+C looked like a failed request. */ + it('lets a Ctrl+C during the access-level lookup end the command', async () => { + fetchStub = mockFetchSteps([{ body: { id: TEST_APP_ID, sdk_key: 'sdk_key', title: 'My App' } }]); + + fetchStub.onSecondCall().callsFake(() => { + process.emit('SIGINT', 'SIGINT'); + + return Promise.reject(new Error('aborted')); + }); + + const { error } = await runCommand(CREATE_IOS); + + expect(error?.oclif?.exit).to.equal(exitCode.cancelled); + }); }); diff --git a/test/commands/auth/login.test.ts b/test/commands/auth/login.test.ts index 6d19435..4e9086e 100644 --- a/test/commands/auth/login.test.ts +++ b/test/commands/auth/login.test.ts @@ -1,32 +1,39 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { Config } from '@oclif/core'; import { runCommand } from '@oclif/test'; import { expect } from 'chai'; +import { resolveToken } from '../../../src/lib/auth.js'; import { assertFetch, mockFetch, restoreFetch } from '../../helpers/mock-fetch.js'; import type sinon from 'sinon'; +const ROOT = join(import.meta.dirname, '..', '..', '..'); + +const deviceBody = { + device_code: 'test-device-code', + expires_in: 300, + interval: 0, + user_code: 'TEST-CODE', + verification_uri: 'https://auth.adapty.io/activate', + verification_uri_complete: 'https://auth.adapty.io/activate?code=TEST-CODE', +}; + +const tokenBody = { + access_token: 'new-token', + expires_in: 86_400, + token_type: 'Bearer', + user: { email: 'test@example.com', name: 'Test User' }, +}; + describe('auth login', () => { let fetchStub: sinon.SinonStub; beforeEach(() => { delete process.env.ADAPTY_TOKEN; - - fetchStub = mockFetch([ - { - device_code: 'test-device-code', - expires_in: 300, - interval: 0, - user_code: 'TEST-CODE', - verification_uri: 'https://auth.adapty.io/activate', - verification_uri_complete: 'https://auth.adapty.io/activate?code=TEST-CODE', - }, - { - access_token: 'new-token', - expires_in: 86_400, - token_type: 'Bearer', - user: { email: 'test@example.com', name: 'Test User' }, - }, - ]); + fetchStub = mockFetch([deviceBody, tokenBody]); }); afterEach(() => { @@ -34,6 +41,12 @@ describe('auth login', () => { delete process.env.ADAPTY_APP_URL; }); + /** The link is printed before any polling starts, so an expired code cuts the wait short. */ + const stubShownLinkOnly = (): void => { + restoreFetch(fetchStub); + fetchStub = mockFetch([{ ...deviceBody, expires_in: 0 }]); + }; + it('calls POST /auth/device then POST /auth/token', async () => { await runCommand('auth login'); expect(fetchStub.callCount).to.equal(2); @@ -52,8 +65,24 @@ describe('auth login', () => { }); }); + it('saves the session in the shape the untouched commands read', async () => { + await runCommand('auth login'); + + const config = await Config.load(ROOT); + const raw = await readFile(join(config.configDir, 'config.json'), 'utf8'); + + expect(JSON.parse(raw)).to.deep.equal({ + access_token: 'new-token', + user: { email: 'test@example.com', name: 'Test User' }, + }); + + expect(await resolveToken(config.configDir)).to.equal('new-token'); + }); + it('points the verification link at ADAPTY_APP_URL when one is configured', async () => { process.env.ADAPTY_APP_URL = 'http://localhost:3000'; + stubShownLinkOnly(); + const { stdout } = await runCommand('auth login'); expect(stdout).to.contain('http://localhost:3000/activate?code=TEST-CODE'); @@ -61,6 +90,8 @@ describe('auth login', () => { }); it('leaves the verification link as issued without ADAPTY_APP_URL', async () => { + stubShownLinkOnly(); + const { stdout } = await runCommand('auth login'); expect(stdout).to.contain('https://auth.adapty.io/activate?code=TEST-CODE'); diff --git a/test/commands/auth/logout.test.ts b/test/commands/auth/logout.test.ts index 140ba68..a034977 100644 --- a/test/commands/auth/logout.test.ts +++ b/test/commands/auth/logout.test.ts @@ -1,11 +1,54 @@ +import { join } from 'node:path'; + +import { Config } from '@oclif/core'; import { runCommand } from '@oclif/test'; import { expect } from 'chai'; +import { readConfig } from '../../../src/lib/config.js'; +import { createFileSessionStore } from '../../../src/sdk/core/session.js'; + +const ROOT = join(import.meta.dirname, '..', '..', '..'); + describe('auth logout', () => { - it('handles logout', async () => { + let config: Config; + + before(async () => { + config = await Config.load(ROOT); + }); + + beforeEach(() => { + delete process.env.ADAPTY_TOKEN; + }); + + it('says so when there was nothing to remove', async () => { const { stdout } = await runCommand('auth logout'); - const valid = stdout.includes('Not currently authenticated') || stdout.includes('Logged out'); - // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- FIXME if you see this - expect(valid).to.be.true; + + expect(stdout).to.contain('Not currently authenticated'); + }); + + it('removes the stored session and warns that the token still lives server-side', async () => { + await createFileSessionStore(config.configDir).save({ token: 'stored-token' }); + + const { stdout } = await runCommand('auth logout'); + + expect(stdout).to.contain('Logged out'); + expect(stdout).to.contain('auth revoke'); + expect(await readConfig(config.configDir)).to.deep.equal({}); + }); + + it('admits that ADAPTY_TOKEN keeps the CLI authenticated', async () => { + process.env.ADAPTY_TOKEN = 'env-token'; + + const { stdout } = await runCommand('auth logout'); + + expect(stdout).to.contain('ADAPTY_TOKEN is still set'); + }); + + it('reports the env token in --json as well', async () => { + process.env.ADAPTY_TOKEN = 'env-token'; + + const { stdout } = await runCommand('auth logout --json'); + + expect(JSON.parse(stdout)).to.deep.equal({ env_token_set: true, status: 'not_authenticated' }); }); }); diff --git a/test/commands/auth/revoke.test.ts b/test/commands/auth/revoke.test.ts new file mode 100644 index 0000000..29f17bc --- /dev/null +++ b/test/commands/auth/revoke.test.ts @@ -0,0 +1,120 @@ +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; + +import { Config } from '@oclif/core'; +import { runCommand } from '@oclif/test'; +import { expect } from 'chai'; + +import { exitCode } from '../../../src/cli/errors.js'; +import { createFileSessionStore } from '../../../src/sdk/core/session.js'; +import { assertFetch, mockFetch, mockFetchFailure, restoreFetch } from '../../helpers/mock-fetch.js'; + +import type sinon from 'sinon'; + +const ROOT = join(import.meta.dirname, '..', '..', '..'); + +describe('auth revoke', () => { + let config: Config; + let fetchStub: sinon.SinonStub; + + before(async () => { + config = await Config.load(ROOT); + }); + + beforeEach(() => { + delete process.env.ADAPTY_TOKEN; + }); + + afterEach(() => { + restoreFetch(fetchStub); + delete process.env.ADAPTY_TOKEN; + }); + + it('succeeds without a token in human and JSON modes', async () => { + fetchStub = mockFetch([{}]); + + const human = await runCommand('auth revoke'); + + expect(human.error).to.equal(undefined); + expect(human.stdout).to.contain('Not currently authenticated.'); + + const json = await runCommand('auth revoke --json'); + + expect(json.error).to.equal(undefined); + expect(JSON.parse(json.stdout)).to.deep.equal({ status: 'not_authenticated' }); + expect(fetchStub.callCount).to.equal(0); + + // Command.catch can swallow a JSON error, so verify the real process exit as well. + for (const flags of [[], ['--json']]) { + const child = spawnSync(process.execPath, [join(ROOT, 'bin/run.js'), 'auth', 'revoke', ...flags], { + encoding: 'utf8', + timeout: 10_000, + }); + + expect(child.status, child.stderr).to.equal(0); + } + }); + + it('sends the stored token to the server, then forgets the session', async () => { + await createFileSessionStore(config.configDir).save({ token: 'stored-token' }); + fetchStub = mockFetch([{}]); + + const { stdout } = await runCommand('auth revoke'); + + assertFetch({ body: { token: 'stored-token' }, callIndex: 0, method: 'POST', path: '/auth/tokens/revoke/', stub: fetchStub }); + expect(stdout).to.contain('Token revoked'); + expect(await createFileSessionStore(config.configDir).load()).to.equal(undefined); + + const repeated = await runCommand('auth revoke --json'); + + expect(JSON.parse(repeated.stdout)).to.deep.equal({ status: 'not_authenticated' }); + expect(fetchStub.callCount).to.equal(1); + }); + + /** The order matters: a session dropped before a failed request could never be revoked. */ + it('keeps the session when the server refuses to revoke', async () => { + await createFileSessionStore(config.configDir).save({ token: 'stored-token' }); + fetchStub = mockFetchFailure({ error_code: 'server_error' }, { status: 500 }); + + const { error } = await runCommand('auth revoke'); + + expect(error?.oclif?.exit).to.equal(exitCode.api); + expect(await createFileSessionStore(config.configDir).load()).to.deep.equal({ token: 'stored-token' }); + }); + + it('warns that a revoked env token is still in the environment', async () => { + process.env.ADAPTY_TOKEN = 'env-token'; + fetchStub = mockFetch([{}]); + + const { stdout } = await runCommand('auth revoke'); + + expect(stdout).to.contain('ADAPTY_TOKEN is still set'); + + assertFetch({ body: { token: 'env-token' }, callIndex: 0, method: 'POST', path: '/auth/tokens/revoke/', stub: fetchStub }); + }); + + it('preserves a different stored session when revoking the environment token', async () => { + const stored = { token: 'stored-token', user: { email: 'dev@example.com', name: 'Dev' } }; + + await createFileSessionStore(config.configDir).save(stored); + process.env.ADAPTY_TOKEN = 'env-token'; + fetchStub = mockFetch([{}]); + + const { stdout } = await runCommand('auth revoke --json'); + + assertFetch({ body: { token: 'env-token' }, callIndex: 0, method: 'POST', path: '/auth/tokens/revoke/', stub: fetchStub }); + expect(JSON.parse(stdout)).to.deep.equal({ env_token_set: true, status: 'revoked' }); + expect(await createFileSessionStore(config.configDir).load()).to.deep.equal(stored); + }); + + it('removes a stored copy of the revoked environment token', async () => { + await createFileSessionStore(config.configDir).save({ token: 'same-token' }); + process.env.ADAPTY_TOKEN = 'same-token'; + fetchStub = mockFetch([{}]); + + await runCommand('auth revoke'); + + assertFetch({ body: { token: 'same-token' }, callIndex: 0, method: 'POST', path: '/auth/tokens/revoke/', stub: fetchStub }); + expect(await createFileSessionStore(config.configDir).load()).to.equal(undefined); + }); +}); diff --git a/test/commands/auth/status.test.ts b/test/commands/auth/status.test.ts index eb17f13..c59a74b 100644 --- a/test/commands/auth/status.test.ts +++ b/test/commands/auth/status.test.ts @@ -1,7 +1,20 @@ +import { join } from 'node:path'; + +import { Config } from '@oclif/core'; import { runCommand } from '@oclif/test'; import { expect } from 'chai'; +import { createFileSessionStore } from '../../../src/sdk/core/session.js'; + +const ROOT = join(import.meta.dirname, '..', '..', '..'); + describe('auth status', () => { + let config: Config; + + before(async () => { + config = await Config.load(ROOT); + }); + it('shows not authenticated when no config', async () => { const { stdout } = await runCommand('auth status'); expect(stdout).to.contain('Not authenticated'); @@ -9,9 +22,37 @@ describe('auth status', () => { it('returns json when --json flag passed', async () => { const { stdout } = await runCommand('auth status --json'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- FIXME if you see this - const result = JSON.parse(stdout); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- FIXME if you see this + const result = JSON.parse(stdout) as { authenticated: boolean; source: string }; + expect(result.authenticated).to.equal(false); + expect(result.source).to.equal('none'); + }); + + it('reads the stored session without touching the network', async () => { + await createFileSessionStore(config.configDir).save({ + token: 'stored-token-1234', + user: { email: 'dev@example.com', name: 'Dev' }, + }); + + const { stdout } = await runCommand('auth status'); + + expect(stdout).to.contain('Email: dev@example.com'); + expect(stdout).to.contain('Token: stored-t****'); + expect(stdout).to.contain(`Config: ${join(config.configDir, 'config.json')}`); + expect(stdout).to.not.contain('stored-token-1234'); + }); + + /** The published CLI reports "not authenticated" here, while every other command works. */ + it('counts an env token as authenticated and names it as the source', async () => { + process.env.ADAPTY_TOKEN = 'env-token-987654'; + + const { stdout } = await runCommand('auth status'); + + expect(stdout).to.contain('Source: ADAPTY_TOKEN (environment)'); + expect(stdout).to.contain('Token: env-toke****'); + + const json = await runCommand('auth status --json'); + + expect(JSON.parse(json.stdout)).to.deep.include({ authenticated: true, source: 'env' }); }); }); diff --git a/test/commands/auth/whoami.test.ts b/test/commands/auth/whoami.test.ts index f7115ec..13f52bc 100644 --- a/test/commands/auth/whoami.test.ts +++ b/test/commands/auth/whoami.test.ts @@ -1,5 +1,7 @@ import { runCommand } from '@oclif/test'; +import { expect } from 'chai'; +import { exitCode } from '../../../src/cli/errors.js'; import { assertFetch, mockFetch, restoreFetch } from '../../helpers/mock-fetch.js'; import type sinon from 'sinon'; @@ -21,4 +23,25 @@ describe('auth whoami', () => { await runCommand('auth whoami'); assertFetch({ callIndex: 0, method: 'GET', path: '/me/', stub: fetchStub }); }); + + it('prints the answer as labelled lines and returns it untouched under --json', async () => { + const { stdout } = await runCommand('auth whoami'); + + expect(stdout).to.contain('Email: test@example.com'); + expect(stdout).to.contain('Name: Test User'); + + const json = await runCommand('auth whoami --json'); + + expect(JSON.parse(json.stdout)).to.deep.equal({ companies: [], email: 'test@example.com', name: 'Test User' }); + }); + + it('refuses to run without a token, before any request', async () => { + delete process.env.ADAPTY_TOKEN; + + const { error } = await runCommand('auth whoami'); + + expect(error?.oclif?.exit).to.equal(exitCode.auth); + expect(error?.message).to.contain('adapty auth login'); + expect(fetchStub.callCount).to.equal(0); + }); }); diff --git a/test/helpers/isolate-config.ts b/test/helpers/isolate-config.ts index 4ede10d..d5c81ee 100644 --- a/test/helpers/isolate-config.ts +++ b/test/helpers/isolate-config.ts @@ -1,9 +1,28 @@ -import { mkdtemp } from 'node:fs/promises'; +import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { Config } from '@oclif/core'; + +let sessionFile: string; + export const mochaHooks = { async beforeAll() { process.env.XDG_CONFIG_HOME = await mkdtemp(join(tmpdir(), 'adapty-cli-config-')); + + // Asked for after XDG is redirected, so the path is the temporary one the tests will use + const config = await Config.load(join(import.meta.dirname, '..', '..')); + + sessionFile = join(config.configDir, 'config.json'); + }, + + /** + * A successful `auth login` in one test leaves a real session behind, and the next test would + * read it as "already authenticated"; an env token has the same cross-test effect. Every test + * starts from a logged-out machine instead. + */ + async beforeEach() { + delete process.env.ADAPTY_TOKEN; + await rm(sessionFile, { force: true }); }, }; diff --git a/test/helpers/rejection.ts b/test/helpers/rejection.ts new file mode 100644 index 0000000..9319650 --- /dev/null +++ b/test/helpers/rejection.ts @@ -0,0 +1,13 @@ +/** + * Awaits a promise that must reject and hands back the error. Chai has no rejection + * assertion without chai-as-promised, and a raw try/catch in every test hides the intent. + */ +export const rejection = async (promise: Promise): Promise => { + try { + await promise; + } catch (error) { + return error; + } + + throw new Error('Expected the promise to reject, but it resolved'); +}; diff --git a/test/sdk/adapty/apps/create.test.ts b/test/sdk/adapty/apps/create.test.ts new file mode 100644 index 0000000..5bd7151 --- /dev/null +++ b/test/sdk/adapty/apps/create.test.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai'; + +import { validateCreateApp } from '../../../../src/sdk/adapty/apps/index.js'; + +import type { CreateAppInput } from '../../../../src/sdk/adapty/index.js'; +import type { Issue } from '../../../../src/sdk/core/errors.js'; + +/** A rule returns a list instead of throwing, which is what makes a table like this possible. */ +const paths = (issues: readonly Issue[]): (string | undefined)[] => issues.map(issue => issue.path); + +describe('validateCreateApp', () => { + const cases: { expected: string[]; input: CreateAppInput; name: string }[] = [ + { expected: [], input: { appleBundleId: 'com.a', platforms: ['ios'], title: 'A' }, name: 'ios with an apple bundle id' }, + { expected: ['appleBundleId'], input: { platforms: ['ios'], title: 'A' }, name: 'ios without one' }, + { expected: ['appleBundleId'], input: { appleBundleId: '', platforms: ['ios'], title: 'A' }, name: 'ios with an empty one' }, + { expected: ['googleBundleId'], input: { platforms: ['android'], title: 'A' }, name: 'android without a google bundle id' }, + { expected: ['appleBundleId', 'googleBundleId'], input: { platforms: ['ios', 'android'], title: 'A' }, name: 'both platforms: both problems at once' }, + { expected: ['title'], input: { googleBundleId: 'com.a', platforms: ['android'], title: ' ' }, name: 'a title of spaces' }, + { expected: ['platform'], input: { platforms: [], title: 'A' }, name: 'no platform at all' }, + ]; + + for (const { expected, input, name } of cases) { + it(name, () => { + expect(paths(validateCreateApp(input))).to.deep.equal(expected); + }); + } +}); diff --git a/test/sdk/adapty/apps/resource.test.ts b/test/sdk/adapty/apps/resource.test.ts new file mode 100644 index 0000000..6815d78 --- /dev/null +++ b/test/sdk/adapty/apps/resource.test.ts @@ -0,0 +1,75 @@ +import { expect } from 'chai'; + +import { createAdapty } from '../../../../src/sdk/adapty/index.js'; +import { ValidationError } from '../../../../src/sdk/core/errors.js'; +import { createScriptedFetch } from '../../../../src/sdk/core/testing.js'; +import { rejection } from '../../../helpers/rejection.js'; + +type Script = Parameters[0]; + +const BASE = 'https://api.example.com/v1'; + +const setup = (script: Script) => { + const scripted = createScriptedFetch(script); + const adapty = createAdapty({ baseUrl: BASE, fetch: scripted.fetch, token: 't' }); + + return { calls: scripted.calls, apps: adapty.apps }; +}; + +const emptyPage = { data: [], meta: { pagination: { count: 0, page: 2, pages: 1 } } }; + +describe('adapty.apps', () => { + it('asks for a page with the names this API uses, and leaves an unset parameter out', async () => { + const { apps, calls } = setup([{ body: emptyPage }]); + + const page = await apps.list({ page: 2 }); + + expect(calls[0]?.url).to.equal(`${BASE}/apps/?page%5Bnumber%5D=2`); + // Passed through as the server sent it: this object is what --json prints + expect(page).to.deep.equal(emptyPage); + }); + + it('turns a camelCase input into a snake_case body and sends no undefined fields', async () => { + const { apps, calls } = setup([{ body: { id: 'a1', sdk_key: 'k', title: 'A' } }]); + + await apps.create({ appleBundleId: 'com.a', platforms: ['ios'], title: 'A' }); + + expect(calls[0]?.method).to.equal('POST'); + expect(calls[0]?.url).to.equal(`${BASE}/apps/`); + + expect(JSON.parse(calls[0]?.body ?? '')).to.deep.equal({ + apple_bundle_id: 'com.a', + platforms: ['ios'], + title: 'A', + }); + }); + + it('breaks a create rule before reaching the network', async () => { + const { apps, calls } = setup([]); + + const error = await rejection(apps.create({ platforms: ['ios'], title: 'A' })); + + expect(error).to.be.instanceOf(ValidationError); + expect((error as ValidationError).issues.map(issue => issue.path)).to.deep.equal(['appleBundleId']); + expect(calls).to.have.length(0); + }); + + it('sends an update as a PUT carrying only the fields that were given', async () => { + const { apps, calls } = setup([{ body: { id: 'a1', title: 'B' } }]); + + await apps.update('a1', { title: 'B' }); + + expect(calls[0]?.method).to.equal('PUT'); + expect(calls[0]?.url).to.equal(`${BASE}/apps/a1/`); + expect(JSON.parse(calls[0]?.body ?? '')).to.deep.equal({ title: 'B' }); + }); + + it('refuses an update with nothing in it, also before the network', async () => { + const { apps, calls } = setup([]); + + const error = await rejection(apps.update('a1', {})); + + expect(error).to.be.instanceOf(ValidationError); + expect(calls).to.have.length(0); + }); +}); diff --git a/test/sdk/adapty/apps/update.test.ts b/test/sdk/adapty/apps/update.test.ts new file mode 100644 index 0000000..06ea55c --- /dev/null +++ b/test/sdk/adapty/apps/update.test.ts @@ -0,0 +1,25 @@ +import { expect } from 'chai'; + +import { validateUpdateApp } from '../../../../src/sdk/adapty/apps/index.js'; + +import type { UpdateAppInput } from '../../../../src/sdk/adapty/index.js'; +import type { Issue } from '../../../../src/sdk/core/errors.js'; + +/** A rule returns a list instead of throwing, which is what makes a table like this possible. */ +const paths = (issues: readonly Issue[]): (string | undefined)[] => issues.map(issue => issue.path); + +describe('validateUpdateApp', () => { + const cases: { expected: (string | undefined)[]; input: UpdateAppInput; name: string }[] = [ + // No path: the input as a whole is the problem, not one of its fields + { expected: [undefined], input: {}, name: 'nothing given' }, + { expected: [], input: { title: 'B' }, name: 'a title only' }, + { expected: [], input: { googleBundleId: 'com.b' }, name: 'a google bundle id only' }, + { expected: ['title'], input: { title: '' }, name: 'an empty title' }, + ]; + + for (const { expected, input, name } of cases) { + it(name, () => { + expect(paths(validateUpdateApp(input))).to.deep.equal(expected); + }); + } +}); diff --git a/test/sdk/adapty/auth.test.ts b/test/sdk/adapty/auth.test.ts new file mode 100644 index 0000000..c9c79e0 --- /dev/null +++ b/test/sdk/adapty/auth.test.ts @@ -0,0 +1,152 @@ +import { expect } from 'chai'; + +import { createAdapty, DEFAULT_ADAPTY_API_URL } from '../../../src/sdk/adapty/index.js'; +import { runDeviceFlow } from '../../../src/sdk/core/auth/device-flow.js'; +import { ApiError } from '../../../src/sdk/core/errors.js'; +import { createFakeClock, createScriptedFetch } from '../../../src/sdk/core/testing.js'; +import { rejection } from '../../helpers/rejection.js'; + +import type { PollResult } from '../../../src/sdk/core/auth/device-flow.js'; + +type Script = Parameters[0]; + +const setup = (script: Script, token?: string) => { + const scripted = createScriptedFetch(script); + const clock = createFakeClock(); + + const adapty = createAdapty({ + clock, + fetch: scripted.fetch, + token, + userAgent: 'adapty-cli/test', + }); + + return { adapty, calls: scripted.calls, clock }; +}; + +const deviceResponse = { + device_code: 'dc_1', + expires_in: 600, + user_code: 'WDJB-MJHT', + verification_uri: 'https://app.adapty.io/activate', + verification_uri_complete: 'https://app.adapty.io/activate?code=WDJB-MJHT', +}; + +const tokenResponse = { + access_token: 'tok_1', + expires_in: 3600, + token_type: 'Bearer', + user: { email: 'dev@example.com', name: 'Dev' }, +}; + +describe('adapty.auth', () => { + it('asks for a device code on the product path and hands core the shape the port declares', async () => { + const { adapty, calls } = setup([{ body: deviceResponse }]); + + const code = await adapty.auth.requestDeviceCode(); + + expect(calls[0]?.url).to.equal(`${DEFAULT_ADAPTY_API_URL}/auth/device/`); + expect(calls[0]?.body).to.equal('{"client_id":"adapty-cli"}'); + expect(calls[0]?.headers.get('user-agent')).to.equal('adapty-cli/test'); + + expect(code).to.deep.equal({ + deviceCode: 'dc_1', + expiresInSec: 600, + intervalSec: 5, + userCode: 'WDJB-MJHT', + verificationUri: 'https://app.adapty.io/activate', + verificationUriComplete: 'https://app.adapty.io/activate?code=WDJB-MJHT', + }); + }); + + it('takes the poll interval from the server when it sends one', async () => { + const { adapty } = setup([{ body: { ...deviceResponse, interval_seconds: 3 } }]); + + expect((await adapty.auth.requestDeviceCode()).intervalSec).to.equal(3); + }); + + it('reads an issued token into the domain shape', async () => { + const { adapty, calls } = setup([{ body: tokenResponse }]); + + expect(await adapty.auth.pollToken('dc_1')).to.deep.equal({ + status: 'authorized', + token: { + accessToken: 'tok_1', + expiresInSec: 3600, + user: { email: 'dev@example.com', name: 'Dev' }, + }, + }); + + expect(calls[0]?.url).to.equal(`${DEFAULT_ADAPTY_API_URL}/auth/token/`); + }); + + it('turns the protocol codes into states instead of errors', async () => { + const expected: Record['status']> = { + access_denied: 'denied', + authorization_pending: 'pending', + expired_token: 'expired', + slow_down: 'slow_down', + }; + + for (const [code, status] of Object.entries(expected)) { + const { adapty } = setup([{ status: 400, body: { error: code } }]); + + expect(await adapty.auth.pollToken('dc_1'), code).to.deep.equal({ status }); + } + }); + + it('reads a protocol code out of a 200 as well, so a pending login is never called authorized', async () => { + const { adapty } = setup([{ body: { error: 'authorization_pending' } }]); + + expect(await adapty.auth.pollToken('dc_1')).to.deep.equal({ status: 'pending' }); + }); + + it('refuses a 200 whose code means nothing to the protocol', async () => { + const { adapty } = setup([{ body: { error: 'teapot' } }]); + + const error = await rejection(adapty.auth.pollToken('dc_1')); + + expect(error).to.be.instanceOf(ApiError); + expect((error as ApiError).code).to.equal('teapot'); + expect((error as ApiError).message).to.contain('Unexpected device flow response'); + }); + + it('lets a real failure through: an unknown code is not a state', async () => { + const { adapty } = setup([{ status: 500, body: { error: 'internal_error' } }]); + + const error = await rejection(adapty.auth.pollToken('dc_1')); + + expect(error).to.be.instanceOf(ApiError); + expect((error as ApiError).code).to.equal('internal_error'); + }); + + it('is accepted by runDeviceFlow as the port itself, with no adapter in between', async () => { + const { adapty, clock } = setup([ + { body: deviceResponse }, + { status: 400, body: { error: 'authorization_pending' } }, + { body: tokenResponse }, + ]); + + const shown: string[] = []; + + const token = await runDeviceFlow(adapty.auth, { + clock, + onCode: code => shown.push(code.userCode), + }); + + expect(token.accessToken).to.equal('tok_1'); + expect(shown).to.deep.equal(['WDJB-MJHT']); + expect(clock.sleeps).to.deep.equal([5000, 5000]); + }); + + it('sends the bearer token on the calls that need one', async () => { + const { adapty, calls } = setup([{ status: 204 }, { body: { email: 'dev@example.com' } }], 'tok_stored'); + + await adapty.auth.revokeToken('tok_stored'); + expect(await adapty.auth.me()).to.deep.equal({ email: 'dev@example.com' }); + + expect(calls[0]?.url).to.equal(`${DEFAULT_ADAPTY_API_URL}/auth/tokens/revoke/`); + expect(calls[0]?.headers.get('authorization')).to.equal('Bearer tok_stored'); + expect(calls[1]?.url).to.equal(`${DEFAULT_ADAPTY_API_URL}/me/`); + }); +}); diff --git a/test/sdk/adapty/errors.test.ts b/test/sdk/adapty/errors.test.ts new file mode 100644 index 0000000..7ef6d54 --- /dev/null +++ b/test/sdk/adapty/errors.test.ts @@ -0,0 +1,46 @@ +import { expect } from 'chai'; + +import { developerErrorParser } from '../../../src/sdk/adapty/index.js'; + +/** The bodies the developer API actually sends, taken from the published src/lib/errors.ts. */ +describe('developerErrorParser', () => { + it('joins field errors into the message and keeps the code', () => { + const parsed = developerErrorParser(400, { + error_code: 'validation_error', + errors: { apple_bundle_id: ['already used'], title: ['must not be blank'] }, + }); + + expect(parsed).to.deep.equal({ + code: 'validation_error', + message: 'apple_bundle_id: already used; title: must not be blank', + }); + }); + + it('prints a non_field_errors message without a field prefix', () => { + const parsed = developerErrorParser(400, { + error_code: 'validation_error', + errors: { non_field_errors: ['the app already exists'] }, + }); + + expect(parsed.message).to.equal('the app already exists'); + }); + + it('falls back to the code when the server sends no field errors', () => { + expect(developerErrorParser(404, { error_code: 'app_not_found' })).to.deep.equal({ + code: 'app_not_found', + message: 'app_not_found', + }); + }); + + it('reads the short shape the auth endpoints use', () => { + expect(developerErrorParser(400, { error: 'authorization_pending' })).to.deep.equal({ + code: 'authorization_pending', + message: 'authorization_pending', + }); + }); + + it('says nothing about a body it does not recognise, leaving the status to speak', () => { + expect(developerErrorParser(500, 'Bad Gateway')).to.deep.equal({}); + expect(developerErrorParser(500, { detail: 'nope' })).to.deep.equal({}); + }); +}); diff --git a/test/sdk/core/auth/device-flow.test.ts b/test/sdk/core/auth/device-flow.test.ts new file mode 100644 index 0000000..2bf1ec6 --- /dev/null +++ b/test/sdk/core/auth/device-flow.test.ts @@ -0,0 +1,165 @@ +import { expect } from 'chai'; + +import { runDeviceFlow } from '../../../../src/sdk/core/auth/device-flow.js'; +import { CancelledError, DeviceFlowDeniedError, DeviceFlowExpiredError, NetworkError } from '../../../../src/sdk/core/errors.js'; +import { createFakeClock } from '../../../../src/sdk/core/testing.js'; +import { rejection } from '../../../helpers/rejection.js'; + +import type { DeviceAuthApi, DeviceCode, PollResult } from '../../../../src/sdk/core/auth/device-flow.js'; + +const code: DeviceCode = { + deviceCode: 'dc_1', + expiresInSec: 12, + intervalSec: 5, + userCode: 'WDJB-MJHT', + verificationUri: 'https://example.com/activate', + verificationUriComplete: undefined, +}; + +type Step = Error | PollResult; + +/** A port with scripted poll answers. The last step repeats forever; an Error is thrown. */ +const fakeApi = (steps: Step[], onPoll?: () => void) => { + let polls = 0; + + const api: DeviceAuthApi = { + pollToken: () => { + polls += 1; + onPoll?.(); + + const step = steps[Math.min(polls, steps.length) - 1] ?? { status: 'pending' }; + + return step instanceof Error ? Promise.reject(step) : Promise.resolve(step); + }, + + requestDeviceCode: () => Promise.resolve(code), + }; + + return { api, polls: () => polls }; +}; + +const silent = { onCode: () => undefined }; + +describe('runDeviceFlow', () => { + it('shows the code, waits the interval between polls and returns the token', async () => { + const clock = createFakeClock(); + const shown: string[] = []; + const { api, polls } = fakeApi([{ status: 'pending' }, { status: 'authorized', token: 'tok' }]); + + const token = await runDeviceFlow(api, { clock, onCode: c => shown.push(c.userCode) }); + + expect(token).to.equal('tok'); + expect(shown).to.deep.equal(['WDJB-MJHT']); + expect(polls()).to.equal(2); + expect(clock.sleeps).to.deep.equal([5000, 5000]); + }); + + it('never polls faster than minIntervalMs, even when the server allows it', async () => { + const clock = createFakeClock(); + const { api } = fakeApi([{ status: 'authorized', token: 'tok' }]); + + api.requestDeviceCode = () => Promise.resolve({ ...code, intervalSec: 1 }); + await runDeviceFlow(api, { clock, ...silent }); + + expect(clock.sleeps).to.deep.equal([5000]); + }); + + it('grows the interval by 5 seconds on slow_down', async () => { + const clock = createFakeClock(); + const { api } = fakeApi([{ status: 'slow_down' }, { status: 'pending' }, { status: 'authorized', token: 'tok' }]); + + api.requestDeviceCode = () => Promise.resolve({ ...code, expiresInSec: 60 }); + await runDeviceFlow(api, { clock, ...silent }); + + expect(clock.sleeps).to.deep.equal([5000, 10_000, 10_000]); + }); + + it('stops polling once expires_in has passed', async () => { + const clock = createFakeClock(); + const { api, polls } = fakeApi([{ status: 'pending' }]); + + expect(await rejection(runDeviceFlow(api, { clock, ...silent }))).to.be.instanceOf(DeviceFlowExpiredError); + // 12 seconds at a 5 second interval: polls at 5, 10 and 15 seconds, then the deadline + expect(polls()).to.equal(3); + }); + + it('reports a user refusal as DeviceFlowDeniedError', async () => { + const { api } = fakeApi([{ status: 'denied' }]); + + const error = await rejection(runDeviceFlow(api, { clock: createFakeClock(), ...silent })); + + expect(error).to.be.instanceOf(DeviceFlowDeniedError); + }); + + it('survives isolated network failures and reports them', async () => { + const clock = createFakeClock(); + const failure = new NetworkError('https://example.com/auth/token/', new TypeError('fetch failed')); + const reported: number[] = []; + const { api, polls } = fakeApi([failure, failure, { status: 'authorized', token: 'tok' }]); + + const token = await runDeviceFlow(api, { + clock, + ...silent, + onTransientError: (_error, consecutive) => reported.push(consecutive), + }); + + expect(token).to.equal('tok'); + expect(polls()).to.equal(3); + expect(reported).to.deep.equal([1, 2]); + }); + + it('gives up with the last error after maxConsecutiveErrors failures in a row', async () => { + const clock = createFakeClock(); + const failure = new NetworkError('https://example.com/auth/token/', new TypeError('fetch failed')); + const { api, polls } = fakeApi([failure]); + + api.requestDeviceCode = () => Promise.resolve({ ...code, expiresInSec: 3600 }); + + const error = await rejection(runDeviceFlow(api, { clock, ...silent, maxConsecutiveErrors: 4 })); + + expect(error).to.be.instanceOf(NetworkError); + expect(polls()).to.equal(4); + }); + + it('gives CancelledError before asking for a code at all when already aborted', async () => { + const controller = new AbortController(); + const shown: string[] = []; + + controller.abort(); + + let requested = 0; + const { api, polls } = fakeApi([{ status: 'authorized', token: 'tok' }]); + + api.requestDeviceCode = () => { + requested += 1; + + return Promise.resolve(code); + }; + + const error = await rejection(runDeviceFlow(api, { + clock: createFakeClock(), + onCode: c => shown.push(c.userCode), + signal: controller.signal, + })); + + expect(error).to.be.instanceOf(CancelledError); + expect(requested).to.equal(0); + expect(shown).to.deep.equal([]); + expect(polls()).to.equal(0); + }); + + it('turns Ctrl+C in the middle of the wait into CancelledError and stops polling', async () => { + const clock = createFakeClock(); + const controller = new AbortController(); + + // the cancellation lands while the first poll is in flight + const { api, polls } = fakeApi([{ status: 'pending' }], () => { + controller.abort(); + }); + + const error = await rejection(runDeviceFlow(api, { clock, signal: controller.signal, ...silent })); + + expect(error).to.be.instanceOf(CancelledError); + expect(polls()).to.equal(1); + }); +}); diff --git a/test/sdk/core/http/client.test.ts b/test/sdk/core/http/client.test.ts new file mode 100644 index 0000000..600bcdf --- /dev/null +++ b/test/sdk/core/http/client.test.ts @@ -0,0 +1,431 @@ +import { expect } from 'chai'; + +import { ApiError, AuthRequiredError, CancelledError, NetworkError } from '../../../../src/sdk/core/errors.js'; +import { createHttp, defaultRetryPolicy, defaultShouldRetry, retry } from '../../../../src/sdk/core/http/index.js'; +import { createFakeClock, createScriptedFetch } from '../../../../src/sdk/core/testing.js'; +import { rejection } from '../../../helpers/rejection.js'; + +import type { HttpOptions } from '../../../../src/sdk/core/http/index.js'; +import type { FakeClock } from '../../../../src/sdk/core/testing.js'; + +type Script = Parameters[0]; + +/** Every seam of the transport is an option, so a test overrides the one it is about. */ +const setup = (script: Script, options: Partial & { clock?: FakeClock } = {}) => { + const clock = options.clock ?? createFakeClock(); + const scripted = createScriptedFetch(script); + + const http = createHttp({ + baseUrl: 'https://api.example.com/api/v1', + fetch: scripted.fetch, + ...options, + clock, + }); + + return { calls: scripted.calls, clock, http }; +}; + +describe('createHttp', () => { + it('builds the URL over the base prefix, sends bearer and JSON, parses the answer', async () => { + const { calls, http } = setup([{ body: { id: 'pw_1' } }], { token: 'tok' }); + + const result = await http.post<{ id: string }>('/apps/app_1/paywalls', { title: 'T' }, { + query: { limit: 10, ids: ['a', 'b'], skip: undefined }, + }); + + expect(result).to.deep.equal({ id: 'pw_1' }); + expect(calls).to.have.lengthOf(1); + expect(calls[0]?.url).to.equal('https://api.example.com/api/v1/apps/app_1/paywalls/?limit=10&ids=a&ids=b'); + expect(calls[0]?.method).to.equal('POST'); + expect(calls[0]?.headers.get('authorization')).to.equal('Bearer tok'); + expect(calls[0]?.headers.get('content-type')).to.equal('application/json'); + expect(calls[0]?.body).to.equal('{"title":"T"}'); + }); + + it('keeps the base path for empty and slash-only root paths', async () => { + const { calls, http } = setup([{ body: {} }, { body: {} }]); + + await http.get(''); + await http.get('/'); + + expect(calls.map(call => call.url)).to.deep.equal([ + 'https://api.example.com/api/v1/', + 'https://api.example.com/api/v1/', + ]); + }); + + it('turns 401 into AuthRequiredError with reason rejected', async () => { + const { http } = setup([{ status: 401, body: { error: 'unauthorized' } }]); + + const error = await rejection(http.get('/me')); + + expect(error).to.be.instanceOf(AuthRequiredError); + expect((error as AuthRequiredError).reason).to.equal('rejected'); + }); + + it('gives undefined for 204 and an empty body, exposes response headers via onResponse', async () => { + const { http } = setup([{ status: 204, headers: { etag: '"v7"' } }]); + let etag: null | string = null; + + const result = await http.put('/flows/f1/config', {}, { + onResponse: (headers) => { + etag = headers.get('etag'); + }, + }); + + expect(result).to.equal(undefined); + expect(etag).to.equal('"v7"'); + }); + + it('sends FormData as is, without content-type: application/json', async () => { + const { calls, http } = setup([{ body: { ok: true } }]); + const form = new FormData(); + + form.set('file', new Blob(['x']), 'x.txt'); + await http.post('/uploads', form); + + expect(calls[0]?.headers.get('content-type')).to.not.equal('application/json'); + }); + + it('turns 4xx into ApiError carrying the code from the body', async () => { + const { http } = setup([{ status: 400, body: { error: 'authorization_pending' } }]); + + const error = await rejection(http.post('/oauth/token', {})); + + expect(error).to.be.instanceOf(ApiError); + expect((error as ApiError).status).to.equal(400); + expect((error as ApiError).code).to.equal('authorization_pending'); + }); + + it('retries a GET after 503 using the delay from Retry-After', async () => { + const { calls, clock, http } = setup([ + { status: 503, body: { error: { code: 'unavailable', message: 'later' } }, headers: { 'retry-after': '2' } }, + { body: [] }, + ]); + + expect(await http.get('/apps')).to.deep.equal([]); + expect(calls).to.have.lengthOf(2); + expect(clock.sleeps).to.deep.equal([2000]); + }); + + it('retries network failures with exponential backoff, then gives up', async () => { + const { calls, clock, http } = setup([ + new TypeError('fetch failed'), + new TypeError('fetch failed'), + new TypeError('fetch failed'), + ]); + + expect(await rejection(http.get('/apps'))).to.be.instanceOf(NetworkError); + expect(calls).to.have.lengthOf(3); + expect(clock.sleeps).to.deep.equal([500, 1000]); + }); + + it('retries a GET when the connection drops while reading the body', async () => { + const clock = createFakeClock(); + let calls = 0; + + const http = createHttp({ + baseUrl: 'https://api.example.com/api/v1', + clock, + fetch: () => { + calls += 1; + + const body = calls === 1 + // eslint-disable-next-line n/no-unsupported-features/node-builtins -- Web streams exist in Node 22; only their stability label changed later. + ? new ReadableStream({ + start(controller) { + controller.error(new TypeError('terminated')); + }, + }) + : '{"ok":true}'; + + return Promise.resolve(new Response(body)); + }, + }); + + expect(await http.get('/apps')).to.deep.equal({ ok: true }); + expect(calls).to.equal(2); + expect(clock.sleeps).to.deep.equal([500]); + }); + + it('reports a body read failure as NetworkError without retrying a POST', async () => { + const cause = new TypeError('terminated'); + const clock = createFakeClock(); + let calls = 0; + + const http = createHttp({ + baseUrl: 'https://api.example.com/api/v1', + clock, + fetch: () => { + calls += 1; + + // eslint-disable-next-line n/no-unsupported-features/node-builtins -- Web streams exist in Node 22; only their stability label changed later. + return Promise.resolve(new Response(new ReadableStream({ + start(controller) { + controller.error(cause); + }, + }))); + }, + }); + + const error = await rejection(http.post('/apps', {})); + + expect(error).to.be.instanceOf(NetworkError); + expect((error as NetworkError).cause).to.equal(cause); + expect((error as NetworkError).url).to.equal('https://api.example.com/api/v1/apps/'); + expect(calls).to.equal(1); + expect(clock.sleeps).to.deep.equal([]); + }); + + it('does not treat a caller response callback failure as a network failure', async () => { + const { calls, clock, http } = setup([{ body: {} }]); + const cause = new Error('callback failed'); + + const error = await rejection(http.get('/apps', { + onResponse: () => { + throw cause; + }, + })); + + expect(error).to.equal(cause); + expect(calls).to.have.lengthOf(1); + expect(clock.sleeps).to.deep.equal([]); + }); + + it('does not retry cancellation during a body read, even if it arrives as a TypeError', async () => { + for (const cause of [new DOMException('aborted', 'AbortError'), new TypeError('terminated')]) { + const controller = new AbortController(); + const clock = createFakeClock(); + let calls = 0; + + const http = createHttp({ + baseUrl: 'https://api.example.com/api/v1', + clock, + fetch: () => { + calls += 1; + + // eslint-disable-next-line n/no-unsupported-features/node-builtins -- Web streams exist in Node 22; only their stability label changed later. + return Promise.resolve(new Response(new ReadableStream({ + pull(stream) { + // AbortError alone must also work without an aborted client signal. + if (cause instanceof TypeError) { + controller.abort(); + } + + stream.error(cause); + }, + }))); + }, + signal: controller.signal, + }); + + expect(await rejection(http.get('/apps'))).to.be.instanceOf(CancelledError); + expect(calls).to.equal(1); + expect(clock.sleeps).to.deep.equal([]); + } + }); + + it('does not retry a POST by default', async () => { + const { calls, http } = setup([{ status: 503, body: {} }]); + + expect(await rejection(http.post('/apps', {}))).to.be.instanceOf(ApiError); + expect(calls).to.have.lengthOf(1); + }); + + it('retries a POST marked idempotent', async () => { + const { calls, http } = setup([{ status: 503, body: {} }, { body: { ok: true } }]); + + expect(await http.post('/writes', {}, { idempotent: true })).to.deep.equal({ ok: true }); + expect(calls).to.have.lengthOf(2); + }); + + it('gives CancelledError for an already aborted signal, without touching the network', async () => { + const controller = new AbortController(); + + controller.abort(); + + const { calls, http } = setup([{ body: {} }], { signal: controller.signal }); + + expect(await rejection(http.get('/apps'))).to.be.instanceOf(CancelledError); + expect(calls).to.have.lengthOf(0); + }); + + it('turns an AbortError from fetch into CancelledError', async () => { + const { http } = setup([new DOMException('aborted', 'AbortError')]); + + expect(await rejection(http.post('/apps', {}))).to.be.instanceOf(CancelledError); + }); + + it('combines the client signal with the per-request one', async () => { + const client = new AbortController(); + const request = new AbortController(); + + request.abort(); + + // only the client signal is passed at construction, so this fires only if the two are combined + const { calls, http } = setup([{ body: {} }], { signal: client.signal }); + + expect(await rejection(http.get('/apps', { signal: request.signal }))).to.be.instanceOf(CancelledError); + expect(calls).to.have.lengthOf(0); + }); + + it('reads an abort during an in-flight request as cancellation, not as a network error to retry', async () => { + const controller = new AbortController(); + const clock = createFakeClock(); + let calls = 0; + + const http = createHttp({ + baseUrl: 'https://api.example.com/api/v1', + clock, + fetch: () => { + calls += 1; + controller.abort(); + + // a runtime that reports an interrupted fetch as something other than AbortError + return Promise.reject(new TypeError('fetch failed')); + }, + signal: controller.signal, + }); + + // a POST carries no retry wrapper, so the conversion in the transport is the only thing that can fire + expect(await rejection(http.post('/apps', {}))).to.be.instanceOf(CancelledError); + expect(calls).to.equal(1); + expect(clock.sleeps).to.deep.equal([]); + }); + + it('reads the third body shape the default parser tolerates: { code, detail }', async () => { + const { http } = setup([{ status: 403, body: { code: 'forbidden', detail: 'not your app' } }]); + + const error = await rejection(http.post('/apps/app_1', {})); + + expect((error as ApiError).code).to.equal('forbidden'); + expect((error as ApiError).message).to.equal('not your app'); + }); + + it('takes the error shape from the parser it was given', async () => { + // the ASA shape: a list of per-item errors rather than one code at the top + const parseError: HttpOptions['parseError'] = (_status, body) => { + const [first] = (body as { errors?: { error_code?: string; message?: string }[] }).errors ?? []; + + return { code: first?.error_code, message: first?.message }; + }; + + const { http } = setup( + [{ status: 400, body: { errors: [{ error_code: 'quota_exceeded', message: 'too many keywords' }] } }], + { parseError }, + ); + + const error = await rejection(http.post('/campaigns', {})); + + expect((error as ApiError).code).to.equal('quota_exceeded'); + expect((error as ApiError).message).to.equal('too many keywords'); + }); + + it('takes the retry rule it was given: a 429 the default would repeat can be refused', async () => { + const { calls, http } = setup([{ status: 429, body: { error: 'cli_cooldown_active' } }], { + shouldRetry: error => (error instanceof ApiError && error.code === 'cli_cooldown_active' ? false : {}), + }); + + expect(await rejection(http.get('/campaigns'))).to.be.instanceOf(ApiError); + expect(calls).to.have.lengthOf(1); + }); + + it('takes a Retry-After given as an HTTP date, measured against the injected clock', async () => { + const start = Date.parse('2030-01-01T12:00:00Z'); + const clock = createFakeClock(start); + + const { calls, http } = setup([ + { status: 503, body: {}, headers: { 'retry-after': new Date(start + 30_000).toUTCString() } }, + { body: { ok: true } }, + ], { clock }); + + expect(await http.get('/apps')).to.deep.equal({ ok: true }); + expect(calls).to.have.lengthOf(2); + expect(clock.sleeps).to.deep.equal([30_000]); + }); + + it('clamps a Retry-After date already in the past to no wait', async () => { + const start = Date.parse('2030-01-01T12:00:00Z'); + const clock = createFakeClock(start); + + const { http } = setup([ + { status: 503, body: {}, headers: { 'retry-after': new Date(start - 60_000).toUTCString() } }, + { body: { ok: true } }, + ], { clock }); + + await http.get('/apps'); + + expect(clock.sleeps).to.deep.equal([0]); + }); + + it('leaves the path alone for a server that wants no trailing slash', async () => { + const { calls, http } = setup([{ body: [] }], { trailingSlash: false }); + + await http.get('/apps', { query: { page: 1 } }); + + expect(calls[0]?.url).to.equal('https://api.example.com/api/v1/apps?page=1'); + }); + + it('keeps a body that is not JSON as text instead of failing to parse it', async () => { + // a proxy answering 502 with an HTML page: the text is more useful than a parse error + const html = '502 Bad Gateway'; + let calls = 0; + + const http = createHttp({ + baseUrl: 'https://api.example.com/api/v1', + clock: createFakeClock(), + fetch: () => { + calls += 1; + + return Promise.resolve(new Response(html, { status: 502 })); + }, + retry: { attempts: 1, baseDelayMs: 0, maxDelayMs: 0 }, + }); + + const error = await rejection(http.get('/apps')); + + expect(error).to.be.instanceOf(ApiError); + expect((error as ApiError).details).to.equal(html); + expect(calls).to.equal(1); + }); + + it('reports a retry to the caller instead of printing anything itself', async () => { + const seen: { attempt: number; delayMs: number }[] = []; + + const { http } = setup([{ status: 503, body: {} }, { body: { ok: true } }], { + onRetry: ({ attempt, delayMs }) => { + seen.push({ attempt, delayMs }); + }, + }); + + await http.get('/apps'); + + expect(seen).to.deep.equal([{ attempt: 1, delayMs: 500 }]); + }); +}); + +describe('retry', () => { + it('notices cancellation in the wait between attempts', async () => { + const controller = new AbortController(); + const clock = createFakeClock(); + let attempts = 0; + + const failing = () => { + attempts += 1; + controller.abort(); + + return Promise.reject(new NetworkError('https://api.example.com/apps/', new TypeError('fetch failed'))); + }; + + const error = await rejection(retry(failing, { + clock, + policy: defaultRetryPolicy, + shouldRetry: defaultShouldRetry, + signal: controller.signal, + })); + + expect(error).to.be.instanceOf(CancelledError); + expect(attempts).to.equal(1); + expect(clock.sleeps).to.deep.equal([]); + }); +}); diff --git a/test/sdk/core/session.test.ts b/test/sdk/core/session.test.ts new file mode 100644 index 0000000..8e6af72 --- /dev/null +++ b/test/sdk/core/session.test.ts @@ -0,0 +1,108 @@ +import { chmod, mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect } from 'chai'; + +import { StorageError } from '../../../src/sdk/core/errors.js'; +import { createFileSessionStore } from '../../../src/sdk/core/session.js'; +import { rejection } from '../../helpers/rejection.js'; + +import type { SessionStore } from '../../../src/sdk/core/session.js'; + +/** File modes mean nothing on Windows, and CI runs there too. */ +const posix = process.platform === 'win32' ? it.skip : it; + +const session = { token: 'tok_secret_value', user: { email: 'dev@example.com', name: 'Dev' } }; + +describe('createFileSessionStore', () => { + let dir: string; + let store: SessionStore; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'adapty-session-')); + store = createFileSessionStore(dir); + }); + + it('keeps the on-disk contract the published CLI already writes', async () => { + await store.save(session); + + expect(store.path).to.equal(join(dir, 'config.json')); + + expect(JSON.parse(await readFile(store.path, 'utf8'))).to.deep.equal({ + access_token: 'tok_secret_value', + user: { email: 'dev@example.com', name: 'Dev' }, + }); + }); + + it('round-trips a session, with and without a user', async () => { + await store.save(session); + expect(await store.load()).to.deep.equal(session); + + await store.save({ token: 'tok_2' }); + expect(await store.load()).to.deep.equal({ token: 'tok_2' }); + }); + + posix('creates the file 0600 and its directory 0700', async () => { + const nested = createFileSessionStore(join(dir, 'deeper')); + + await nested.save(session); + + expect((await stat(nested.path)).mode & 0o777).to.equal(0o600); + expect((await stat(join(dir, 'deeper'))).mode & 0o777).to.equal(0o700); + }); + + posix('tightens the mode of a file that already went loose', async () => { + // writeFile's `mode` applies only on creation, so a pre-existing 0644 file would keep it + await writeFile(store.path, '{}\n', { mode: 0o600 }); + await chmod(store.path, 0o644); + + await store.save(session); + + expect((await stat(store.path)).mode & 0o777).to.equal(0o600); + }); + + it('reads a missing file as "not logged in"', async () => { + expect(await store.load()).to.equal(undefined); + }); + + it('reports corrupted JSON as a StorageError naming the file', async () => { + await writeFile(store.path, '{ this is not json', { mode: 0o600 }); + + const error = await rejection(store.load()); + + expect(error).to.be.instanceOf(StorageError); + expect((error as StorageError).path).to.equal(store.path); + expect((error as StorageError).message).to.contain(store.path); + }); + + it('lets a read failure that is not "no such file" through', async () => { + // a directory where the file should be: the store must not report this as "not logged in" + await mkdir(store.path); + + const error = await rejection(store.load()); + + expect(error).to.be.instanceOf(Error); + expect(error).to.not.be.instanceOf(StorageError); + }); + + it('treats an unknown shape as "not logged in" instead of failing', async () => { + await writeFile(store.path, JSON.stringify({ token: 'from a newer version' }), { mode: 0o600 }); + + expect(await store.load()).to.equal(undefined); + }); + + it('drops a user that is not fully there, keeping the token', async () => { + await writeFile(store.path, JSON.stringify({ access_token: 'tok', user: { email: 'a@b.c' } }), { mode: 0o600 }); + + expect(await store.load()).to.deep.equal({ token: 'tok' }); + }); + + it('clears the session, and stays quiet when there is nothing to clear', async () => { + await store.save(session); + await store.clear(); + + expect(await store.load()).to.equal(undefined); + await store.clear(); + }); +}); diff --git a/test/sdk/core/validation.test.ts b/test/sdk/core/validation.test.ts new file mode 100644 index 0000000..b7a3730 --- /dev/null +++ b/test/sdk/core/validation.test.ts @@ -0,0 +1,28 @@ +import { expect } from 'chai'; + +import { ValidationError } from '../../../src/sdk/core/errors.js'; +import { assertValid } from '../../../src/sdk/core/validation.js'; + +describe('assertValid', () => { + it('passes an empty list through', () => { + expect(() => { + assertValid([]); + }).to.not.throw(); + }); + + it('throws one ValidationError carrying every issue', () => { + const issues = [ + { message: 'required when platforms include ios', path: 'appleBundleId' }, + { message: 'nothing to update: pass at least one field' }, + ]; + + try { + assertValid(issues); + expect.fail('expected assertValid to throw'); + } catch (error) { + expect(error).to.be.instanceOf(ValidationError); + expect((error as ValidationError).issues).to.deep.equal(issues); + expect((error as ValidationError).kind).to.equal('validation'); + } + }); +});