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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "adapty",
"description": "Adapty command line interface",
"version": "0.5.0",
"version": "0.6.0",
"author": "Adapty team <support@adapty.io>",
"bin": {
"adapty": "./bin/run.js"
Expand Down Expand Up @@ -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"
},
Expand Down
34 changes: 34 additions & 0 deletions src/commands/flows/config/get.ts
Original file line number Diff line number Diff line change
@@ -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<FlowConfigDTO> {
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<FlowConfigDTO>(`/apps/${flags.app}/flows/${args.flow_id}/config`)

printResponse(result as unknown as Record<string, unknown>, this.log.bind(this))

return result
}
}
100 changes: 100 additions & 0 deletions src/commands/flows/config/update.ts
Original file line number Diff line number Diff line change
@@ -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<FlowConfigDTO> {
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<FlowRemoteConfigDTO[]>(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<FlowConfigDTO>(`/apps/${flags.app}/flows/${args.flow_id}/config`, body)

this.log('Flow config saved!')
printResponse(result as unknown as Record<string, unknown>, this.log.bind(this))

return result
}

private parseJson<T>(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<Record<string, unknown>> {
if (flags.config !== undefined) {
return this.parseJson<Record<string, unknown>>(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<Record<string, unknown>>(raw, path)
}

private async readStdin(): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of process.stdin) chunks.push(chunk as Buffer)
return Buffer.concat(chunks).toString('utf8')
}
}
31 changes: 31 additions & 0 deletions src/commands/flows/create.ts
Original file line number Diff line number Diff line change
@@ -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<FlowDTO> {
const {flags} = await this.parse(FlowsCreate)
const client = await createAuthenticatedClient(this.config)

const body: FlowWriteRequestDTO = {name: flags.name}

const result = await client.post<FlowDTO>(`/apps/${flags.app}/flows`, body)

this.log('Flow created!')
printResponse(result as unknown as Record<string, unknown>, this.log.bind(this))

return result
}
}
34 changes: 34 additions & 0 deletions src/commands/flows/get.ts
Original file line number Diff line number Diff line change
@@ -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<FlowDTO> {
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<FlowDTO>(`/apps/${flags.app}/flows/${args.flow_id}`)

printResponse(result as unknown as Record<string, unknown>, this.log.bind(this))

return result
}
}
27 changes: 27 additions & 0 deletions src/commands/flows/list.ts
Original file line number Diff line number Diff line change
@@ -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<PaginatedResponse<FlowDTO>> {
const {flags} = await this.parse(FlowsList)
const client = await createAuthenticatedClient(this.config)
const result = await client.get<PaginatedResponse<FlowDTO>>(`/apps/${flags.app}/flows`, paginationParams(flags))

printList(result.data as unknown as Record<string, unknown>[], this.log.bind(this), result.meta.pagination)

return result
}
}
34 changes: 34 additions & 0 deletions src/lib/api-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
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<string, unknown>
/** 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
Expand Down
21 changes: 20 additions & 1 deletion src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -42,10 +44,27 @@ export class ApiError extends Error {
public fieldErrors: Record<string, string[]>,
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_<status>` fallbacks.
if (!errorCode.startsWith('http_')) this.code = errorCode
}

private static humanMessage(errorCode: string, fieldErrors: Record<string, string[]>, 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 {
Expand Down
Loading
Loading