@getxflow/cli 0.1.1 → 0.1.4

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 CHANGED
@@ -20,8 +20,10 @@ 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` | запуск функций по времени (таймер-триггеры) |
25
27
  | `logs` | ошибки выложенного приложения в браузере |
26
28
  | `projects list` / `projects get` / `open` | проекты организации и адреса приложения |
27
29
  | `skills` | инструкция о платформе для ИИ-агента (формат [Agent Skills](https://agentskills.io)) |
package/dist/args.js CHANGED
@@ -24,6 +24,8 @@ const BOOLEAN_FLAGS = new Set([
24
24
  'no-push',
25
25
  'skip-build',
26
26
  'all',
27
+ 'dry-run',
28
+ 'allow-destructive',
27
29
  ]);
28
30
  function parseArgs(argv) {
29
31
  const words = [];
package/dist/bin.js CHANGED
@@ -11,8 +11,10 @@ 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");
14
15
  const functions_1 = require("./commands/functions");
15
16
  const logs_1 = require("./commands/logs");
17
+ const schedules_1 = require("./commands/schedules");
16
18
  const skills_1 = require("./commands/skills");
17
19
  const sources_1 = require("./commands/sources");
18
20
  const deploy_1 = require("./commands/deploy");
@@ -82,6 +84,30 @@ async function run(args) {
82
84
  case 'logs':
83
85
  await (0, logs_1.logs)(rest);
84
86
  return;
87
+ case 'schedules':
88
+ if (second === 'set') {
89
+ await (0, schedules_1.schedulesSet)(rest);
90
+ return;
91
+ }
92
+ if (second === 'rm' || second === 'remove') {
93
+ await (0, schedules_1.schedulesRemove)(rest);
94
+ return;
95
+ }
96
+ if (second === undefined || second === 'list') {
97
+ await (0, schedules_1.schedulesList)();
98
+ return;
99
+ }
100
+ throw new errors_1.CliError(`Неизвестная команда: schedules ${second}`, 'Есть list, set и rm');
101
+ case 'db':
102
+ if (second === 'migrate') {
103
+ await (0, db_1.dbMigrate)(rest);
104
+ return;
105
+ }
106
+ if (second === undefined || second === 'status') {
107
+ await (0, db_1.dbStatus)();
108
+ return;
109
+ }
110
+ throw new errors_1.CliError(`Неизвестная команда: db ${second}`, 'Есть status и migrate');
85
111
  case 'status':
86
112
  await (0, sources_1.status)();
87
113
  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
+ }
@@ -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
  }
@@ -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
  }
@@ -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,207 @@ 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
- ${(0, ui_1.bold)('Справочное')}
45
- xflow logs [--limit N] ошибки выложенного приложения в браузере
46
- xflow projects list проекты организации
47
- xflow projects get [id] карточка проекта
48
- xflow open [--live] открыть приложение в браузере
49
- xflow whoami чей ключ и что он может
50
- xflow logout забыть ключ
51
-
52
- ${(0, ui_1.bold)('Окружение')}
53
- XFLOW_TOKEN ключ доступа (для CI: вместо xflow login)
54
- XFLOW_API_URL адрес платформы, если он не app.getxflow.com
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
+
48
+ ${(0, ui_1.bold)('База данных')}
49
+ xflow db status какие миграции применены, а какие ждут
50
+ xflow db migrate [--dry-run] [--allow-destructive]
51
+ применить миграции из migrations/*.sql
52
+
53
+ ${(0, ui_1.bold)('Справочное')}
54
+ xflow logs [--limit N] ошибки выложенного приложения в браузере
55
+ xflow projects list проекты организации
56
+ xflow projects get [id] карточка проекта
57
+ xflow open [--live] открыть приложение в браузере
58
+ xflow whoami чей ключ и что он может
59
+ xflow logout забыть ключ
60
+
61
+ ${(0, ui_1.bold)('Окружение')}
62
+ XFLOW_TOKEN ключ доступа (для CI: вместо xflow login)
63
+ XFLOW_API_URL адрес платформы, если он не app.getxflow.com
64
+
56
65
  Подробнее о команде: xflow help <команда>`);
57
66
  }
