@getxflow/cli 0.12.1 → 0.14.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/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,139 @@ 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 connection = args.words[1];
215
+ const path = args.words[2];
216
+ if (!connection) {
217
+ throw new errors_1.CliError('A connection is required', 'Its alias in this project, or its name from the first column of 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,
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
+ // Named the account only when it was not called by its alias: with several accounts of one
254
+ // service under the same name, the alias is the only thing the caller already knew.
255
+ if (!data.alias)
256
+ (0, ui_1.note)((0, ui_1.dim)(` ${data.label} (${data.connection_id}), not linked to this project`));
257
+ (0, ui_1.note)((0, ui_1.dim)(` ${request.method} ${request.url}`));
258
+ for (const line of allHeaders(request.headers))
259
+ (0, ui_1.note)((0, ui_1.dim)(` ${line}`));
260
+ const size = response.truncated ? `at least ${(0, ui_1.formatBytes)(response.bytes)}` : (0, ui_1.formatBytes)(response.bytes);
261
+ (0, ui_1.note)((0, ui_1.dim)(` ${response.status} in ${data.elapsed_ms} ms, ${size}`));
262
+ for (const line of shownHeaders(response.headers))
263
+ (0, ui_1.note)((0, ui_1.dim)(` ${line}`));
264
+ const text = typeof response.body === 'string' ? response.body : JSON.stringify(response.body, null, 2);
265
+ const shown = head(text);
266
+ if (shown.text)
267
+ (0, ui_1.out)(shown.text);
268
+ if (shown.cut || response.truncated) {
269
+ (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 ' +
270
+ 'parameters (a limit, a shorter period), or process the whole set in a cloud function'));
271
+ }
272
+ if (!response.ok) {
273
+ throw new errors_1.CliError(`The API answered ${response.status}`, 'The answer above is theirs, not the platform own');
274
+ }
275
+ }
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 <name> <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,46 @@ 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
+ An account linked to this project is called by its alias. One that is only available is
430
+ called by its name, and no linking is needed: what the command asks is that you could have
431
+ linked it yourself, which needs the ${(0, ui_1.bold)('connections:link')} right and the account being
432
+ granted to you personally. Either way nothing widens: linked credentials already reach the
433
+ functions of this project, and an account you may link you may also read by deploying a
434
+ function into it. An account granted to somebody else stays out of reach, and the refusal
435
+ says which of the two is missing, the right or the grant.
436
+
437
+ The call goes out for real: ${(0, ui_1.bold)('POST')} and ${(0, ui_1.bold)('DELETE')} change things in
438
+ somebody's live account, and no flag will undo that.`,
398
439
  schedules: `${(0, ui_1.bold)('xflow schedules')}: running functions on a timer
399
440
 
400
441
  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.1';
5
+ exports.CLI_VERSION = '0.14.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.1",
3
+ "version": "0.14.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.1. They travel inside the package, so the copy
27
+ These instructions ship with xflow CLI 0.14.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
 
@@ -364,10 +364,46 @@ naming rules and the flags. Unlink refuses while a function still reads one of t
364
364
  variables and names those functions, so read that list before reaching for `--force`.
365
365
 
366
366
  Linking needs the `connections:link` right on the key, and only accounts granted to the
367
- owner of the key personally can be linked at all. If the right was taken away, say so and
367
+ owner of the key personally can be linked at all; the same right covers calling an unlinked
368
+ account. If the right was taken away, say so and
368
369
  ask the person to turn it back on in the platform settings under Developers: a key cannot
369
370
  grant it to itself. Connecting a new account and switching one off stay with a person too.
370
371
 
372
+ ### Asking an API what it returns
373
+
374
+ Before writing a function against an unfamiliar API, find out what it actually answers:
375
+
376
+ ```
377
+ xflow connections call WB /api/v1/supplier/stocks?dateFrom=2026-09-01
378
+ xflow connections call "Яндекс Метрика" /management/v1/counters
379
+ xflow connections call OZON /v1/product/list --method POST --data '{}' \
380
+ --header '{"Client-Id":"{CLIENT_ID}"}'
381
+ ```
382
+
383
+ The platform attaches the credentials and makes the request itself, so no deploy is needed and
384
+ the key never reaches you. A linked account is called by its alias, an `available, not linked`
385
+ one by its name: **linking first is not needed to look**, so do not ask a person about that.
386
+
387
+ **Calling is looking, not wiring**: no variable reaches the functions because you called
388
+ something. To use the account in code, link it and deploy.
389
+
390
+ Two refusals do need a person, and different ones: `connections:link` right — the owner of the
391
+ key grants it under Developers; `not granted to you` — ask whoever connected the account.
392
+
393
+ `{NAME}` in the path or in a header value is filled in from the fields of the connection: the
394
+ names are what `xflow env` shows without the alias in front, so `OZON_CLIENT_ID` is written
395
+ `{CLIENT_ID}`. That is how APIs wanting a second header are called, and you never see the
396
+ value. Only the hosts of that connector are reachable and a refusal lists them.
397
+
398
+ **Ask for the shape, not the volume.** One day and `limit=1` is enough to learn what the
399
+ fields are called; the answer is printed head first and says when there is more. The whole
400
+ dataset belongs in a cloud function, which is where you were going anyway. `--json | jq
401
+ '.response.body | keys'` gives the field names without the body entering your context at all.
402
+
403
+ The call goes out for real, against a live account: `POST` and `DELETE` change things there,
404
+ and nothing undoes that. Read the method of the endpoint before calling it, and ask the
405
+ person first when the endpoint is not clearly a read.
406
+
371
407
  ## Schedules
372
408
 
373
409
  `xflow schedules set report "0 3 ? * * *"` runs a function daily at 03:00. Six fields, UTC,