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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ src/
placements/ # list, get, create, update (audiences[] or deprecated --paywall-id)
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)
# capture is the caller's job, the CLI only builds the URL;
# validate — advisory publishability check, always 200, exits non-zero when invalid)
segments/ # list, get
access-levels/ # list, get, create, update
asa/ # Apple Search Ads: whoami, connect, orgs, apps, campaigns, ad-groups, keywords,
Expand Down
2 changes: 1 addition & 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.6.0",
"version": "0.6.1-beta.0",
"author": "Adapty team <support@adapty.io>",
"bin": {
"adapty": "./bin/run.js"
Expand Down
101 changes: 101 additions & 0 deletions src/commands/flows/config/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {Args, Command, Flags} from '@oclif/core'
import {readFile} from 'node:fs/promises'

import type {FlowConfigValidateRequestDTO, FlowConfigValidationDTO} 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 FlowsConfigValidate extends Command {
static args = {
flow_id: Args.string({description: 'Flow ID (UUID)', required: true}),
}
static description =
'Check whether a builder config is publishable (advisory; does not save). Exits non-zero when the config is not publishable.'
static enableJsonFlag = true
static examples = [
'<%= config.bin %> flows config validate --app UUID FLOW_UUID --config-file config.json',
'cat config.json | <%= config.bin %> flows config validate --app UUID FLOW_UUID --config-file -',
'<%= config.bin %> flows config validate --app UUID FLOW_UUID --config \'{"screens":[],"locales":[]}\'',
]
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'],
}),
source: Flags.string({
default: 'adapty-cli',
description: 'Caller attribution, sent as X-Adapty-Source (e.g. byo-cli)',
}),
}

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

if (!isValidUuid(args.flow_id)) {
this.error('Invalid flow ID format.', {exit: 2})
}

const config = await this.readConfig(flags)
const body: FlowConfigValidateRequestDTO = {config}

const client = await createAuthenticatedClient(this.config)
const result = await client.post<FlowConfigValidationDTO>(
`/apps/${flags.app}/flows/${args.flow_id}/config/validate`,
body,
undefined,
{headers: {'X-Adapty-Source': flags.source}},
)

this.log(result.valid ? 'Config is publishable.' : 'Config is NOT publishable.')
printResponse(result as unknown as Record<string, unknown>, this.log.bind(this))

// Advisory endpoint always returns HTTP 200; surface the verdict as an exit code so scripts and agents
// can gate on it. The JSON/`valid` field stays the source of truth for programmatic consumers.
if (!result.valid) {
process.exitCode = 1
}

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')
}
}
18 changes: 18 additions & 0 deletions src/lib/api-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,24 @@ export interface FlowConfigWriteRequestDTO {
remote_configs?: FlowRemoteConfigDTO[]
}

export interface FlowConfigValidateRequestDTO {
config: Record<string, unknown>
}

export interface FlowConfigIssueDTO {
/** Machine code, relayed from the transformer; absent until it reports path-level diagnostics. */
code?: null | string
message: string
/** Location of the issue; absent until the transformer reports path-level diagnostics. */
path?: null | string
severity: string
}

export interface FlowConfigValidationDTO {
issues: FlowConfigIssueDTO[]
valid: boolean
}

export interface SegmentDTO {
description: null | string
id: string
Expand Down
36 changes: 36 additions & 0 deletions test/commands/flows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,42 @@ describe('flows', () => {
})
})

it('config validate POSTs to /config/validate with the source header', async () => {
process.env.ADAPTY_TOKEN = 'test-token'
fetchStub = mockFetch([{issues: [], valid: true}])
await runCommand([
'flows',
'config',
'validate',
TEST_RESOURCE_ID,
'--app',
TEST_APP_ID,
'--config',
'{"locales":[],"screens":[]}',
'--source',
'byo-cli',
])
assertFetch({
body: {config: {locales: [], screens: []}},
callIndex: 0,
headers: {'X-Adapty-Source': 'byo-cli'},
method: 'POST',
path: `/apps/${TEST_APP_ID}/flows/${TEST_RESOURCE_ID}/config/validate/`,
stub: fetchStub,
})
})

it('config validate exits non-zero when the config is not publishable', async () => {
process.env.ADAPTY_TOKEN = 'test-token'
fetchStub = mockFetch([{issues: [{message: 'missing screens', severity: 'error'}], valid: false}])
await runCommand(`flows config validate ${TEST_RESOURCE_ID} --app ${TEST_APP_ID} --config {"screens":[]}`)
if (process.exitCode !== 1) {
throw new Error(`Expected exit code 1, got ${process.exitCode}`)
}

process.exitCode = 0
})

it('config update forwards remote-configs and the optimistic lock token', async () => {
process.env.ADAPTY_TOKEN = 'test-token'
fetchStub = mockFetch([CONFIG_RESPONSE])
Expand Down
Loading