58
67
  const TOPICS = {
59
- logs: `${(0, ui_1.bold)('xflow logs')} и ${(0, ui_1.bold)('xflow functions logs')} — ошибки проекта
60
-
61
- Один поток на проект: падения выложенного приложения в браузере и падения
62
- облачных функций. Хранятся последние 200 записей, дальше вытесняются.
63
-
64
- --limit N сколько показать (по умолчанию 10)
65
-
66
- Функция сообщает о падении сама: свои логи Yandex наружу отдаёт только по gRPC,
67
- поэтому обёртка ловит исключение, уносит вместе с ним хвост консоли (последние 40
68
- строк ${(0, ui_1.bold)('console.log')} этого вызова) и отправляет на платформу. Успешные вызовы не
69
- пишут ничего: иначе каждый запрос платил бы за это задержкой.
70
-
71
- Не попадёт сюда: падение на старте модуля (функция не успевает дойти до обёртки),
72
- превышение 30 секунд и нехватка памяти. Такое видно ответом на
73
- ${(0, ui_1.bold)('xflow functions invoke')}.
74
-
75
- Ошибки браузера собирает ${(0, ui_1.bold)('src/utils/error-logger.ts')} шаблона и только с
68
+ db: `${(0, ui_1.bold)('xflow db migrate')} — применить миграции
69
+
70
+ Файлы лежат в репозитории: ${(0, ui_1.bold)('migrations/0001_init.sql')}, ${(0, ui_1.bold)('migrations/0002_orders.sql')} и так
71
+ далее. Порядок по имени файла, поэтому номер в начале обязателен. Каждая миграция
72
+ применяется своей транзакцией, история хранится в самой базе, и повторно применённое
73
+ не запускается.
74
+
75
+ --dry-run показать, что применится, и не трогать базу
76
+ --allow-destructive разрешить операции, уничтожающие данные
77
+
78
+ Про разрушающие. Истории базы платформа не хранит и бэкапов не делает, поэтому
79
+ ${(0, ui_1.bold)('DROP TABLE')}, ${(0, ui_1.bold)('DROP COLUMN')}, ${(0, ui_1.bold)('TRUNCATE')} и ${(0, ui_1.bold)('DELETE FROM')} без условия
80
+ отклоняются без явного флага. С флагом содержимое затронутых таблиц уезжает в дамп
81
+ и хранится 7 суток это страховка «сразу заметил», а не резервная копия.
82
+ ${(0, ui_1.bold)('DROP DATABASE')} не пропускается никогда: база общая для организации.
83
+
84
+ Изменять уже применённый файл бесполезно: сверка идёт по имени, а не по
85
+ содержимому. ${(0, ui_1.bold)('xflow db status')} такой файл покажет отдельно — заводите новую
86
+ миграцию.
87
+
88
+ Одна логическая база может быть привязана к нескольким проектам, поэтому в истории
89
+ попадаются миграции, которых нет в вашем репозитории. Это нормально, но означает,
90
+ что ваша миграция может сломать чужое приложение.`,
91
+ logs: `${(0, ui_1.bold)('xflow logs')} и ${(0, ui_1.bold)('xflow functions logs')} — ошибки проекта
92
+
93
+ Один поток на проект: падения выложенного приложения в браузере и падения
94
+ облачных функций. Хранятся последние 200 записей, дальше вытесняются.
95
+
96
+ --limit N сколько показать (по умолчанию 10)
97
+
98
+ Функция сообщает о падении сама: свои логи Yandex наружу отдаёт только по gRPC,
99
+ поэтому обёртка ловит исключение, уносит вместе с ним хвост консоли (последние 40
100
+ строк ${(0, ui_1.bold)('console.log')} этого вызова) и отправляет на платформу. Успешные вызовы не
101
+ пишут ничего: иначе каждый запрос платил бы за это задержкой.
102
+
103
+ Не попадёт сюда: падение на старте модуля (функция не успевает дойти до обёртки),
104
+ превышение 30 секунд и нехватка памяти. Такое видно ответом на
105
+ ${(0, ui_1.bold)('xflow functions invoke')}.
106
+
107
+ Ошибки браузера собирает ${(0, ui_1.bold)('src/utils/error-logger.ts')} шаблона и только с
76
108
  выложенных адресов: локальный ${(0, ui_1.bold)('npm run dev')} сюда не пишет.`,
77
- invoke: `${(0, ui_1.bold)('xflow functions invoke')} <имя> вызвать функцию
78
-
79
- Вызывает так же, как её вызывает приложение: с заголовком X-Project-Token.
80
- Токен берётся из карточки проекта на платформе, локальный .env не нужен.
81
-
82
- --data '{"a":1}' тело запроса (по умолчанию метод становится POST)
83
- --method GET другой метод
84
-
85
- Печатает статус, время ответа и тело. Ненулевой код возврата на статусе 4xx и
86
- 5xx: в CI такой вызов должен ронять шаг. Причину падения показывает
109
+ schedules: `${(0, ui_1.bold)('xflow schedules')} — запуск функций по времени
110
+
111
+ Расписание это таймер-триггер Yandex: он зовёт функцию сам, без участия
112
+ приложения и браузера.
113
+
114
+ xflow schedules set отчёт "0 3 ? * * *" каждый день в 03:00
115
+ xflow schedules set сводка "0 */4 ? * * *" каждые 4 часа
116
+ xflow schedules rm отчёт снять все расписания функции
117
+
118
+ Формат из шести полей: ${(0, ui_1.bold)('минуты часы день-месяца месяц день-недели год')}. Ровно одно
119
+ из полей «День месяца» и «День недели» должно быть ${(0, ui_1.bold)('?')} — это не наша причуда, так
120
+ устроен Яндекс. ${(0, ui_1.bold)('Время всегда UTC')}, местное он не знает.
121
+
122
+ --payload '{"режим":"полный"}' тело, которое получит функция
123
+
124
+ Запуск по расписанию приходит хендлеру как POST без заголовков, и токен проекта
125
+ у него не проверяется: снаружи такое событие не подделать. Функция при этом должна
126
+ быть уже выложена — расписание ссылается на неё, а не наоборот.
127
+
128
+ Упавший запуск виден в ${(0, ui_1.bold)('xflow functions logs')}: по расписанию за функцией никто не
129
+ смотрит, поэтому обёртка отчитывается о падении так же, как при обычном вызове.`,
130
+ invoke: `${(0, ui_1.bold)('xflow functions invoke')} <имя> — вызвать функцию
131
+
132
+ Вызывает так же, как её вызывает приложение: с заголовком X-Project-Token.
133
+ Токен берётся из карточки проекта на платформе, локальный .env не нужен.
134
+
135
+ --data '{"a":1}' тело запроса (по умолчанию метод становится POST)
136
+ --method GET другой метод
137
+
138
+ Печатает статус, время ответа и тело. Ненулевой код возврата на статусе 4xx и
139
+ 5xx: в CI такой вызов должен ронять шаг. Причину падения показывает
87
140
  ${(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
-
141
+ skills: `${(0, ui_1.bold)('xflow skills')} — инструкция о платформе для ИИ-агента
142
+
143
+ Кладёт файл в формате Agent Skills (agentskills.io): что такое XFlow, как выкладывать
144
+ и публиковать, откуда брать компоненты дизайн-системы, где смотреть ошибки прода.
145
+ Агент подхватывает его сам, когда задача про платформу, и не тратит контекст в
146
+ остальное время.
147
+
148
+ Файл общий для инструментов, а папки у них разные, поэтому кладём в обе:
149
+ ${(0, ui_1.bold)('.claude/skills/xflow/')} читает Claude Code, ${(0, ui_1.bold)('.agents/skills/xflow/')} — Codex и OpenClaw.
150
+
151
+ --global положить в домашнюю папку, тогда скилл виден во всех проектах
152
+
153
+ ${(0, ui_1.bold)('AGENTS.md')} и ${(0, ui_1.bold)('CLAUDE.md')} команда не трогает: это ваши файлы, в них ваши правила.
154
+ Наше знание живёт в своей папке и переустанавливается поверх без спора за файл.
155
+
103
156
  Скилл едет вместе с 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, запуск агентом) подтвердить нельзя: расхождение версий должно ронять сборку,
