@getxflow/cli 0.12.0 → 0.13.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/dist/api.js CHANGED
@@ -94,7 +94,7 @@ async function send(client, path, options = {}) {
94
94
  }
95
95
  catch (e) {
96
96
  const reason = e instanceof Error ? e.message : String(e);
97
- throw new ApiError(`The platform is unreachable: ${reason}`, 'network', 0, `Check the connection and the address ${client.apiUrl}`);
97
+ throw new ApiError(`The platform is unreachable: ${reason}`, 'network', 0, `Check the connection and the address ${client.apiUrl}. Inside an agent sandbox the network is usually switched off: then the command belongs to a person in their own terminal`);
98
98
  }
99
99
  assertProtocol(response);
100
100
  return response;
package/dist/bin.js CHANGED
@@ -133,11 +133,15 @@ async function run(args) {
133
133
  await (0, connections_1.connectionsUnlink)(rest);
134
134
  return;
135
135
  }
136
+ if (second === 'call') {
137
+ await (0, connections_1.connectionsCall)(rest);
138
+ return;
139
+ }
136
140
  if (second === undefined || second === 'list') {
137
141
  await (0, connections_1.connectionsList)(rest);
138
142
  return;
139
143
  }
140
- throw new errors_1.CliError(`Unknown command: connections ${second}`, 'Available: list, link and unlink');
144
+ throw new errors_1.CliError(`Unknown command: connections ${second}`, 'Available: list, link, unlink and call');
141
145
  case 'schedules':
