@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.
- package/README.md +1 -1
- package/dist/api.js +21 -6
- package/dist/args.js +1 -1
- package/dist/bin.js +15 -3
- package/dist/commands/connections.js +4 -4
- package/dist/commands/db.js +27 -64
- package/dist/commands/deploy.js +170 -216
- package/dist/commands/dev.js +15 -29
- package/dist/commands/env.js +6 -3
- package/dist/commands/functions.js +27 -119
- package/dist/commands/logs.js +19 -15
- package/dist/commands/pages.js +1 -0
- package/dist/commands/projects.js +134 -190
- package/dist/commands/sources.js +218 -120
- package/dist/commands/storage.js +2 -2
- package/dist/config.js +82 -26
- package/dist/flags.js +19 -12
- package/dist/help.js +219 -216
- package/dist/limits.js +6 -6
- package/dist/session.js +167 -52
- package/dist/template.js +29 -125
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/skills/xflow/SKILL.md +682 -739
|
@@ -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
|
|
23
|
-
if (
|
|
24
|
-
(0, ui_1.note)(
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
//
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
}
|
package/dist/commands/logs.js
CHANGED
|
@@ -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') : '
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/commands/pages.js
CHANGED
|
@@ -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';
|