157
+ push: `${(0, ui_1.bold)('xflow push')} — отправить исходники
158
+
159
+ Уходит вся рабочая копия целиком, одной ревизией. Не отправляются: node_modules,
160
+ .git, dist, build, .next, любые .env, а также всё, что перечислено в .xflowignore
161
+ и в поле ignore файла xflow.json.
162
+
163
+ Если на сервере ревизия новее той, от которой вы работали, push отклоняется.
164
+ Это значит, что кто-то запушил раньше: заберите его правки рядом
165
+ (${(0, ui_1.bold)('xflow pull --into ./server-copy')}), сведите их у себя в git и повторите.
166
+
167
+ --force перетирает серверную ревизию. Перед этим CLI показывает, чью работу вы
168
+ затираете, и просит подтверждение вводом имени проекта. В неинтерактивном режиме
169
+ (CI, запуск агентом) подтвердить нельзя: расхождение версий должно ронять сборку,
117
170
  а не молча уничтожать чужую работу.`,
118
- pull: `${(0, ui_1.bold)('xflow pull')} — забрать исходники
119
-
120
- По умолчанию забирает последнюю ревизию в папку проекта и отказывается писать в
121
- непустую: слить изменения платформа не умеет, это работа git.
122
-
123
- --into <папка> выгрузить рядом, чтобы сравнить
124
- --revision <N> конкретная ревизия (список: xflow deployments)
171
+ pull: `${(0, ui_1.bold)('xflow pull')} — забрать исходники
172
+
173
+ По умолчанию забирает последнюю ревизию в папку проекта и отказывается писать в
174
+ непустую: слить изменения платформа не умеет, это работа git.
175
+
176
+ --into <папка> выгрузить рядом, чтобы сравнить
177
+ --revision <N> конкретная ревизия (список: xflow deployments)
125
178
  --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
