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
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ 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)
flows/ # list, get, create; config/ (get, update — builder config with optimistic lock;
# preview — local config → render URL, opens on a TTY, prints bare URL when piped;
# capture is the caller's job, the CLI only builds the URL)
segments/ # list, get
access-levels/ # list, get, create, update
asa/ # Apple Search Ads: whoami, connect, orgs, apps, campaigns, ad-groups, keywords,
Expand All @@ -36,10 +38,12 @@ src/
errors.ts # ApiError, NetworkError, AuthRequiredError
flags.ts # shared flags: --app (UUID), pagination
output.ts # printResponse(), printList() helpers (auto-formats snake_case keys)
app-url.ts # dashboard base URL (ADAPTY_APP_URL): route building + rehosting API-issued links
asa-client.ts # factory: ApiClient against the ASA service (errorFormat 'asa')
asa-flags.ts # shared asa flags: scope filters, period, money, batch caps
asa-confirm.ts # mutation preview + confirmation prompt (--yes; refuses when piped or --json)
asa-schemas.ts # response typings for asa entities
preview.ts # flow config normalization + render URL / gzip fragment building
```

## Conventions
Expand All @@ -52,6 +56,16 @@ src/
- 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
- `ADAPTY_APP_URL` env sets the dashboard base URL (default `https://app.adapty.io`), via `lib/app-url.ts`:
`flows config preview` builds the fixed `/flow-preview` route on it, and `auth login` rehosts the
API-issued verification link onto it (only when the env var is set — the API may serve that link from
another host)
- `flows config preview` only builds a URL: no browser automation, no Playwright, no screenshot. Capture
belongs to the caller (its own browser tool, or the flow skill's reference script)
- `flows config preview` always carries the config in the URL fragment; there is no file hand-off flag. It is
a quick-look escape hatch for small configs — past ~32KB of pretty-printed JSON the render page turns slow
and unreliable. The URL is long either way (~113K chars for a 668KB flow), so callers pipe it into the
screenshot tool rather than print or read it
- API base: `https://api-admin.adapty.io/api/v1/developer`
- `asa` topic talks to its own service: base `https://api-asa-admin.adapty.io/api/v1/cli`, overridden by
`ADAPTY_ASA_API_URL`; same bearer token, but errors follow the ASA shape (per-item `errors[]`, FastAPI
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,13 +256,41 @@ and the outcome shows up in `adapty asa automations runs`.
| `--page` | Page number (default: 1) |
| `--page-size` | Items per page (default: 20, max: 100; `asa` commands: default 100, max 1000) |

## Paywall Preview

`adapty flows config preview <config_file>` turns a local flow config into a render URL. On a TTY it opens the
browser; piped or with `--json` it prints the URL alone. Screenshotting is the caller's job — the CLI only
builds the URL.

> **Small configs only.** This is a quick-look escape hatch. The config travels in the URL fragment, and past
> roughly **32KB of pretty-printed JSON** the render page becomes slow and unreliable. Trim to the screen you
> care about, or preview the saved flow in the dashboard builder instead.

> **That URL is huge.** Even a config the page renders happily produces thousands of characters, and a 668KB
> flow yields ~113,000. Nobody should read it — **agents especially should never let it into their context.**
> Pipe it into whatever captures the screenshot:
>
> ```sh
> adapty flows config preview flow.json --screen scr_abc | node capture.mjs --out shot.png
>
> # or, for a tool that wants a flag instead of stdin:
> node capture.mjs --url "$(adapty flows config preview flow.json --screen scr_abc)" --out shot.png
> ```
>
> Prefer the pipe: it has no size limit, while an argument is capped by the shell (~1MB, so a config around
> 6MB).

See [skills/adapty-cli/references/cli-commands.md](skills/adapty-cli/references/cli-commands.md#preview) for
the flags and the size ceiling.

## Environment Variables

| Variable | Description |
| -------------------- | --------------------------------------------------------------------------------------- |
| `ADAPTY_TOKEN` | Override stored auth token |
| `ADAPTY_API_URL` | Override Developer API base URL (default: `https://api-admin.adapty.io/api/v1/developer`) |
| `ADAPTY_ASA_API_URL` | Override Apple Search Ads base URL (default: `https://api-asa-admin.adapty.io/api/v1/cli`) |
| `ADAPTY_APP_URL` | Override dashboard base URL (default: `https://app.adapty.io`). Used by `flows config preview` for the fixed `/flow-preview` route, and by `auth login` to keep the verification link on that host |

The two API URLs are independent: pointing `ADAPTY_API_URL` at a staging host leaves `adapty asa` on the ASA
default, and the other way round.
Expand Down
58 changes: 58 additions & 0 deletions skills/adapty-cli/references/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,64 @@ Read-only. Response shape: `{id, title, description}`. Filters are not exposed v
| `access-levels create` | `--app`, `--sdk-id`, `--title` |
| `access-levels update <access_level_id>` | `--app`, `--title` |

## Preview

| Command | Required flags |
|-------------------------------------------|----------------|
| `flows config preview <config_file>` | none |

Takes a **local** flow config JSON file, normalizes it, and builds a render URL that carries the whole config
in its gzipped fragment. **Treat it as a quick-look escape hatch for small configs:** past roughly **32KB of
pretty-printed JSON** the render page turns slow and unreliable, so trim to the screen you are working on
rather than throwing a whole 600KB flow at it. **No API call and no `--app`.** The CLI does not screenshot anything — it owns the
fragment format, capture is yours: open the URL with your browser/computer-use tool and screenshot the
`[data-screen-content]` element.

Accepts either a dashboard-api envelope (`{config, remote_configs, ...}`) or a bare builder config; both
normalize to `{flow, remoteConfigs}` (camelCase: that payload is a wire format shared with the render page).
`screens` must be an array — that is what the render page's own payload guard requires, so the CLI rejects
anything it would reject.

Render page location is **env-only**: `ADAPTY_APP_URL` (default `https://app.adapty.io`) sets the host; the
`/flow-preview` route is fixed and there is no flag for it. The same env var also moves `auth login`'s
verification link onto that host, so a local or staging dashboard stays consistent across both commands.

Flags: `--screen` (default: the render page falls back to the flow's first screen), `--device` (default:
`iphone-14`), `--orientation` (`portrait` | `landscape`, default `portrait`).

Output depends on where stdout goes, because the URL is far too long to read:

- **TTY** — opens the URL in the browser and prints a one-line confirmation, not the URL.
- **Piped or redirected** — prints the bare URL and nothing else.
- **`--json`** — `{render_url}`, and never opens a browser.

⚠️ **Never read this command's output.** Piped or `--json`, it emits one very long line — thousands of
characters even for a config the page renders well, ~113,000 for a 668KB flow — because the entire config is
gzipped into the fragment. Running it as a bare command and letting the output land in your transcript burns
context for zero information. Always hand it to the next process instead — and never `echo`, `cat` or
`--json | jq .render_url` it just to look.

`render_url` is `<host>/flow-preview?screen=<id>&device=<id>&orientation=<o>#config=<base64url(gzip(json))>`.
The fragment is gzipped unconditionally and carries **no prefix** — the page compresses too, so there is no
plain shape to mark it apart from. An unknown `device` renders an error message instead of a screen, so pass
one the builder knows.

**Keep the URL out of your context.** Pipe it straight into whatever captures the screenshot — stdin has no
size limit:

```sh
adapty flows config preview flow.json --screen scr_abc | node capture.mjs --out shot.png
```

If the tool insists on a flag, command substitution works too, capped by the shell's ~1MB argument limit (a
config around 6MB, since configs compress roughly 6x):

```sh
node capture.mjs --url "$(adapty flows config preview flow.json --screen scr_abc)" --out shot.png
```

There is no file-based hand-off flag: the config always rides in the URL.

## Apple Search Ads (`asa` topic)

Different service behind the same token. **No `--app`**: every command is scoped to the company the token
Expand Down
16 changes: 14 additions & 2 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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'
Expand Down Expand Up @@ -53,12 +54,14 @@
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: ${device.verification_uri_complete}\n`)
this.log(`If browser doesn't open, visit: ${verificationUrl}\n`)

if (process.stdin.isTTY === true) {
try {
await open(device.verification_uri_complete)
await open(verificationUrl)
} catch {
// browser open failed silently — URL already printed
}
Expand Down Expand Up @@ -91,7 +94,7 @@
})
} catch (error) {
if (error instanceof ApiError) {
switch (error.errorCode) {

Check warning on line 97 in src/commands/auth/login.ts

View workflow job for this annotation

GitHub Actions / unit-tests (ubuntu-latest, lts/*)

Blocks are nested too deeply (5). Maximum allowed is 4

Check warning on line 97 in src/commands/auth/login.ts

View workflow job for this annotation

GitHub Actions / unit-tests (ubuntu-latest, lts/-1)

Blocks are nested too deeply (5). Maximum allowed is 4

Check warning on line 97 in src/commands/auth/login.ts

View workflow job for this annotation

GitHub Actions / unit-tests (windows-latest, lts/*)

Blocks are nested too deeply (5). Maximum allowed is 4

Check warning on line 97 in src/commands/auth/login.ts

View workflow job for this annotation

GitHub Actions / unit-tests (windows-latest, lts/-1)

Blocks are nested too deeply (5). Maximum allowed is 4
case 'authorization_pending': {
continue
}
Expand Down Expand Up @@ -150,4 +153,13 @@
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})
}
}
}
91 changes: 91 additions & 0 deletions src/commands/flows/config/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {Args, Command, Flags} from '@oclif/core'
import {readFile} from 'node:fs/promises'
import {resolve} from 'node:path'
import open from 'open'

import {APP_URL_ENV_VAR} from '../../../lib/app-url.js'
import {
buildRenderUrl,
DEFAULT_DEVICE_ID,
DEFAULT_ORIENTATION,
normalizePreviewConfig,
ORIENTATIONS,
type PreviewPayload,
} from '../../../lib/preview.js'

export interface PreviewResult {
render_url: string
}

function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}

export default class FlowsConfigPreview extends Command {
static args = {
config_file: Args.string({description: 'Path to a local flow config JSON file', required: true}),
}
static description = `Build a render URL for a local flow config and open it. A quick-look escape hatch for small configs: the whole config rides in the URL fragment, and past roughly 32KB of pretty-printed JSON the render page gets slow and unreliable. Opens the browser on a TTY; when piped or with --json, prints the URL alone — and that URL is long (~113K characters for a 668KB flow), so pipe it into whatever takes the screenshot ("| node capture.mjs") or pass it with --url and command substitution. Never print or read it: agents burn context for zero information. The render host comes from $${APP_URL_ENV_VAR}.`
static enableJsonFlag = true
static examples = [
'<%= config.bin %> flows config preview ./config.json',
'<%= config.bin %> flows config preview ./config.json --screen welcome --device ipad-pro --orientation landscape',
'# pipe the URL straight into a screenshot tool, never print it\n<%= config.bin %> flows config preview ./config.json | node capture.mjs --out shot.png',
'# or pass it as an argument, for tools that want a flag\nnode capture.mjs --url "$(<%= config.bin %> flows config preview ./config.json)" --out shot.png',
]
static flags = {
device: Flags.string({default: DEFAULT_DEVICE_ID, description: 'Device frame to render in'}),
orientation: Flags.string({
default: DEFAULT_ORIENTATION,
description: 'Device orientation to render in',
options: [...ORIENTATIONS],
}),
screen: Flags.string({description: "Screen ID to render (default: the flow's first screen)"}),
}

async run(): Promise<PreviewResult> {
const {args, flags} = await this.parse(FlowsConfigPreview)

const configPath = resolve(args.config_file)
let raw: unknown
try {
raw = JSON.parse(await readFile(configPath, 'utf8'))
} catch (error) {
this.error(`Could not read config file ${configPath}: ${describeError(error)}`, {exit: 2})
}

let payload: PreviewPayload
try {
payload = normalizePreviewConfig(raw)
} catch (error) {
this.error(describeError(error), {exit: 2})
}

let renderUrl: string
try {
renderUrl = buildRenderUrl({device: flags.device, orientation: flags.orientation, screen: flags.screen}, payload)
} catch (error) {
this.error(describeError(error), {exit: 2})
}

const result: PreviewResult = {render_url: renderUrl}
if (this.jsonEnabled()) return result

// Piped output stays a bare URL so it composes — the whole config rides in the fragment, which
// is why a TTY gets the browser opened instead of a screenful of base64.
if (process.stdout.isTTY !== true) {
this.log(renderUrl)
return result
}

const target = [flags.screen ?? 'first screen', flags.device, flags.orientation].join(', ')
try {
await open(renderUrl)
this.log(`Opened the preview in your browser (${target}).`)
} catch {
this.log(renderUrl)
}

return result
}
}
35 changes: 35 additions & 0 deletions src/lib/app-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export const APP_URL_ENV_VAR = 'ADAPTY_APP_URL'
export const DEFAULT_APP_URL = 'https://app.adapty.io'

/** Dashboard origin: ADAPTY_APP_URL when set, production otherwise. Only its origin is used. */
function appBaseUrl(): URL {
const base = process.env[APP_URL_ENV_VAR] ?? DEFAULT_APP_URL
try {
return new URL(base)
} catch {
throw new Error(`Invalid ${APP_URL_ENV_VAR}: ${base}`)
}
}

/** Builds a dashboard URL for a fixed route, e.g. the flow preview page. */
export function appUrl(path: string): URL {
return new URL(path, appBaseUrl())
}

/**
* Moves a link the API issued (the device-flow verification URI) onto the configured dashboard
* host, so pointing the CLI at a local or staging dashboard keeps the browser there too. Without
* ADAPTY_APP_URL the link is left exactly as issued — the API is free to serve it from any host.
*/
export function onAppHost(issuedUrl: string): string {
if (!process.env[APP_URL_ENV_VAR]) return issuedUrl

let issued: URL
try {
issued = new URL(issuedUrl)
} catch {
return issuedUrl
}

return appUrl(`${issued.pathname}${issued.search}${issued.hash}`).toString()
}
70 changes: 70 additions & 0 deletions src/lib/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {gzipSync} from 'node:zlib'

import {appUrl} from './app-url.js'

/** The render route is fixed; only its host is configurable, via ADAPTY_APP_URL. */
export const PREVIEW_PATH = '/flow-preview'
export const DEFAULT_DEVICE_ID = 'iphone-14'
/** Orientations the render page accepts; anything else falls back to its own default. */
export const ORIENTATIONS = ['landscape', 'portrait'] as const
export const DEFAULT_ORIENTATION = 'portrait'

/** Payload the render page expects. Wire format shared with the UI, hence camelCase. */
export interface PreviewPayload {
flow: Record<string, unknown>
remoteConfigs: unknown[]
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

/**
* Accepts either a dashboard-api envelope (`{config, remote_configs, ...}`) or a bare
* builder config, and returns the shape the render page injects.
*
* `screens` must be an array: that is exactly what the page's own payload guard checks
* before it treats the payload as a builder draft, so a config without it would be
* rejected there instead — better to say so here, against the file the user named.
*/
export function normalizePreviewConfig(raw: unknown): PreviewPayload {
if (!isRecord(raw)) {
throw new Error('Config file must contain a JSON object.')
}

const envelopeConfig = isRecord(raw.config) ? raw.config : null
const flow = envelopeConfig ?? raw
if (!Array.isArray(flow.screens)) {
throw new TypeError(
'Unrecognized config file. Expected a dashboard-api envelope with a `config` object, or a bare builder config — either way `screens` must be an array.',
)
}

const remoteConfigs = envelopeConfig && Array.isArray(raw.remote_configs) ? raw.remote_configs : []
return {flow, remoteConfigs}
}

/**
* Fragment wire format shared with the render page: bare `base64url(gzip(utf8(JSON)))`, no
* prefix — the page compresses unconditionally too, so there is no plain shape to mark it
* apart from. Node's base64url already omits the `=` padding the page strips.
*/
function encodeConfigFragment(payload: PreviewPayload): string {
return gzipSync(Buffer.from(JSON.stringify(payload), 'utf8')).toString('base64url')
}

export interface RenderTarget {
device: string
orientation: string
/** Omitted lets the render page fall back to the flow's first screen. */
screen?: string
}

export function buildRenderUrl(target: RenderTarget, payload: PreviewPayload): string {
const url = appUrl(PREVIEW_PATH)
if (target.screen) url.searchParams.set('screen', target.screen)
url.searchParams.set('device', target.device)
url.searchParams.set('orientation', target.orientation)
url.hash = `config=${encodeConfigFragment(payload)}`
return url.toString()
}
Loading
Loading