adapty 0.4.0 → 0.6.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # Adapty CLI
6
6
 
7
- [Adapty Developer CLI](https://adapty.io/docs/developer-cli). Manage apps, products, paywalls, placements, and access levels from your terminal.
7
+ [Adapty Developer CLI](https://adapty.io/docs/developer-cli). Manage apps, products, paywalls, placements, flows, and access levels from your terminal.
8
8
 
9
9
  ## Installation
10
10
 
@@ -77,6 +77,20 @@ adapty placements create --app UUID [flags]
77
77
  adapty placements update --app UUID PLACEMENT_ID [flags]
78
78
  ```
79
79
 
80
+ ### Flows
81
+
82
+ ```sh
83
+ adapty flows list --app UUID [--page N] [--page-size N]
84
+ adapty flows get --app UUID FLOW_ID
85
+ adapty flows create --app UUID --name "Name"
86
+ adapty flows config get --app UUID FLOW_ID
87
+ adapty flows config update --app UUID FLOW_ID (--config JSON | --config-file PATH|-) [--remote-configs JSON] [--expected-updated-at MS]
88
+ ```
89
+
90
+ A freshly created flow has no config until the first `flows config update`; `flows config get` returns 404
91
+ until then. Pass `--expected-updated-at` (the `updated_at` from a prior `config get`) to fail instead of
92
+ overwriting a concurrent dashboard edit.
93
+
80
94
  ### Access Levels
81
95
 
82
96
  ```sh
@@ -136,6 +150,28 @@ adapty asa ads create --ad-group UUID --creative-id 4321 --name "Summer ad"
136
150
  adapty asa ads update AD_ID [--name "..."] [--status PAUSED]
137
151
  ```
138
152
 
153
+ Bulk structure creation submits a whole tree — campaigns with nested ad groups, keywords, negative keywords
154
+ and ads — in one call. The input is a JSON structure (the natural path for an AI agent: generate it, pipe it
155
+ in), or a native Apple Ads template converted server-side. The whole payload is validated before anything is
156
+ created — a rejection lists every bad node; on acceptance the command polls progress and prints the final
157
+ report (`success` / `partial` / `failed`, with per-object failures):
158
+
159
+ ```sh
160
+ adapty asa campaigns bulk-create --file structure.json # JSON structure, waits and reports
161
+ cat structure.json | adapty asa campaigns bulk-create --file - # same, from stdin
162
+ adapty asa campaigns bulk-create --file structure.json --no-wait
163
+ adapty asa campaigns bulk-create --from-file Campaign_And_Adgroup_Template.xlsx --org-id 1234567
164
+ adapty asa campaigns bulk-create --from-file keywords_template.csv --org-id 1234567 --preview
165
+ adapty asa campaigns bulk-status OPERATION_ID
166
+ ```
167
+
168
+ The structure is `{campaign_group_id | campaign_group_internal_id, campaigns: [...]}` where each campaign
169
+ node either creates (`payload`) or addresses an existing campaign by id (an *anchor*, optionally carrying
170
+ `update_payload`), and nests `ad_groups` with `keywords`, `negative_keywords` and `ads` the same way.
171
+ `--from-file` takes the native Apple Ads templates and needs `--org-id` (the Apple org id from
172
+ `asa orgs list`); `--preview` prints the converted request without creating anything. Conversion issues are
173
+ reported with their sheet, row and column.
174
+
139
175
  Keywords are always applied as a batch, at most 100 per call, and a partial rejection is reported per item:
140
176
 
141
177
  ```sh
@@ -0,0 +1,25 @@
1
+ import { Command } from '@oclif/core';
2
+ import type { AsaBulkOperationStateDTO } from '../../../lib/asa-schemas.js';
3
+ export default class AsaCampaignsBulkCreate extends Command {
4
+ static description: string;
5
+ static enableJsonFlag: boolean;
6
+ static examples: string[];
7
+ static flags: {
8
+ file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ 'from-file': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ 'org-id': import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ 'poll-interval': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
12
+ preview: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ timeout: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
14
+ wait: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ 'idempotency-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ };
18
+ run(): Promise<AsaBulkOperationStateDTO | Record<string, unknown>>;
19
+ private convertTemplate;
20
+ private countStructure;
21
+ private formatIssue;
22
+ private readStructure;
23
+ private report;
24
+ private waitForCompletion;
25
+ }
@@ -0,0 +1,177 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { basename } from 'node:path';
4
+ import { asaWrite, createAsaClient, noteReplay } from '../../../lib/asa-client.js';
5
+ import { confirmFlags, confirmMutation } from '../../../lib/asa-confirm.js';
6
+ import { idempotencyFlags } from '../../../lib/asa-flags.js';
7
+ import { printResponse } from '../../../lib/output.js';
8
+ const TERMINAL_STATUSES = new Set(['failed', 'partial', 'success']);
9
+ export default class AsaCampaignsBulkCreate extends Command {
10
+ static description = 'Create a whole campaign structure (campaigns → ad groups → keywords/negative keywords/ads) in one bulk operation';
11
+ static enableJsonFlag = true;
12
+ static examples = [
13
+ '<%= config.bin %> asa campaigns bulk-create --file structure.json',
14
+ 'cat structure.json | <%= config.bin %> asa campaigns bulk-create --file -',
15
+ '<%= config.bin %> asa campaigns bulk-create --from-file Campaign_And_Adgroup_Template.xlsx --org-id 1234567',
16
+ '<%= config.bin %> asa campaigns bulk-create --from-file keywords_template.csv --org-id 1234567 --preview',
17
+ ];
18
+ static flags = {
19
+ ...confirmFlags,
20
+ ...idempotencyFlags,
21
+ file: Flags.string({
22
+ description: 'JSON file with the campaign structure (the POST /bulk-operations/ body); use "-" for stdin',
23
+ exactlyOne: ['file', 'from-file'],
24
+ }),
25
+ 'from-file': Flags.string({
26
+ description: 'Apple Ads bulk template (Campaign_And_Adgroup_Template.xlsx or keywords .csv) to convert and submit',
27
+ }),
28
+ 'org-id': Flags.integer({
29
+ description: 'Apple org id (campaign_group_id) the converted template will target; required with --from-file',
30
+ }),
31
+ 'poll-interval': Flags.integer({ default: 5, description: 'Seconds between progress polls' }),
32
+ preview: Flags.boolean({
33
+ description: 'With --from-file: print the converted request and stop, nothing is created',
34
+ }),
35
+ timeout: Flags.integer({ default: 900, description: 'Max seconds to wait for the operation to finish' }),
36
+ wait: Flags.boolean({
37
+ allowNo: true,
38
+ default: true,
39
+ description: 'Poll the operation until it finishes; --no-wait returns the operation id immediately',
40
+ }),
41
+ };
42
+ async run() {
43
+ const { flags } = await this.parse(AsaCampaignsBulkCreate);
44
+ const client = await createAsaClient(this.config);
45
+ const body = flags['from-file'] ? await this.convertTemplate(client, flags) : await this.readStructure(flags.file);
46
+ if (flags.preview) {
47
+ this.log(JSON.stringify(body, null, 2));
48
+ return body;
49
+ }
50
+ const counts = this.countStructure(body);
51
+ const summary = `Bulk-create: ${counts.campaigns} campaign node(s), ${counts.adGroups} ad group(s), ` +
52
+ `${counts.keywords} keyword(s), ${counts.negativeKeywords} negative keyword(s), ${counts.ads} ad(s)`;
53
+ await confirmMutation(this, { body, method: 'POST', path: '/bulk-operations/', summary }, flags.yes);
54
+ const { replayed, result } = await asaWrite(client, 'post', '/bulk-operations', {
55
+ body,
56
+ idempotencyKey: flags['idempotency-key'],
57
+ });
58
+ noteReplay(replayed, this.log.bind(this));
59
+ this.log(`Operation accepted: ${result.operation_id}`);
60
+ if (!flags.wait) {
61
+ this.log(`Check progress with: adapty asa campaigns bulk-status ${result.operation_id}`);
62
+ return { operation_id: result.operation_id };
63
+ }
64
+ const state = await this.waitForCompletion(client, result.operation_id, flags['poll-interval'], flags.timeout);
65
+ this.report(state);
66
+ return state;
67
+ }
68
+ async convertTemplate(client, flags) {
69
+ if (flags['org-id'] === undefined)
70
+ this.error('--org-id is required with --from-file.', { exit: 2 });
71
+ const path = flags['from-file'];
72
+ let content;
73
+ try {
74
+ content = await readFile(path);
75
+ }
76
+ catch {
77
+ this.error(`Cannot read file: ${path}`, { exit: 2 });
78
+ }
79
+ const form = new FormData();
80
+ form.append('file', new Blob([new Uint8Array(content)]), basename(path));
81
+ form.append('campaign_group_id', String(flags['org-id']));
82
+ const result = await client.postForm('/bulk-operations/convert', form);
83
+ for (const warning of result.warnings)
84
+ this.warn(this.formatIssue(warning));
85
+ if (result.errors.length > 0 || result.request === null) {
86
+ for (const issue of result.errors)
87
+ this.log(this.formatIssue(issue));
88
+ this.error(`Template conversion failed with ${result.errors.length} error(s); nothing was created.`, { exit: 2 });
89
+ }
90
+ return result.request;
91
+ }
92
+ countStructure(body) {
93
+ const counts = { adGroups: 0, ads: 0, campaigns: 0, keywords: 0, negativeKeywords: 0 };
94
+ const campaigns = Array.isArray(body.campaigns) ? body.campaigns : [];
95
+ counts.campaigns = campaigns.length;
96
+ for (const campaign of campaigns) {
97
+ if (typeof campaign !== 'object' || campaign === null)
98
+ continue;
99
+ const campaignNode = campaign;
100
+ counts.negativeKeywords += Array.isArray(campaignNode.negative_keywords) ? campaignNode.negative_keywords.length : 0;
101
+ const adGroups = Array.isArray(campaignNode.ad_groups) ? campaignNode.ad_groups : [];
102
+ counts.adGroups += adGroups.length;
103
+ for (const adGroup of adGroups) {
104
+ if (typeof adGroup !== 'object' || adGroup === null)
105
+ continue;
106
+ const adGroupNode = adGroup;
107
+ counts.keywords += Array.isArray(adGroupNode.keywords) ? adGroupNode.keywords.length : 0;
108
+ counts.negativeKeywords += Array.isArray(adGroupNode.negative_keywords) ? adGroupNode.negative_keywords.length : 0;
109
+ counts.ads += Array.isArray(adGroupNode.ads) ? adGroupNode.ads.length : 0;
110
+ }
111
+ }
112
+ return counts;
113
+ }
114
+ formatIssue(issue) {
115
+ const place = [issue.sheet, issue.row === null ? undefined : `row ${issue.row}`, issue.column ?? undefined]
116
+ .filter(Boolean)
117
+ .join(', ');
118
+ return place ? `${place}: ${issue.message}` : issue.message;
119
+ }
120
+ async readStructure(source) {
121
+ let raw;
122
+ if (source === '-') {
123
+ const chunks = [];
124
+ for await (const chunk of process.stdin)
125
+ chunks.push(chunk);
126
+ raw = Buffer.concat(chunks).toString('utf8');
127
+ }
128
+ else {
129
+ try {
130
+ raw = await readFile(source, 'utf8');
131
+ }
132
+ catch {
133
+ this.error(`Cannot read file: ${source}`, { exit: 2 });
134
+ }
135
+ }
136
+ try {
137
+ return JSON.parse(raw);
138
+ }
139
+ catch {
140
+ this.error('The structure is not valid JSON.', { exit: 2 });
141
+ }
142
+ }
143
+ report(state) {
144
+ const { counts } = state;
145
+ this.log(`Finished: ${state.status} — ${counts.applied}/${counts.total} applied, ${counts.failed} failed`);
146
+ const failedObjects = state.objects.filter((object) => object.status === 'failed');
147
+ for (const object of failedObjects) {
148
+ printResponse(object, this.log.bind(this));
149
+ this.log('---');
150
+ }
151
+ if (state.status === 'failed')
152
+ this.error('Bulk operation failed.', { exit: 1 });
153
+ if (state.status === 'partial')
154
+ this.warn('Some objects were not created — see the failures above.');
155
+ }
156
+ async waitForCompletion(client, operationId, pollIntervalSeconds, timeoutSeconds) {
157
+ const startedAt = Date.now();
158
+ let lastProgress = '';
159
+ for (;;) {
160
+ const state = await client.get(`/bulk-operations/${operationId}`);
161
+ const progress = `${state.status}: ${state.counts.applied}/${state.counts.total} applied, ${state.counts.failed} failed`;
162
+ if (progress !== lastProgress) {
163
+ this.log(progress);
164
+ lastProgress = progress;
165
+ }
166
+ if (TERMINAL_STATUSES.has(state.status))
167
+ return state;
168
+ if ((Date.now() - startedAt) / 1000 >= timeoutSeconds) {
169
+ this.warn(`Still ${state.status} after ${timeoutSeconds}s — check later with: adapty asa campaigns bulk-status ${operationId}`);
170
+ return state;
171
+ }
172
+ await new Promise((resolve) => {
173
+ setTimeout(resolve, pollIntervalSeconds * 1000);
174
+ });
175
+ }
176
+ }
177
+ }
@@ -0,0 +1,15 @@
1
+ import { Command } from '@oclif/core';
2
+ import type { AsaBulkOperationStateDTO } from '../../../lib/asa-schemas.js';
3
+ export default class AsaCampaignsBulkStatus extends Command {
4
+ static args: {
5
+ 'operation-id': import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static description: string;
8
+ static enableJsonFlag: boolean;
9
+ static examples: string[];
10
+ static flags: {
11
+ page: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'page-size': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
13
+ };
14
+ run(): Promise<AsaBulkOperationStateDTO>;
15
+ }
@@ -0,0 +1,26 @@
1
+ import { Args, Command } from '@oclif/core';
2
+ import { createAsaClient } from '../../../lib/asa-client.js';
3
+ import { asaPaginationFlags } from '../../../lib/asa-flags.js';
4
+ import { isValidUuid, paginationParams } from '../../../lib/flags.js';
5
+ import { printResponse } from '../../../lib/output.js';
6
+ export default class AsaCampaignsBulkStatus extends Command {
7
+ static args = {
8
+ 'operation-id': Args.string({ description: 'Bulk operation ID returned by bulk-create', required: true }),
9
+ };
10
+ static description = 'Progress board of one bulk operation: counts, pipelines and the per-object log';
11
+ static enableJsonFlag = true;
12
+ static examples = ['<%= config.bin %> asa campaigns bulk-status 660e8400-e29b-41d4-a716-446655440001'];
13
+ static flags = {
14
+ ...asaPaginationFlags,
15
+ };
16
+ async run() {
17
+ const { args, flags } = await this.parse(AsaCampaignsBulkStatus);
18
+ const operationId = args['operation-id'];
19
+ if (!isValidUuid(operationId))
20
+ this.error('Invalid operation ID format.', { exit: 2 });
21
+ const client = await createAsaClient(this.config);
22
+ const state = await client.get(`/bulk-operations/${operationId}`, paginationParams(flags));
23
+ printResponse(state, this.log.bind(this));
24
+ return state;
25
+ }
26
+ }
@@ -17,7 +17,8 @@ export default class AsaConnect extends Command {
17
17
  this.log(`If the browser doesn't open, visit: ${authUrl}\n`);
18
18
  this.log('The link is valid for one hour. Sign in to the Adapty dashboard in that browser first — the last');
19
19
  this.log('step is authorized by the dashboard session, not by this CLI.');
20
- await open(authUrl).catch(() => false);
20
+ if (process.stdin.isTTY === true)
21
+ await open(authUrl).catch(() => false);
21
22
  if (!flags.wait)
22
23
  return { auth_url: authUrl };
23
24
  const deadline = Date.now() + flags.timeout * 1000;
@@ -29,11 +29,13 @@ export default class AuthLogin extends Command {
29
29
  }
30
30
  this.log(`\nYour code: ${device.user_code}\n`);
31
31
  this.log(`If browser doesn't open, visit: ${device.verification_uri_complete}\n`);
32
- try {
33
- await open(device.verification_uri_complete);
34
- }
35
- catch {
36
- // browser open failed silently — URL already printed
32
+ if (process.stdin.isTTY === true) {
33
+ try {
34
+ await open(device.verification_uri_complete);
35
+ }
36
+ catch {
37
+ // browser open failed silently — URL already printed
38
+ }
37
39
  }
38
40
  this.log('Waiting for authorization...');
39
41
  let interval = Math.max((device.interval_seconds || 5) * 1000, 5000);
@@ -0,0 +1,14 @@
1
+ import { Command } from '@oclif/core';
2
+ import type { FlowConfigDTO } from '../../../lib/api-schemas.js';
3
+ export default class FlowsConfigGet extends Command {
4
+ static args: {
5
+ flow_id: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static description: string;
8
+ static enableJsonFlag: boolean;
9
+ static examples: string[];
10
+ static flags: {
11
+ app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ };
13
+ run(): Promise<FlowConfigDTO>;
14
+ }
@@ -0,0 +1,25 @@
1
+ import { Args, Command } from '@oclif/core';
2
+ import { createAuthenticatedClient } from '../../../lib/client-from-config.js';
3
+ import { appFlag, isValidUuid } from '../../../lib/flags.js';
4
+ import { printResponse } from '../../../lib/output.js';
5
+ export default class FlowsConfigGet extends Command {
6
+ static args = {
7
+ flow_id: Args.string({ description: 'Flow ID (UUID)', required: true }),
8
+ };
9
+ static description = 'Read the flow builder config (404 until the config has been written at least once)';
10
+ static enableJsonFlag = true;
11
+ static examples = ['<%= config.bin %> flows config get --app UUID 550e8400-e29b-41d4-a716-446655440000'];
12
+ static flags = {
13
+ ...appFlag,
14
+ };
15
+ async run() {
16
+ const { args, flags } = await this.parse(FlowsConfigGet);
17
+ if (!isValidUuid(args.flow_id)) {
18
+ this.error('Invalid flow ID format.', { exit: 2 });
19
+ }
20
+ const client = await createAuthenticatedClient(this.config);
21
+ const result = await client.get(`/apps/${flags.app}/flows/${args.flow_id}/config`);
22
+ printResponse(result, this.log.bind(this));
23
+ return result;
24
+ }
25
+ }
@@ -0,0 +1,21 @@
1
+ import { Command } from '@oclif/core';
2
+ import type { FlowConfigDTO } from '../../../lib/api-schemas.js';
3
+ export default class FlowsConfigUpdate extends Command {
4
+ static args: {
5
+ flow_id: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static description: string;
8
+ static enableJsonFlag: boolean;
9
+ static examples: string[];
10
+ static flags: {
11
+ config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'config-file': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ 'expected-updated-at': import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ 'remote-configs': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
16
+ };
17
+ run(): Promise<FlowConfigDTO>;
18
+ private parseJson;
19
+ private readConfig;
20
+ private readStdin;
21
+ }
@@ -0,0 +1,84 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { createAuthenticatedClient } from '../../../lib/client-from-config.js';
4
+ import { appFlag, isValidUuid } from '../../../lib/flags.js';
5
+ import { printResponse } from '../../../lib/output.js';
6
+ export default class FlowsConfigUpdate extends Command {
7
+ static args = {
8
+ flow_id: Args.string({ description: 'Flow ID (UUID)', required: true }),
9
+ };
10
+ static description = 'Write the flow builder config (creates the first version, or edits/forks the current one)';
11
+ static enableJsonFlag = true;
12
+ static examples = [
13
+ '<%= config.bin %> flows config update --app UUID FLOW_UUID --config-file config.json',
14
+ 'cat config.json | <%= config.bin %> flows config update --app UUID FLOW_UUID --config-file -',
15
+ '<%= config.bin %> flows config update --app UUID FLOW_UUID --config \'{"screens":[],"locales":[]}\' --expected-updated-at 1755001800000',
16
+ ];
17
+ static flags = {
18
+ ...appFlag,
19
+ config: Flags.string({
20
+ description: 'Builder config as a JSON string',
21
+ exactlyOne: ['config', 'config-file'],
22
+ }),
23
+ 'config-file': Flags.string({
24
+ description: 'JSON file with the builder config, or - to read stdin',
25
+ exactlyOne: ['config', 'config-file'],
26
+ }),
27
+ 'expected-updated-at': Flags.integer({
28
+ description: 'Optimistic lock: the `updated_at` from a prior config read. Omit for last-write-wins.',
29
+ }),
30
+ 'remote-configs': Flags.string({
31
+ description: 'JSON array of remote config entries: [{locale, data}]',
32
+ }),
33
+ };
34
+ async run() {
35
+ const { args, flags } = await this.parse(FlowsConfigUpdate);
36
+ if (!isValidUuid(args.flow_id)) {
37
+ this.error('Invalid flow ID format.', { exit: 2 });
38
+ }
39
+ const config = await this.readConfig(flags);
40
+ const body = { config };
41
+ if (flags['remote-configs'] !== undefined) {
42
+ body.remote_configs = this.parseJson(flags['remote-configs'], '--remote-configs');
43
+ }
44
+ if (flags['expected-updated-at'] !== undefined) {
45
+ body.expected_updated_at = flags['expected-updated-at'];
46
+ }
47
+ const client = await createAuthenticatedClient(this.config);
48
+ const result = await client.put(`/apps/${flags.app}/flows/${args.flow_id}/config`, body);
49
+ this.log('Flow config saved!');
50
+ printResponse(result, this.log.bind(this));
51
+ return result;
52
+ }
53
+ parseJson(raw, label) {
54
+ try {
55
+ return JSON.parse(raw);
56
+ }
57
+ catch (error) {
58
+ this.error(`Invalid ${label} JSON: ${error instanceof Error ? error.message : String(error)}`, { exit: 2 });
59
+ }
60
+ }
61
+ async readConfig(flags) {
62
+ if (flags.config !== undefined) {
63
+ return this.parseJson(flags.config, '--config');
64
+ }
65
+ const path = flags['config-file'];
66
+ if (path === undefined) {
67
+ this.error('Provide --config or --config-file.', { exit: 2 });
68
+ }
69
+ let raw;
70
+ try {
71
+ raw = path === '-' ? await this.readStdin() : await readFile(path, 'utf8');
72
+ }
73
+ catch {
74
+ this.error(`Could not read ${path}.`, { exit: 2 });
75
+ }
76
+ return this.parseJson(raw, path);
77
+ }
78
+ async readStdin() {
79
+ const chunks = [];
80
+ for await (const chunk of process.stdin)
81
+ chunks.push(chunk);
82
+ return Buffer.concat(chunks).toString('utf8');
83
+ }
84
+ }
@@ -0,0 +1,12 @@
1
+ import { Command } from '@oclif/core';
2
+ import type { FlowDTO } from '../../lib/api-schemas.js';
3
+ export default class FlowsCreate extends Command {
4
+ static description: string;
5
+ static enableJsonFlag: boolean;
6
+ static examples: string[];
7
+ static flags: {
8
+ name: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ };
11
+ run(): Promise<FlowDTO>;
12
+ }
@@ -0,0 +1,22 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import { createAuthenticatedClient } from '../../lib/client-from-config.js';
3
+ import { appFlag } from '../../lib/flags.js';
4
+ import { printResponse } from '../../lib/output.js';
5
+ export default class FlowsCreate extends Command {
6
+ static description = 'Create a flow (row only — write its config with `flows config update`)';
7
+ static enableJsonFlag = true;
8
+ static examples = ['<%= config.bin %> flows create --app UUID --name "Onboarding"'];
9
+ static flags = {
10
+ ...appFlag,
11
+ name: Flags.string({ description: 'Flow name', required: true }),
12
+ };
13
+ async run() {
14
+ const { flags } = await this.parse(FlowsCreate);
15
+ const client = await createAuthenticatedClient(this.config);
16
+ const body = { name: flags.name };
17
+ const result = await client.post(`/apps/${flags.app}/flows`, body);
18
+ this.log('Flow created!');
19
+ printResponse(result, this.log.bind(this));
20
+ return result;
21
+ }
22
+ }
@@ -0,0 +1,14 @@
1
+ import { Command } from '@oclif/core';
2
+ import type { FlowDTO } from '../../lib/api-schemas.js';
3
+ export default class FlowsGet extends Command {
4
+ static args: {
5
+ flow_id: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static description: string;
8
+ static enableJsonFlag: boolean;
9
+ static examples: string[];
10
+ static flags: {
11
+ app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ };
13
+ run(): Promise<FlowDTO>;
14
+ }
@@ -0,0 +1,25 @@
1
+ import { Args, Command } from '@oclif/core';
2
+ import { createAuthenticatedClient } from '../../lib/client-from-config.js';
3
+ import { appFlag, isValidUuid } from '../../lib/flags.js';
4
+ import { printResponse } from '../../lib/output.js';
5
+ export default class FlowsGet extends Command {
6
+ static args = {
7
+ flow_id: Args.string({ description: 'Flow ID (UUID)', required: true }),
8
+ };
9
+ static description = 'Get flow details';
10
+ static enableJsonFlag = true;
11
+ static examples = ['<%= config.bin %> flows get --app UUID 550e8400-e29b-41d4-a716-446655440000'];
12
+ static flags = {
13
+ ...appFlag,
14
+ };
15
+ async run() {
16
+ const { args, flags } = await this.parse(FlowsGet);
17
+ if (!isValidUuid(args.flow_id)) {
18
+ this.error('Invalid flow ID format.', { exit: 2 });
19
+ }
20
+ const client = await createAuthenticatedClient(this.config);
21
+ const result = await client.get(`/apps/${flags.app}/flows/${args.flow_id}`);
22
+ printResponse(result, this.log.bind(this));
23
+ return result;
24
+ }
25
+ }
@@ -0,0 +1,14 @@
1
+ import { Command } from '@oclif/core';
2
+ import type { FlowDTO } from '../../lib/api-schemas.js';
3
+ import { type PaginatedResponse } from '../../lib/flags.js';
4
+ export default class FlowsList extends Command {
5
+ static description: string;
6
+ static enableJsonFlag: boolean;
7
+ static examples: string[];
8
+ static flags: {
9
+ page: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
10
+ 'page-size': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
11
+ app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ };
13
+ run(): Promise<PaginatedResponse<FlowDTO>>;
14
+ }
@@ -0,0 +1,20 @@
1
+ import { Command } from '@oclif/core';
2
+ import { createAuthenticatedClient } from '../../lib/client-from-config.js';
3
+ import { appFlag, paginationFlags, paginationParams } from '../../lib/flags.js';
4
+ import { printList } from '../../lib/output.js';
5
+ export default class FlowsList extends Command {
6
+ static description = 'List flows for an app';
7
+ static enableJsonFlag = true;
8
+ static examples = ['<%= config.bin %> flows list --app 550e8400-...'];
9
+ static flags = {
10
+ ...appFlag,
11
+ ...paginationFlags,
12
+ };
13
+ async run() {
14
+ const { flags } = await this.parse(FlowsList);
15
+ const client = await createAuthenticatedClient(this.config);
16
+ const result = await client.get(`/apps/${flags.app}/flows`, paginationParams(flags));
17
+ printList(result.data, this.log.bind(this), result.meta.pagination);
18
+ return result;
19
+ }
20
+ }
@@ -20,6 +20,7 @@ export declare class ApiClient {
20
20
  constructor(opts?: ApiClientOptions);
21
21
  get<T = unknown>(path: string, params?: QueryParams): Promise<T>;
22
22
  post<T = unknown>(path: string, body?: unknown, params?: QueryParams, opts?: RequestOptions): Promise<T>;
23
+ postForm<T = unknown>(path: string, form: FormData, params?: QueryParams, opts?: RequestOptions): Promise<T>;
23
24
  put<T = unknown>(path: string, body?: unknown, params?: QueryParams, opts?: RequestOptions): Promise<T>;
24
25
  private buildUrl;
25
26
  private isRetryableRateLimit;
@@ -29,6 +29,9 @@ export class ApiClient {
29
29
  method: 'POST',
30
30
  }, opts);
31
31
  }
32
+ async postForm(path, form, params, opts) {
33
+ return this.request(this.buildUrl(path, params), { body: form, method: 'POST' }, opts);
34
+ }
32
35
  async put(path, body, params, opts) {
33
36
  return this.request(this.buildUrl(path, params), {
34
37
  body: body ? JSON.stringify(body) : undefined,
@@ -60,7 +63,7 @@ export class ApiClient {
60
63
  const headers = {
61
64
  'User-Agent': this.userAgent,
62
65
  };
63
- if (init.body) {
66
+ if (init.body && !(init.body instanceof FormData)) {
64
67
  headers['Content-Type'] = 'application/json';
65
68
  }
66
69
  if (this.token) {