@getxflow/cli 0.1.9 → 0.2.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 +45 -45
- package/dist/api.js +5 -9
- package/dist/args.js +2 -10
- package/dist/bin.js +3 -6
- package/dist/commands/auth.js +18 -13
- package/dist/commands/db.js +3 -10
- package/dist/commands/deploy.js +40 -18
- package/dist/commands/env.js +6 -15
- package/dist/commands/functions.js +4 -113
- package/dist/commands/logs.js +1 -2
- package/dist/commands/mcp.js +6 -13
- package/dist/commands/projects.js +4 -16
- package/dist/commands/skills.js +18 -42
- package/dist/commands/sources.js +7 -16
- package/dist/config.js +8 -8
- package/dist/credentials.js +9 -10
- package/dist/errors.js +1 -1
- package/dist/help.js +257 -244
- package/dist/limits.js +5 -15
- package/dist/session.js +5 -9
- package/dist/template.js +25 -33
- package/dist/tree.js +5 -18
- package/dist/types.js +1 -8
- package/dist/ui.js +36 -14
- package/dist/version.js +3 -7
- package/dist/zip.js +15 -8
- package/package.json +1 -1
- package/skills/xflow/SKILL.md +21 -16
package/README.md
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
# @getxflow/cli
|
|
2
|
-
|
|
3
|
-
CLI for the [XFlow](https://getxflow.com) platform: source sync, builds and releases.
|
|
4
|
-
|
|
5
|
-
```bash
|
|
6
|
-
npm i -g @getxflow/cli
|
|
7
|
-
xflow login
|
|
8
|
-
xflow init my-app
|
|
9
|
-
cd my-app && npm install
|
|
10
|
-
xflow deploy
|
|
11
|
-
xflow publish
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
Requires Node 20+. The package has no dependencies: it holds an access key, and nobody
|
|
15
|
-
else's code belongs next to it.
|
|
16
|
-
|
|
17
|
-
| Command | What it does |
|
|
18
|
-
|---|---|
|
|
19
|
-
| `login` / `logout` / `whoami` | sign in through the browser, sign out, whose key this is and what it can do |
|
|
20
|
-
| `init` / `link` | new project, link a folder to an existing one |
|
|
21
|
-
| `status` / `push` / `pull` | state, sending and fetching sources |
|
|
22
|
-
| `deploy` / `publish` / `rollback` / `deployments` | build, publish, roll back, version history |
|
|
23
|
-
| `db status` / `db migrate` | migrations from `migrations/*.sql` with a gate on destructive ones |
|
|
24
|
-
| `functions list`
|
|
25
|
-
| `functions invoke` / `functions logs` | call a function, look at its crashes with the stack |
|
|
26
|
-
| `schedules list` / `set` / `rm` | running functions on a timer (timer triggers) |
|
|
27
|
-
| `env` / `env check` / `env set` | environment variables of the functions, values are never handed back |
|
|
28
|
-
| `logs` | browser errors from the released application |
|
|
29
|
-
| `projects list` / `projects get` / `open` | projects of the organization and the application addresses |
|
|
30
|
-
| `skills` | platform instructions for an AI agent ([Agent Skills](https://agentskills.io) format) |
|
|
31
|
-
| `mcp install` | connect the agent to the platform directly, without a terminal |
|
|
32
|
-
|
|
33
|
-
Help for one command: `xflow help <command>`.
|
|
34
|
-
|
|
35
|
-
You write the application yourself or with your agent: the platform stays out of the code.
|
|
36
|
-
`xflow skills` puts a description of the platform alongside it, which Claude Code, Codex and
|
|
37
|
-
OpenClaw pick up on their own when a task touches XFlow. Your `AGENTS.md` and `CLAUDE.md` are
|
|
38
|
-
left alone.
|
|
39
|
-
|
|
40
|
-
`xflow mcp install` connects the agent to the platform directly: the database, migrations,
|
|
41
|
-
functions, schedules, variables and versions become its tools, with no command output to parse.
|
|
42
|
-
Code, builds and releases stay in the CLI.
|
|
43
|
-
|
|
44
|
-
The key is stored in `~/.xflow/credentials.json`, the project configuration in `xflow.json`.
|
|
45
|
-
For CI, pass the key in the `XFLOW_TOKEN` variable.
|
|
1
|
+
# @getxflow/cli
|
|
2
|
+
|
|
3
|
+
CLI for the [XFlow](https://getxflow.com) platform: source sync, builds and releases.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm i -g @getxflow/cli
|
|
7
|
+
xflow login
|
|
8
|
+
xflow init my-app
|
|
9
|
+
cd my-app && npm install
|
|
10
|
+
xflow deploy
|
|
11
|
+
xflow publish
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires Node 20+. The package has no dependencies: it holds an access key, and nobody
|
|
15
|
+
else's code belongs next to it.
|
|
16
|
+
|
|
17
|
+
| Command | What it does |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `login` / `logout` / `whoami` | sign in through the browser, sign out, whose key this is and what it can do |
|
|
20
|
+
| `init` / `link` | new project, link a folder to an existing one |
|
|
21
|
+
| `status` / `push` / `pull` | state, sending and fetching sources |
|
|
22
|
+
| `deploy` / `publish` / `rollback` / `deployments` | build, publish, roll back, version history |
|
|
23
|
+
| `db status` / `db migrate` | migrations from `migrations/*.sql` with a gate on destructive ones |
|
|
24
|
+
| `functions list` | cloud functions of the project from `functions/<name>/index.ts`, shipped by `deploy` |
|
|
25
|
+
| `functions invoke` / `functions logs` | call a function, look at its crashes with the stack |
|
|
26
|
+
| `schedules list` / `set` / `rm` | running functions on a timer (timer triggers) |
|
|
27
|
+
| `env` / `env check` / `env set` | environment variables of the functions, values are never handed back |
|
|
28
|
+
| `logs` | browser errors from the released application |
|
|
29
|
+
| `projects list` / `projects get` / `open` | projects of the organization and the application addresses |
|
|
30
|
+
| `skills` | platform instructions for an AI agent ([Agent Skills](https://agentskills.io) format) |
|
|
31
|
+
| `mcp install` | connect the agent to the platform directly, without a terminal |
|
|
32
|
+
|
|
33
|
+
Help for one command: `xflow help <command>`.
|
|
34
|
+
|
|
35
|
+
You write the application yourself or with your agent: the platform stays out of the code.
|
|
36
|
+
`xflow skills` puts a description of the platform alongside it, which Claude Code, Codex and
|
|
37
|
+
OpenClaw pick up on their own when a task touches XFlow. Your `AGENTS.md` and `CLAUDE.md` are
|
|
38
|
+
left alone.
|
|
39
|
+
|
|
40
|
+
`xflow mcp install` connects the agent to the platform directly: the database, migrations,
|
|
41
|
+
functions, schedules, variables and versions become its tools, with no command output to parse.
|
|
42
|
+
Code, builds and releases stay in the CLI.
|
|
43
|
+
|
|
44
|
+
The key is stored in `~/.xflow/credentials.json`, the project configuration in `xflow.json`.
|
|
45
|
+
For CI, pass the key in the `XFLOW_TOKEN` variable.
|
package/dist/api.js
CHANGED
|
@@ -10,9 +10,9 @@ class ApiError extends Error {
|
|
|
10
10
|
code;
|
|
11
11
|
status;
|
|
12
12
|
hint;
|
|
13
|
-
/**
|
|
13
|
+
/** Check violations, e.g. why a build was rejected. */
|
|
14
14
|
issues;
|
|
15
|
-
/**
|
|
15
|
+
/** Set when a plan limit was hit. */
|
|
16
16
|
limit;
|
|
17
17
|
constructor(message, code, status, hint, issues, limit) {
|
|
18
18
|
super(message);
|
|
@@ -26,7 +26,7 @@ class ApiError extends Error {
|
|
|
26
26
|
}
|
|
27
27
|
exports.ApiError = ApiError;
|
|
28
28
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
29
|
-
/**
|
|
29
|
+
/** Archive uploads and downloads. */
|
|
30
30
|
const TRANSFER_TIMEOUT_MS = 5 * 60_000;
|
|
31
31
|
function parseVersion(value) {
|
|
32
32
|
return value
|
|
@@ -58,13 +58,11 @@ async function send(client, path, options = {}) {
|
|
|
58
58
|
'X-Xflow-Cli': version_1.CLI_VERSION,
|
|
59
59
|
...headers,
|
|
60
60
|
};
|
|
61
|
-
// Вход по device flow идёт без ключа: пустой Bearer выглядел бы как испорченный.
|
|
62
61
|
if (client.token)
|
|
63
62
|
requestHeaders.Authorization = `Bearer ${client.token}`;
|
|
64
63
|
let payload;
|
|
65
64
|
if (Buffer.isBuffer(body)) {
|
|
66
|
-
//
|
|
67
|
-
// байты вокруг наших. Плюс тип понятен и DOM-, и node-типизации fetch.
|
|
65
|
+
// Copy out of Node's shared Buffer pool before handing the bytes to fetch.
|
|
68
66
|
payload = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
69
67
|
requestHeaders['Content-Type'] = requestHeaders['Content-Type'] ?? 'application/zip';
|
|
70
68
|
}
|
|
@@ -104,18 +102,16 @@ async function apiJson(client, path, options = {}) {
|
|
|
104
102
|
throw await readError(response);
|
|
105
103
|
return (await response.json());
|
|
106
104
|
}
|
|
107
|
-
/** Ответ-файл: архив шаблона отдаётся телом, а не ссылкой (он лежит в базе, а не в S3). */
|
|
108
105
|
async function apiBinary(client, path) {
|
|
109
106
|
const response = await send(client, path, { timeoutMs: TRANSFER_TIMEOUT_MS });
|
|
110
107
|
if (!response.ok)
|
|
111
108
|
throw await readError(response);
|
|
112
109
|
return Buffer.from(await response.arrayBuffer());
|
|
113
110
|
}
|
|
114
|
-
/** Загрузка архива на сервер: тот же разбор ошибок, но с длинным таймаутом. */
|
|
115
111
|
async function apiUpload(client, path, body, headers = {}) {
|
|
116
112
|
return apiJson(client, path, { method: 'POST', body, headers, timeoutMs: TRANSFER_TIMEOUT_MS });
|
|
117
113
|
}
|
|
118
|
-
/**
|
|
114
|
+
/** Signed URL download: no Authorization header attached. */
|
|
119
115
|
async function downloadUrl(url) {
|
|
120
116
|
let response;
|
|
121
117
|
try {
|
package/dist/args.js
CHANGED
|
@@ -1,19 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Разбор аргументов.
|
|
4
|
-
*
|
|
5
|
-
* Своими руками, без зависимостей: пакет с ключом доступа внутри не должен
|
|
6
|
-
* тянуть за собой чужой код ради двадцати строк логики.
|
|
7
|
-
*/
|
|
2
|
+
/** Argument parsing, dependency-free. */
|
|
8
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
4
|
exports.parseArgs = parseArgs;
|
|
10
5
|
exports.flagString = flagString;
|
|
11
6
|
exports.flagBool = flagBool;
|
|
12
7
|
exports.flagNumber = flagNumber;
|
|
13
|
-
/**
|
|
14
|
-
* Флаги без значения. Список явный, потому что иначе `--force ./dir` съедает
|
|
15
|
-
* путь как значение флага, и разработчик получает загадочную ошибку.
|
|
16
|
-
*/
|
|
8
|
+
/** Flags that never take a value, so `--force ./dir` keeps the path as a word. */
|
|
17
9
|
const BOOLEAN_FLAGS = new Set([
|
|
18
10
|
'force',
|
|
19
11
|
'yes',
|
package/dist/bin.js
CHANGED
|
@@ -21,7 +21,6 @@ const schedules_1 = require("./commands/schedules");
|
|
|
21
21
|
const skills_1 = require("./commands/skills");
|
|
22
22
|
const sources_1 = require("./commands/sources");
|
|
23
23
|
const deploy_1 = require("./commands/deploy");
|
|
24
|
-
/** Одно место, где команда превращается в действие. Слова, а не флаги: `projects list`. */
|
|
25
24
|
async function run(args) {
|
|
26
25
|
const [first, second] = args.words;
|
|
27
26
|
const rest = { ...args, words: args.words.slice(1) };
|
|
@@ -70,9 +69,9 @@ async function run(args) {
|
|
|
70
69
|
}
|
|
71
70
|
throw new errors_1.CliError(`Unknown command: mcp ${second}`, 'Available: install');
|
|
72
71
|
case 'functions':
|
|
72
|
+
// The command is gone, but silence would leave the habit unexplained.
|
|
73
73
|
if (second === 'deploy') {
|
|
74
|
-
|
|
75
|
-
return;
|
|
74
|
+
throw new errors_1.CliError('Functions are deployed by the build now', 'Run xflow deploy: it ships the functions and then builds the application with their addresses');
|
|
76
75
|
}
|
|
77
76
|
if (second === 'invoke') {
|
|
78
77
|
await (0, functions_1.functionsInvoke)(rest);
|
|
@@ -86,7 +85,7 @@ async function run(args) {
|
|
|
86
85
|
await (0, functions_1.functionsList)();
|
|
87
86
|
return;
|
|
88
87
|
}
|
|
89
|
-
throw new errors_1.CliError(`Unknown command: functions ${second}`, 'Available: list,
|
|
88
|
+
throw new errors_1.CliError(`Unknown command: functions ${second}`, 'Available: list, invoke and logs');
|
|
90
89
|
case 'logs':
|
|
91
90
|
await (0, logs_1.logs)(rest);
|
|
92
91
|
return;
|
|
@@ -174,7 +173,6 @@ async function main() {
|
|
|
174
173
|
catch (e) {
|
|
175
174
|
if (e instanceof api_1.ApiError) {
|
|
176
175
|
(0, ui_1.fail)(e.message);
|
|
177
|
-
// Отказ по тарифу помечаем явно: иначе агент правит команду, которая верна.
|
|
178
176
|
if (e.limit)
|
|
179
177
|
(0, ui_1.note)((0, ui_1.dim)(` ${(0, limits_1.limitLine)(e.limit)}`));
|
|
180
178
|
if (e.hint)
|
|
@@ -189,7 +187,6 @@ async function main() {
|
|
|
189
187
|
return 1;
|
|
190
188
|
}
|
|
191
189
|
(0, ui_1.fail)(e instanceof Error ? e.message : String(e));
|
|
192
|
-
// Стек прячем: он полезен только нам, и то по запросу.
|
|
193
190
|
if (process.env.XFLOW_DEBUG && e instanceof Error && e.stack)
|
|
194
191
|
(0, ui_1.note)((0, ui_1.dim)(e.stack));
|
|
195
192
|
return 1;
|
package/dist/commands/auth.js
CHANGED
|
@@ -23,17 +23,11 @@ function localConfig() {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
-
/**
|
|
27
|
-
* Вход человека: device flow.
|
|
28
|
-
*
|
|
29
|
-
* Ключ не проходит ни через копипаст, ни через контекст агента — он выпускается
|
|
30
|
-
* на сервере после подтверждения в браузере и приезжает сюда ровно один раз.
|
|
31
|
-
*/
|
|
26
|
+
/** Device flow: the key is issued after confirmation in the browser, never typed. */
|
|
32
27
|
async function login() {
|
|
33
28
|
const config = localConfig();
|
|
34
29
|
const client = (0, session_1.anonymous)(config);
|
|
35
|
-
//
|
|
36
|
-
// доступов лежат одинаковые строки и непонятно, какой компьютер отзывать.
|
|
30
|
+
// The machine name becomes the key label on the platform.
|
|
37
31
|
const start = await (0, api_1.apiJson)(client, '/api/v1/auth/device', {
|
|
38
32
|
method: 'POST',
|
|
39
33
|
body: { client_name: (0, node_os_1.hostname)() },
|
|
@@ -43,9 +37,19 @@ async function login() {
|
|
|
43
37
|
(0, ui_1.note)(` Computer: ${(0, node_os_1.hostname)()}`);
|
|
44
38
|
(0, ui_1.note)(` Sign-in link: ${start.verification_uri_complete}`);
|
|
45
39
|
(0, ui_1.note)('');
|
|
46
|
-
// Браузер не открываем сами: команду часто запускает агент, и всплывшее окно
|
|
47
|
-
// забирает у человека внимание, не объяснив, что он подтверждает.
|
|
48
40
|
(0, ui_1.step)('Open the link and check the code on the page against the one above');
|
|
41
|
+
const stopOffer = (0, ui_1.offerBrowser)(start.verification_uri_complete);
|
|
42
|
+
if (process.stdin.isTTY)
|
|
43
|
+
(0, ui_1.note)((0, ui_1.dim)(' Or press ENTER to open it in the browser'));
|
|
44
|
+
try {
|
|
45
|
+
await awaitConfirmation(client, start);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
stopOffer();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Poll until the code is confirmed or expires. */
|
|
52
|
+
async function awaitConfirmation(client, start) {
|
|
49
53
|
const deadline = Date.now() + start.expires_in * 1000;
|
|
50
54
|
let intervalMs = Math.max(start.interval, 1) * 1000;
|
|
51
55
|
for (;;) {
|
|
@@ -61,7 +65,7 @@ async function login() {
|
|
|
61
65
|
});
|
|
62
66
|
}
|
|
63
67
|
catch (e) {
|
|
64
|
-
//
|
|
68
|
+
// Slow down on rate limits instead of failing the sign-in.
|
|
65
69
|
if (e instanceof api_1.ApiError && e.code === 'rate_limited') {
|
|
66
70
|
intervalMs = Math.min(intervalMs * 2, 30_000);
|
|
67
71
|
continue;
|
|
@@ -88,7 +92,7 @@ function logout() {
|
|
|
88
92
|
(0, ui_1.warn)(`There is no stored key for ${apiUrl}`);
|
|
89
93
|
}
|
|
90
94
|
}
|
|
91
|
-
/**
|
|
95
|
+
/** Suffix for a non-active subscription. */
|
|
92
96
|
const SUBSCRIPTION_STATE = {
|
|
93
97
|
past_due: ', the period has ended',
|
|
94
98
|
suspended: ', work is suspended',
|
|
@@ -101,7 +105,8 @@ async function whoami() {
|
|
|
101
105
|
(0, ui_1.out)(`Organization: ${me.organization.name ?? me.organization.id}`);
|
|
102
106
|
(0, ui_1.out)(`Key: xfk_${me.key.prefix}… (${me.key.scopes.join(', ')})`);
|
|
103
107
|
(0, ui_1.out)(`Platform: ${client.apiUrl}`);
|
|
104
|
-
|
|
108
|
+
// Same condition as in connect: for an address out of xflow.json the env key is ignored.
|
|
109
|
+
if (process.env.XFLOW_TOKEN?.trim() && client.apiUrl === (0, config_1.apiUrlFor)()) {
|
|
105
110
|
(0, ui_1.note)((0, ui_1.dim)(' The key comes from XFLOW_TOKEN, the one stored in ~/.xflow is not used'));
|
|
106
111
|
}
|
|
107
112
|
else if (!(0, credentials_1.readCredential)(client.apiUrl)) {
|
package/dist/commands/db.js
CHANGED
|
@@ -11,18 +11,12 @@ const config_1 = require("../config");
|
|
|
11
11
|
const errors_1 = require("../errors");
|
|
12
12
|
const session_1 = require("../session");
|
|
13
13
|
const ui_1 = require("../ui");
|
|
14
|
-
/**
|
|
15
|
-
* Миграции базы проекта.
|
|
16
|
-
*
|
|
17
|
-
* Файлы лежат в репозитории (`migrations/0001_init.sql`), историю применённых
|
|
18
|
-
* помнит сама база. Отсюда правило: локально мы только читаем и сортируем, а что
|
|
19
|
-
* из этого новое, решает сервер.
|
|
20
|
-
*/
|
|
14
|
+
/** Migration files live in the repo; the applied history lives in the database. */
|
|
21
15
|
const MIGRATIONS_DIR = 'migrations';
|
|
22
16
|
function checksum(sql) {
|
|
23
17
|
return (0, node_crypto_1.createHash)('sha256').update(sql).digest('hex').slice(0, 16);
|
|
24
18
|
}
|
|
25
|
-
/**
|
|
19
|
+
/** File-name order is the queue. */
|
|
26
20
|
function readMigrations(root) {
|
|
27
21
|
const dir = (0, node_path_1.join)(root, MIGRATIONS_DIR);
|
|
28
22
|
if (!(0, node_fs_1.existsSync)(dir))
|
|
@@ -68,8 +62,7 @@ async function dbStatus() {
|
|
|
68
62
|
(0, ui_1.formatAge)(row.applied_at),
|
|
69
63
|
]);
|
|
70
64
|
}
|
|
71
|
-
//
|
|
72
|
-
// на той же логической базе.
|
|
65
|
+
// Applied but missing locally: usually another project on the same logical database.
|
|
73
66
|
const localNames = new Set(local.map((migration) => migration.name));
|
|
74
67
|
for (const row of history.applied) {
|
|
75
68
|
if (!localNames.has(row.name))
|
package/dist/commands/deploy.js
CHANGED
|
@@ -4,26 +4,20 @@ exports.deploy = deploy;
|
|
|
4
4
|
exports.publish = publish;
|
|
5
5
|
exports.rollback = rollback;
|
|
6
6
|
exports.deployments = deployments;
|
|
7
|
+
const node_fs_1 = require("node:fs");
|
|
8
|
+
const node_path_1 = require("node:path");
|
|
7
9
|
const api_1 = require("../api");
|
|
8
10
|
const args_1 = require("../args");
|
|
9
11
|
const config_1 = require("../config");
|
|
10
12
|
const errors_1 = require("../errors");
|
|
11
13
|
const session_1 = require("../session");
|
|
14
|
+
const template_1 = require("../template");
|
|
12
15
|
const ui_1 = require("../ui");
|
|
13
16
|
const sources_1 = require("./sources");
|
|
14
17
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
-
/**
|
|
16
|
-
* Потолок ожидания сборки в терминале. Чуть больше, чем срок, после которого
|
|
17
|
-
* платформа закрывает молчащую сборку: так разработчик увидит внятный отказ, а
|
|
18
|
-
* не «идёт дольше ожидаемого» на сборке, которую уже похоронили.
|
|
19
|
-
*/
|
|
18
|
+
/** Slightly longer than the platform's own build timeout. */
|
|
20
19
|
const WAIT_LIMIT_MS = 16 * 60_000;
|
|
21
|
-
/**
|
|
22
|
-
* Показать, чем проект не подошёл платформе.
|
|
23
|
-
*
|
|
24
|
-
* Печатаем весь список: правила известны заранее (скилл xflow), а узнавать их по
|
|
25
|
-
* одному через повторные запуски значит терять круг на каждое нарушение.
|
|
26
|
-
*/
|
|
20
|
+
/** Print every check violation at once. */
|
|
27
21
|
function reportIssues(e) {
|
|
28
22
|
const issues = e.issues ?? [];
|
|
29
23
|
for (const issue of issues) {
|
|
@@ -33,13 +27,7 @@ function reportIssues(e) {
|
|
|
33
27
|
}
|
|
34
28
|
throw new errors_1.CliError(e.message, e.hint);
|
|
35
29
|
}
|
|
36
|
-
/**
|
|
37
|
-
* Дождаться конца сборки, печатая фазы.
|
|
38
|
-
*
|
|
39
|
-
* Платформа отвечает на запуск сразу и не держит соединение: сборка живёт в
|
|
40
|
-
* песочнице и переживает перезапуск платформы, поэтому её состояние забирается
|
|
41
|
-
* опросом, а не потоком.
|
|
42
|
-
*/
|
|
30
|
+
/** Poll the build until it finishes, printing phases. */
|
|
43
31
|
async function waitForBuild(client, projectId, started) {
|
|
44
32
|
const deadline = Date.now() + WAIT_LIMIT_MS;
|
|
45
33
|
let shown = '';
|
|
@@ -55,6 +43,24 @@ async function waitForBuild(client, projectId, started) {
|
|
|
55
43
|
}
|
|
56
44
|
throw new errors_1.CliError('The build is taking longer than expected', `To check the state: xflow deployments. Version ${started.deploy_id}`);
|
|
57
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Write the function addresses into the local .env.
|
|
48
|
+
*
|
|
49
|
+
* The build bakes its own copy of the map into the bundle, this one is for
|
|
50
|
+
* `npm run dev`: without it the local run calls a function that it has no
|
|
51
|
+
* address for.
|
|
52
|
+
*/
|
|
53
|
+
async function refreshFunctionsEnv(root, client, projectId) {
|
|
54
|
+
const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
|
|
55
|
+
// No .env at all (fresh clone): recreate it fully, like link does.
|
|
56
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(root, '.env')) && card.project_token) {
|
|
57
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, '.env'), (0, template_1.envFile)(card.project_token, client.apiUrl, card.functions), 'utf-8');
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
(0, template_1.writeEnvValue)(root, template_1.FUNCTIONS_ENV_KEY, (0, template_1.functionsEnvValue)(card.functions));
|
|
61
|
+
}
|
|
62
|
+
return card.functions.filter((fn) => fn.invoke_url).map((fn) => fn.name);
|
|
63
|
+
}
|
|
58
64
|
async function deploy(args) {
|
|
59
65
|
const { root, config } = (0, config_1.requireProject)();
|
|
60
66
|
const client = (0, session_1.connect)(config);
|
|
@@ -83,6 +89,11 @@ async function deploy(args) {
|
|
|
83
89
|
reportIssues(e);
|
|
84
90
|
throw e;
|
|
85
91
|
}
|
|
92
|
+
// Removing a function cannot be undone: a new one gets a new address. Say it
|
|
93
|
+
// out loud rather than leaving it to be discovered in functions list.
|
|
94
|
+
if (started.removed_functions?.length) {
|
|
95
|
+
(0, ui_1.warn)(`Removed from the cloud, gone from the sources: ${started.removed_functions.join(', ')}`);
|
|
96
|
+
}
|
|
86
97
|
const build = await waitForBuild(client, config.projectId, started);
|
|
87
98
|
if (build.phase === 'failed') {
|
|
88
99
|
if (build.log_tail) {
|
|
@@ -93,6 +104,17 @@ async function deploy(args) {
|
|
|
93
104
|
}
|
|
94
105
|
(0, ui_1.ok)(`Version ${build.deploy_id} built from revision ${build.revision} in ${build.elapsed_s} s`);
|
|
95
106
|
(0, ui_1.out)(build.project_url);
|
|
107
|
+
// The functions were shipped by the build itself, so their addresses are known
|
|
108
|
+
// only now. A failure here does not undo a finished build: warn and stop there.
|
|
109
|
+
try {
|
|
110
|
+
const functions = await refreshFunctionsEnv(root, client, config.projectId);
|
|
111
|
+
if (functions.length > 0) {
|
|
112
|
+
(0, ui_1.note)((0, ui_1.dim)(` Functions in .env: ${functions.join(', ')}`));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
(0, ui_1.warn)('Could not refresh the function addresses in .env, the build itself is fine');
|
|
117
|
+
}
|
|
96
118
|
(0, ui_1.note)((0, ui_1.dim)(' Show it to visitors: xflow publish'));
|
|
97
119
|
}
|
|
98
120
|
async function publish() {
|
package/dist/commands/env.js
CHANGED
|
@@ -13,9 +13,9 @@ const errors_1 = require("../errors");
|
|
|
13
13
|
const session_1 = require("../session");
|
|
14
14
|
const ui_1 = require("../ui");
|
|
15
15
|
const FUNCTIONS_DIR = 'functions';
|
|
16
|
-
/**
|
|
16
|
+
/** Matches `process.env.NAME` and `process.env['NAME']`. */
|
|
17
17
|
const ENV_REFERENCE = /process\.env(?:\.([A-Z0-9_]+)|\[['"]([A-Z0-9_]+)['"]\])/g;
|
|
18
|
-
/**
|
|
18
|
+
/** Set by the platform itself. */
|
|
19
19
|
const PROVIDED = new Set([
|
|
20
20
|
'XFLOW_PROJECT_ID',
|
|
21
21
|
'XFLOW_PROJECT_TOKEN',
|
|
@@ -37,7 +37,7 @@ function sourceFiles(dir, found = []) {
|
|
|
37
37
|
}
|
|
38
38
|
return found;
|
|
39
39
|
}
|
|
40
|
-
/**
|
|
40
|
+
/** Which variables the project's functions read, and in which of them. */
|
|
41
41
|
function referencedByFunctions(root) {
|
|
42
42
|
const needed = new Map();
|
|
43
43
|
const provided = new Map();
|
|
@@ -73,12 +73,7 @@ async function envList() {
|
|
|
73
73
|
(0, ui_1.table)(rows.map((row) => [row.name, row.scope === 'project' ? 'this project only' : 'the whole organization']));
|
|
74
74
|
(0, ui_1.note)((0, ui_1.dim)(' The platform never returns values: they are visible only inside the function'));
|
|
75
75
|
}
|
|
76
|
-
/**
|
|
77
|
-
* Сверить, хватает ли функциям переменных.
|
|
78
|
-
*
|
|
79
|
-
* Это ответ на самый частый способ потерять полчаса: функция выкатилась, а падает
|
|
80
|
-
* на пустом process.env, потому что переменную забыли записать наверх.
|
|
81
|
-
*/
|
|
76
|
+
/** Check that every referenced variable is stored. */
|
|
82
77
|
async function envCheck() {
|
|
83
78
|
const { names, root } = await fetchVariables();
|
|
84
79
|
const { needed, provided } = referencedByFunctions(root);
|
|
@@ -86,9 +81,6 @@ async function envCheck() {
|
|
|
86
81
|
(0, ui_1.note)('The functions of this project read no environment variables');
|
|
87
82
|
return;
|
|
88
83
|
}
|
|
89
|
-
// Платформенные показываем отдельной таблицей, а не молчим о них: раньше
|
|
90
|
-
// функция, читающая только DATABASE_URL, получала ответ «переменных не
|
|
91
|
-
// читают», то есть команда делала ложное утверждение о собственном коде.
|
|
92
84
|
if (provided.size > 0) {
|
|
93
85
|
(0, ui_1.out)((0, ui_1.bold)('Provided by the platform:'));
|
|
94
86
|
(0, ui_1.table)([...provided.entries()].sort().map(([name, users]) => [name, users.join(', ')]));
|
|
@@ -131,11 +123,10 @@ async function envSet(args) {
|
|
|
131
123
|
body: { name, value, scope: (0, args_1.flagString)(args, 'scope') === 'project' ? 'project' : 'organization' },
|
|
132
124
|
});
|
|
133
125
|
(0, ui_1.ok)(`${(0, ui_1.bold)(result.name)} stored (${result.scope === 'project' ? 'this project only' : 'the whole organization'})`);
|
|
134
|
-
//
|
|
135
|
-
// не передеплоили, в её окружении лежит прежнее.
|
|
126
|
+
// Values reach a function on its next deploy.
|
|
136
127
|
const users = referencedByFunctions(root).needed.get(name);
|
|
137
128
|
if (users && users.length > 0) {
|
|
138
|
-
(0, ui_1.note)((0, ui_1.dim)(` For the value to arrive,
|
|
129
|
+
(0, ui_1.note)((0, ui_1.dim)(` For the value to arrive, run xflow deploy: it ships ${users.join(', ')} again`));
|
|
139
130
|
}
|
|
140
131
|
}
|
|
141
132
|
async function envRemove(args) {
|
|
@@ -2,75 +2,19 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.functionsList = functionsList;
|
|
4
4
|
exports.functionsInvoke = functionsInvoke;
|
|
5
|
-
exports.functionsDeploy = functionsDeploy;
|
|
6
|
-
const node_fs_1 = require("node:fs");
|
|
7
|
-
const node_path_1 = require("node:path");
|
|
8
5
|
const api_1 = require("../api");
|
|
9
6
|
const args_1 = require("../args");
|
|
10
7
|
const config_1 = require("../config");
|
|
11
8
|
const errors_1 = require("../errors");
|
|
12
9
|
const session_1 = require("../session");
|
|
13
|
-
const template_1 = require("../template");
|
|
14
10
|
const ui_1 = require("../ui");
|
|
15
|
-
const ENTRY_NAMES = ['index.ts', 'index.js', 'index.mjs'];
|
|
16
|
-
/** Папка с функциями внутри проекта. Та же, что была до пивота: менять её незачем. */
|
|
17
11
|
const FUNCTIONS_DIR = 'functions';
|
|
18
|
-
function entryFor(root, name) {
|
|
19
|
-
for (const entry of ENTRY_NAMES) {
|
|
20
|
-
const candidate = (0, node_path_1.join)(root, FUNCTIONS_DIR, name, entry);
|
|
21
|
-
if ((0, node_fs_1.existsSync)(candidate))
|
|
22
|
-
return candidate;
|
|
23
|
-
}
|
|
24
|
-
return null;
|
|
25
|
-
}
|
|
26
|
-
function discover(root) {
|
|
27
|
-
const dir = (0, node_path_1.join)(root, FUNCTIONS_DIR);
|
|
28
|
-
if (!(0, node_fs_1.existsSync)(dir))
|
|
29
|
-
return [];
|
|
30
|
-
return (0, node_fs_1.readdirSync)(dir)
|
|
31
|
-
.filter((name) => (0, node_fs_1.statSync)((0, node_path_1.join)(dir, name)).isDirectory())
|
|
32
|
-
.filter((name) => entryFor(root, name) !== null)
|
|
33
|
-
.sort();
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Собрать функцию в один файл.
|
|
37
|
-
*
|
|
38
|
-
* esbuild берём из node_modules проекта, а не тащим зависимостью в CLI: он и так
|
|
39
|
-
* есть в каждом приложении на платформе (внутри Vite), а пакет без зависимостей
|
|
40
|
-
* ставится быстрее и не требует доверия к нашему списку.
|
|
41
|
-
*
|
|
42
|
-
* `pg` оставляем снаружи бандла сознательно: серверная обёртка подставляет схему
|
|
43
|
-
* проекта в каждое соединение, а сделать это можно только с общим драйвером.
|
|
44
|
-
*/
|
|
45
|
-
function bundle(root, entry) {
|
|
46
|
-
let esbuild;
|
|
47
|
-
try {
|
|
48
|
-
esbuild = require(require.resolve('esbuild', { paths: [root] }));
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
throw new errors_1.CliError('Could not find esbuild in the project', 'Install it: npm i -D esbuild. It usually comes with Vite already');
|
|
52
|
-
}
|
|
53
|
-
const result = esbuild.buildSync({
|
|
54
|
-
entryPoints: [entry],
|
|
55
|
-
bundle: true,
|
|
56
|
-
platform: 'node',
|
|
57
|
-
target: 'node20',
|
|
58
|
-
format: 'cjs',
|
|
59
|
-
external: ['pg'],
|
|
60
|
-
write: false,
|
|
61
|
-
logLevel: 'silent',
|
|
62
|
-
});
|
|
63
|
-
const text = result.outputFiles[0]?.text;
|
|
64
|
-
if (!text)
|
|
65
|
-
throw new errors_1.CliError(`Building ${entry} produced nothing`);
|
|
66
|
-
return text;
|
|
67
|
-
}
|
|
68
12
|
async function functionsList() {
|
|
69
13
|
const { config } = (0, config_1.requireProject)();
|
|
70
14
|
const client = (0, session_1.connect)(config);
|
|
71
15
|
const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/functions`);
|
|
72
16
|
if (data.functions.length === 0) {
|
|
73
|
-
(0, ui_1.note)(`No functions. Put the code in ${FUNCTIONS_DIR}/<name>/index.ts and run xflow
|
|
17
|
+
(0, ui_1.note)(`No functions. Put the code in ${FUNCTIONS_DIR}/<name>/index.ts and run xflow deploy`);
|
|
74
18
|
return;
|
|
75
19
|
}
|
|
76
20
|
(0, ui_1.table)(data.functions.map((fn) => [
|
|
@@ -80,27 +24,6 @@ async function functionsList() {
|
|
|
80
24
|
fn.error_message ?? '',
|
|
81
25
|
]));
|
|
82
26
|
}
|
|
83
|
-
/**
|
|
84
|
-
* Записать адреса функций в `.env`, откуда их заберёт сборщик.
|
|
85
|
-
*
|
|
86
|
-
* Подставляем на сборке, а не спрашиваем у платформы в рантайме: иначе каждый
|
|
87
|
-
* запуск приложения начинался бы с похода к нам, и мы стали бы обязательным
|
|
88
|
-
* участником работы чужого приложения.
|
|
89
|
-
*/
|
|
90
|
-
async function refreshFunctionsEnv(root, client, projectId) {
|
|
91
|
-
const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
|
|
92
|
-
// Файла может не быть вовсе: в исходники он не уходит, а клон делают из git.
|
|
93
|
-
// Дописывать в такой один адрес функции бессмысленно — без токена вызов всё
|
|
94
|
-
// равно получит 401, поэтому восстанавливаем целиком, как это делает link.
|
|
95
|
-
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(root, '.env')) && card.project_token) {
|
|
96
|
-
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, '.env'), (0, template_1.envFile)(card.project_token, client.apiUrl, card.functions), 'utf-8');
|
|
97
|
-
}
|
|
98
|
-
else {
|
|
99
|
-
(0, template_1.writeEnvValue)(root, template_1.FUNCTIONS_ENV_KEY, (0, template_1.functionsEnvValue)(card.functions));
|
|
100
|
-
}
|
|
101
|
-
return card.functions.filter((fn) => fn.invoke_url).map((fn) => fn.name);
|
|
102
|
-
}
|
|
103
|
-
/** Ответ функции: JSON разворачиваем, прочее отдаём как есть. */
|
|
104
27
|
function prettyBody(text) {
|
|
105
28
|
try {
|
|
106
29
|
return JSON.stringify(JSON.parse(text), null, 2);
|
|
@@ -109,12 +32,7 @@ function prettyBody(text) {
|
|
|
109
32
|
return text;
|
|
110
33
|
}
|
|
111
34
|
}
|
|
112
|
-
/**
|
|
113
|
-
* Вызвать функцию так же, как её вызывает приложение.
|
|
114
|
-
*
|
|
115
|
-
* Токен проекта берём из карточки на платформе, а не из локального `.env`: в
|
|
116
|
-
* свежем клоне файла нет вовсе, а команда должна работать сразу после link.
|
|
117
|
-
*/
|
|
35
|
+
/** Call a function the same way the application does. */
|
|
118
36
|
async function functionsInvoke(args) {
|
|
119
37
|
const { config } = (0, config_1.requireProject)();
|
|
120
38
|
const client = (0, session_1.connect)(config);
|
|
@@ -126,7 +44,7 @@ async function functionsInvoke(args) {
|
|
|
126
44
|
if (!fn || !fn.invoke_url) {
|
|
127
45
|
throw new errors_1.CliError(`Function ${name} is not deployed`, card.functions.length > 0
|
|
128
46
|
? `Deployed: ${card.functions.map((item) => item.name).join(', ')}`
|
|
129
|
-
:
|
|
47
|
+
: 'Functions ship with the build: xflow deploy');
|
|
130
48
|
}
|
|
131
49
|
const data = (0, args_1.flagString)(args, 'data');
|
|
132
50
|
const method = ((0, args_1.flagString)(args, 'method') ?? (data ? 'POST' : 'GET')).toUpperCase();
|
|
@@ -141,8 +59,7 @@ async function functionsInvoke(args) {
|
|
|
141
59
|
'X-Project-Token': card.project_token ?? '',
|
|
142
60
|
},
|
|
143
61
|
body: sendsBody ? (data ?? '{}') : undefined,
|
|
144
|
-
//
|
|
145
|
-
// собственный таймаут, а не свой.
|
|
62
|
+
// Wait past the function's own 90 s cap to see its timeout, not ours.
|
|
146
63
|
signal: AbortSignal.timeout(100_000),
|
|
147
64
|
});
|
|
148
65
|
}
|
|
@@ -158,29 +75,3 @@ async function functionsInvoke(args) {
|
|
|
158
75
|
throw new errors_1.CliError(`The function answered ${response.status}`, `The stack and console output: xflow functions logs ${name}`);
|
|
159
76
|
}
|
|
160
77
|
}
|
|
161
|
-
async function functionsDeploy(args) {
|
|
162
|
-
const { root, config } = (0, config_1.requireProject)();
|
|
163
|
-
const client = (0, session_1.connect)(config);
|
|
164
|
-
const wanted = args.words[1];
|
|
165
|
-
const names = wanted ? [wanted] : discover(root);
|
|
166
|
-
if (names.length === 0) {
|
|
167
|
-
throw new errors_1.CliError(`The project has no functions`, `Create ${FUNCTIONS_DIR}/<name>/index.ts exporting handler and try again`);
|
|
168
|
-
}
|
|
169
|
-
for (const name of names) {
|
|
170
|
-
const entry = entryFor(root, name);
|
|
171
|
-
if (!entry) {
|
|
172
|
-
throw new errors_1.CliError(`Could not find ${FUNCTIONS_DIR}/${name}/index.ts`, `Available functions: ${discover(root).join(', ') || 'none at all'}`);
|
|
173
|
-
}
|
|
174
|
-
(0, ui_1.step)(`Building ${name}`);
|
|
175
|
-
const code = bundle(root, entry);
|
|
176
|
-
(0, ui_1.step)(`Deploying ${name}`);
|
|
177
|
-
const result = await (0, api_1.apiUpload)(client, `/api/v1/projects/${config.projectId}/functions?name=${encodeURIComponent(name)}`, Buffer.from(code, 'utf-8'), { 'Content-Type': 'application/javascript' });
|
|
178
|
-
(0, ui_1.ok)(`${(0, ui_1.bold)(result.name)} deployed`);
|
|
179
|
-
(0, ui_1.out)(result.url);
|
|
180
|
-
if (result.secrets.length > 0) {
|
|
181
|
-
(0, ui_1.note)((0, ui_1.dim)(` Organization secrets in the environment: ${result.secrets.join(', ')}`));
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
const available = await refreshFunctionsEnv(root, client, config.projectId);
|
|
185
|
-
(0, ui_1.note)((0, ui_1.dim)(` Addresses in .env updated (${available.join(', ')}). In the frontend: xflow.functions.invoke('${names[0]}')`));
|
|
186
|
-
}
|