@getxflow/cli 0.1.8 → 0.1.10
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 +31 -30
- package/dist/api.js +14 -23
- package/dist/args.js +3 -11
- package/dist/bin.js +10 -9
- package/dist/commands/auth.js +48 -41
- package/dist/commands/db.js +21 -28
- package/dist/commands/deploy.js +21 -36
- package/dist/commands/env.js +22 -31
- package/dist/commands/functions.js +26 -48
- package/dist/commands/logs.js +7 -8
- package/dist/commands/mcp.js +20 -27
- package/dist/commands/projects.js +27 -39
- package/dist/commands/schedules.js +8 -8
- package/dist/commands/skills.js +24 -48
- package/dist/commands/sources.js +38 -47
- package/dist/config.js +13 -13
- package/dist/credentials.js +9 -10
- package/dist/errors.js +1 -1
- package/dist/help.js +215 -216
- package/dist/limits.js +68 -0
- package/dist/session.js +6 -10
- package/dist/template.js +13 -21
- package/dist/tree.js +5 -18
- package/dist/types.js +1 -8
- package/dist/ui.js +47 -25
- package/dist/version.js +3 -7
- package/dist/zip.js +22 -15
- package/package.json +22 -22
- package/skills/xflow/SKILL.md +19 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @getxflow/cli
|
|
2
2
|
|
|
3
|
-
CLI
|
|
3
|
+
CLI for the [XFlow](https://getxflow.com) platform: source sync, builds and releases.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm i -g @getxflow/cli
|
|
@@ -11,34 +11,35 @@ xflow deploy
|
|
|
11
11
|
xflow publish
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
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
16
|
|
|
17
|
-
|
|
|
17
|
+
| Command | What it does |
|
|
18
18
|
|---|---|
|
|
19
|
-
| `login` / `logout` / `whoami` |
|
|
20
|
-
| `init` / `link` |
|
|
21
|
-
| `status` / `push` / `pull` |
|
|
22
|
-
| `deploy` / `publish` / `rollback` / `deployments` |
|
|
23
|
-
| `db status` / `db migrate` |
|
|
24
|
-
| `functions list` / `functions deploy` |
|
|
25
|
-
| `functions invoke` / `functions logs` |
|
|
26
|
-
| `schedules list` / `set` / `rm` |
|
|
27
|
-
| `env` / `env check` / `env set` |
|
|
28
|
-
| `logs` |
|
|
29
|
-
| `projects list` / `projects get` / `open` |
|
|
30
|
-
| `skills` |
|
|
31
|
-
| `mcp install` |
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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` / `functions deploy` | cloud functions of the project from `functions/<name>/index.ts` |
|
|
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
|
@@ -6,32 +6,27 @@ exports.apiBinary = apiBinary;
|
|
|
6
6
|
exports.apiUpload = apiUpload;
|
|
7
7
|
exports.downloadUrl = downloadUrl;
|
|
8
8
|
const version_1 = require("./version");
|
|
9
|
-
/**
|
|
10
|
-
* Клиент публичного API платформы.
|
|
11
|
-
*
|
|
12
|
-
* Ошибка сервера приходит в одном формате (`error`, `code`, `hint`) — здесь она
|
|
13
|
-
* превращается в исключение, у которого есть что показать человеку. Плюс
|
|
14
|
-
* проверка версии протокола: старый CLI обязан сказать «обновись», а не падать
|
|
15
|
-
* на разборе неизвестного ответа.
|
|
16
|
-
*/
|
|
17
9
|
class ApiError extends Error {
|
|
18
10
|
code;
|
|
19
11
|
status;
|
|
20
12
|
hint;
|
|
21
|
-
/**
|
|
13
|
+
/** Check violations, e.g. why a build was rejected. */
|
|
22
14
|
issues;
|
|
23
|
-
|
|
15
|
+
/** Set when a plan limit was hit. */
|
|
16
|
+
limit;
|
|
17
|
+
constructor(message, code, status, hint, issues, limit) {
|
|
24
18
|
super(message);
|
|
25
19
|
this.name = 'ApiError';
|
|
26
20
|
this.code = code;
|
|
27
21
|
this.status = status;
|
|
28
22
|
this.hint = hint;
|
|
29
23
|
this.issues = issues;
|
|
24
|
+
this.limit = limit;
|
|
30
25
|
}
|
|
31
26
|
}
|
|
32
27
|
exports.ApiError = ApiError;
|
|
33
28
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
34
|
-
/**
|
|
29
|
+
/** Archive uploads and downloads. */
|
|
35
30
|
const TRANSFER_TIMEOUT_MS = 5 * 60_000;
|
|
36
31
|
function parseVersion(value) {
|
|
37
32
|
return value
|
|
@@ -53,7 +48,7 @@ function isOlder(current, required) {
|
|
|
53
48
|
function assertProtocol(response) {
|
|
54
49
|
const required = response.headers.get('x-xflow-cli-min');
|
|
55
50
|
if (required && isOlder(version_1.CLI_VERSION, required)) {
|
|
56
|
-
throw new ApiError(
|
|
51
|
+
throw new ApiError(`The platform requires CLI ${required} or newer, ${version_1.CLI_VERSION} is installed`, 'outdated_cli', response.status, 'Update: npm i -g @getxflow/cli');
|
|
57
52
|
}
|
|
58
53
|
}
|
|
59
54
|
async function send(client, path, options = {}) {
|
|
@@ -63,13 +58,11 @@ async function send(client, path, options = {}) {
|
|
|
63
58
|
'X-Xflow-Cli': version_1.CLI_VERSION,
|
|
64
59
|
...headers,
|
|
65
60
|
};
|
|
66
|
-
// Вход по device flow идёт без ключа: пустой Bearer выглядел бы как испорченный.
|
|
67
61
|
if (client.token)
|
|
68
62
|
requestHeaders.Authorization = `Bearer ${client.token}`;
|
|
69
63
|
let payload;
|
|
70
64
|
if (Buffer.isBuffer(body)) {
|
|
71
|
-
//
|
|
72
|
-
// байты вокруг наших. Плюс тип понятен и DOM-, и node-типизации fetch.
|
|
65
|
+
// Copy out of Node's shared Buffer pool before handing the bytes to fetch.
|
|
73
66
|
payload = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
74
67
|
requestHeaders['Content-Type'] = requestHeaders['Content-Type'] ?? 'application/zip';
|
|
75
68
|
}
|
|
@@ -88,7 +81,7 @@ async function send(client, path, options = {}) {
|
|
|
88
81
|
}
|
|
89
82
|
catch (e) {
|
|
90
83
|
const reason = e instanceof Error ? e.message : String(e);
|
|
91
|
-
throw new ApiError(
|
|
84
|
+
throw new ApiError(`The platform is unreachable: ${reason}`, 'network', 0, `Check the connection and the address ${client.apiUrl}`);
|
|
92
85
|
}
|
|
93
86
|
assertProtocol(response);
|
|
94
87
|
return response;
|
|
@@ -97,10 +90,10 @@ async function readError(response) {
|
|
|
97
90
|
const text = await response.text().catch(() => '');
|
|
98
91
|
try {
|
|
99
92
|
const parsed = JSON.parse(text);
|
|
100
|
-
return new ApiError(parsed.error ||
|
|
93
|
+
return new ApiError(parsed.error || `Request rejected (${response.status})`, parsed.code || 'unknown', response.status, parsed.hint, parsed.issues, parsed.limit);
|
|
101
94
|
}
|
|
102
95
|
catch {
|
|
103
|
-
return new ApiError(
|
|
96
|
+
return new ApiError(`Request rejected (${response.status})`, 'unknown', response.status, text.slice(0, 200) || undefined);
|
|
104
97
|
}
|
|
105
98
|
}
|
|
106
99
|
async function apiJson(client, path, options = {}) {
|
|
@@ -109,28 +102,26 @@ async function apiJson(client, path, options = {}) {
|
|
|
109
102
|
throw await readError(response);
|
|
110
103
|
return (await response.json());
|
|
111
104
|
}
|
|
112
|
-
/** Ответ-файл: архив шаблона отдаётся телом, а не ссылкой (он лежит в базе, а не в S3). */
|
|
113
105
|
async function apiBinary(client, path) {
|
|
114
106
|
const response = await send(client, path, { timeoutMs: TRANSFER_TIMEOUT_MS });
|
|
115
107
|
if (!response.ok)
|
|
116
108
|
throw await readError(response);
|
|
117
109
|
return Buffer.from(await response.arrayBuffer());
|
|
118
110
|
}
|
|
119
|
-
/** Загрузка архива на сервер: тот же разбор ошибок, но с длинным таймаутом. */
|
|
120
111
|
async function apiUpload(client, path, body, headers = {}) {
|
|
121
112
|
return apiJson(client, path, { method: 'POST', body, headers, timeoutMs: TRANSFER_TIMEOUT_MS });
|
|
122
113
|
}
|
|
123
|
-
/**
|
|
114
|
+
/** Signed URL download: no Authorization header attached. */
|
|
124
115
|
async function downloadUrl(url) {
|
|
125
116
|
let response;
|
|
126
117
|
try {
|
|
127
118
|
response = await fetch(url, { signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) });
|
|
128
119
|
}
|
|
129
120
|
catch (e) {
|
|
130
|
-
throw new ApiError(
|
|
121
|
+
throw new ApiError(`Could not download the archive: ${e instanceof Error ? e.message : e}`, 'network', 0);
|
|
131
122
|
}
|
|
132
123
|
if (!response.ok) {
|
|
133
|
-
throw new ApiError(
|
|
124
|
+
throw new ApiError(`Could not download the archive (${response.status})`, 'download_failed', response.status);
|
|
134
125
|
}
|
|
135
126
|
return Buffer.from(await response.arrayBuffer());
|
|
136
127
|
}
|
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',
|
|
@@ -80,7 +72,7 @@ function flagNumber(args, name) {
|
|
|
80
72
|
return undefined;
|
|
81
73
|
const parsed = Number(value);
|
|
82
74
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
83
|
-
throw new Error(
|
|
75
|
+
throw new Error(`The value of --${name} has to be an integer, got "${value}"`);
|
|
84
76
|
}
|
|
85
77
|
return parsed;
|
|
86
78
|
}
|
package/dist/bin.js
CHANGED
|
@@ -6,6 +6,7 @@ const args_1 = require("./args");
|
|
|
6
6
|
const config_1 = require("./config");
|
|
7
7
|
const errors_1 = require("./errors");
|
|
8
8
|
const help_1 = require("./help");
|
|
9
|
+
const limits_1 = require("./limits");
|
|
9
10
|
const ui_1 = require("./ui");
|
|
10
11
|
const zip_1 = require("./zip");
|
|
11
12
|
const version_1 = require("./version");
|
|
@@ -20,7 +21,6 @@ const schedules_1 = require("./commands/schedules");
|
|
|
20
21
|
const skills_1 = require("./commands/skills");
|
|
21
22
|
const sources_1 = require("./commands/sources");
|
|
22
23
|
const deploy_1 = require("./commands/deploy");
|
|
23
|
-
/** Одно место, где команда превращается в действие. Слова, а не флаги: `projects list`. */
|
|
24
24
|
async function run(args) {
|
|
25
25
|
const [first, second] = args.words;
|
|
26
26
|
const rest = { ...args, words: args.words.slice(1) };
|
|
@@ -58,7 +58,7 @@ async function run(args) {
|
|
|
58
58
|
await (0, projects_1.list)();
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
|
-
throw new errors_1.CliError(
|
|
61
|
+
throw new errors_1.CliError(`Unknown command: projects ${second}`, 'Available: list and get');
|
|
62
62
|
case 'skills':
|
|
63
63
|
(0, skills_1.skills)(rest);
|
|
64
64
|
return;
|
|
@@ -67,7 +67,7 @@ async function run(args) {
|
|
|
67
67
|
await (0, mcp_1.mcpInstall)(rest);
|
|
68
68
|
return;
|
|
69
69
|
}
|
|
70
|
-
throw new errors_1.CliError(
|
|
70
|
+
throw new errors_1.CliError(`Unknown command: mcp ${second}`, 'Available: install');
|
|
71
71
|
case 'functions':
|
|
72
72
|
if (second === 'deploy') {
|
|
73
73
|
await (0, functions_1.functionsDeploy)(rest);
|
|
@@ -85,7 +85,7 @@ async function run(args) {
|
|
|
85
85
|
await (0, functions_1.functionsList)();
|
|
86
86
|
return;
|
|
87
87
|
}
|
|
88
|
-
throw new errors_1.CliError(
|
|
88
|
+
throw new errors_1.CliError(`Unknown command: functions ${second}`, 'Available: list, deploy, invoke and logs');
|
|
89
89
|
case 'logs':
|
|
90
90
|
await (0, logs_1.logs)(rest);
|
|
91
91
|
return;
|
|
@@ -106,7 +106,7 @@ async function run(args) {
|
|
|
106
106
|
await (0, env_1.envList)();
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
|
-
throw new errors_1.CliError(
|
|
109
|
+
throw new errors_1.CliError(`Unknown command: env ${second}`, 'Available: list, check, set and rm');
|
|
110
110
|
case 'schedules':
|
|
111
111
|
if (second === 'set') {
|
|
112
112
|
await (0, schedules_1.schedulesSet)(rest);
|
|
@@ -120,7 +120,7 @@ async function run(args) {
|
|
|
120
120
|
await (0, schedules_1.schedulesList)();
|
|
121
121
|
return;
|
|
122
122
|
}
|
|
123
|
-
throw new errors_1.CliError(
|
|
123
|
+
throw new errors_1.CliError(`Unknown command: schedules ${second}`, 'Available: list, set and rm');
|
|
124
124
|
case 'db':
|
|
125
125
|
if (second === 'migrate') {
|
|
126
126
|
await (0, db_1.dbMigrate)(rest);
|
|
@@ -130,7 +130,7 @@ async function run(args) {
|
|
|
130
130
|
await (0, db_1.dbStatus)();
|
|
131
131
|
return;
|
|
132
132
|
}
|
|
133
|
-
throw new errors_1.CliError(
|
|
133
|
+
throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status and migrate');
|
|
134
134
|
case 'status':
|
|
135
135
|
await (0, sources_1.status)();
|
|
136
136
|
return;
|
|
@@ -153,7 +153,7 @@ async function run(args) {
|
|
|
153
153
|
await (0, deploy_1.deployments)();
|
|
154
154
|
return;
|
|
155
155
|
default:
|
|
156
|
-
throw new errors_1.CliError(
|
|
156
|
+
throw new errors_1.CliError(`Unknown command: ${first}`, 'The list of commands: xflow help');
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
async function main() {
|
|
@@ -173,6 +173,8 @@ async function main() {
|
|
|
173
173
|
catch (e) {
|
|
174
174
|
if (e instanceof api_1.ApiError) {
|
|
175
175
|
(0, ui_1.fail)(e.message);
|
|
176
|
+
if (e.limit)
|
|
177
|
+
(0, ui_1.note)((0, ui_1.dim)(` ${(0, limits_1.limitLine)(e.limit)}`));
|
|
176
178
|
if (e.hint)
|
|
177
179
|
(0, ui_1.note)((0, ui_1.dim)(` ${e.hint}`));
|
|
178
180
|
return 1;
|
|
@@ -185,7 +187,6 @@ async function main() {
|
|
|
185
187
|
return 1;
|
|
186
188
|
}
|
|
187
189
|
(0, ui_1.fail)(e instanceof Error ? e.message : String(e));
|
|
188
|
-
// Стек прячем: он полезен только нам, и то по запросу.
|
|
189
190
|
if (process.env.XFLOW_DEBUG && e instanceof Error && e.stack)
|
|
190
191
|
(0, ui_1.note)((0, ui_1.dim)(e.stack));
|
|
191
192
|
return 1;
|
package/dist/commands/auth.js
CHANGED
|
@@ -8,6 +8,7 @@ const api_1 = require("../api");
|
|
|
8
8
|
const config_1 = require("../config");
|
|
9
9
|
const credentials_1 = require("../credentials");
|
|
10
10
|
const errors_1 = require("../errors");
|
|
11
|
+
const limits_1 = require("../limits");
|
|
11
12
|
const session_1 = require("../session");
|
|
12
13
|
const ui_1 = require("../ui");
|
|
13
14
|
function localConfig() {
|
|
@@ -22,34 +23,38 @@ function localConfig() {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
|
-
/**
|
|
26
|
-
* Вход человека: device flow.
|
|
27
|
-
*
|
|
28
|
-
* Ключ не проходит ни через копипаст, ни через контекст агента — он выпускается
|
|
29
|
-
* на сервере после подтверждения в браузере и приезжает сюда ровно один раз.
|
|
30
|
-
*/
|
|
26
|
+
/** Device flow: the key is issued after confirmation in the browser, never typed. */
|
|
31
27
|
async function login() {
|
|
32
28
|
const config = localConfig();
|
|
33
29
|
const client = (0, session_1.anonymous)(config);
|
|
34
|
-
//
|
|
35
|
-
// доступов лежат одинаковые строки и непонятно, какой компьютер отзывать.
|
|
30
|
+
// The machine name becomes the key label on the platform.
|
|
36
31
|
const start = await (0, api_1.apiJson)(client, '/api/v1/auth/device', {
|
|
37
32
|
method: 'POST',
|
|
38
33
|
body: { client_name: (0, node_os_1.hostname)() },
|
|
39
34
|
});
|
|
40
35
|
(0, ui_1.note)('');
|
|
41
|
-
(0, ui_1.note)(`
|
|
42
|
-
(0, ui_1.note)(`
|
|
43
|
-
(0, ui_1.note)(`
|
|
36
|
+
(0, ui_1.note)(` Confirmation code: ${(0, ui_1.bold)(start.user_code)}`);
|
|
37
|
+
(0, ui_1.note)(` Computer: ${(0, node_os_1.hostname)()}`);
|
|
38
|
+
(0, ui_1.note)(` Sign-in link: ${start.verification_uri_complete}`);
|
|
44
39
|
(0, ui_1.note)('');
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
(
|
|
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) {
|
|
48
53
|
const deadline = Date.now() + start.expires_in * 1000;
|
|
49
54
|
let intervalMs = Math.max(start.interval, 1) * 1000;
|
|
50
55
|
for (;;) {
|
|
51
56
|
if (Date.now() > deadline) {
|
|
52
|
-
throw new errors_1.CliError('
|
|
57
|
+
throw new errors_1.CliError('The code has expired', 'Try again: xflow login');
|
|
53
58
|
}
|
|
54
59
|
await sleep(intervalMs);
|
|
55
60
|
let poll;
|
|
@@ -60,7 +65,7 @@ async function login() {
|
|
|
60
65
|
});
|
|
61
66
|
}
|
|
62
67
|
catch (e) {
|
|
63
|
-
//
|
|
68
|
+
// Slow down on rate limits instead of failing the sign-in.
|
|
64
69
|
if (e instanceof api_1.ApiError && e.code === 'rate_limited') {
|
|
65
70
|
intervalMs = Math.min(intervalMs * 2, 30_000);
|
|
66
71
|
continue;
|
|
@@ -74,54 +79,56 @@ async function login() {
|
|
|
74
79
|
organizationId: poll.organization_id,
|
|
75
80
|
projectId: poll.project_id,
|
|
76
81
|
});
|
|
77
|
-
(0, ui_1.ok)(
|
|
82
|
+
(0, ui_1.ok)(`Signed in, the key is stored for ${client.apiUrl}`);
|
|
78
83
|
return;
|
|
79
84
|
}
|
|
80
85
|
}
|
|
81
86
|
function logout() {
|
|
82
87
|
const apiUrl = (0, config_1.apiUrlFor)(localConfig());
|
|
83
88
|
if ((0, credentials_1.forgetCredential)(apiUrl)) {
|
|
84
|
-
(0, ui_1.ok)(
|
|
89
|
+
(0, ui_1.ok)(`The key for ${apiUrl} is deleted`);
|
|
85
90
|
}
|
|
86
91
|
else {
|
|
87
|
-
(0, ui_1.warn)(
|
|
92
|
+
(0, ui_1.warn)(`There is no stored key for ${apiUrl}`);
|
|
88
93
|
}
|
|
89
94
|
}
|
|
90
|
-
/**
|
|
95
|
+
/** Suffix for a non-active subscription. */
|
|
91
96
|
const SUBSCRIPTION_STATE = {
|
|
92
|
-
past_due: ',
|
|
93
|
-
suspended: ',
|
|
94
|
-
terminated: ',
|
|
95
|
-
};
|
|
96
|
-
const QUOTA_LABELS = {
|
|
97
|
-
projects: 'проекты',
|
|
98
|
-
functions: 'функции',
|
|
99
|
-
developers: 'разработчики',
|
|
100
|
-
members: 'участники',
|
|
97
|
+
past_due: ', the period has ended',
|
|
98
|
+
suspended: ', work is suspended',
|
|
99
|
+
terminated: ', the subscription is terminated',
|
|
101
100
|
};
|
|
102
101
|
async function whoami() {
|
|
103
102
|
const config = localConfig();
|
|
104
103
|
const client = (0, session_1.connect)(config);
|
|
105
104
|
const me = await (0, api_1.apiJson)(client, '/api/v1/me');
|
|
106
|
-
(0, ui_1.out)(
|
|
107
|
-
(0, ui_1.out)(
|
|
108
|
-
(0, ui_1.out)(
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
(0, ui_1.out)(`Organization: ${me.organization.name ?? me.organization.id}`);
|
|
106
|
+
(0, ui_1.out)(`Key: xfk_${me.key.prefix}… (${me.key.scopes.join(', ')})`);
|
|
107
|
+
(0, ui_1.out)(`Platform: ${client.apiUrl}`);
|
|
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)()) {
|
|
110
|
+
(0, ui_1.note)((0, ui_1.dim)(' The key comes from XFLOW_TOKEN, the one stored in ~/.xflow is not used'));
|
|
111
111
|
}
|
|
112
112
|
else if (!(0, credentials_1.readCredential)(client.apiUrl)) {
|
|
113
|
-
(0, ui_1.note)((0, ui_1.dim)('
|
|
113
|
+
(0, ui_1.note)((0, ui_1.dim)(' The key is not stored'));
|
|
114
114
|
}
|
|
115
115
|
if (me.billing) {
|
|
116
|
+
const pkg = me.billing.package_title ? `, ${me.billing.package_title}` : '';
|
|
116
117
|
(0, ui_1.out)('');
|
|
117
|
-
(0, ui_1.out)(
|
|
118
|
-
(0, ui_1.out)(`Баланс: ${me.billing.balance ?? '—'}`);
|
|
118
|
+
(0, ui_1.out)(`Plan: ${me.billing.plan_title}${pkg}${SUBSCRIPTION_STATE[me.billing.status] ?? ''}`);
|
|
119
119
|
}
|
|
120
120
|
if (me.quotas) {
|
|
121
121
|
(0, ui_1.out)('');
|
|
122
|
-
(0, ui_1.out)('
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
122
|
+
(0, ui_1.out)('Plan limits:');
|
|
123
|
+
(0, ui_1.table)((0, limits_1.quotaRows)(me.quotas));
|
|
124
|
+
}
|
|
125
|
+
if (me.features) {
|
|
126
|
+
const interval = me.features.schedule_min_interval_min;
|
|
127
|
+
(0, ui_1.out)('');
|
|
128
|
+
(0, ui_1.out)(`Schedules: ${me.features.schedules
|
|
129
|
+
? interval
|
|
130
|
+
? `yes, no more often than one run every ${interval} min`
|
|
131
|
+
: 'yes'
|
|
132
|
+
: 'no'}`);
|
|
126
133
|
}
|
|
127
134
|
}
|
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))
|
|
@@ -38,7 +32,7 @@ function readMigrations(root) {
|
|
|
38
32
|
function requireMigrations(root) {
|
|
39
33
|
const migrations = readMigrations(root);
|
|
40
34
|
if (migrations.length === 0) {
|
|
41
|
-
throw new errors_1.CliError(
|
|
35
|
+
throw new errors_1.CliError(`The project has no migrations`, `Put the SQL in ${MIGRATIONS_DIR}/0001_init.sql and try again`);
|
|
42
36
|
}
|
|
43
37
|
return migrations;
|
|
44
38
|
}
|
|
@@ -48,37 +42,36 @@ async function dbStatus() {
|
|
|
48
42
|
const local = readMigrations(root);
|
|
49
43
|
const history = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/db/migrate`);
|
|
50
44
|
const applied = new Map(history.applied.map((row) => [row.name, row]));
|
|
51
|
-
(0, ui_1.out)(
|
|
45
|
+
(0, ui_1.out)(`Schema: ${(0, ui_1.bold)(history.schema)}`);
|
|
52
46
|
(0, ui_1.out)('');
|
|
53
47
|
if (local.length === 0 && applied.size === 0) {
|
|
54
|
-
(0, ui_1.note)(
|
|
48
|
+
(0, ui_1.note)(`No migrations. The first one: ${MIGRATIONS_DIR}/0001_init.sql`);
|
|
55
49
|
return;
|
|
56
50
|
}
|
|
57
51
|
const rows = [];
|
|
58
52
|
for (const migration of local) {
|
|
59
53
|
const row = applied.get(migration.name);
|
|
60
54
|
if (!row) {
|
|
61
|
-
rows.push([migration.name, '
|
|
55
|
+
rows.push([migration.name, 'waiting', '']);
|
|
62
56
|
continue;
|
|
63
57
|
}
|
|
64
58
|
const changed = row.checksum !== checksum(migration.sql);
|
|
65
59
|
rows.push([
|
|
66
60
|
migration.name,
|
|
67
|
-
changed ? '
|
|
61
|
+
changed ? 'applied, file changed' : 'applied',
|
|
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))
|
|
76
|
-
rows.push([row.name, '
|
|
69
|
+
rows.push([row.name, 'applied, file missing', (0, ui_1.formatAge)(row.applied_at)]);
|
|
77
70
|
}
|
|
78
71
|
(0, ui_1.table)(rows);
|
|
79
|
-
if (rows.some((row) => row[1] === '
|
|
72
|
+
if (rows.some((row) => row[1] === 'applied, file changed')) {
|
|
80
73
|
(0, ui_1.out)('');
|
|
81
|
-
(0, ui_1.warn)('
|
|
74
|
+
(0, ui_1.warn)('A changed file will not be applied again: write a new migration');
|
|
82
75
|
}
|
|
83
76
|
}
|
|
84
77
|
async function dbMigrate(args) {
|
|
@@ -86,7 +79,7 @@ async function dbMigrate(args) {
|
|
|
86
79
|
const client = (0, session_1.connect)(config);
|
|
87
80
|
const migrations = requireMigrations(root);
|
|
88
81
|
const dryRun = (0, args_1.flagBool)(args, 'dry-run');
|
|
89
|
-
(0, ui_1.step)(dryRun ?
|
|
82
|
+
(0, ui_1.step)(dryRun ? `Checking the migrations (${migrations.length} in the directory)` : `Applying the migrations (${migrations.length} in the directory)`);
|
|
90
83
|
const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/db/migrate`, {
|
|
91
84
|
method: 'POST',
|
|
92
85
|
body: {
|
|
@@ -97,34 +90,34 @@ async function dbMigrate(args) {
|
|
|
97
90
|
timeoutMs: 300_000,
|
|
98
91
|
});
|
|
99
92
|
if (result.destructive.length > 0) {
|
|
100
|
-
(0, ui_1.warn)('
|
|
93
|
+
(0, ui_1.warn)('Destructive operations:');
|
|
101
94
|
for (const hit of result.destructive)
|
|
102
95
|
(0, ui_1.note)((0, ui_1.dim)(` ${hit}`));
|
|
103
96
|
}
|
|
104
97
|
if (result.dry_run) {
|
|
105
98
|
if (result.pending.length === 0) {
|
|
106
|
-
(0, ui_1.ok)('
|
|
99
|
+
(0, ui_1.ok)('Nothing to apply: every migration is already in the database');
|
|
107
100
|
return;
|
|
108
101
|
}
|
|
109
102
|
(0, ui_1.out)('');
|
|
110
|
-
(0, ui_1.out)(
|
|
103
|
+
(0, ui_1.out)(`Will be applied (${result.pending.length}):`);
|
|
111
104
|
for (const name of result.pending)
|
|
112
105
|
(0, ui_1.out)(` ${name}`);
|
|
113
|
-
(0, ui_1.note)((0, ui_1.dim)('
|
|
106
|
+
(0, ui_1.note)((0, ui_1.dim)(' Dry run: the database was not changed'));
|
|
114
107
|
return;
|
|
115
108
|
}
|
|
116
109
|
for (const dump of result.dumps) {
|
|
117
|
-
(0, ui_1.note)((0, ui_1.dim)(`
|
|
110
|
+
(0, ui_1.note)((0, ui_1.dim)(` Dump of ${dump.table}: ${dump.rows} rows${dump.truncated ? ' (cut at the cap)' : ''}, kept for ${result.dump_retention_days} days`));
|
|
118
111
|
}
|
|
119
112
|
if (result.applied.length > 0) {
|
|
120
|
-
(0, ui_1.ok)(
|
|
113
|
+
(0, ui_1.ok)(`Applied: ${result.applied.join(', ')}`);
|
|
121
114
|
}
|
|
122
115
|
else if (!result.failed) {
|
|
123
|
-
(0, ui_1.ok)('
|
|
116
|
+
(0, ui_1.ok)('Nothing to apply: every migration is already in the database');
|
|
124
117
|
}
|
|
125
118
|
if (result.failed) {
|
|
126
119
|
throw new errors_1.CliError(`${result.failed.name}: ${result.failed.error}`, result.applied.length > 0
|
|
127
|
-
?
|
|
128
|
-
: '
|
|
120
|
+
? `What ran before it stays in the database (${result.applied.join(', ')}), the rest did not run`
|
|
121
|
+
: 'The database is unchanged: the migration rolled back as a whole');
|
|
129
122
|
}
|
|
130
123
|
}
|