@getxflow/cli 0.1.1 → 0.1.5
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 +3 -0
- package/dist/args.js +2 -0
- package/dist/bin.js +45 -0
- package/dist/commands/db.js +130 -0
- package/dist/commands/deploy.js +3 -0
- package/dist/commands/env.js +143 -0
- package/dist/commands/functions.js +24 -0
- package/dist/commands/logs.js +1 -1
- package/dist/commands/projects.js +2 -2
- package/dist/commands/schedules.js +60 -0
- package/dist/help.js +216 -139
- package/dist/template.js +41 -12
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/xflow/SKILL.md +37 -6
package/README.md
CHANGED
|
@@ -20,8 +20,11 @@ xflow publish
|
|
|
20
20
|
| `init` / `link` | новый проект, связать папку с существующим |
|
|
21
21
|
| `status` / `push` / `pull` | состояние, отправка и получение исходников |
|
|
22
22
|
| `deploy` / `publish` / `rollback` / `deployments` | сборка, публикация, откат, история версий |
|
|
23
|
+
| `db status` / `db migrate` | миграции из `migrations/*.sql` с гейтом на разрушающие |
|
|
23
24
|
| `functions list` / `functions deploy` | облачные функции проекта из `functions/<имя>/index.ts` |
|
|
24
25
|
| `functions invoke` / `functions logs` | вызвать функцию, посмотреть её падения со стеком |
|
|
26
|
+
| `schedules list` / `set` / `rm` | запуск функций по времени (таймер-триггеры) |
|
|
27
|
+
| `env` / `env check` / `env set` | переменные окружения функций, значения наружу не отдаются |
|
|
25
28
|
| `logs` | ошибки выложенного приложения в браузере |
|
|
26
29
|
| `projects list` / `projects get` / `open` | проекты организации и адреса приложения |
|
|
27
30
|
| `skills` | инструкция о платформе для ИИ-агента (формат [Agent Skills](https://agentskills.io)) |
|
package/dist/args.js
CHANGED
package/dist/bin.js
CHANGED
|
@@ -11,8 +11,11 @@ const zip_1 = require("./zip");
|
|
|
11
11
|
const version_1 = require("./version");
|
|
12
12
|
const auth_1 = require("./commands/auth");
|
|
13
13
|
const projects_1 = require("./commands/projects");
|
|
14
|
+
const db_1 = require("./commands/db");
|
|
15
|
+
const env_1 = require("./commands/env");
|
|
14
16
|
const functions_1 = require("./commands/functions");
|
|
15
17
|
const logs_1 = require("./commands/logs");
|
|
18
|
+
const schedules_1 = require("./commands/schedules");
|
|
16
19
|
const skills_1 = require("./commands/skills");
|
|
17
20
|
const sources_1 = require("./commands/sources");
|
|
18
21
|
const deploy_1 = require("./commands/deploy");
|
|
@@ -82,6 +85,48 @@ async function run(args) {
|
|
|
82
85
|
case 'logs':
|
|
83
86
|
await (0, logs_1.logs)(rest);
|
|
84
87
|
return;
|
|
88
|
+
case 'env':
|
|
89
|
+
if (second === 'set') {
|
|
90
|
+
await (0, env_1.envSet)(rest);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (second === 'rm' || second === 'remove') {
|
|
94
|
+
await (0, env_1.envRemove)(rest);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (second === 'check') {
|
|
98
|
+
await (0, env_1.envCheck)();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (second === undefined || second === 'list' || second === 'pull') {
|
|
102
|
+
await (0, env_1.envList)();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
throw new errors_1.CliError(`Неизвестная команда: env ${second}`, 'Есть list, check, set и rm');
|
|
106
|
+
case 'schedules':
|
|
107
|
+
if (second === 'set') {
|
|
108
|
+
await (0, schedules_1.schedulesSet)(rest);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (second === 'rm' || second === 'remove') {
|
|
112
|
+
await (0, schedules_1.schedulesRemove)(rest);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (second === undefined || second === 'list') {
|
|
116
|
+
await (0, schedules_1.schedulesList)();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
throw new errors_1.CliError(`Неизвестная команда: schedules ${second}`, 'Есть list, set и rm');
|
|
120
|
+
case 'db':
|
|
121
|
+
if (second === 'migrate') {
|
|
122
|
+
await (0, db_1.dbMigrate)(rest);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (second === undefined || second === 'status') {
|
|
126
|
+
await (0, db_1.dbStatus)();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
throw new errors_1.CliError(`Неизвестная команда: db ${second}`, 'Есть status и migrate');
|
|
85
130
|
case 'status':
|
|
86
131
|
await (0, sources_1.status)();
|
|
87
132
|
return;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.dbStatus = dbStatus;
|
|
4
|
+
exports.dbMigrate = dbMigrate;
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
const node_path_1 = require("node:path");
|
|
8
|
+
const api_1 = require("../api");
|
|
9
|
+
const args_1 = require("../args");
|
|
10
|
+
const config_1 = require("../config");
|
|
11
|
+
const errors_1 = require("../errors");
|
|
12
|
+
const session_1 = require("../session");
|
|
13
|
+
const ui_1 = require("../ui");
|
|
14
|
+
/**
|
|
15
|
+
* Миграции базы проекта.
|
|
16
|
+
*
|
|
17
|
+
* Файлы лежат в репозитории (`migrations/0001_init.sql`), историю применённых
|
|
18
|
+
* помнит сама база. Отсюда правило: локально мы только читаем и сортируем, а что
|
|
19
|
+
* из этого новое, решает сервер.
|
|
20
|
+
*/
|
|
21
|
+
const MIGRATIONS_DIR = 'migrations';
|
|
22
|
+
function checksum(sql) {
|
|
23
|
+
return (0, node_crypto_1.createHash)('sha256').update(sql).digest('hex').slice(0, 16);
|
|
24
|
+
}
|
|
25
|
+
/** Порядок по имени файла: `0001_` идёт раньше `0002_`, это и есть очередь. */
|
|
26
|
+
function readMigrations(root) {
|
|
27
|
+
const dir = (0, node_path_1.join)(root, MIGRATIONS_DIR);
|
|
28
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
29
|
+
return [];
|
|
30
|
+
return (0, node_fs_1.readdirSync)(dir)
|
|
31
|
+
.filter((name) => name.toLowerCase().endsWith('.sql'))
|
|
32
|
+
.sort()
|
|
33
|
+
.map((file) => ({
|
|
34
|
+
name: file.replace(/\.sql$/i, ''),
|
|
35
|
+
sql: (0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, file), 'utf-8'),
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
function requireMigrations(root) {
|
|
39
|
+
const migrations = readMigrations(root);
|
|
40
|
+
if (migrations.length === 0) {
|
|
41
|
+
throw new errors_1.CliError(`В проекте нет миграций`, `Положите SQL в ${MIGRATIONS_DIR}/0001_init.sql и повторите`);
|
|
42
|
+
}
|
|
43
|
+
return migrations;
|
|
44
|
+
}
|
|
45
|
+
async function dbStatus() {
|
|
46
|
+
const { root, config } = (0, config_1.requireProject)();
|
|
47
|
+
const client = (0, session_1.connect)(config);
|
|
48
|
+
const local = readMigrations(root);
|
|
49
|
+
const history = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/db/migrate`);
|
|
50
|
+
const applied = new Map(history.applied.map((row) => [row.name, row]));
|
|
51
|
+
(0, ui_1.out)(`Схема: ${(0, ui_1.bold)(history.schema)}`);
|
|
52
|
+
(0, ui_1.out)('');
|
|
53
|
+
if (local.length === 0 && applied.size === 0) {
|
|
54
|
+
(0, ui_1.note)(`Миграций нет. Первая: ${MIGRATIONS_DIR}/0001_init.sql`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const rows = [];
|
|
58
|
+
for (const migration of local) {
|
|
59
|
+
const row = applied.get(migration.name);
|
|
60
|
+
if (!row) {
|
|
61
|
+
rows.push([migration.name, 'ждёт применения', '']);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const changed = row.checksum !== checksum(migration.sql);
|
|
65
|
+
rows.push([
|
|
66
|
+
migration.name,
|
|
67
|
+
changed ? 'применена, файл изменён' : 'применена',
|
|
68
|
+
(0, ui_1.formatAge)(row.applied_at),
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
// Применённое, чего нет локально: чаще всего чужая миграция из другого проекта
|
|
72
|
+
// на той же логической базе.
|
|
73
|
+
const localNames = new Set(local.map((migration) => migration.name));
|
|
74
|
+
for (const row of history.applied) {
|
|
75
|
+
if (!localNames.has(row.name))
|
|
76
|
+
rows.push([row.name, 'применена, файла нет', (0, ui_1.formatAge)(row.applied_at)]);
|
|
77
|
+
}
|
|
78
|
+
(0, ui_1.table)(rows);
|
|
79
|
+
if (rows.some((row) => row[1] === 'применена, файл изменён')) {
|
|
80
|
+
(0, ui_1.out)('');
|
|
81
|
+
(0, ui_1.warn)('Изменённый файл повторно не применится: заведите новую миграцию');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function dbMigrate(args) {
|
|
85
|
+
const { root, config } = (0, config_1.requireProject)();
|
|
86
|
+
const client = (0, session_1.connect)(config);
|
|
87
|
+
const migrations = requireMigrations(root);
|
|
88
|
+
const dryRun = (0, args_1.flagBool)(args, 'dry-run');
|
|
89
|
+
(0, ui_1.step)(dryRun ? `Проверяю миграции (${migrations.length} в каталоге)` : `Применяю миграции (${migrations.length} в каталоге)`);
|
|
90
|
+
const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/db/migrate`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
body: {
|
|
93
|
+
migrations,
|
|
94
|
+
dry_run: dryRun,
|
|
95
|
+
allow_destructive: (0, args_1.flagBool)(args, 'allow-destructive'),
|
|
96
|
+
},
|
|
97
|
+
timeoutMs: 300_000,
|
|
98
|
+
});
|
|
99
|
+
if (result.destructive.length > 0) {
|
|
100
|
+
(0, ui_1.warn)('Разрушающие операции:');
|
|
101
|
+
for (const hit of result.destructive)
|
|
102
|
+
(0, ui_1.note)((0, ui_1.dim)(` ${hit}`));
|
|
103
|
+
}
|
|
104
|
+
if (result.dry_run) {
|
|
105
|
+
if (result.pending.length === 0) {
|
|
106
|
+
(0, ui_1.ok)('Применять нечего: все миграции уже в базе');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
(0, ui_1.out)('');
|
|
110
|
+
(0, ui_1.out)(`Будут применены (${result.pending.length}):`);
|
|
111
|
+
for (const name of result.pending)
|
|
112
|
+
(0, ui_1.out)(` ${name}`);
|
|
113
|
+
(0, ui_1.note)((0, ui_1.dim)(' Пробный прогон: база не менялась'));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
for (const dump of result.dumps) {
|
|
117
|
+
(0, ui_1.note)((0, ui_1.dim)(` Дамп ${dump.table}: строк ${dump.rows}${dump.truncated ? ' (обрезан по потолку)' : ''}, хранится ${result.dump_retention_days} суток`));
|
|
118
|
+
}
|
|
119
|
+
if (result.applied.length > 0) {
|
|
120
|
+
(0, ui_1.ok)(`Применено: ${result.applied.join(', ')}`);
|
|
121
|
+
}
|
|
122
|
+
else if (!result.failed) {
|
|
123
|
+
(0, ui_1.ok)('Применять нечего: все миграции уже в базе');
|
|
124
|
+
}
|
|
125
|
+
if (result.failed) {
|
|
126
|
+
throw new errors_1.CliError(`${result.failed.name}: ${result.failed.error}`, result.applied.length > 0
|
|
127
|
+
? `Применённое до неё осталось в базе (${result.applied.join(', ')}), остальное не запускалось`
|
|
128
|
+
: 'База не изменилась: миграция откатилась целиком');
|
|
129
|
+
}
|
|
130
|
+
}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -14,6 +14,7 @@ const session_1 = require("../session");
|
|
|
14
14
|
const tree_1 = require("../tree");
|
|
15
15
|
const zip_1 = require("../zip");
|
|
16
16
|
const ui_1 = require("../ui");
|
|
17
|
+
const functions_1 = require("./functions");
|
|
17
18
|
const sources_1 = require("./sources");
|
|
18
19
|
/** Потолок артефакта на сервере. */
|
|
19
20
|
const MAX_ARTIFACT_BYTES = 100 * 1024 * 1024;
|
|
@@ -55,6 +56,8 @@ async function deploy(args) {
|
|
|
55
56
|
revision = (await (0, sources_1.pushSources)(root, config, client, { force: (0, args_1.flagBool)(args, 'force') })).revision;
|
|
56
57
|
}
|
|
57
58
|
if (!(0, args_1.flagBool)(args, 'skip-build')) {
|
|
59
|
+
// Перед сборкой, а не после: адреса функций попадают в бандл на этом шаге.
|
|
60
|
+
await (0, functions_1.refreshFunctionsEnv)(root, client, config.projectId);
|
|
58
61
|
(0, ui_1.step)(`Сборка: ${buildCommand}`);
|
|
59
62
|
await runBuild(buildCommand, root);
|
|
60
63
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.envList = envList;
|
|
4
|
+
exports.envCheck = envCheck;
|
|
5
|
+
exports.envSet = envSet;
|
|
6
|
+
exports.envRemove = envRemove;
|
|
7
|
+
const node_fs_1 = require("node:fs");
|
|
8
|
+
const node_path_1 = require("node:path");
|
|
9
|
+
const api_1 = require("../api");
|
|
10
|
+
const args_1 = require("../args");
|
|
11
|
+
const config_1 = require("../config");
|
|
12
|
+
const errors_1 = require("../errors");
|
|
13
|
+
const session_1 = require("../session");
|
|
14
|
+
const ui_1 = require("../ui");
|
|
15
|
+
const FUNCTIONS_DIR = 'functions';
|
|
16
|
+
/** Что функции просят у окружения: `process.env.NAME` и `process.env['NAME']`. */
|
|
17
|
+
const ENV_REFERENCE = /process\.env(?:\.([A-Z0-9_]+)|\[['"]([A-Z0-9_]+)['"]\])/g;
|
|
18
|
+
/** Свои переменные платформа кладёт сама, спрашивать их у пользователя незачем. */
|
|
19
|
+
const PROVIDED = new Set([
|
|
20
|
+
'XFLOW_PROJECT_ID',
|
|
21
|
+
'XFLOW_PROJECT_TOKEN',
|
|
22
|
+
'XFLOW_PROJECT_SCHEMA',
|
|
23
|
+
'XFLOW_FUNCTION_NAME',
|
|
24
|
+
'XFLOW_API_URL',
|
|
25
|
+
'DATABASE_URL',
|
|
26
|
+
'NODE_ENV',
|
|
27
|
+
]);
|
|
28
|
+
function sourceFiles(dir, found = []) {
|
|
29
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
30
|
+
return found;
|
|
31
|
+
for (const entry of (0, node_fs_1.readdirSync)(dir)) {
|
|
32
|
+
const full = (0, node_path_1.join)(dir, entry);
|
|
33
|
+
if ((0, node_fs_1.statSync)(full).isDirectory())
|
|
34
|
+
sourceFiles(full, found);
|
|
35
|
+
else if (/\.(ts|js|mjs)$/.test(entry))
|
|
36
|
+
found.push(full);
|
|
37
|
+
}
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
/** Какие переменные упоминают функции проекта, и в какой из них. */
|
|
41
|
+
function referencedByFunctions(root) {
|
|
42
|
+
const references = new Map();
|
|
43
|
+
for (const file of sourceFiles((0, node_path_1.join)(root, FUNCTIONS_DIR))) {
|
|
44
|
+
const functionName = file.slice((0, node_path_1.join)(root, FUNCTIONS_DIR).length + 1).split(/[\\/]/)[0];
|
|
45
|
+
const code = (0, node_fs_1.readFileSync)(file, 'utf-8');
|
|
46
|
+
for (const match of code.matchAll(ENV_REFERENCE)) {
|
|
47
|
+
const name = match[1] || match[2];
|
|
48
|
+
if (!name || PROVIDED.has(name))
|
|
49
|
+
continue;
|
|
50
|
+
const users = references.get(name) ?? [];
|
|
51
|
+
if (!users.includes(functionName))
|
|
52
|
+
users.push(functionName);
|
|
53
|
+
references.set(name, users);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return references;
|
|
57
|
+
}
|
|
58
|
+
async function fetchVariables() {
|
|
59
|
+
const { root, config } = (0, config_1.requireProject)();
|
|
60
|
+
const client = (0, session_1.connect)(config);
|
|
61
|
+
const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/env`);
|
|
62
|
+
return { names: new Set(data.variables.map((row) => row.name)), rows: data.variables, root };
|
|
63
|
+
}
|
|
64
|
+
async function envList() {
|
|
65
|
+
const { rows } = await fetchVariables();
|
|
66
|
+
if (rows.length === 0) {
|
|
67
|
+
(0, ui_1.note)('Переменных нет');
|
|
68
|
+
(0, ui_1.note)((0, ui_1.dim)(' Записать: xflow env set SMTP_PASSWORD=секрет'));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
(0, ui_1.table)(rows.map((row) => [row.name, row.scope === 'project' ? 'только этот проект' : 'вся организация']));
|
|
72
|
+
(0, ui_1.note)((0, ui_1.dim)(' Значения платформа не отдаёт: их видно только внутри функции'));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Сверить, хватает ли функциям переменных.
|
|
76
|
+
*
|
|
77
|
+
* Это ответ на самый частый способ потерять полчаса: функция выкатилась, а падает
|
|
78
|
+
* на пустом process.env, потому что переменную забыли записать наверх.
|
|
79
|
+
*/
|
|
80
|
+
async function envCheck() {
|
|
81
|
+
const { names, root } = await fetchVariables();
|
|
82
|
+
const referenced = referencedByFunctions(root);
|
|
83
|
+
if (referenced.size === 0) {
|
|
84
|
+
(0, ui_1.note)('Функции проекта не читают переменных окружения');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const missing = [];
|
|
88
|
+
const present = [];
|
|
89
|
+
for (const [name, users] of [...referenced.entries()].sort()) {
|
|
90
|
+
const row = [name, users.join(', ')];
|
|
91
|
+
if (names.has(name))
|
|
92
|
+
present.push(row);
|
|
93
|
+
else
|
|
94
|
+
missing.push(row);
|
|
95
|
+
}
|
|
96
|
+
if (present.length > 0) {
|
|
97
|
+
(0, ui_1.out)((0, ui_1.bold)('На платформе:'));
|
|
98
|
+
(0, ui_1.table)(present);
|
|
99
|
+
}
|
|
100
|
+
if (missing.length === 0) {
|
|
101
|
+
(0, ui_1.ok)('Всем функциям хватает переменных');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
(0, ui_1.out)('');
|
|
105
|
+
(0, ui_1.fail)('Нет на платформе:');
|
|
106
|
+
(0, ui_1.table)(missing);
|
|
107
|
+
throw new errors_1.CliError(`Переменных не хватает: ${missing.length}`, 'Записать: xflow env set ИМЯ=значение. Пока их нет, функция получит undefined');
|
|
108
|
+
}
|
|
109
|
+
async function envSet(args) {
|
|
110
|
+
const { root, config } = (0, config_1.requireProject)();
|
|
111
|
+
const client = (0, session_1.connect)(config);
|
|
112
|
+
const pair = args.words[1];
|
|
113
|
+
if (!pair || !pair.includes('=')) {
|
|
114
|
+
throw new errors_1.CliError('Нужна пара ИМЯ=значение', 'Например: xflow env set SMTP_PASSWORD=секрет');
|
|
115
|
+
}
|
|
116
|
+
const name = pair.slice(0, pair.indexOf('=')).trim();
|
|
117
|
+
const value = pair.slice(pair.indexOf('=') + 1);
|
|
118
|
+
const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/env`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
body: { name, value, scope: (0, args_1.flagString)(args, 'scope') === 'project' ? 'project' : 'organization' },
|
|
121
|
+
});
|
|
122
|
+
(0, ui_1.ok)(`${(0, ui_1.bold)(result.name)} записана (${result.scope === 'project' ? 'только этот проект' : 'вся организация'})`);
|
|
123
|
+
// Значение попадает в функцию на выкатке, а не в момент записи: пока функцию
|
|
124
|
+
// не передеплоили, в её окружении лежит прежнее.
|
|
125
|
+
const users = referencedByFunctions(root).get(name);
|
|
126
|
+
if (users && users.length > 0) {
|
|
127
|
+
(0, ui_1.note)((0, ui_1.dim)(` Чтобы значение доехало, перевыложите: xflow functions deploy ${users.join(' && xflow functions deploy ')}`));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function envRemove(args) {
|
|
131
|
+
const { config } = (0, config_1.requireProject)();
|
|
132
|
+
const client = (0, session_1.connect)(config);
|
|
133
|
+
const name = args.words[1];
|
|
134
|
+
if (!name)
|
|
135
|
+
throw new errors_1.CliError('Нужно имя переменной', 'Что записано: xflow env');
|
|
136
|
+
const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/env?name=${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
137
|
+
if (!result.removed) {
|
|
138
|
+
(0, ui_1.note)(`Переменной ${name} нет`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
(0, ui_1.ok)(`${name} удалена`);
|
|
142
|
+
(0, ui_1.note)((0, ui_1.dim)(' В уже выложенных функциях значение останется до следующей выкатки'));
|
|
143
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.functionsList = functionsList;
|
|
4
|
+
exports.refreshFunctionsEnv = refreshFunctionsEnv;
|
|
4
5
|
exports.functionsInvoke = functionsInvoke;
|
|
5
6
|
exports.functionsDeploy = functionsDeploy;
|
|
6
7
|
const node_fs_1 = require("node:fs");
|
|
@@ -10,6 +11,7 @@ const args_1 = require("../args");
|
|
|
10
11
|
const config_1 = require("../config");
|
|
11
12
|
const errors_1 = require("../errors");
|
|
12
13
|
const session_1 = require("../session");
|
|
14
|
+
const template_1 = require("../template");
|
|
13
15
|
const ui_1 = require("../ui");
|
|
14
16
|
const ENTRY_NAMES = ['index.ts', 'index.js', 'index.mjs'];
|
|
15
17
|
/** Папка с функциями внутри проекта. Та же, что была до пивота: менять её незачем. */
|
|
@@ -79,6 +81,26 @@ async function functionsList() {
|
|
|
79
81
|
fn.error_message ?? fn.invoke_url ?? '',
|
|
80
82
|
]));
|
|
81
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Записать адреса функций в `.env`, откуда их заберёт сборщик.
|
|
86
|
+
*
|
|
87
|
+
* Подставляем на сборке, а не спрашиваем у платформы в рантайме: иначе каждый
|
|
88
|
+
* запуск приложения начинался бы с похода к нам, и мы стали бы обязательным
|
|
89
|
+
* участником работы чужого приложения.
|
|
90
|
+
*/
|
|
91
|
+
async function refreshFunctionsEnv(root, client, projectId) {
|
|
92
|
+
const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
|
|
93
|
+
// Файла может не быть вовсе: в исходники он не уходит, а клон делают из git.
|
|
94
|
+
// Дописывать в такой один адрес функции бессмысленно — без токена вызов всё
|
|
95
|
+
// равно получит 401, поэтому восстанавливаем целиком, как это делает link.
|
|
96
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(root, '.env')) && card.project_token) {
|
|
97
|
+
(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');
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
(0, template_1.writeEnvValue)(root, template_1.FUNCTIONS_ENV_KEY, (0, template_1.functionsEnvValue)(card.functions));
|
|
101
|
+
}
|
|
102
|
+
return card.functions.filter((fn) => fn.invoke_url).map((fn) => fn.name);
|
|
103
|
+
}
|
|
82
104
|
/** Ответ функции: JSON разворачиваем, прочее отдаём как есть. */
|
|
83
105
|
function prettyBody(text) {
|
|
84
106
|
try {
|
|
@@ -160,4 +182,6 @@ async function functionsDeploy(args) {
|
|
|
160
182
|
(0, ui_1.note)((0, ui_1.dim)(` Секреты организации в окружении: ${result.secrets.join(', ')}`));
|
|
161
183
|
}
|
|
162
184
|
}
|
|
185
|
+
const available = await refreshFunctionsEnv(root, client, config.projectId);
|
|
186
|
+
(0, ui_1.note)((0, ui_1.dim)(` Адреса в .env обновлены (${available.join(', ')}). Во фронтенде: xflow.functions.invoke('${names[0]}')`));
|
|
163
187
|
}
|
package/dist/commands/logs.js
CHANGED
|
@@ -34,7 +34,7 @@ function render(rows) {
|
|
|
34
34
|
for (const line of lines.slice(0, STACK_MAX_LINES))
|
|
35
35
|
(0, ui_1.out)((0, ui_1.dim)(` ${line}`));
|
|
36
36
|
if (lines.length > STACK_MAX_LINES)
|
|
37
|
-
(0, ui_1.out)((0, ui_1.dim)(` … ещё ${lines.length - STACK_MAX_LINES}
|
|
37
|
+
(0, ui_1.out)((0, ui_1.dim)(` … ещё строк: ${lines.length - STACK_MAX_LINES}`));
|
|
38
38
|
}
|
|
39
39
|
(0, ui_1.out)('');
|
|
40
40
|
}
|
|
@@ -128,8 +128,8 @@ async function link(args) {
|
|
|
128
128
|
// В исходники .env не уходит, поэтому в свежем клоне его нет вовсе, и приложение
|
|
129
129
|
// молча теряет доступ к облачным функциям. Восстанавливаем, но чужой не трогаем.
|
|
130
130
|
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(dir, '.env')) && card.project_token) {
|
|
131
|
-
write(dir, '.env', (0, template_1.envFile)(card.project_token, client.apiUrl));
|
|
132
|
-
(0, ui_1.note)((0, ui_1.dim)(' Создан .env с токеном проекта'));
|
|
131
|
+
write(dir, '.env', (0, template_1.envFile)(card.project_token, client.apiUrl, card.functions));
|
|
132
|
+
(0, ui_1.note)((0, ui_1.dim)(' Создан .env с токеном проекта и адресами функций'));
|
|
133
133
|
}
|
|
134
134
|
(0, ui_1.ok)(`Папка связана с проектом «${card.name}»`);
|
|
135
135
|
(0, ui_1.note)((0, ui_1.dim)(` ${(0, node_path_1.join)(dir, config_1.CONFIG_FILE)}`));
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.schedulesList = schedulesList;
|
|
4
|
+
exports.schedulesSet = schedulesSet;
|
|
5
|
+
exports.schedulesRemove = schedulesRemove;
|
|
6
|
+
const api_1 = require("../api");
|
|
7
|
+
const args_1 = require("../args");
|
|
8
|
+
const config_1 = require("../config");
|
|
9
|
+
const errors_1 = require("../errors");
|
|
10
|
+
const session_1 = require("../session");
|
|
11
|
+
const ui_1 = require("../ui");
|
|
12
|
+
async function schedulesList() {
|
|
13
|
+
const { config } = (0, config_1.requireProject)();
|
|
14
|
+
const client = (0, session_1.connect)(config);
|
|
15
|
+
const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/schedules`);
|
|
16
|
+
if (data.schedules.length === 0) {
|
|
17
|
+
(0, ui_1.note)('Расписаний нет');
|
|
18
|
+
(0, ui_1.note)((0, ui_1.dim)(' Запускать функцию по времени: xflow schedules set <функция> "0 3 ? * * *"'));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
(0, ui_1.table)(data.schedules.map((row) => [
|
|
22
|
+
row.function,
|
|
23
|
+
row.cron,
|
|
24
|
+
row.description === row.cron ? '' : row.description,
|
|
25
|
+
row.status === 'active' ? '' : (row.error ?? row.status),
|
|
26
|
+
]));
|
|
27
|
+
}
|
|
28
|
+
async function schedulesSet(args) {
|
|
29
|
+
const { config } = (0, config_1.requireProject)();
|
|
30
|
+
const client = (0, session_1.connect)(config);
|
|
31
|
+
const functionName = args.words[1];
|
|
32
|
+
const cron = args.words[2];
|
|
33
|
+
if (!functionName || !cron) {
|
|
34
|
+
throw new errors_1.CliError('Нужны имя функции и расписание', 'Например: xflow schedules set nightly-report "0 3 ? * * *" — каждый день в 03:00 UTC');
|
|
35
|
+
}
|
|
36
|
+
const row = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/schedules`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
body: { function: functionName, cron, payload: (0, args_1.flagString)(args, 'payload') ?? null },
|
|
39
|
+
});
|
|
40
|
+
(0, ui_1.ok)(`${(0, ui_1.bold)(row.function)} запускается ${row.description}`);
|
|
41
|
+
(0, ui_1.note)((0, ui_1.dim)(` Время в UTC. Проверить вручную: xflow functions invoke ${row.function}`));
|
|
42
|
+
}
|
|
43
|
+
async function schedulesRemove(args) {
|
|
44
|
+
const { config } = (0, config_1.requireProject)();
|
|
45
|
+
const client = (0, session_1.connect)(config);
|
|
46
|
+
const functionName = args.words[1];
|
|
47
|
+
if (!functionName) {
|
|
48
|
+
throw new errors_1.CliError('Нужно имя функции', 'Что запускается по времени: xflow schedules list');
|
|
49
|
+
}
|
|
50
|
+
const cron = args.words[2];
|
|
51
|
+
const query = new URLSearchParams({ function: functionName });
|
|
52
|
+
if (cron)
|
|
53
|
+
query.set('cron', cron);
|
|
54
|
+
const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/schedules?${query.toString()}`, { method: 'DELETE' });
|
|
55
|
+
if (result.removed === 0) {
|
|
56
|
+
(0, ui_1.note)(`У функции ${functionName} нет такого расписания`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
(0, ui_1.ok)(`Снято расписаний: ${result.removed}`);
|
|
60
|
+
}
|
package/dist/help.js
CHANGED
|
@@ -12,154 +12,231 @@ function help(topic) {
|
|
|
12
12
|
(0, ui_1.out)(TOPICS[topic]);
|
|
13
13
|
return;
|
|
14
14
|
}
|
|
15
|
-
(0, ui_1.out)(`${(0, ui_1.bold)('xflow')} ${(0, ui_1.dim)(version_1.CLI_VERSION)} — хостинг приложений XFlow
|
|
16
|
-
|
|
17
|
-
${(0, ui_1.bold)('Начало работы')}
|
|
18
|
-
xflow login вход через браузер
|
|
19
|
-
xflow init [папка] новый проект из шаблона платформы
|
|
20
|
-
xflow templates какие шаблоны доступны
|
|
21
|
-
xflow link <id> связать текущую папку с существующим проектом
|
|
22
|
-
xflow skills [--global] положить инструкцию о платформе для ИИ-агента
|
|
23
|
-
|
|
24
|
-
${(0, ui_1.bold)('Код')}
|
|
25
|
-
xflow status что на сервере и чем отличается локальная копия
|
|
26
|
-
xflow push [--force] отправить исходники новой ревизией
|
|
27
|
-
xflow pull [--into dir] [--revision N]
|
|
28
|
-
забрать исходники (по умолчанию последнюю ревизию)
|
|
29
|
-
|
|
30
|
-
${(0, ui_1.bold)('Выкладка')}
|
|
31
|
-
xflow deploy [--no-push] [--skip-build]
|
|
32
|
-
отправить код, собрать локально и выложить на dev
|
|
33
|
-
xflow publish показать dev-версию посетителям
|
|
34
|
-
xflow rollback <номер версии> вернуть dev-адрес на прошлую версию
|
|
35
|
-
xflow deployments история версий
|
|
36
|
-
|
|
37
|
-
${(0, ui_1.bold)('Функции')}
|
|
38
|
-
xflow functions list что выложено
|
|
39
|
-
xflow functions deploy [имя] собрать и выкатить функцию
|
|
40
|
-
xflow functions invoke <имя> [--data '{"a":1}']
|
|
41
|
-
вызвать функцию и показать ответ
|
|
42
|
-
xflow functions logs [имя] падения функций: стек и вывод консоли
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
xflow
|
|
47
|
-
xflow
|
|
48
|
-
xflow
|
|
49
|
-
xflow
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
15
|
+
(0, ui_1.out)(`${(0, ui_1.bold)('xflow')} ${(0, ui_1.dim)(version_1.CLI_VERSION)} — хостинг приложений XFlow
|
|
16
|
+
|
|
17
|
+
${(0, ui_1.bold)('Начало работы')}
|
|
18
|
+
xflow login вход через браузер
|
|
19
|
+
xflow init [папка] новый проект из шаблона платформы
|
|
20
|
+
xflow templates какие шаблоны доступны
|
|
21
|
+
xflow link <id> связать текущую папку с существующим проектом
|
|
22
|
+
xflow skills [--global] положить инструкцию о платформе для ИИ-агента
|
|
23
|
+
|
|
24
|
+
${(0, ui_1.bold)('Код')}
|
|
25
|
+
xflow status что на сервере и чем отличается локальная копия
|
|
26
|
+
xflow push [--force] отправить исходники новой ревизией
|
|
27
|
+
xflow pull [--into dir] [--revision N]
|
|
28
|
+
забрать исходники (по умолчанию последнюю ревизию)
|
|
29
|
+
|
|
30
|
+
${(0, ui_1.bold)('Выкладка')}
|
|
31
|
+
xflow deploy [--no-push] [--skip-build]
|
|
32
|
+
отправить код, собрать локально и выложить на dev
|
|
33
|
+
xflow publish показать dev-версию посетителям
|
|
34
|
+
xflow rollback <номер версии> вернуть dev-адрес на прошлую версию
|
|
35
|
+
xflow deployments история версий
|
|
36
|
+
|
|
37
|
+
${(0, ui_1.bold)('Функции')}
|
|
38
|
+
xflow functions list что выложено
|
|
39
|
+
xflow functions deploy [имя] собрать и выкатить функцию
|
|
40
|
+
xflow functions invoke <имя> [--data '{"a":1}']
|
|
41
|
+
вызвать функцию и показать ответ
|
|
42
|
+
xflow functions logs [имя] падения функций: стек и вывод консоли
|
|
43
|
+
xflow schedules list что запускается по времени
|
|
44
|
+
xflow schedules set <имя> "0 3 ? * * *"
|
|
45
|
+
запускать функцию по расписанию (UTC)
|
|
46
|
+
xflow schedules rm <имя> снять расписание
|
|
47
|
+
xflow env [check] переменные функций: что записано, чего не хватает
|
|
48
|
+
xflow env set ИМЯ=значение записать переменную
|
|
49
|
+
xflow env rm ИМЯ удалить переменную
|
|
50
|
+
|
|
51
|
+
${(0, ui_1.bold)('База данных')}
|
|
52
|
+
xflow db status какие миграции применены, а какие ждут
|
|
53
|
+
xflow db migrate [--dry-run] [--allow-destructive]
|
|
54
|
+
применить миграции из migrations/*.sql
|
|
55
|
+
|
|
56
|
+
${(0, ui_1.bold)('Справочное')}
|
|
57
|
+
xflow logs [--limit N] ошибки выложенного приложения в браузере
|
|
58
|
+
xflow projects list проекты организации
|
|
59
|
+
xflow projects get [id] карточка проекта
|
|
60
|
+
xflow open [--live] открыть приложение в браузере
|
|
61
|
+
xflow whoami чей ключ и что он может
|
|
62
|
+
xflow logout забыть ключ
|
|
63
|
+
|
|
64
|
+
${(0, ui_1.bold)('Окружение')}
|
|
65
|
+
XFLOW_TOKEN ключ доступа (для CI: вместо xflow login)
|
|
66
|
+
XFLOW_API_URL адрес платформы, если он не app.getxflow.com
|
|
67
|
+
|
|
56
68
|
Подробнее о команде: xflow help <команда>`);
|
|
57
69
|
}
|
|
58
70
|
const TOPICS = {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
${(0, ui_1.bold)('
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
db: `${(0, ui_1.bold)('xflow db migrate')} — применить миграции
|
|
72
|
+
|
|
73
|
+
Файлы лежат в репозитории: ${(0, ui_1.bold)('migrations/0001_init.sql')}, ${(0, ui_1.bold)('migrations/0002_orders.sql')} и так
|
|
74
|
+
далее. Порядок — по имени файла, поэтому номер в начале обязателен. Каждая миграция
|
|
75
|
+
применяется своей транзакцией, история хранится в самой базе, и повторно применённое
|
|
76
|
+
не запускается.
|
|
77
|
+
|
|
78
|
+
--dry-run показать, что применится, и не трогать базу
|
|
79
|
+
--allow-destructive разрешить операции, уничтожающие данные
|
|
80
|
+
|
|
81
|
+
Про разрушающие. Истории базы платформа не хранит и бэкапов не делает, поэтому
|
|
82
|
+
${(0, ui_1.bold)('DROP TABLE')}, ${(0, ui_1.bold)('DROP COLUMN')}, ${(0, ui_1.bold)('TRUNCATE')} и ${(0, ui_1.bold)('DELETE FROM')} без условия
|
|
83
|
+
отклоняются без явного флага. С флагом содержимое затронутых таблиц уезжает в дамп
|
|
84
|
+
и хранится 7 суток — это страховка «сразу заметил», а не резервная копия.
|
|
85
|
+
${(0, ui_1.bold)('DROP DATABASE')} не пропускается никогда: база общая для организации.
|
|
86
|
+
|
|
87
|
+
Изменять уже применённый файл бесполезно: сверка идёт по имени, а не по
|
|
88
|
+
содержимому. ${(0, ui_1.bold)('xflow db status')} такой файл покажет отдельно — заводите новую
|
|
89
|
+
миграцию.
|
|
90
|
+
|
|
91
|
+
Одна логическая база может быть привязана к нескольким проектам, поэтому в истории
|
|
92
|
+
попадаются миграции, которых нет в вашем репозитории. Это нормально, но означает,
|
|
93
|
+
что ваша миграция может сломать чужое приложение.`,
|
|
94
|
+
logs: `${(0, ui_1.bold)('xflow logs')} и ${(0, ui_1.bold)('xflow functions logs')} — ошибки проекта
|
|
95
|
+
|
|
96
|
+
Один поток на проект: падения выложенного приложения в браузере и падения
|
|
97
|
+
облачных функций. Хранятся последние 200 записей, дальше вытесняются.
|
|
98
|
+
|
|
99
|
+
--limit N сколько показать (по умолчанию 10)
|
|
100
|
+
|
|
101
|
+
Функция сообщает о падении сама: свои логи Yandex наружу отдаёт только по gRPC,
|
|
102
|
+
поэтому обёртка ловит исключение, уносит вместе с ним хвост консоли (последние 40
|
|
103
|
+
строк ${(0, ui_1.bold)('console.log')} этого вызова) и отправляет на платформу. Успешные вызовы не
|
|
104
|
+
пишут ничего: иначе каждый запрос платил бы за это задержкой.
|
|
105
|
+
|
|
106
|
+
Не попадёт сюда: падение на старте модуля (функция не успевает дойти до обёртки),
|
|
107
|
+
превышение 30 секунд и нехватка памяти. Такое видно ответом на
|
|
108
|
+
${(0, ui_1.bold)('xflow functions invoke')}.
|
|
109
|
+
|
|
110
|
+
Ошибки браузера собирает ${(0, ui_1.bold)('src/utils/error-logger.ts')} шаблона и только с
|
|
76
111
|
выложенных адресов: локальный ${(0, ui_1.bold)('npm run dev')} сюда не пишет.`,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
112
|
+
env: `${(0, ui_1.bold)('xflow env')} — переменные окружения функций
|
|
113
|
+
|
|
114
|
+
Ключи, пароли и адреса чужих сервисов хранит платформа, а не репозиторий.
|
|
115
|
+
Значение уходит наверх и обратно не возвращается: увидеть его можно только внутри
|
|
116
|
+
функции. Так что своё же значение придётся хранить там, где вы его взяли.
|
|
117
|
+
|
|
118
|
+
xflow env что записано (имена, не значения)
|
|
119
|
+
xflow env check чего не хватает функциям этого проекта
|
|
120
|
+
xflow env set SMTP_PASSWORD=… записать
|
|
121
|
+
xflow env rm SMTP_PASSWORD удалить
|
|
122
|
+
|
|
123
|
+
--scope project видно только этому проекту, а не всей организации
|
|
124
|
+
|
|
125
|
+
${(0, ui_1.bold)('check')} читает исходники в ${(0, ui_1.bold)('functions/')} и ищет обращения ${(0, ui_1.bold)('process.env.ИМЯ')}. Это же
|
|
126
|
+
правило действует на выкатке: функция получает только те переменные, которые сама
|
|
127
|
+
упоминает по имени. Имя, собранное выражением (${(0, ui_1.bold)("process.env['KEY_' + n]")}), в окружение
|
|
128
|
+
не попадёт — читайте переменные буквально.
|
|
129
|
+
|
|
130
|
+
Значение доезжает до функции на выкатке, а не в момент записи: после ${(0, ui_1.bold)('env set')} нужно
|
|
131
|
+
${(0, ui_1.bold)('xflow functions deploy')} затронутых функций. То же и после удаления: в уже выложенной
|
|
132
|
+
функции старое значение останется до следующей выкатки.`,
|
|
133
|
+
schedules: `${(0, ui_1.bold)('xflow schedules')} — запуск функций по времени
|
|
134
|
+
|
|
135
|
+
Расписание это таймер-триггер Yandex: он зовёт функцию сам, без участия
|
|
136
|
+
приложения и браузера.
|
|
137
|
+
|
|
138
|
+
xflow schedules set отчёт "0 3 ? * * *" каждый день в 03:00
|
|
139
|
+
xflow schedules set сводка "0 */4 ? * * *" каждые 4 часа
|
|
140
|
+
xflow schedules rm отчёт снять все расписания функции
|
|
141
|
+
|
|
142
|
+
Формат из шести полей: ${(0, ui_1.bold)('минуты часы день-месяца месяц день-недели год')}. Ровно одно
|
|
143
|
+
из полей «День месяца» и «День недели» должно быть ${(0, ui_1.bold)('?')} — это не наша причуда, так
|
|
144
|
+
устроен Яндекс. ${(0, ui_1.bold)('Время всегда UTC')}, местное он не знает.
|
|
145
|
+
|
|
146
|
+
--payload '{"режим":"полный"}' тело, которое получит функция
|
|
147
|
+
|
|
148
|
+
Запуск по расписанию приходит хендлеру как POST без заголовков, и токен проекта
|
|
149
|
+
у него не проверяется: снаружи такое событие не подделать. Функция при этом должна
|
|
150
|
+
быть уже выложена — расписание ссылается на неё, а не наоборот.
|
|
151
|
+
|
|
152
|
+
Упавший запуск виден в ${(0, ui_1.bold)('xflow functions logs')}: по расписанию за функцией никто не
|
|
153
|
+
смотрит, поэтому обёртка отчитывается о падении так же, как при обычном вызове.`,
|
|
154
|
+
invoke: `${(0, ui_1.bold)('xflow functions invoke')} <имя> — вызвать функцию
|
|
155
|
+
|
|
156
|
+
Вызывает так же, как её вызывает приложение: с заголовком X-Project-Token.
|
|
157
|
+
Токен берётся из карточки проекта на платформе, локальный .env не нужен.
|
|
158
|
+
|
|
159
|
+
--data '{"a":1}' тело запроса (по умолчанию метод становится POST)
|
|
160
|
+
--method GET другой метод
|
|
161
|
+
|
|
162
|
+
Печатает статус, время ответа и тело. Ненулевой код возврата на статусе 4xx и
|
|
163
|
+
5xx: в CI такой вызов должен ронять шаг. Причину падения показывает
|
|
87
164
|
${(0, ui_1.bold)('xflow functions logs <имя>')}.`,
|
|
88
|
-
skills: `${(0, ui_1.bold)('xflow skills')} — инструкция о платформе для ИИ-агента
|
|
89
|
-
|
|
90
|
-
Кладёт файл в формате Agent Skills (agentskills.io): что такое XFlow, как выкладывать
|
|
91
|
-
и публиковать, откуда брать компоненты дизайн-системы, где смотреть ошибки прода.
|
|
92
|
-
Агент подхватывает его сам, когда задача про платформу, и не тратит контекст в
|
|
93
|
-
остальное время.
|
|
94
|
-
|
|
95
|
-
Файл общий для инструментов, а папки у них разные, поэтому кладём в обе:
|
|
96
|
-
${(0, ui_1.bold)('.claude/skills/xflow/')} читает Claude Code, ${(0, ui_1.bold)('.agents/skills/xflow/')} — Codex и OpenClaw.
|
|
97
|
-
|
|
98
|
-
--global положить в домашнюю папку, тогда скилл виден во всех проектах
|
|
99
|
-
|
|
100
|
-
${(0, ui_1.bold)('AGENTS.md')} и ${(0, ui_1.bold)('CLAUDE.md')} команда не трогает: это ваши файлы, в них ваши правила.
|
|
101
|
-
Наше знание живёт в своей папке и переустанавливается поверх без спора за файл.
|
|
102
|
-
|
|
165
|
+
skills: `${(0, ui_1.bold)('xflow skills')} — инструкция о платформе для ИИ-агента
|
|
166
|
+
|
|
167
|
+
Кладёт файл в формате Agent Skills (agentskills.io): что такое XFlow, как выкладывать
|
|
168
|
+
и публиковать, откуда брать компоненты дизайн-системы, где смотреть ошибки прода.
|
|
169
|
+
Агент подхватывает его сам, когда задача про платформу, и не тратит контекст в
|
|
170
|
+
остальное время.
|
|
171
|
+
|
|
172
|
+
Файл общий для инструментов, а папки у них разные, поэтому кладём в обе:
|
|
173
|
+
${(0, ui_1.bold)('.claude/skills/xflow/')} читает Claude Code, ${(0, ui_1.bold)('.agents/skills/xflow/')} — Codex и OpenClaw.
|
|
174
|
+
|
|
175
|
+
--global положить в домашнюю папку, тогда скилл виден во всех проектах
|
|
176
|
+
|
|
177
|
+
${(0, ui_1.bold)('AGENTS.md')} и ${(0, ui_1.bold)('CLAUDE.md')} команда не трогает: это ваши файлы, в них ваши правила.
|
|
178
|
+
Наше знание живёт в своей папке и переустанавливается поверх без спора за файл.
|
|
179
|
+
|
|
103
180
|
Скилл едет вместе с CLI, поэтому после обновления пакета команду стоит повторить.`,
|
|
104
|
-
push: `${(0, ui_1.bold)('xflow push')} — отправить исходники
|
|
105
|
-
|
|
106
|
-
Уходит вся рабочая копия целиком, одной ревизией. Не отправляются: node_modules,
|
|
107
|
-
.git, dist, build, .next, любые .env, а также всё, что перечислено в .xflowignore
|
|
108
|
-
и в поле ignore файла xflow.json.
|
|
109
|
-
|
|
110
|
-
Если на сервере ревизия новее той, от которой вы работали, push отклоняется.
|
|
111
|
-
Это значит, что кто-то запушил раньше: заберите его правки рядом
|
|
112
|
-
(${(0, ui_1.bold)('xflow pull --into ./server-copy')}), сведите их у себя в git и повторите.
|
|
113
|
-
|
|
114
|
-
--force перетирает серверную ревизию. Перед этим CLI показывает, чью работу вы
|
|
115
|
-
затираете, и просит подтверждение вводом имени проекта. В неинтерактивном режиме
|
|
116
|
-
(CI, запуск агентом) подтвердить нельзя: расхождение версий должно ронять сборку,
|
|
181
|
+
push: `${(0, ui_1.bold)('xflow push')} — отправить исходники
|
|
182
|
+
|
|
183
|
+
Уходит вся рабочая копия целиком, одной ревизией. Не отправляются: node_modules,
|
|
184
|
+
.git, dist, build, .next, любые .env, а также всё, что перечислено в .xflowignore
|
|
185
|
+
и в поле ignore файла xflow.json.
|
|
186
|
+
|
|
187
|
+
Если на сервере ревизия новее той, от которой вы работали, push отклоняется.
|
|
188
|
+
Это значит, что кто-то запушил раньше: заберите его правки рядом
|
|
189
|
+
(${(0, ui_1.bold)('xflow pull --into ./server-copy')}), сведите их у себя в git и повторите.
|
|
190
|
+
|
|
191
|
+
--force перетирает серверную ревизию. Перед этим CLI показывает, чью работу вы
|
|
192
|
+
затираете, и просит подтверждение вводом имени проекта. В неинтерактивном режиме
|
|
193
|
+
(CI, запуск агентом) подтвердить нельзя: расхождение версий должно ронять сборку,
|
|
117
194
|
а не молча уничтожать чужую работу.`,
|
|
118
|
-
pull: `${(0, ui_1.bold)('xflow pull')} — забрать исходники
|
|
119
|
-
|
|
120
|
-
По умолчанию забирает последнюю ревизию в папку проекта и отказывается писать в
|
|
121
|
-
непустую: слить изменения платформа не умеет, это работа git.
|
|
122
|
-
|
|
123
|
-
--into <папка> выгрузить рядом, чтобы сравнить
|
|
124
|
-
--revision <N> конкретная ревизия (список: xflow deployments)
|
|
195
|
+
pull: `${(0, ui_1.bold)('xflow pull')} — забрать исходники
|
|
196
|
+
|
|
197
|
+
По умолчанию забирает последнюю ревизию в папку проекта и отказывается писать в
|
|
198
|
+
непустую: слить изменения платформа не умеет, это работа git.
|
|
199
|
+
|
|
200
|
+
--into <папка> выгрузить рядом, чтобы сравнить
|
|
201
|
+
--revision <N> конкретная ревизия (список: xflow deployments)
|
|
125
202
|
--force перезаписать папку целиком`,
|
|
126
|
-
deploy: `${(0, ui_1.bold)('xflow deploy')} — собрать и выложить
|
|
127
|
-
|
|
128
|
-
Три шага: отправка исходников, локальная сборка, заливка результата. Команда
|
|
129
|
-
сборки и каталог результата берутся из xflow.json (по умолчанию npm run build
|
|
130
|
-
и dist).
|
|
131
|
-
|
|
132
|
-
--no-push не отправлять исходники, выложить из последней серверной ревизии
|
|
133
|
-
--skip-build не собирать, взять готовый каталог сборки
|
|
134
|
-
--force разрешить перезапись серверной ревизии при отправке
|
|
135
|
-
|
|
136
|
-
Сборка идёт на вашей машине, поэтому её результат зависит от вашей версии Node.
|
|
137
|
-
Платформа раздаёт статику: сборка без index.html в корне отклоняется.
|
|
138
|
-
|
|
203
|
+
deploy: `${(0, ui_1.bold)('xflow deploy')} — собрать и выложить
|
|
204
|
+
|
|
205
|
+
Три шага: отправка исходников, локальная сборка, заливка результата. Команда
|
|
206
|
+
сборки и каталог результата берутся из xflow.json (по умолчанию npm run build
|
|
207
|
+
и dist).
|
|
208
|
+
|
|
209
|
+
--no-push не отправлять исходники, выложить из последней серверной ревизии
|
|
210
|
+
--skip-build не собирать, взять готовый каталог сборки
|
|
211
|
+
--force разрешить перезапись серверной ревизии при отправке
|
|
212
|
+
|
|
213
|
+
Сборка идёт на вашей машине, поэтому её результат зависит от вашей версии Node.
|
|
214
|
+
Платформа раздаёт статику: сборка без index.html в корне отклоняется.
|
|
215
|
+
|
|
139
216
|
Выложенное видно только по dev-адресу. Посетителям — после ${(0, ui_1.bold)('xflow publish')}.`,
|
|
140
|
-
init: `${(0, ui_1.bold)('xflow init')} [папка] — новый проект
|
|
141
|
-
|
|
142
|
-
Разворачивает шаблон платформы: React на Vite, Tailwind, набор компонентов
|
|
143
|
-
(${(0, ui_1.bold)('src/components/ui')}) и готовые блоки (${(0, ui_1.bold)('src/components/blocks')}) — таблица,
|
|
144
|
-
форма, фильтры, канбан, графики. Тот же шаблон получают проекты, созданные в вебе,
|
|
145
|
-
поэтому приложения выглядят одинаково.
|
|
146
|
-
|
|
147
|
-
Строить интерфейс поверх этих компонентов и токенов из ${(0, ui_1.bold)('src/index.css')} — не
|
|
148
|
-
формальность: своя палитра поверх них выглядит чужеродно внутри платформы.
|
|
149
|
-
|
|
150
|
-
--name <имя> имя проекта (по умолчанию — имя папки)
|
|
151
|
-
--template <id> другой шаблон (список: xflow templates)
|
|
152
|
-
--database <id> привязать логическую базу организации
|
|
153
|
-
|
|
154
|
-
Порядок: проект заводится на платформе первым, потому что без него неоткуда взять
|
|
155
|
-
токен для .env. Если запись файлов сорвётся, проект останется пустым, и CLI скажет,
|
|
217
|
+
init: `${(0, ui_1.bold)('xflow init')} [папка] — новый проект
|
|
218
|
+
|
|
219
|
+
Разворачивает шаблон платформы: React на Vite, Tailwind, набор компонентов
|
|
220
|
+
(${(0, ui_1.bold)('src/components/ui')}) и готовые блоки (${(0, ui_1.bold)('src/components/blocks')}) — таблица,
|
|
221
|
+
форма, фильтры, канбан, графики. Тот же шаблон получают проекты, созданные в вебе,
|
|
222
|
+
поэтому приложения выглядят одинаково.
|
|
223
|
+
|
|
224
|
+
Строить интерфейс поверх этих компонентов и токенов из ${(0, ui_1.bold)('src/index.css')} — не
|
|
225
|
+
формальность: своя палитра поверх них выглядит чужеродно внутри платформы.
|
|
226
|
+
|
|
227
|
+
--name <имя> имя проекта (по умолчанию — имя папки)
|
|
228
|
+
--template <id> другой шаблон (список: xflow templates)
|
|
229
|
+
--database <id> привязать логическую базу организации
|
|
230
|
+
|
|
231
|
+
Порядок: проект заводится на платформе первым, потому что без него неоткуда взять
|
|
232
|
+
токен для .env. Если запись файлов сорвётся, проект останется пустым, и CLI скажет,
|
|
156
233
|
как подобрать его командой link.`,
|
|
157
|
-
rollback: `${(0, ui_1.bold)('xflow rollback')} <номер версии> — вернуть прошлую сборку
|
|
158
|
-
|
|
159
|
-
Переключает dev-адрес на выбранную версию. Возвращается только сборка: исходники
|
|
160
|
-
остаются на своей ревизии, и вернуть их — отдельное решение
|
|
161
|
-
(${(0, ui_1.bold)('xflow pull --revision N --into ./старая-версия')}).
|
|
162
|
-
|
|
163
|
-
Посетители продолжают видеть опубликованную версию, пока не выполнен
|
|
234
|
+
rollback: `${(0, ui_1.bold)('xflow rollback')} <номер версии> — вернуть прошлую сборку
|
|
235
|
+
|
|
236
|
+
Переключает dev-адрес на выбранную версию. Возвращается только сборка: исходники
|
|
237
|
+
остаются на своей ревизии, и вернуть их — отдельное решение
|
|
238
|
+
(${(0, ui_1.bold)('xflow pull --revision N --into ./старая-версия')}).
|
|
239
|
+
|
|
240
|
+
Посетители продолжают видеть опубликованную версию, пока не выполнен
|
|
164
241
|
${(0, ui_1.bold)('xflow publish')}.`,
|
|
165
242
|
};
|
package/dist/template.js
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Файлы, которые CLI добавляет поверх шаблона платформы.
|
|
4
|
-
*
|
|
5
|
-
* Сам шаблон приезжает архивом с платформы (`GET /api/v1/templates/{id}/archive`)
|
|
6
|
-
* и здесь не дублируется: своя копия неизбежно разъехалась бы с той, из которой
|
|
7
|
-
* создаются проекты в вебе, и приложения перестали бы выглядеть одинаково.
|
|
8
|
-
*
|
|
9
|
-
* Здесь только то, чего в шаблоне нет и быть не может: `.env` с токеном
|
|
10
|
-
* конкретного проекта и правила для git.
|
|
11
|
-
*/
|
|
12
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FUNCTIONS_ENV_KEY = void 0;
|
|
4
|
+
exports.functionsEnvValue = functionsEnvValue;
|
|
13
5
|
exports.envFile = envFile;
|
|
6
|
+
exports.writeEnvValue = writeEnvValue;
|
|
14
7
|
exports.scaffoldFiles = scaffoldFiles;
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_path_1 = require("node:path");
|
|
15
10
|
const GITIGNORE = `node_modules/
|
|
16
11
|
dist/
|
|
17
12
|
.xflow/
|
|
@@ -36,12 +31,46 @@ xflow publish # показать dev-версию посетителям
|
|
|
36
31
|
|
|
37
32
|
Полезное: \`xflow status\` — что на сервере, \`xflow deployments\` — история версий.
|
|
38
33
|
`;
|
|
34
|
+
/** Адреса облачных функций для сборки: имя → URL, одной строкой JSON. */
|
|
35
|
+
exports.FUNCTIONS_ENV_KEY = 'VITE_XFLOW_FUNCTIONS';
|
|
36
|
+
function functionsEnvValue(functions) {
|
|
37
|
+
const map = {};
|
|
38
|
+
for (const fn of functions) {
|
|
39
|
+
if (fn.invoke_url)
|
|
40
|
+
map[fn.name] = fn.invoke_url;
|
|
41
|
+
}
|
|
42
|
+
return JSON.stringify(map);
|
|
43
|
+
}
|
|
39
44
|
/**
|
|
40
45
|
* `.env` в исходники не уходит (его пропускают и push, и сервер), поэтому в свежем
|
|
41
46
|
* клоне его восстанавливает `xflow link`.
|
|
42
47
|
*/
|
|
43
|
-
function envFile(projectToken, apiUrl) {
|
|
44
|
-
return
|
|
48
|
+
function envFile(projectToken, apiUrl, functions = []) {
|
|
49
|
+
return [
|
|
50
|
+
`VITE_XFLOW_PROJECT_TOKEN=${projectToken}`,
|
|
51
|
+
`VITE_XFLOW_API_URL=${apiUrl.replace(/^https?:\/\//, '')}`,
|
|
52
|
+
`${exports.FUNCTIONS_ENV_KEY}=${functionsEnvValue(functions)}`,
|
|
53
|
+
'',
|
|
54
|
+
].join('\n');
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Обновить одну переменную в `.env`, не трогая остальные.
|
|
58
|
+
*
|
|
59
|
+
* Файл наш, но живёт у разработчика: рядом с нашими тремя строками у него может
|
|
60
|
+
* лежать что угодно своё, и переписывать файл целиком мы не вправе.
|
|
61
|
+
*/
|
|
62
|
+
function writeEnvValue(root, key, value) {
|
|
63
|
+
const path = (0, node_path_1.join)(root, '.env');
|
|
64
|
+
const line = `${key}=${value}`;
|
|
65
|
+
if (!(0, node_fs_1.existsSync)(path)) {
|
|
66
|
+
(0, node_fs_1.writeFileSync)(path, `${line}\n`, 'utf-8');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const text = (0, node_fs_1.readFileSync)(path, 'utf-8');
|
|
70
|
+
const existing = new RegExp(`^${key}=.*$`, 'm');
|
|
71
|
+
const next = existing.test(text) ? text.replace(existing, line) : `${text.replace(/\n*$/, '\n')}${line}\n`;
|
|
72
|
+
if (next !== text)
|
|
73
|
+
(0, node_fs_1.writeFileSync)(path, next, 'utf-8');
|
|
45
74
|
}
|
|
46
75
|
function scaffoldFiles(projectName) {
|
|
47
76
|
return [
|
package/dist/version.js
CHANGED
|
@@ -6,6 +6,6 @@ exports.DEFAULT_API_URL = exports.CLI_VERSION = void 0;
|
|
|
6
6
|
* рантайме нельзя, после сборки он лежит на уровень выше dist и в бандл не
|
|
7
7
|
* попадает.
|
|
8
8
|
*/
|
|
9
|
-
exports.CLI_VERSION = '0.1.
|
|
9
|
+
exports.CLI_VERSION = '0.1.5';
|
|
10
10
|
/** Адрес платформы по умолчанию. Переопределяется XFLOW_API_URL и полем `api` в xflow.json. */
|
|
11
11
|
exports.DEFAULT_API_URL = 'https://app.getxflow.com';
|
package/package.json
CHANGED
package/skills/xflow/SKILL.md
CHANGED
|
@@ -49,12 +49,43 @@ and `xflow functions logs <name>` shows the failures, each with its stack and th
|
|
|
49
49
|
output of that call. Only failed calls are logged, so an empty output means the function
|
|
50
50
|
never crashed, not that logging is broken.
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
From the app, call a function through `src/lib/xflow.ts`:
|
|
53
|
+
`await xflow.functions.invoke('send-mail', { body: { to } })`. It carries the project
|
|
54
|
+
token for you. Addresses are baked into the build: the CLI writes them to `.env` when
|
|
55
|
+
you deploy a function, so a frontend built before the function existed cannot see it,
|
|
56
|
+
and needs `xflow deploy` again.
|
|
57
|
+
|
|
58
|
+
Treat a function as a public API: the token ships inside the frontend bundle, so anyone
|
|
59
|
+
who opens the app can call it.
|
|
60
|
+
|
|
61
|
+
Keys and passwords live on the platform, not in the repository: `xflow env set SMTP_PASSWORD=…`
|
|
62
|
+
writes one, `xflow env` lists the names, `xflow env check` tells you which variables your
|
|
63
|
+
functions read but the platform does not have. Values never come back out — the only place
|
|
64
|
+
they exist is inside the running function.
|
|
65
|
+
|
|
66
|
+
A function receives only the variables it mentions by name via `process.env.NAME`, so never
|
|
67
|
+
assemble a variable name from an expression. New values arrive on the next
|
|
68
|
+
`xflow functions deploy`, not at the moment they are written.
|
|
69
|
+
|
|
70
|
+
To run a function on a timer: `xflow schedules set report "0 3 ? * * *"` (daily at 03:00).
|
|
71
|
+
Six fields, UTC, and exactly one of day-of-month / day-of-week must be `?` — that is
|
|
72
|
+
how Yandex wants it. A scheduled run reaches the handler as a POST with no headers.
|
|
73
|
+
|
|
74
|
+
## Database
|
|
75
|
+
|
|
76
|
+
Schema changes are files: `migrations/0001_init.sql`, `migrations/0002_orders.sql`, applied
|
|
77
|
+
in filename order by `xflow db migrate`. `xflow db status` shows what is applied and what
|
|
78
|
+
waits. History lives in the database itself, so an already applied file is never re-run and
|
|
79
|
+
editing it changes nothing: write a new migration instead.
|
|
80
|
+
|
|
81
|
+
The platform keeps no database history and no backups. Anything that destroys data
|
|
82
|
+
(`DROP TABLE`, `DROP COLUMN`, `TRUNCATE`, `DELETE FROM` without a condition) is refused
|
|
83
|
+
unless you pass `--allow-destructive`, and with that flag the affected tables are dumped
|
|
84
|
+
first and kept for 7 days. Check with `--dry-run` before applying.
|
|
85
|
+
|
|
86
|
+
One logical database can be shared by several projects, so your migration can break an app
|
|
87
|
+
you do not see. `xflow db status` lists applied migrations that have no file in your
|
|
88
|
+
repository: that is what someone else's project did.
|
|
58
89
|
|
|
59
90
|
## Syncing code
|
|
60
91
|
|