diff --git a/CLAUDE.md b/CLAUDE.md index 4ed74bf..6a3bb89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ src/ products/ # list, get, create, update paywalls/ # list, get, create, update, placements (placements using a paywall) placements/ # list, get, create, update (audiences[] or deprecated --paywall-id) + flows/ # list, get, create; config/ (get, update — builder config with optimistic lock) segments/ # list, get access-levels/ # list, get, create, update asa/ # Apple Search Ads: whoami, connect, orgs, apps, campaigns, ad-groups, keywords, diff --git a/README.md b/README.md index 246809a..3ad0874 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Adapty CLI -[Adapty Developer CLI](https://adapty.io/docs/developer-cli). Manage apps, products, paywalls, placements, and access levels from your terminal. +[Adapty Developer CLI](https://adapty.io/docs/developer-cli). Manage apps, products, paywalls, placements, flows, and access levels from your terminal. ## Installation @@ -77,6 +77,20 @@ adapty placements create --app UUID [flags] adapty placements update --app UUID PLACEMENT_ID [flags] ``` +### Flows + +```sh +adapty flows list --app UUID [--page N] [--page-size N] +adapty flows get --app UUID FLOW_ID +adapty flows create --app UUID --name "Name" +adapty flows config get --app UUID FLOW_ID +adapty flows config update --app UUID FLOW_ID (--config JSON | --config-file PATH|-) [--remote-configs JSON] [--expected-updated-at MS] +``` + +A freshly created flow has no config until the first `flows config update`; `flows config get` returns 404 +until then. Pass `--expected-updated-at` (the `updated_at` from a prior `config get`) to fail instead of +overwriting a concurrent dashboard edit. + ### Access Levels ```sh diff --git a/package.json b/package.json index f853d04..ddb578b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "adapty", "description": "Adapty command line interface", - "version": "0.5.0", + "version": "0.6.0", "author": "Adapty team ", "bin": { "adapty": "./bin/run.js" @@ -80,6 +80,12 @@ "placements": { "description": "Manage placements" }, + "flows": { + "description": "Manage flows (onboardings)" + }, + "flows:config": { + "description": "Read and write a flow's builder config" + }, "segments": { "description": "List segments" }, diff --git a/src/commands/flows/config/get.ts b/src/commands/flows/config/get.ts new file mode 100644 index 0000000..b774ede --- /dev/null +++ b/src/commands/flows/config/get.ts @@ -0,0 +1,34 @@ +import {Args, Command} from '@oclif/core' + +import type {FlowConfigDTO} from '../../../lib/api-schemas.js' + +import {createAuthenticatedClient} from '../../../lib/client-from-config.js' +import {appFlag, isValidUuid} from '../../../lib/flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class FlowsConfigGet extends Command { + static args = { + flow_id: Args.string({description: 'Flow ID (UUID)', required: true}), + } +static description = 'Read the flow builder config (404 until the config has been written at least once)' +static enableJsonFlag = true +static examples = ['<%= config.bin %> flows config get --app UUID 550e8400-e29b-41d4-a716-446655440000'] +static flags = { + ...appFlag, + } + + async run(): Promise { + const {args, flags} = await this.parse(FlowsConfigGet) + + if (!isValidUuid(args.flow_id)) { + this.error('Invalid flow ID format.', {exit: 2}) + } + + const client = await createAuthenticatedClient(this.config) + const result = await client.get(`/apps/${flags.app}/flows/${args.flow_id}/config`) + + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/flows/config/update.ts b/src/commands/flows/config/update.ts new file mode 100644 index 0000000..b38e5b4 --- /dev/null +++ b/src/commands/flows/config/update.ts @@ -0,0 +1,100 @@ +import {Args, Command, Flags} from '@oclif/core' +import {readFile} from 'node:fs/promises' + +import type {FlowConfigDTO, FlowConfigWriteRequestDTO, FlowRemoteConfigDTO} from '../../../lib/api-schemas.js' + +import {createAuthenticatedClient} from '../../../lib/client-from-config.js' +import {appFlag, isValidUuid} from '../../../lib/flags.js' +import {printResponse} from '../../../lib/output.js' + +export default class FlowsConfigUpdate extends Command { + static args = { + flow_id: Args.string({description: 'Flow ID (UUID)', required: true}), + } +static description = 'Write the flow builder config (creates the first version, or edits/forks the current one)' +static enableJsonFlag = true +static examples = [ + '<%= config.bin %> flows config update --app UUID FLOW_UUID --config-file config.json', + 'cat config.json | <%= config.bin %> flows config update --app UUID FLOW_UUID --config-file -', + '<%= config.bin %> flows config update --app UUID FLOW_UUID --config \'{"screens":[],"locales":[]}\' --expected-updated-at 1755001800000', + ] +static flags = { + ...appFlag, + config: Flags.string({ + description: 'Builder config as a JSON string', + exactlyOne: ['config', 'config-file'], + }), + 'config-file': Flags.string({ + description: 'JSON file with the builder config, or - to read stdin', + exactlyOne: ['config', 'config-file'], + }), + 'expected-updated-at': Flags.integer({ + description: 'Optimistic lock: the `updated_at` from a prior config read. Omit for last-write-wins.', + }), + 'remote-configs': Flags.string({ + description: 'JSON array of remote config entries: [{locale, data}]', + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(FlowsConfigUpdate) + + if (!isValidUuid(args.flow_id)) { + this.error('Invalid flow ID format.', {exit: 2}) + } + + const config = await this.readConfig(flags) + + const body: FlowConfigWriteRequestDTO = {config} + + if (flags['remote-configs'] !== undefined) { + body.remote_configs = this.parseJson(flags['remote-configs'], '--remote-configs') + } + + if (flags['expected-updated-at'] !== undefined) { + body.expected_updated_at = flags['expected-updated-at'] + } + + const client = await createAuthenticatedClient(this.config) + const result = await client.put(`/apps/${flags.app}/flows/${args.flow_id}/config`, body) + + this.log('Flow config saved!') + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } + + private parseJson(raw: string, label: string): T { + try { + return JSON.parse(raw) as T + } catch (error) { + this.error(`Invalid ${label} JSON: ${error instanceof Error ? error.message : String(error)}`, {exit: 2}) + } + } + + private async readConfig(flags: {config?: string; 'config-file'?: string}): Promise> { + if (flags.config !== undefined) { + return this.parseJson>(flags.config, '--config') + } + + const path = flags['config-file'] + if (path === undefined) { + this.error('Provide --config or --config-file.', {exit: 2}) + } + + let raw: string + try { + raw = path === '-' ? await this.readStdin() : await readFile(path, 'utf8') + } catch { + this.error(`Could not read ${path}.`, {exit: 2}) + } + + return this.parseJson>(raw, path) + } + + private async readStdin(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) chunks.push(chunk as Buffer) + return Buffer.concat(chunks).toString('utf8') + } +} diff --git a/src/commands/flows/create.ts b/src/commands/flows/create.ts new file mode 100644 index 0000000..c679322 --- /dev/null +++ b/src/commands/flows/create.ts @@ -0,0 +1,31 @@ +import {Command, Flags} from '@oclif/core' + +import type {FlowDTO, FlowWriteRequestDTO} from '../../lib/api-schemas.js' + +import {createAuthenticatedClient} from '../../lib/client-from-config.js' +import {appFlag} from '../../lib/flags.js' +import {printResponse} from '../../lib/output.js' + +export default class FlowsCreate extends Command { + static description = 'Create a flow (row only — write its config with `flows config update`)' +static enableJsonFlag = true +static examples = ['<%= config.bin %> flows create --app UUID --name "Onboarding"'] +static flags = { + ...appFlag, + name: Flags.string({description: 'Flow name', required: true}), + } + + async run(): Promise { + const {flags} = await this.parse(FlowsCreate) + const client = await createAuthenticatedClient(this.config) + + const body: FlowWriteRequestDTO = {name: flags.name} + + const result = await client.post(`/apps/${flags.app}/flows`, body) + + this.log('Flow created!') + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/flows/get.ts b/src/commands/flows/get.ts new file mode 100644 index 0000000..43d7301 --- /dev/null +++ b/src/commands/flows/get.ts @@ -0,0 +1,34 @@ +import {Args, Command} from '@oclif/core' + +import type {FlowDTO} from '../../lib/api-schemas.js' + +import {createAuthenticatedClient} from '../../lib/client-from-config.js' +import {appFlag, isValidUuid} from '../../lib/flags.js' +import {printResponse} from '../../lib/output.js' + +export default class FlowsGet extends Command { + static args = { + flow_id: Args.string({description: 'Flow ID (UUID)', required: true}), + } +static description = 'Get flow details' +static enableJsonFlag = true +static examples = ['<%= config.bin %> flows get --app UUID 550e8400-e29b-41d4-a716-446655440000'] +static flags = { + ...appFlag, + } + + async run(): Promise { + const {args, flags} = await this.parse(FlowsGet) + + if (!isValidUuid(args.flow_id)) { + this.error('Invalid flow ID format.', {exit: 2}) + } + + const client = await createAuthenticatedClient(this.config) + const result = await client.get(`/apps/${flags.app}/flows/${args.flow_id}`) + + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/commands/flows/list.ts b/src/commands/flows/list.ts new file mode 100644 index 0000000..379619f --- /dev/null +++ b/src/commands/flows/list.ts @@ -0,0 +1,27 @@ +import {Command} from '@oclif/core' + +import type {FlowDTO} from '../../lib/api-schemas.js' + +import {createAuthenticatedClient} from '../../lib/client-from-config.js' +import {appFlag, type PaginatedResponse, paginationFlags, paginationParams} from '../../lib/flags.js' +import {printList} from '../../lib/output.js' + +export default class FlowsList extends Command { + static description = 'List flows for an app' +static enableJsonFlag = true +static examples = ['<%= config.bin %> flows list --app 550e8400-...'] +static flags = { + ...appFlag, + ...paginationFlags, + } + + async run(): Promise> { + const {flags} = await this.parse(FlowsList) + const client = await createAuthenticatedClient(this.config) + const result = await client.get>(`/apps/${flags.app}/flows`, paginationParams(flags)) + + printList(result.data as unknown as Record[], this.log.bind(this), result.meta.pagination) + + return result + } +} diff --git a/src/lib/api-schemas.ts b/src/lib/api-schemas.ts index cf0df04..1762a7d 100644 --- a/src/lib/api-schemas.ts +++ b/src/lib/api-schemas.ts @@ -104,6 +104,40 @@ export interface PaywallWriteRequestDTO { title: string } +/** Flow lifecycle status. Values come from the server-side `FlowStatus` enum (e.g. draft, dirty, published). */ +export type FlowStatus = string + +export interface FlowDTO { + id: string + name: string + status: FlowStatus + updated_at: string +} + +export interface FlowRemoteConfigDTO { + data: string + locale: string +} + +export interface FlowConfigDTO { + config: Record + remote_configs: FlowRemoteConfigDTO[] + status: FlowStatus + /** Millisecond timestamp of the last content change; the value `expected_updated_at` is compared against on write. */ + updated_at: number +} + +export interface FlowWriteRequestDTO { + name: string +} + +export interface FlowConfigWriteRequestDTO { + config: Record + /** Optimistic lock: the `updated_at` from a prior config read. Omit for last-write-wins. */ + expected_updated_at?: null | number + remote_configs?: FlowRemoteConfigDTO[] +} + export interface SegmentDTO { description: null | string id: string diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 8c94dce..91c62c9 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -33,6 +33,8 @@ export function describeListedError(error: ListedError): {code: string | undefin } export class ApiError extends Error { + // Read by oclif's prettyPrint to render a `Code: ...` line under the message. + code?: string detail?: string retryAfterSeconds?: number @@ -42,10 +44,27 @@ export class ApiError extends Error { public fieldErrors: Record, opts: ApiErrorOptions = {}, ) { - super(opts.detail ?? errorCode) + // `message` is what oclif's default error renderer prints. Prefer the server's human text (an ASA + // `detail` or the developer API's field errors) over the bare code, which reads as gibberish on its own. + super(ApiError.humanMessage(errorCode, fieldErrors, opts.detail)) this.name = 'ApiError' this.detail = opts.detail this.retryAfterSeconds = opts.retryAfterSeconds + // Surface real server codes as a secondary line; hide synthetic `http_` fallbacks. + if (!errorCode.startsWith('http_')) this.code = errorCode + } + + private static humanMessage(errorCode: string, fieldErrors: Record, detail?: string): string { + if (detail) return detail + + const parts: string[] = [] + for (const [field, msgs] of Object.entries(fieldErrors)) { + for (const msg of msgs) { + parts.push(field === 'non_field_errors' ? msg : `${field}: ${msg}`) + } + } + + return parts.length > 0 ? parts.join('; ') : errorCode } toHuman(): string { diff --git a/test/commands/flows.test.ts b/test/commands/flows.test.ts new file mode 100644 index 0000000..7666c44 --- /dev/null +++ b/test/commands/flows.test.ts @@ -0,0 +1,119 @@ +import {runCommand} from '@oclif/test' +import sinon from 'sinon' + +import { + assertFetch, + EMPTY_LIST_RESPONSE, + mockFetch, + restoreFetch, + TEST_APP_ID, + TEST_RESOURCE_ID, +} from '../helpers/mock-fetch.js' + +const FLOW_RESPONSE = {id: TEST_RESOURCE_ID, name: 'Onboarding', status: 'draft', updated_at: '2026-08-12T10:30:00Z'} +const CONFIG_RESPONSE = { + config: {locales: [{code: 'en'}], screens: [{id: 'welcome'}]}, + remote_configs: [], + status: 'draft', + updated_at: 1_755_001_800_000, +} + +describe('flows', () => { + let fetchStub: sinon.SinonStub + + afterEach(() => { + restoreFetch(fetchStub) + delete process.env.ADAPTY_TOKEN + }) + + it('list calls GET /apps/{app}/flows', async () => { + process.env.ADAPTY_TOKEN = 'test-token' + fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) + await runCommand(`flows list --app ${TEST_APP_ID}`) + assertFetch({callIndex: 0, method: 'GET', path: `/apps/${TEST_APP_ID}/flows/`, stub: fetchStub}) + }) + + it('get calls GET /apps/{app}/flows/{id}', async () => { + process.env.ADAPTY_TOKEN = 'test-token' + fetchStub = mockFetch([FLOW_RESPONSE]) + await runCommand(`flows get ${TEST_RESOURCE_ID} --app ${TEST_APP_ID}`) + assertFetch({callIndex: 0, method: 'GET', path: `/apps/${TEST_APP_ID}/flows/${TEST_RESOURCE_ID}/`, stub: fetchStub}) + }) + + it('create calls POST /apps/{app}/flows', async () => { + process.env.ADAPTY_TOKEN = 'test-token' + fetchStub = mockFetch([FLOW_RESPONSE]) + await runCommand(`flows create --app ${TEST_APP_ID} --name "Onboarding"`) + assertFetch({ + body: {name: 'Onboarding'}, + callIndex: 0, + method: 'POST', + path: `/apps/${TEST_APP_ID}/flows/`, + stub: fetchStub, + }) + }) + + it('config get calls GET /apps/{app}/flows/{id}/config', async () => { + process.env.ADAPTY_TOKEN = 'test-token' + fetchStub = mockFetch([CONFIG_RESPONSE]) + await runCommand(`flows config get ${TEST_RESOURCE_ID} --app ${TEST_APP_ID}`) + assertFetch({ + callIndex: 0, + method: 'GET', + path: `/apps/${TEST_APP_ID}/flows/${TEST_RESOURCE_ID}/config/`, + stub: fetchStub, + }) + }) + + it('config update calls PUT /apps/{app}/flows/{id}/config with the inline config', async () => { + process.env.ADAPTY_TOKEN = 'test-token' + fetchStub = mockFetch([CONFIG_RESPONSE]) + await runCommand([ + 'flows', + 'config', + 'update', + TEST_RESOURCE_ID, + '--app', + TEST_APP_ID, + '--config', + '{"locales":[],"screens":[]}', + ]) + assertFetch({ + body: {config: {locales: [], screens: []}}, + callIndex: 0, + method: 'PUT', + path: `/apps/${TEST_APP_ID}/flows/${TEST_RESOURCE_ID}/config/`, + stub: fetchStub, + }) + }) + + it('config update forwards remote-configs and the optimistic lock token', async () => { + process.env.ADAPTY_TOKEN = 'test-token' + fetchStub = mockFetch([CONFIG_RESPONSE]) + await runCommand([ + 'flows', + 'config', + 'update', + TEST_RESOURCE_ID, + '--app', + TEST_APP_ID, + '--config', + '{"screens":[]}', + '--remote-configs', + '[{"data":"{}","locale":"en"}]', + '--expected-updated-at', + '1755001800000', + ]) + assertFetch({ + body: { + config: {screens: []}, + expected_updated_at: 1_755_001_800_000, + remote_configs: [{data: '{}', locale: 'en'}], + }, + callIndex: 0, + method: 'PUT', + path: `/apps/${TEST_APP_ID}/flows/${TEST_RESOURCE_ID}/config/`, + stub: fetchStub, + }) + }) +}) diff --git a/test/lib/errors.test.ts b/test/lib/errors.test.ts index a250e10..6b70932 100644 --- a/test/lib/errors.test.ts +++ b/test/lib/errors.test.ts @@ -10,6 +10,25 @@ describe('parseApiError', () => { expect(error.detail).to.equal(undefined) }) + it('surfaces developer field errors as the human message and the code as a secondary line', () => { + const nonField = parseApiError(404, {error_code: 'validation_error', errors: {non_field_errors: ['Flow version does not exist.']}}) + expect(nonField.message).to.equal('Flow version does not exist.') + expect(nonField.code).to.equal('validation_error') + + const named = parseApiError(400, {error_code: 'validation_error', errors: {title: ['is required']}}) + expect(named.message).to.equal('title: is required') + }) + + it('falls back to the bare code as message when there is nothing more human, and hides synthetic codes', () => { + const coded = parseApiError(400, {error_code: 'some_code'}) + expect(coded.message).to.equal('some_code') + expect(coded.code).to.equal('some_code') + + const synthetic = parseApiError(500, {}) + expect(synthetic.message).to.equal('http_500') + expect(synthetic.code).to.equal(undefined) + }) + it('leaves the Developer API untouched by the ASA branches', () => { const listed = parseApiError(404, {errors: [{error_code: 'cli_entity_not_found', message: 'No campaign.'}]}) const stringDetail = parseApiError(400, {detail: 'idempotency_key is required'})