diff --git a/CLAUDE.md b/CLAUDE.md index d59d8d6..791be88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,9 @@ 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); + # media/ (upload — multipart image upload, returns CDN url to reference in a config) segments/ # list, get access-levels/ # list, get, create, update asa/ # Apple Search Ads: whoami, connect, orgs, apps, campaigns, ad-groups, keywords, diff --git a/package.json b/package.json index 4605eaa..8da38e9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "adapty", "description": "Adapty command line interface", - "version": "0.7.0", + "version": "0.8.0-beta.0", "author": "Adapty team ", "bin": { "adapty": "./bin/run.js" diff --git a/src/commands/flows/config/validate.ts b/src/commands/flows/config/validate.ts new file mode 100644 index 0000000..422820b --- /dev/null +++ b/src/commands/flows/config/validate.ts @@ -0,0 +1,95 @@ +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'], + }), + } + + async run(): Promise { + 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( + `/apps/${flags.app}/flows/${args.flow_id}/config/validate`, + body, + ) + + this.log(result.valid ? 'Config is publishable.' : 'Config is NOT publishable.') + printResponse(result as unknown as Record, 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(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/media/upload.ts b/src/commands/flows/media/upload.ts new file mode 100644 index 0000000..8d6c8b2 --- /dev/null +++ b/src/commands/flows/media/upload.ts @@ -0,0 +1,57 @@ +import {Args, Command} from '@oclif/core' +import {readFile} from 'node:fs/promises' +import {basename, extname} from 'node:path' + +import type {MediaDTO} 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' + +const IMAGE_MIME_TYPES: Record = { + '.gif': 'image/gif', + '.heic': 'image/heic', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', +} + +export default class FlowsMediaUpload extends Command { + static args = { + file: Args.string({description: 'Path to the image file to upload', required: true}), + } +static description = 'Upload an image as a flow builder asset; prints the CDN URL to reference from a flow config' +static enableJsonFlag = true +static examples = ['<%= config.bin %> flows media upload --app UUID ./onboarding-hero.png'] +static flags = { + ...appFlag, + } + + async run(): Promise { + const {args, flags} = await this.parse(FlowsMediaUpload) + + let content: Buffer + try { + content = await readFile(args.file) + } catch { + this.error(`Cannot read file: ${args.file}`, {exit: 2}) + } + + const name = basename(args.file) + const type = IMAGE_MIME_TYPES[extname(name).toLowerCase()] ?? 'application/octet-stream' + + const form = new FormData() + form.append('file', new Blob([new Uint8Array(content)], {type}), name) + + const client = await createAuthenticatedClient(this.config) + const result = await client.postForm(`/apps/${flags.app}/flows/media/images`, form) + + this.log('Image uploaded!') + // `preview_base64` is a long data blob with no value in human output; --json still returns it in full. + printResponse({id: result.id, name: result.name, url: result.url}, this.log.bind(this)) + + return result + } +} diff --git a/src/lib/api-schemas.ts b/src/lib/api-schemas.ts index 1762a7d..9328d9f 100644 --- a/src/lib/api-schemas.ts +++ b/src/lib/api-schemas.ts @@ -138,6 +138,33 @@ export interface FlowConfigWriteRequestDTO { remote_configs?: FlowRemoteConfigDTO[] } +export interface FlowConfigValidateRequestDTO { + config: Record +} + +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 MediaDTO { + id: number + name: string + /** Base64-encoded preview thumbnail; absent when no preview was generated. */ + preview_base64?: null | string + /** CDN URL to reference from a flow config. */ + url: string +} + export interface SegmentDTO { description: null | string id: string diff --git a/test/commands/flows.test.ts b/test/commands/flows.test.ts index 7666c44..dc86bb7 100644 --- a/test/commands/flows.test.ts +++ b/test/commands/flows.test.ts @@ -1,4 +1,7 @@ import {runCommand} from '@oclif/test' +import {mkdtemp, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' import sinon from 'sinon' import { @@ -87,6 +90,39 @@ describe('flows', () => { }) }) + it('config validate POSTs the config to /config/validate', 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":[]}', + ]) + assertFetch({ + body: {config: {locales: [], screens: []}}, + callIndex: 0, + 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]) @@ -116,4 +152,25 @@ describe('flows', () => { stub: fetchStub, }) }) + + it('media upload POSTs multipart to /flows/media/images with the file field', async () => { + process.env.ADAPTY_TOKEN = 'test-token' + fetchStub = mockFetch([{id: 42, name: 'hero.png', preview_base64: 'x', url: 'https://cdn/hero.png'}]) + const dir = await mkdtemp(join(tmpdir(), 'adapty-media-')) + const path = join(dir, 'hero.png') + await writeFile(path, Buffer.from([0x89, 0x50, 0x4e, 0x47])) + + await runCommand(`flows media upload ${path} --app ${TEST_APP_ID}`) + + const init = fetchStub.getCall(0).args[1] as {body: FormData; method: string} + if (init.method !== 'POST') throw new Error(`Expected POST, got ${init.method}`) + if (!(init.body instanceof FormData)) throw new Error('Expected multipart FormData body') + const file = init.body.get('file') as File + if (file.name !== 'hero.png') throw new Error(`Expected file name hero.png, got ${file.name}`) + if (file.type !== 'image/png') throw new Error(`Expected image/png, got ${file.type}`) + + const url = fetchStub.getCall(0).args[0] as string + const expected = `/apps/${TEST_APP_ID}/flows/media/images/` + if (!url.endsWith(expected)) throw new Error(`Expected path ${expected}, got ${url}`) + }) })