@getxflow/cli 0.34.1 → 1.0.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.
@@ -1,7 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.functionsList = functionsList;
4
- exports.failureHint = failureHint;
5
4
  exports.functionsInvoke = functionsInvoke;
6
5
  const api_1 = require("../api");
7
6
  const args_1 = require("../args");
@@ -9,45 +8,25 @@ const errors_1 = require("../errors");
9
8
  const json_arg_1 = require("../json-arg");
10
9
  const session_1 = require("../session");
11
10
  const ui_1 = require("../ui");
12
- /** Who can call the function: anyone, an outside service holding a key, or the app alone. */
13
- function accessLabel(fn) {
14
- const keys = fn.external_keys ? `external (${fn.external_keys} key${fn.external_keys > 1 ? 's' : ''})` : '';
15
- if (fn.is_public)
16
- return keys ? `public, ${keys}` : 'public';
17
- return keys || 'in-app only';
18
- }
19
- const FUNCTIONS_DIR = 'functions';
20
11
  async function functionsList(args) {
21
12
  const { projectId, client } = await (0, session_1.projectTarget)(args);
22
- const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/functions`);
23
- if (data.functions.length === 0 && !data.shared?.length) {
24
- (0, ui_1.note)(`No functions. Put the code in ${FUNCTIONS_DIR}/<name>/index.ts and run xflow deploy`);
13
+ const { functions } = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/functions`);
14
+ if (functions.length === 0) {
15
+ (0, ui_1.note)('No functions. Put the code in functions/<name>/index.ts at the project root and run xflow deploy functions');
25
16
  return;
26
17
  }
27
- if (data.functions.length > 0) {
28
- (0, ui_1.table)(data.functions.map((fn) => [
29
- fn.name,
30
- fn.status === 'deployed' ? 'deployed' : fn.status === 'failed' ? 'failed' : fn.status,
31
- accessLabel(fn),
32
- fn.last_deployed_at ? (0, ui_1.formatAge)(fn.last_deployed_at) : '-',
33
- // The address is not a secret (the app bundle carries it): a page or a
34
- // webhook needs it, and asking the user to copy it out of the UI was a detour.
35
- fn.invoke_url ?? '',
36
- fn.error_message ?? '',
37
- ]));
38
- }
39
- // Not rows of the table above: these are not this project's to deploy, rename or
40
- // delete, and invoke and logs do not reach them from here either. Under the table
41
- // with the id they need, so calling one is the same command plus --project.
42
- if (data.shared?.length) {
43
- if (data.functions.length > 0)
44
- (0, ui_1.note)();
45
- (0, ui_1.note)((0, ui_1.dim)(' Also callable from this project by name, code in another project of the organization:'));
46
- const width = Math.max(...data.shared.map((fn) => fn.name.length));
47
- for (const fn of data.shared) {
48
- (0, ui_1.note)((0, ui_1.dim)(` ${fn.name.padEnd(width)} --project ${fn.project_id}`));
49
- }
50
- }
18
+ (0, ui_1.table)(functions.map((fn) => [
19
+ fn.name,
20
+ fn.status,
21
+ fn.callers.length > 0 ? fn.callers.join(', ') : 'no callers',
22
+ fn.open ? 'open' : '',
23
+ fn.external_keys ? `${fn.external_keys} key${fn.external_keys > 1 ? 's' : ''}` : '',
24
+ fn.last_called_at ? `called ${(0, ui_1.formatAge)(fn.last_called_at)}` : 'never called',
25
+ fn.last_error_at ? `failed ${(0, ui_1.formatAge)(fn.last_error_at)}` : '',
26
+ ]));
27
+ (0, ui_1.note)((0, ui_1.dim)(' callers: the app folders and page names allowed to call it (functions in the root xflow.json)'));
28
+ (0, ui_1.note)((0, ui_1.dim)(' open: a caller is public, so anyone on the internet can call it; no callers: a workflow'));
29
+ (0, ui_1.note)((0, ui_1.dim)(` Webhooks and other functions call ${functions[0].url.replace(/\/[^/]+$/, '/<name>')} with a key`));
51
30
  }