-
179
+ deploy: `${(0, ui_1.bold)('xflow deploy')} — собрать и выложить
180
+
181
+ Три шага: отправка исходников, локальная сборка, заливка результата. Команда
182
+ сборки и каталог результата берутся из xflow.json (по умолчанию npm run build
183
+ и dist).
184
+
185
+ --no-push не отправлять исходники, выложить из последней серверной ревизии
186
+ --skip-build не собирать, взять готовый каталог сборки
187
+ --force разрешить перезапись серверной ревизии при отправке
188
+
189
+ Сборка идёт на вашей машине, поэтому её результат зависит от вашей версии Node.
190
+ Платформа раздаёт статику: сборка без index.html в корне отклоняется.
191
+
139
192
  Выложенное видно только по 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 скажет,
193
+ init: `${(0, ui_1.bold)('xflow init')} [папка] — новый проект
194
+
195
+ Разворачивает шаблон платформы: React на Vite, Tailwind, набор компонентов
196
+ (${(0, ui_1.bold)('src/components/ui')}) и готовые блоки (${(0, ui_1.bold)('src/components/blocks')}) — таблица,
197
+ форма, фильтры, канбан, графики. Тот же шаблон получают проекты, созданные в вебе,
198
+ поэтому приложения выглядят одинаково.
199
+
200
+ Строить интерфейс поверх этих компонентов и токенов из ${(0, ui_1.bold)('src/index.css')} — не
201
+ формальность: своя палитра поверх них выглядит чужеродно внутри платформы.
202
+
203
+ --name <имя> имя проекта (по умолчанию — имя папки)
204
+ --template <id> другой шаблон (список: xflow templates)
205
+ --database <id> привязать логическую базу организации
206
+
207
+ Порядок: проект заводится на платформе первым, потому что без него неоткуда взять
208
+ токен для .env. Если запись файлов сорвётся, проект останется пустым, и CLI скажет,
156
209
  как подобрать его командой 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
- Посетители продолжают видеть опубликованную версию, пока не выполнен
210
+ rollback: `${(0, ui_1.bold)('xflow rollback')} <номер версии> — вернуть прошлую сборку
211
+
212
+ Переключает dev-адрес на выбранную версию. Возвращается только сборка: исходники
213
+ остаются на своей ревизии, и вернуть их — отдельное решение
214
+ (${(0, ui_1.bold)('xflow pull --revision N --into ./старая-версия')}).
215
+
216
+ Посетители продолжают видеть опубликованную версию, пока не выполнен
164
217
  ${(0, ui_1.bold)('xflow publish')}.`,
165
218
  };
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 `VITE_XFLOW_PROJECT_TOKEN=${projectToken}\nVITE_XFLOW_API_URL=${apiUrl.replace(/^https?:\/\//, '')}\n`;
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.1';
9
+ exports.CLI_VERSION = '0.1.4';
10
10
  /** Адрес платформы по умолчанию. Переопределяется XFLOW_API_URL и полем `api` в xflow.json. */
11
11
  exports.DEFAULT_API_URL = 'https://app.getxflow.com';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getxflow/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "CLI платформы XFlow: синхронизация кода, деплой и публикация приложений",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -49,13 +49,38 @@ 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
- Calls from the app must carry the `X-Project-Token` header; the token is already in
53
- the app environment as `VITE_XFLOW_PROJECT_TOKEN`. Treat a function as a public API:
54
- that token ships inside the frontend bundle.
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.
55
60
 
56
61
  Organization secrets reach a function only if it mentions them via `process.env`, so
57
62
  read them by name and do not build variable names dynamically.
58
63
 
64
+ To run a function on a timer: `xflow schedules set report "0 3 ? * * *"` (daily at 03:00).
65
+ Six fields, UTC, and exactly one of day-of-month / day-of-week must be `?` — that is
66
+ how Yandex wants it. A scheduled run reaches the handler as a POST with no headers.
67
+
68
+ ## Database
69
+
70
+ Schema changes are files: `migrations/0001_init.sql`, `migrations/0002_orders.sql`, applied
71
+ in filename order by `xflow db migrate`. `xflow db status` shows what is applied and what
72
+ waits. History lives in the database itself, so an already applied file is never re-run and
73
+ editing it changes nothing: write a new migration instead.
74
+
75
+ The platform keeps no database history and no backups. Anything that destroys data
76
+ (`DROP TABLE`, `DROP COLUMN`, `TRUNCATE`, `DELETE FROM` without a condition) is refused
77
+ unless you pass `--allow-destructive`, and with that flag the affected tables are dumped
78
+ first and kept for 7 days. Check with `--dry-run` before applying.
79
+
80
+ One logical database can be shared by several projects, so your migration can break an app
81
+ you do not see. `xflow db status` lists applied migrations that have no file in your
82
+ repository: that is what someone else's project did.
83
+
59
84
  ## Syncing code
60
85
 
61
86
  `xflow status` shows how the local copy differs from the server revision.