142
146
  if (second === 'set') {
143
147
  await (0, schedules_1.schedulesSet)(rest);
@@ -3,10 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.connectionsList = connectionsList;
4
4
  exports.connectionsLink = connectionsLink;
5
5
  exports.connectionsUnlink = connectionsUnlink;
6
+ exports.connectionsCall = connectionsCall;
6
7
  const api_1 = require("../api");
7
8
  const args_1 = require("../args");
8
9
  const config_1 = require("../config");
9
10
  const errors_1 = require("../errors");
11
+ const json_arg_1 = require("../json-arg");
10
12
  const session_1 = require("../session");
11
13
  const ui_1 = require("../ui");
12
14
  const env_1 = require("./env");
@@ -135,3 +137,135 @@ async function connectionsUnlink(args) {
135
137
  (0, ui_1.ok)(`${(0, ui_1.bold)(row.label)} unlinked, ${result.alias} is gone`);
136
138
  (0, ui_1.note)((0, ui_1.dim)(' Functions already deployed keep the values until their next deploy'));
137
139
  }
140
+ /** Budget for the shown body. Enough to read the shape of an answer, cheap in context. */
141
+ const SHOWN_CHARS = 8000;
142
+ /**
143
+ * Head of the body, cut on a line boundary so JSON is never sliced mid-structure. Only the
144
+ * head is kept: a stitched head and tail would read as one answer and invite conclusions the
145
+ * data does not support.
146
+ */
147
+ function head(text) {
148
+ if (text.length <= SHOWN_CHARS)
149
+ return { text, cut: false };
150
+ const kept = [];
151
+ let used = 0;
152
+ for (const line of text.split('\n')) {
153
+ if (used + line.length + 1 > SHOWN_CHARS)
154
+ break;
155
+ kept.push(line);
156
+ used += line.length + 1;
157
+ }
158
+ // One minified line longer than the whole budget: cut it by characters, there is no
159
+ // boundary to respect.
160
+ if (kept.length === 0)
161
+ return { text: text.slice(0, SHOWN_CHARS), cut: true };
162
+ return { text: kept.join('\n'), cut: true };
163
+ }
164
+ /**
165
+ * Response headers worth a line each: rate limits, the type, and where a redirect points.
166
+ * A CDN adds two dozen more, and on every call that is noise.
167
+ *
168
+ * Request headers are not filtered: there are a few, they are yours, and seeing them is how
169
+ * a `{NAME}` the connection does not have shows itself.
170
+ */
171
+ function shownHeaders(headers) {
172
+ const wanted = (name) => name === 'content-type' || name === 'retry-after' || name === 'location' || name.startsWith('x-ratelimit');
173
+ return Object.entries(headers)
174
+ .filter(([name]) => wanted(name.toLowerCase()))
175
+ .map(([name, value]) => `${name}: ${value}`);
176
+ }
177
+ function allHeaders(headers) {
178
+ return Object.entries(headers).map(([name, value]) => `${name}: ${value}`);
179
+ }
180
+ /** Headers typed as JSON. Values may carry {NAME} placeholders, the platform fills those in. */
181
+ function headerArg(args) {
182
+ const raw = (0, args_1.flagString)(args, 'header');
183
+ if (raw === undefined)
184
+ return undefined;
185
+ const problem = (0, json_arg_1.jsonProblem)(raw);
186
+ if (problem)
187
+ throw new errors_1.CliError(`--header is not valid JSON: ${raw}`, (0, json_arg_1.jsonHint)('header', raw));
188
+ const parsed = JSON.parse(raw);
189
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
190
+ throw new errors_1.CliError('--header takes an object', 'For example: --header \'{"Client-Id":"{CLIENT_ID}"}\'');
191
+ }
192
+ const headers = {};
193
+ for (const [name, value] of Object.entries(parsed)) {
194
+ if (typeof value !== 'string') {
195
+ throw new errors_1.CliError(`The value of ${name} has to be a string`, 'Header values are text');
196
+ }
197
+ headers[name] = value;
198
+ }
199
+ return headers;
200
+ }
201
+ /** Is the body meant to be JSON? A form content-type says it is not, and then it goes as typed. */
202
+ function looksJson(headers) {
203
+ for (const [name, value] of Object.entries(headers ?? {})) {
204
+ if (name.toLowerCase() === 'content-type')
205
+ return /json/i.test(value);
206
+ }
207
+ return true;
208
+ }
209
+ /**
210
+ * One-off call to the API behind a connected account, with the platform attaching the
211
+ * credentials. For looking at what an API returns without deploying a function for it.
212
+ */
213
+ async function connectionsCall(args) {
214
+ const alias = args.words[1];
215
+ const path = args.words[2];
216
+ if (!alias) {
217
+ throw new errors_1.CliError('A connection is required', 'Its alias in this project: xflow connections');
218
+ }
219
+ if (!path) {
220
+ throw new errors_1.CliError('A path is required', 'For example: xflow connections call WB /api/v1/supplier/stocks?dateFrom=2026-09-01');
221
+ }
222
+ const headers = headerArg(args);
223
+ const body = (0, json_arg_1.bodyArg)(args, 'data');
224
+ // Judged before anything goes over the network, the same as in functions invoke: a broken
225
+ // body is the caller's own line. A form body is not judged at all, it is text by design.
226
+ if (body && looksJson(headers)) {
227
+ const problem = (0, json_arg_1.jsonProblem)(body.text);
228
+ if (problem) {
229
+ throw body.fromFile
230
+ ? new errors_1.CliError(`${body.fromFile} is not valid JSON: ${problem}`, 'The file has to hold one JSON value')
231
+ : new errors_1.CliError(`--data is not valid JSON: ${body.text}`, (0, json_arg_1.jsonHint)('data', body.text));
232
+ }
233
+ }
234
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
235
+ const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/connections/call`, {
236
+ method: 'POST',
237
+ body: {
238
+ connection: alias,
239
+ path,
240
+ method: (0, args_1.flagString)(args, 'method') ?? (body ? 'POST' : 'GET'),
241
+ ...(body ? { body: body.text } : {}),
242
+ ...(headers ? { headers } : {}),
243
+ },
244
+ // Past the platform's own cap on the call, so a slow API reads as a slow API and not as
245
+ // an unreachable platform.
246
+ timeoutMs: 60_000,
247
+ });
248
+ if ((0, args_1.flagBool)(args, 'json')) {
249
+ (0, ui_1.out)(JSON.stringify(data, null, 2));
250
+ return;
251
+ }
252
+ const { request, response } = data;
253
+ (0, ui_1.note)((0, ui_1.dim)(` ${request.method} ${request.url}`));
254
+ for (const line of allHeaders(request.headers))
255
+ (0, ui_1.note)((0, ui_1.dim)(` ${line}`));
256
+ const size = response.truncated ? `at least ${(0, ui_1.formatBytes)(response.bytes)}` : (0, ui_1.formatBytes)(response.bytes);
257
+ (0, ui_1.note)((0, ui_1.dim)(` ${response.status} in ${data.elapsed_ms} ms, ${size}`));
258
+ for (const line of shownHeaders(response.headers))
259
+ (0, ui_1.note)((0, ui_1.dim)(` ${line}`));
260
+ const text = typeof response.body === 'string' ? response.body : JSON.stringify(response.body, null, 2);
261
+ const shown = head(text);
262
+ if (shown.text)
263
+ (0, ui_1.out)(shown.text);
264
+ if (shown.cut || response.truncated) {
265
+ (0, ui_1.note)((0, ui_1.dim)(' This is the head of the answer, not all of it. Narrow the request with the API own ' +
266
+ 'parameters (a limit, a shorter period), or process the whole set in a cloud function'));
267
+ }
268
+ if (!response.ok) {
269
+ throw new errors_1.CliError(`The API answered ${response.status}`, 'The answer above is theirs, not the platform own');
270
+ }
271
+ }
@@ -73,13 +73,29 @@ function skillPath(base, agent, global) {
73
73
  function installed(base, agent, global) {
74
74
  return (0, node_fs_1.existsSync)(skillPath(base, agent, global));
75
75
  }
76
+ /**
77
+ * A refused write turned into an answer. Agent sandboxes keep `.agents`, `.codex` and
78
+ * `.git` read-only precisely so that an agent cannot rewrite its own instructions, so
79
+ * this is the ordinary outcome of an agent running the command, not a broken machine.
80
+ */
81
+ function writeRefused(path, e) {
82
+ const code = e instanceof Error ? e.code : undefined;
83
+ if (code !== 'EACCES' && code !== 'EPERM' && code !== 'EROFS')
84
+ return e;
85
+ return new errors_1.CliError(`No permission to write ${path}`, 'An agent sandbox usually keeps .agents, .codex and .git read-only. Ask the person to run this command in their own terminal');
86
+ }
76
87
  /** Writes only when the content differs. */
77
88
  function writeIfChanged(path, content) {
78
89
  const before = (0, node_fs_1.existsSync)(path) ? (0, node_fs_1.readFileSync)(path, 'utf-8') : null;
79
90
  if (before === content)
80
91
  return null;
81
- (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
82
- (0, node_fs_1.writeFileSync)(path, content, 'utf-8');
92
+ try {
93
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
94
+ (0, node_fs_1.writeFileSync)(path, content, 'utf-8');
95
+ }
96
+ catch (e) {
97
+ throw writeRefused(path, e);
98
+ }
83
99
  return before === null ? 'new' : 'updated';
84
100
  }
85
101
  /**
@@ -118,7 +134,12 @@ function writePointer(base, create) {
118
134
  }
119
135
  if (before === next)
120
136
  return null;
121
- (0, node_fs_1.writeFileSync)(path, next, 'utf-8');
137
+ try {
138
+ (0, node_fs_1.writeFileSync)(path, next, 'utf-8');
139
+ }
140
+ catch (e) {
141
+ throw writeRefused(path, e);
142
+ }
122
143
  return { path, state: before === null ? 'new' : 'updated' };
123
144
  }
124
145
  function install(base, ids, global) {
@@ -268,8 +289,11 @@ function installSkillQuietly(base) {
268
289
  }
269
290
  return true;
270
291
  }
271
- catch {
272
- (0, ui_1.note)((0, ui_1.dim)(' Could not lay out the AI agent instructions: xflow skills'));
292
+ catch (e) {
293
+ // The reason travels with the failure: a refused write is fixed by a person running
294
+ // the command, and "try xflow skills" alone sends the agent round the same wall.
295
+ (0, ui_1.note)((0, ui_1.dim)(` Could not lay out the AI agent instructions: ${e instanceof Error ? e.message : e}`));
296
+ (0, ui_1.note)((0, ui_1.dim)(` ${e instanceof errors_1.CliError && e.hint ? e.hint : 'Try again: xflow skills'}`));
273
297
  return false;
274
298
  }
275
299
  }
package/dist/flags.js CHANGED
@@ -61,6 +61,7 @@ exports.KNOWN = {
61
61
  'connections list': ['project'],
62
62
  'connections link': ['as', 'project'],
63
63
  'connections unlink': ['force'],
64
+ 'connections call': ['method', 'data', 'data-file', 'header', 'json', 'project'],
64
65
  schedules: ['project'],
65
66
  'schedules list': ['project'],
66
67
  'schedules set': ['payload', 'payload-file', 'project'],
package/dist/help.js CHANGED
@@ -367,6 +367,8 @@ the platform into the function.
367
367
  xflow connections link <name> --as ALIAS
368
368
  hand its credentials to the functions
369
369
  xflow connections unlink <ALIAS> take them away again
370
+ xflow connections call <ALIAS> <path>
371
+ ask that API something, once, without deploying
370
372
 
371
373
  Every row says whether the connection is linked to this project (its alias) and what
372
374
  state the access is in. ${(0, ui_1.bold)('available, not linked')} is the useful one: the account
@@ -394,7 +396,41 @@ not to look for another route. Either way only the accounts granted to you perso
394
396
  be linked at all: that rule holds whatever the key is allowed to do.
395
397
 
396
398
  Connecting a new account and switching one off stay with a person, in the platform
397
- interface. There is no command for either.`,
399
+ interface. There is no command for either.
400
+
401
+ ${(0, ui_1.bold)('call')} answers the question an API only answers by being asked: what does this
402
+ endpoint actually return, and is the field you need in it. The platform attaches the
403
+ credentials of the connected account and makes the request itself, so nothing has to be
404
+ deployed to find out, and the key never reaches you.
405
+
406
+ xflow connections call WB /api/v1/supplier/stocks?dateFrom=2026-09-01
407
+ xflow connections call AMO /api/v4/leads --method POST --data '{"name":"Test"}'
408
+ xflow connections call OZON /v1/product/list --method POST --data '{}' \\
409
+ --header '{"Client-Id":"{CLIENT_ID}"}'
410
+
411
+ The path is written after the address of the connector, query string and all, or given in
412
+ full as ${(0, ui_1.bold)('https://...')}. Only the hosts of that connector are reachable, and a
413
+ refusal lists them. ${(0, ui_1.bold)('--method')} defaults to GET, or to POST when there is a body.
414
+
415
+ ${(0, ui_1.bold)('{NAME}')} in the path or in a header value is filled in by the platform from the
416
+ fields of the connection. That is how the two-header APIs are called: Ozon wants
417
+ ${(0, ui_1.bold)('Client-Id')} beside its key, Yandex Direct wants ${(0, ui_1.bold)('Client-Login')} for an
418
+ agency account, and you never see those values. The names are the ones ${(0, ui_1.bold)('xflow env')}
419
+ shows without the alias in front: ${(0, ui_1.bold)('OZON_CLIENT_ID')} is written ${(0, ui_1.bold)('{CLIENT_ID}')}.
420
+ A name the connection does not have is left in the text as you typed it, so a typo shows up
421
+ in the printed request instead of failing somewhere far away.
422
+
423
+ The answer is printed head first, at most a few kilobytes, and it says so when there is
424
+ more. Ask for the shape, not the volume: one day and ${(0, ui_1.bold)('limit=1')} tells you what the
425
+ fields are called, while the whole dataset belongs in a cloud function. ${(0, ui_1.bold)('--json')}
426
+ prints the envelope for a pipe: ${(0, ui_1.bold)("--json | jq '.response.body | keys'")} gives the
427
+ field names without the body ever entering your context.
428
+
429
+ Only an account already linked to this project can be called, because its credentials
430
+ already reach the functions of that project: the command saves a deploy, it does not widen
431
+ what you may touch. An account that is merely available has to be linked first, and that is
432
+ a separate right and a personal grant. The call also goes out for real: ${(0, ui_1.bold)('POST')} and
433
+ ${(0, ui_1.bold)('DELETE')} change things in somebody's live account, and no flag will undo that.`,
398
434
  schedules: `${(0, ui_1.bold)('xflow schedules')}: running functions on a timer
399
435
 
400
436
  A schedule is a Yandex timer trigger: it calls the function itself, with no
package/dist/json-arg.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.jsonProblem = jsonProblem;
4
4
  exports.jsonHint = jsonHint;
5
+ exports.bodyArg = bodyArg;
5
6
  exports.jsonArg = jsonArg;
6
7
  const node_fs_1 = require("node:fs");
7
8
  const args_1 = require("./args");
@@ -39,8 +40,13 @@ function jsonHint(flag, raw) {
39
40
  }
40
41
  return `A single JSON value is expected, or keep the body in a file: ${file}`;
41
42
  }
42
- /** The body from --<flag> or --<flag>-file, already known to be JSON. */
43
- function jsonArg(args, flag) {
43
+ /**
44
+ * The body from --<flag> or --<flag>-file, whatever it is.
45
+ *
46
+ * Not every body is JSON: a connector call carrying a form body sends the text as typed, and
47
+ * judging it as JSON there would refuse a correct request.
48
+ */
49
+ function bodyArg(args, flag) {
44
50
  const fileFlag = `${flag}-file`;
45
51
  const inline = args.flags[flag];
46
52
  const path = (0, args_1.flagString)(args, fileFlag);
@@ -49,21 +55,25 @@ function jsonArg(args, flag) {
49
55
  }
50
56
  if (path !== undefined) {
51
57
  if (!(0, node_fs_1.existsSync)(path)) {
52
- throw new errors_1.CliError(`No file ${path}`, `--${fileFlag} takes the path to a file with one JSON value`);
58
+ throw new errors_1.CliError(`No file ${path}`, `--${fileFlag} takes the path to a file with the body`);
53
59
  }
54
- const text = (0, node_fs_1.readFileSync)(path, 'utf-8').trim();
55
- const problem = jsonProblem(text);
56
- if (problem)
57
- throw new errors_1.CliError(`${path} is not valid JSON: ${problem}`, 'The file has to hold one JSON value');
58
- return text;
60
+ return { text: (0, node_fs_1.readFileSync)(path, 'utf-8').trim(), fromFile: path };
59
61
  }
60
62
  if (inline === undefined)
61
63
  return undefined;
62
64
  if (inline === true)
63
65
  throw new errors_1.CliError(`--${flag} needs a value`, `For example: --${flag} '{"mode":"full"}'`);
64
- const raw = String(inline).trim();
65
- const problem = jsonProblem(raw);
66
- if (problem)
67
- throw new errors_1.CliError(`--${flag} is not valid JSON: ${raw}`, jsonHint(flag, raw));
68
- return raw;
66
+ return { text: String(inline).trim(), fromFile: null };
67
+ }
68
+ /** The body from --<flag> or --<flag>-file, already known to be JSON. */
69
+ function jsonArg(args, flag) {
70
+ const body = bodyArg(args, flag);
71
+ if (body === undefined)
72
+ return undefined;
73
+ const problem = jsonProblem(body.text);
74
+ if (!problem)
75
+ return body.text;
76
+ throw body.fromFile
77
+ ? new errors_1.CliError(`${body.fromFile} is not valid JSON: ${problem}`, 'The file has to hold one JSON value')
78
+ : new errors_1.CliError(`--${flag} is not valid JSON: ${body.text}`, jsonHint(flag, body.text));
69
79
  }
package/dist/version.js CHANGED
@@ -2,6 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_API_URL = exports.CLI_VERSION = void 0;
4
4
  /** Keep in sync with cli/package.json. */
5
- exports.CLI_VERSION = '0.12.0';
5
+ exports.CLI_VERSION = '0.13.0';
6
6
  /** Overridden by XFLOW_API_URL or the `api` field in xflow.json. */
7
7
  exports.DEFAULT_API_URL = 'https://app.getxflow.com';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getxflow/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "CLI for the XFlow platform: source sync, deployment and publishing of applications",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -24,7 +24,7 @@ contains the fix.
24
24
 
25
25
  ## Keeping these instructions current
26
26
 
27
- These instructions ship with xflow CLI 0.12.0. They travel inside the package, so the copy
27
+ These instructions ship with xflow CLI 0.13.0. They travel inside the package, so the copy
28
28
  you are reading can be older than the CLI answering your commands, and nothing about that
29
29
  is visible in the text itself.
30
30
 
@@ -368,6 +368,36 @@ owner of the key personally can be linked at all. If the right was taken away, s
368
368
  ask the person to turn it back on in the platform settings under Developers: a key cannot
369
369
  grant it to itself. Connecting a new account and switching one off stay with a person too.
370
370
 
371
+ ### Asking an API what it returns
372
+
373
+ Before writing a function against an unfamiliar API, find out what it actually answers:
374
+
375
+ ```
376
+ xflow connections call WB /api/v1/supplier/stocks?dateFrom=2026-09-01
377
+ xflow connections call OZON /v1/product/list --method POST --data '{}' \
378
+ --header '{"Client-Id":"{CLIENT_ID}"}'
379
+ ```
380
+
381
+ The platform attaches the credentials and makes the request itself, so no deploy is needed
382
+ and the key never reaches you. Only an account already linked to this project can be called:
383
+ its credentials already reach that project's functions, so this saves a deploy rather than
384
+ widening what you may touch. An account that is only `available, not linked` has to be linked
385
+ first, and that stays a decision for a person.
386
+
387
+ `{NAME}` in the path or in a header value is filled in from the fields of the connection: the
388
+ names are what `xflow env` shows without the alias in front, so `OZON_CLIENT_ID` is written
389
+ `{CLIENT_ID}`. That is how APIs wanting a second header are called, and you never see the
390
+ value. Only the hosts of that connector are reachable and a refusal lists them.
391
+
392
+ **Ask for the shape, not the volume.** One day and `limit=1` is enough to learn what the
393
+ fields are called; the answer is printed head first and says when there is more. The whole
394
+ dataset belongs in a cloud function, which is where you were going anyway. `--json | jq
395
+ '.response.body | keys'` gives the field names without the body entering your context at all.
396
+
397
+ The call goes out for real, against a live account: `POST` and `DELETE` change things there,
398
+ and nothing undoes that. Read the method of the endpoint before calling it, and ask the
399
+ person first when the endpoint is not clearly a read.
400
+
371
401
  ## Schedules
372
402
 
373
403
  `xflow schedules set report "0 3 ? * * *"` runs a function daily at 03:00. Six fields, UTC,