52
31
  function prettyBody(text) {
53
32
  try {
@@ -57,94 +36,23 @@ function prettyBody(text) {
57
36
  return text;
58
37
  }
59
38
  }
60
- /**
61
- * Refusals the wrapper writes itself, before the handler runs. Nothing about them
62
- * reaches the logs, and the answer already names the cause.
63
- *
64
- * Matched by value and not by the presence of the field, because a handler is free to
65
- * put a reason of its own into the body it returns, and that one did run.
66
- *
67
- * Keep in sync with lib/yandex-functions/call-outcome.ts, which the MCP tool calls:
68
- * this package ships without dependencies and cannot reach into the platform. One test
69
- * holds the two halves together.
70
- */
71
- const WRAPPER_REASONS = ['no_pass', 'invalid', 'expired', 'no_access', 'unavailable', 'role', 'access'];
72
- /** What to look at next, when the function did not answer with a success. */
73
- function failureHint(name, status, body) {
74
- let reason;
75
- try {
76
- reason = JSON.parse(body).reason;
77
- }
78
- catch {
79
- reason = undefined;
80
- }
81
- const logs = `The stack and console output: xflow functions logs ${name}`;
82
- if (typeof reason !== 'string' || !WRAPPER_REASONS.includes(reason))
83
- return logs;
84
- if (reason !== 'role' || status < 500)
85
- return undefined;
86
- // Two different answers wear this one: a threshold the platform cannot read, refused
87
- // before the handler and so absent from the logs, and a handler that answered 5xx with
88
- // a reason of that name itself, which is logged. Emptiness there tells them apart.
89
- return `${logs}. Nothing there means the declared minRole is the problem: fix functions/${name}/index.ts`;
90
- }
91
- /** Call a function the same way the application does. */
39
+ /** Call a function as the person who owns the key, through the platform. */
92
40
  async function functionsInvoke(args) {
93
41
  const { projectId, client } = await (0, session_1.projectTarget)(args);
94
42
  const name = args.words[1];
95
43
  if (!name)
96
44
  throw new errors_1.CliError('A function name is required', 'What is deployed: xflow functions list');
97
- // The body is judged before anything goes over the network: a broken one is the
98
- // user's own line, and it costs nothing to say so at once.
45
+ // Judged before anything goes over the network: a broken body is the user's own line.
99
46
  const data = (0, json_arg_1.jsonArg)(args, 'data');
100
47
  const method = ((0, args_1.flagString)(args, 'method') ?? (data ? 'POST' : 'GET')).toUpperCase();
101
- const sendsBody = method !== 'GET' && method !== 'HEAD';
102
- const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
103
- const fn = card.functions.find((item) => item.name === name);
104
- if (!fn || !fn.invoke_url) {
105
- throw new errors_1.CliError(`Function ${name} is not deployed`, card.functions.length > 0
106
- ? `Deployed: ${card.functions.map((item) => item.name).join(', ')}`
107
- : 'Functions ship with the build: xflow deploy');
108
- }
109
- // A pass for the person who owns the key: the project token alone is not an
110
- // identity, and a project built with the current template refuses without one.
111
- // A read-only key cannot get a pass, so the call still goes out without it.
112
- let pass = '';
113
- try {
114
- const issued = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/pass`, { method: 'POST' });
115
- pass = issued.pass;
116
- }
117
- catch {
118
- (0, ui_1.note)((0, ui_1.dim)(' Could not get a visitor pass: calling with the project token only'));
119
- }
120
- // The cap the deployed version actually carries, not the plan's current one:
121
- // they diverge until the next build. Null means deployed before the platform
122
- // recorded caps, that is with 90.
123
- const capS = fn.execution_timeout_s ?? 90;
124
- const started = Date.now();
125
- let response;
126
- try {
127
- response = await fetch(fn.invoke_url, {
128
- method,
129
- headers: {
130
- 'Content-Type': 'application/json',
131
- 'X-Project-Token': card.project_token ?? '',
132
- ...(pass ? { 'X-Project-Pass': pass } : {}),
133
- },
134
- body: sendsBody ? (data ?? '{}') : undefined,
135
- // Wait past the function's own cap to see its timeout, not ours.
136
- signal: AbortSignal.timeout((capS + 10) * 1000),
137
- });
138
- }
139
- catch (e) {
140
- throw new errors_1.CliError(`The function did not answer: ${e instanceof Error ? e.message : String(e)}`, 'Check that it is deployed: xflow functions list');
141
- }
142
- const elapsed = Date.now() - started;
143
- const text = await response.text();
144
- (0, ui_1.note)((0, ui_1.dim)(`${response.status} ${response.statusText} in ${elapsed} ms`));
145
- if (text)
146
- (0, ui_1.out)((0, args_1.flagBool)(args, 'json') ? text : prettyBody(text));
147
- if (!response.ok) {
148
- throw new errors_1.CliError(`The function answered ${response.status}`, failureHint(name, response.status, text));
149
- }
48
+ const answer = await (0, api_1.apiForward)(client, `/api/v1/projects/${projectId}/functions/${encodeURIComponent(name)}/invoke`, {
49
+ method: 'POST',
50
+ body: { method, ...(data !== undefined && method !== 'GET' && method !== 'HEAD' ? { body: data } : {}) },
51
+ timeoutMs: 15 * 60_000,
52
+ });
53
+ (0, ui_1.note)((0, ui_1.dim)(`${answer.status} in ${answer.elapsedMs} ms`));
54
+ if (answer.body)
55
+ (0, ui_1.out)((0, args_1.flagBool)(args, 'json') ? answer.body : prettyBody(answer.body));
56
+ if (answer.status >= 400)
57
+ throw new errors_1.CliError(`The function answered ${answer.status}`, answer.hint);
150
58
  }
@@ -4,6 +4,7 @@ exports.logs = logs;
4
4
  exports.functionsLogs = functionsLogs;
5
5
  const api_1 = require("../api");
6
6
  const args_1 = require("../args");
7
+ const config_1 = require("../config");
7
8
  const session_1 = require("../session");
8
9
  const ui_1 = require("../ui");
9
10
  const DEFAULT_LIMIT = 10;
@@ -13,18 +14,10 @@ function stamp(iso) {
13
14
  const pad = (value) => String(value).padStart(2, '0');
14
15
  return `${pad(date.getDate())}.${pad(date.getMonth() + 1)} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
15
16
  }
16
- async function fetchLogs(source, name, args) {
17
- const { projectId, client } = await (0, session_1.projectTarget)(args);
18
- const query = new URLSearchParams({ source, limit: String((0, args_1.flagNumber)(args, 'limit') ?? DEFAULT_LIMIT) });
19
- if (name)
20
- query.set('name', name);
21
- const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/logs?${query.toString()}`);
22
- return data.logs;
23
- }
24
17
  /** Newest at the bottom. */
25
- function render(rows) {
18
+ function render(rows, appLabel) {
26
19
  for (const row of [...rows].reverse()) {
27
- const label = row.source === 'function' ? (row.function ?? 'function') : 'browser';
20
+ const label = row.source === 'function' ? (row.function ?? 'function') : appLabel(row.app_id ?? '');
28
21
  (0, ui_1.out)(`${(0, ui_1.dim)(stamp(row.timestamp))} ${(0, ui_1.bold)(label)} ${row.message}`);
29
22
  if (row.stack) {
30
23
  const lines = row.stack.split('\n');
@@ -36,22 +29,33 @@ function render(rows) {
36
29
  (0, ui_1.out)('');
37
30
  }
38
31
  }
32
+ /** Browser errors: of this app in its folder or with --app, of every app in the project root. */
39
33
  async function logs(args) {
40
- const rows = await fetchLogs('client', undefined, args);
34
+ const target = await (0, session_1.streamTarget)(args, undefined, false);
35
+ const limit = String((0, args_1.flagNumber)(args, 'limit') ?? DEFAULT_LIMIT);
36
+ const path = target.kind === 'app'
37
+ ? `/api/v1/apps/${target.appId}/logs?limit=${limit}`
38
+ : `/api/v1/projects/${target.projectId}/logs?source=client&limit=${limit}`;
39
+ const { logs: rows } = await (0, api_1.apiJson)(target.client, path);
41
40
  if (rows.length === 0) {
42
41
  (0, ui_1.note)('No browser errors');
43
- (0, ui_1.note)((0, ui_1.dim)(' This collects crashes of the released application, not of a local npm run dev'));
42
+ (0, ui_1.note)((0, ui_1.dim)(' This collects crashes of the published app, not of a local xflow dev'));
44
43
  return;
45
44
  }
46
- render(rows);
45
+ const folders = new Map(target.kind === 'project' && target.root ? (0, config_1.localApps)(target.root).map((app) => [app.appId, `apps/${app.folder}`]) : []);
46
+ render(rows, (id) => folders.get(id) ?? 'browser');
47
47
  }
48
48
  async function functionsLogs(args) {
49
49
  const name = args.words[1];
50
- const rows = await fetchLogs('function', name, args);
50
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
51
+ const query = new URLSearchParams({ source: 'function', limit: String((0, args_1.flagNumber)(args, 'limit') ?? DEFAULT_LIMIT) });
52
+ if (name)
53
+ query.set('name', name);
54
+ const { logs: rows } = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/logs?${query.toString()}`);
51
55
  if (rows.length === 0) {
52
56
  (0, ui_1.note)(name ? `Function ${name} has not crashed` : 'No function has crashed');
53
57
  (0, ui_1.note)((0, ui_1.dim)(' Only failed calls land here: successful ones write nothing'));
54
58
  return;
55
59
  }
56
- render(rows);
60
+ render(rows, () => 'browser');
57
61
  }
@@ -98,6 +98,7 @@ async function pagesCreate(args) {
98
98
  (0, ui_1.ok)(`Page "${result.page.title}" is live: ${result.file_count} file(s), ${(0, ui_1.formatBytes)(result.size_bytes)}`);
99
99
  (0, ui_1.out)(result.page.url);
100
100
  (0, ui_1.note)((0, ui_1.dim)(` Anyone with the address sees it within seconds. The next version: xflow pages deploy ${result.page.slug} <path>`));
101
+ (0, ui_1.note)((0, ui_1.dim)(` A backend for it: name "${result.page.slug}" in functions.<name>.callers of a project, xflow deploy functions, then fetch /_fn/<name>`));
101
102
  }
102
103
  function contentTypeOf(upload) {
103
104
  return upload.kind === 'zip' ? 'application/zip' : 'text/html';