@getxflow/cli 0.0.1 → 0.1.1
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 +36 -13
- package/dist/api.js +133 -0
- package/dist/args.js +82 -0
- package/dist/bin.js +150 -0
- package/dist/commands/auth.js +111 -0
- package/dist/commands/deploy.js +123 -0
- package/dist/commands/functions.js +163 -0
- package/dist/commands/logs.js +60 -0
- package/dist/commands/projects.js +190 -0
- package/dist/commands/skills.js +50 -0
- package/dist/commands/sources.js +188 -0
- package/dist/config.js +95 -0
- package/dist/credentials.js +66 -0
- package/dist/errors.js +13 -0
- package/dist/help.js +165 -0
- package/dist/open-url.js +24 -0
- package/dist/session.js +26 -0
- package/dist/template.js +51 -0
- package/dist/tree.js +161 -0
- package/dist/types.js +3 -0
- package/dist/ui.js +113 -0
- package/dist/version.js +11 -0
- package/dist/zip.js +173 -0
- package/package.json +22 -27
- package/skills/xflow/SKILL.md +110 -0
- package/index.js +0 -2
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.functionsList = functionsList;
|
|
4
|
+
exports.functionsInvoke = functionsInvoke;
|
|
5
|
+
exports.functionsDeploy = functionsDeploy;
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const node_path_1 = require("node:path");
|
|
8
|
+
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
|
+
const ENTRY_NAMES = ['index.ts', 'index.js', 'index.mjs'];
|
|
15
|
+
/** Папка с функциями внутри проекта. Та же, что была до пивота: менять её незачем. */
|
|
16
|
+
const FUNCTIONS_DIR = 'functions';
|
|
17
|
+
function entryFor(root, name) {
|
|
18
|
+
for (const entry of ENTRY_NAMES) {
|
|
19
|
+
const candidate = (0, node_path_1.join)(root, FUNCTIONS_DIR, name, entry);
|
|
20
|
+
if ((0, node_fs_1.existsSync)(candidate))
|
|
21
|
+
return candidate;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
function discover(root) {
|
|
26
|
+
const dir = (0, node_path_1.join)(root, FUNCTIONS_DIR);
|
|
27
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
28
|
+
return [];
|
|
29
|
+
return (0, node_fs_1.readdirSync)(dir)
|
|
30
|
+
.filter((name) => (0, node_fs_1.statSync)((0, node_path_1.join)(dir, name)).isDirectory())
|
|
31
|
+
.filter((name) => entryFor(root, name) !== null)
|
|
32
|
+
.sort();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Собрать функцию в один файл.
|
|
36
|
+
*
|
|
37
|
+
* esbuild берём из node_modules проекта, а не тащим зависимостью в CLI: он и так
|
|
38
|
+
* есть в каждом приложении на платформе (внутри Vite), а пакет без зависимостей
|
|
39
|
+
* ставится быстрее и не требует доверия к нашему списку.
|
|
40
|
+
*
|
|
41
|
+
* `pg` оставляем снаружи бандла сознательно: серверная обёртка подставляет схему
|
|
42
|
+
* проекта в каждое соединение, а сделать это можно только с общим драйвером.
|
|
43
|
+
*/
|
|
44
|
+
function bundle(root, entry) {
|
|
45
|
+
let esbuild;
|
|
46
|
+
try {
|
|
47
|
+
esbuild = require(require.resolve('esbuild', { paths: [root] }));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new errors_1.CliError('Не нашёл esbuild в проекте', 'Установите его: npm i -D esbuild. Обычно он уже стоит вместе с Vite');
|
|
51
|
+
}
|
|
52
|
+
const result = esbuild.buildSync({
|
|
53
|
+
entryPoints: [entry],
|
|
54
|
+
bundle: true,
|
|
55
|
+
platform: 'node',
|
|
56
|
+
target: 'node20',
|
|
57
|
+
format: 'cjs',
|
|
58
|
+
external: ['pg'],
|
|
59
|
+
write: false,
|
|
60
|
+
logLevel: 'silent',
|
|
61
|
+
});
|
|
62
|
+
const text = result.outputFiles[0]?.text;
|
|
63
|
+
if (!text)
|
|
64
|
+
throw new errors_1.CliError(`Сборка ${entry} не дала результата`);
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
async function functionsList() {
|
|
68
|
+
const { config } = (0, config_1.requireProject)();
|
|
69
|
+
const client = (0, session_1.connect)(config);
|
|
70
|
+
const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/functions`);
|
|
71
|
+
if (data.functions.length === 0) {
|
|
72
|
+
(0, ui_1.note)(`Функций нет. Положите код в ${FUNCTIONS_DIR}/<имя>/index.ts и выполните xflow functions deploy`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
(0, ui_1.table)(data.functions.map((fn) => [
|
|
76
|
+
fn.name,
|
|
77
|
+
fn.status === 'deployed' ? 'выложена' : fn.status === 'failed' ? 'ошибка' : fn.status,
|
|
78
|
+
fn.last_deployed_at ? (0, ui_1.formatAge)(fn.last_deployed_at) : '—',
|
|
79
|
+
fn.error_message ?? fn.invoke_url ?? '',
|
|
80
|
+
]));
|
|
81
|
+
}
|
|
82
|
+
/** Ответ функции: JSON разворачиваем, прочее отдаём как есть. */
|
|
83
|
+
function prettyBody(text) {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.stringify(JSON.parse(text), null, 2);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return text;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Вызвать функцию так же, как её вызывает приложение.
|
|
93
|
+
*
|
|
94
|
+
* Токен проекта берём из карточки на платформе, а не из локального `.env`: в
|
|
95
|
+
* свежем клоне файла нет вовсе, а команда должна работать сразу после link.
|
|
96
|
+
*/
|
|
97
|
+
async function functionsInvoke(args) {
|
|
98
|
+
const { config } = (0, config_1.requireProject)();
|
|
99
|
+
const client = (0, session_1.connect)(config);
|
|
100
|
+
const name = args.words[1];
|
|
101
|
+
if (!name)
|
|
102
|
+
throw new errors_1.CliError('Нужно имя функции', 'Что выложено: xflow functions list');
|
|
103
|
+
const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}`);
|
|
104
|
+
const fn = card.functions.find((item) => item.name === name);
|
|
105
|
+
if (!fn || !fn.invoke_url) {
|
|
106
|
+
throw new errors_1.CliError(`Функция ${name} не выложена`, card.functions.length > 0
|
|
107
|
+
? `Выложены: ${card.functions.map((item) => item.name).join(', ')}`
|
|
108
|
+
: `Выкатить: xflow functions deploy ${name}`);
|
|
109
|
+
}
|
|
110
|
+
const data = (0, args_1.flagString)(args, 'data');
|
|
111
|
+
const method = ((0, args_1.flagString)(args, 'method') ?? (data ? 'POST' : 'GET')).toUpperCase();
|
|
112
|
+
const sendsBody = method !== 'GET' && method !== 'HEAD';
|
|
113
|
+
const started = Date.now();
|
|
114
|
+
let response;
|
|
115
|
+
try {
|
|
116
|
+
response = await fetch(fn.invoke_url, {
|
|
117
|
+
method,
|
|
118
|
+
headers: {
|
|
119
|
+
'Content-Type': 'application/json',
|
|
120
|
+
'X-Project-Token': card.project_token ?? '',
|
|
121
|
+
},
|
|
122
|
+
body: sendsBody ? (data ?? '{}') : undefined,
|
|
123
|
+
// У функции свой потолок в 30 секунд: ждём чуть дольше, чтобы увидеть её
|
|
124
|
+
// собственный таймаут, а не свой.
|
|
125
|
+
signal: AbortSignal.timeout(40_000),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
throw new errors_1.CliError(`Функция не ответила: ${e instanceof Error ? e.message : String(e)}`, 'Проверьте, что она выложена: xflow functions list');
|
|
130
|
+
}
|
|
131
|
+
const elapsed = Date.now() - started;
|
|
132
|
+
const text = await response.text();
|
|
133
|
+
(0, ui_1.note)((0, ui_1.dim)(`${response.status} ${response.statusText} за ${elapsed} мс`));
|
|
134
|
+
if (text)
|
|
135
|
+
(0, ui_1.out)(prettyBody(text));
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
throw new errors_1.CliError(`Функция ответила ${response.status}`, `Стек и вывод консоли: xflow functions logs ${name}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function functionsDeploy(args) {
|
|
141
|
+
const { root, config } = (0, config_1.requireProject)();
|
|
142
|
+
const client = (0, session_1.connect)(config);
|
|
143
|
+
const wanted = args.words[1];
|
|
144
|
+
const names = wanted ? [wanted] : discover(root);
|
|
145
|
+
if (names.length === 0) {
|
|
146
|
+
throw new errors_1.CliError(`В проекте нет функций`, `Создайте ${FUNCTIONS_DIR}/<имя>/index.ts с экспортом handler и повторите`);
|
|
147
|
+
}
|
|
148
|
+
for (const name of names) {
|
|
149
|
+
const entry = entryFor(root, name);
|
|
150
|
+
if (!entry) {
|
|
151
|
+
throw new errors_1.CliError(`Не нашёл ${FUNCTIONS_DIR}/${name}/index.ts`, `Доступные функции: ${discover(root).join(', ') || 'нет ни одной'}`);
|
|
152
|
+
}
|
|
153
|
+
(0, ui_1.step)(`Собираю ${name}`);
|
|
154
|
+
const code = bundle(root, entry);
|
|
155
|
+
(0, ui_1.step)(`Выкладываю ${name}`);
|
|
156
|
+
const result = await (0, api_1.apiUpload)(client, `/api/v1/projects/${config.projectId}/functions?name=${encodeURIComponent(name)}`, Buffer.from(code, 'utf-8'), { 'Content-Type': 'application/javascript' });
|
|
157
|
+
(0, ui_1.ok)(`${(0, ui_1.bold)(result.name)} выложена`);
|
|
158
|
+
(0, ui_1.out)(result.url);
|
|
159
|
+
if (result.secrets.length > 0) {
|
|
160
|
+
(0, ui_1.note)((0, ui_1.dim)(` Секреты организации в окружении: ${result.secrets.join(', ')}`));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.logs = logs;
|
|
4
|
+
exports.functionsLogs = functionsLogs;
|
|
5
|
+
const api_1 = require("../api");
|
|
6
|
+
const args_1 = require("../args");
|
|
7
|
+
const config_1 = require("../config");
|
|
8
|
+
const session_1 = require("../session");
|
|
9
|
+
const ui_1 = require("../ui");
|
|
10
|
+
const DEFAULT_LIMIT = 10;
|
|
11
|
+
/** Стек с хвостом консоли бывает длинным: показываем начало, оно и есть причина. */
|
|
12
|
+
const STACK_MAX_LINES = 14;
|
|
13
|
+
function stamp(iso) {
|
|
14
|
+
const date = new Date(iso);
|
|
15
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
16
|
+
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
17
|
+
}
|
|
18
|
+
async function fetchLogs(source, name, args) {
|
|
19
|
+
const { config } = (0, config_1.requireProject)();
|
|
20
|
+
const client = (0, session_1.connect)(config);
|
|
21
|
+
const query = new URLSearchParams({ source, limit: String((0, args_1.flagNumber)(args, 'limit') ?? DEFAULT_LIMIT) });
|
|
22
|
+
if (name)
|
|
23
|
+
query.set('name', name);
|
|
24
|
+
const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/logs?${query.toString()}`);
|
|
25
|
+
return data.logs;
|
|
26
|
+
}
|
|
27
|
+
/** Свежее — внизу: так последняя ошибка оказывается перед глазами, а не уезжает вверх. */
|
|
28
|
+
function render(rows) {
|
|
29
|
+
for (const row of [...rows].reverse()) {
|
|
30
|
+
const label = row.source === 'function' ? (row.function ?? 'функция') : 'браузер';
|
|
31
|
+
(0, ui_1.out)(`${(0, ui_1.dim)(stamp(row.timestamp))} ${(0, ui_1.bold)(label)} ${row.message}`);
|
|
32
|
+
if (row.stack) {
|
|
33
|
+
const lines = row.stack.split('\n');
|
|
34
|
+
for (const line of lines.slice(0, STACK_MAX_LINES))
|
|
35
|
+
(0, ui_1.out)((0, ui_1.dim)(` ${line}`));
|
|
36
|
+
if (lines.length > STACK_MAX_LINES)
|
|
37
|
+
(0, ui_1.out)((0, ui_1.dim)(` … ещё ${lines.length - STACK_MAX_LINES} строк`));
|
|
38
|
+
}
|
|
39
|
+
(0, ui_1.out)('');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function logs(args) {
|
|
43
|
+
const rows = await fetchLogs('client', undefined, args);
|
|
44
|
+
if (rows.length === 0) {
|
|
45
|
+
(0, ui_1.note)('Ошибок из браузера нет');
|
|
46
|
+
(0, ui_1.note)((0, ui_1.dim)(' Сюда попадают падения выложенного приложения, а не локального npm run dev'));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
render(rows);
|
|
50
|
+
}
|
|
51
|
+
async function functionsLogs(args) {
|
|
52
|
+
const name = args.words[1];
|
|
53
|
+
const rows = await fetchLogs('function', name, args);
|
|
54
|
+
if (rows.length === 0) {
|
|
55
|
+
(0, ui_1.note)(name ? `Функция ${name} не падала` : 'Функции не падали');
|
|
56
|
+
(0, ui_1.note)((0, ui_1.dim)(' Сюда попадают только неудачные вызовы: успешные ничего не пишут'));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
render(rows);
|
|
60
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.templates = templates;
|
|
4
|
+
exports.init = init;
|
|
5
|
+
exports.link = link;
|
|
6
|
+
exports.list = list;
|
|
7
|
+
exports.get = get;
|
|
8
|
+
exports.open = open;
|
|
9
|
+
const node_fs_1 = require("node:fs");
|
|
10
|
+
const node_path_1 = require("node:path");
|
|
11
|
+
const api_1 = require("../api");
|
|
12
|
+
const args_1 = require("../args");
|
|
13
|
+
const config_1 = require("../config");
|
|
14
|
+
const errors_1 = require("../errors");
|
|
15
|
+
const open_url_1 = require("../open-url");
|
|
16
|
+
const session_1 = require("../session");
|
|
17
|
+
const template_1 = require("../template");
|
|
18
|
+
const zip_1 = require("../zip");
|
|
19
|
+
const ui_1 = require("../ui");
|
|
20
|
+
/** Пустой ли каталог. `.git` не считается: связывать свежий клон — обычное дело. */
|
|
21
|
+
function isEmptyEnough(dir) {
|
|
22
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
23
|
+
return true;
|
|
24
|
+
return (0, node_fs_1.readdirSync)(dir).every((name) => name === '.git');
|
|
25
|
+
}
|
|
26
|
+
function write(root, path, content) {
|
|
27
|
+
const full = (0, node_path_1.join)(root, path);
|
|
28
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(full), { recursive: true });
|
|
29
|
+
(0, node_fs_1.writeFileSync)(full, content);
|
|
30
|
+
}
|
|
31
|
+
function escapeHtml(value) {
|
|
32
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
33
|
+
}
|
|
34
|
+
/** Заголовок вкладки в шаблоне общий на всех: у проекта он должен быть свой. */
|
|
35
|
+
function titleFromProject(path, content, name) {
|
|
36
|
+
if (path !== 'index.html')
|
|
37
|
+
return content;
|
|
38
|
+
const html = content.toString('utf-8').replace(/<title>[^<]*<\/title>/, `<title>${escapeHtml(name)}</title>`);
|
|
39
|
+
return Buffer.from(html, 'utf-8');
|
|
40
|
+
}
|
|
41
|
+
async function templates() {
|
|
42
|
+
const client = (0, session_1.connect)();
|
|
43
|
+
const { templates: list } = await (0, api_1.apiJson)(client, '/api/v1/templates');
|
|
44
|
+
if (list.length === 0) {
|
|
45
|
+
(0, ui_1.note)('Шаблонов нет');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
(0, ui_1.table)(list.map((t) => [t.id, t.name, t.is_default ? 'по умолчанию' : '', t.description ?? '']));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Создать проект: шаблон платформы, локальная папка, проект на платформе.
|
|
52
|
+
*
|
|
53
|
+
* Шаблон приезжает с платформы, а не лежит в этом пакете. Иначе появились бы две
|
|
54
|
+
* стартовые точки — веб и CLI, — и приложения из них выглядели бы по-разному, хотя
|
|
55
|
+
* платформа у них одна.
|
|
56
|
+
*
|
|
57
|
+
* Отсюда и порядок: проект заводится первым, потому что без него неоткуда взять
|
|
58
|
+
* токен для `.env`. Если запись файлов сорвётся, проект останется пустым, и CLI
|
|
59
|
+
* скажет, как его подобрать (`xflow link`), — молча оставлять сироту нельзя.
|
|
60
|
+
*/
|
|
61
|
+
async function init(args) {
|
|
62
|
+
const target = (0, node_path_1.resolve)(args.words[0] ?? '.');
|
|
63
|
+
const name = (0, args_1.flagString)(args, 'name') ?? (0, node_path_1.basename)(target);
|
|
64
|
+
const client = (0, session_1.connect)();
|
|
65
|
+
if (!isEmptyEnough(target)) {
|
|
66
|
+
throw new errors_1.CliError(`Каталог ${target} не пуст`, 'Создайте проект в пустой папке: xflow init my-app. Связать существующую папку с проектом: xflow link <id>');
|
|
67
|
+
}
|
|
68
|
+
let templateId = (0, args_1.flagString)(args, 'template');
|
|
69
|
+
if (!templateId) {
|
|
70
|
+
const { templates: list } = await (0, api_1.apiJson)(client, '/api/v1/templates');
|
|
71
|
+
templateId = (list.find((t) => t.is_default) ?? list[0])?.id;
|
|
72
|
+
if (!templateId)
|
|
73
|
+
throw new errors_1.CliError('На платформе нет ни одного шаблона');
|
|
74
|
+
}
|
|
75
|
+
(0, ui_1.step)('Забираю шаблон');
|
|
76
|
+
const archive = await (0, api_1.apiBinary)(client, `/api/v1/templates/${templateId}/archive`);
|
|
77
|
+
const files = (0, zip_1.zipRead)(archive);
|
|
78
|
+
(0, ui_1.step)('Создаю проект на платформе');
|
|
79
|
+
const project = await (0, api_1.apiJson)(client, '/api/v1/projects', {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
body: { name, database_id: (0, args_1.flagString)(args, 'database') ?? null },
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
(0, node_fs_1.mkdirSync)(target, { recursive: true });
|
|
85
|
+
for (const file of files)
|
|
86
|
+
write(target, file.path, titleFromProject(file.path, file.content, name));
|
|
87
|
+
for (const file of (0, template_1.scaffoldFiles)(name))
|
|
88
|
+
write(target, file.path, file.content);
|
|
89
|
+
write(target, '.env', (0, template_1.envFile)(project.project_token ?? '', client.apiUrl));
|
|
90
|
+
const config = {
|
|
91
|
+
projectId: project.id,
|
|
92
|
+
build: { command: 'npm run build', dir: 'dist' },
|
|
93
|
+
};
|
|
94
|
+
(0, config_1.writeConfig)(target, config);
|
|
95
|
+
(0, config_1.ignoreStateInGit)(target);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
(0, ui_1.note)((0, ui_1.dim)(` Проект «${project.name}» уже создан (${project.id}).`));
|
|
99
|
+
(0, ui_1.note)((0, ui_1.dim)(' Разберитесь с папкой и подберите его: xflow link ' + project.id));
|
|
100
|
+
throw e;
|
|
101
|
+
}
|
|
102
|
+
(0, ui_1.ok)(`Проект «${project.name}» создан: ${files.length} файлов шаблона`);
|
|
103
|
+
(0, ui_1.out)('');
|
|
104
|
+
(0, ui_1.out)(` ${(0, ui_1.bold)('Дальше:')}`);
|
|
105
|
+
(0, ui_1.out)(` cd ${(0, node_path_1.basename)(target)}`);
|
|
106
|
+
(0, ui_1.out)(' npm install');
|
|
107
|
+
(0, ui_1.out)(' npm run dev # разработка');
|
|
108
|
+
(0, ui_1.out)(' xflow skills # рассказать вашему ИИ-агенту про платформу');
|
|
109
|
+
(0, ui_1.out)(' xflow deploy # отправить код, собрать и выложить');
|
|
110
|
+
}
|
|
111
|
+
/** Связать текущую папку с уже существующим проектом. */
|
|
112
|
+
async function link(args) {
|
|
113
|
+
const projectId = args.words[0];
|
|
114
|
+
if (!projectId) {
|
|
115
|
+
throw new errors_1.CliError('Нужен идентификатор проекта', 'Список проектов: xflow projects list');
|
|
116
|
+
}
|
|
117
|
+
const root = (0, config_1.findProjectRoot)() ?? process.cwd();
|
|
118
|
+
const existing = (0, node_fs_1.existsSync)((0, node_path_1.join)(root, config_1.CONFIG_FILE)) ? (0, config_1.readConfig)(root) : undefined;
|
|
119
|
+
const client = (0, session_1.connect)(existing);
|
|
120
|
+
const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
|
|
121
|
+
const dir = existing ? root : process.cwd();
|
|
122
|
+
(0, config_1.writeConfig)(dir, {
|
|
123
|
+
...(existing ?? {}),
|
|
124
|
+
projectId: card.id,
|
|
125
|
+
build: existing?.build ?? { command: 'npm run build', dir: 'dist' },
|
|
126
|
+
});
|
|
127
|
+
(0, config_1.ignoreStateInGit)(dir);
|
|
128
|
+
// В исходники .env не уходит, поэтому в свежем клоне его нет вовсе, и приложение
|
|
129
|
+
// молча теряет доступ к облачным функциям. Восстанавливаем, но чужой не трогаем.
|
|
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 с токеном проекта'));
|
|
133
|
+
}
|
|
134
|
+
(0, ui_1.ok)(`Папка связана с проектом «${card.name}»`);
|
|
135
|
+
(0, ui_1.note)((0, ui_1.dim)(` ${(0, node_path_1.join)(dir, config_1.CONFIG_FILE)}`));
|
|
136
|
+
}
|
|
137
|
+
async function list() {
|
|
138
|
+
const root = (0, config_1.findProjectRoot)();
|
|
139
|
+
const client = (0, session_1.connect)(root ? (0, config_1.readConfig)(root) : undefined);
|
|
140
|
+
const { projects } = await (0, api_1.apiJson)(client, '/api/v1/projects');
|
|
141
|
+
if (projects.length === 0) {
|
|
142
|
+
(0, ui_1.note)('Проектов нет. Создать: xflow init');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
(0, ui_1.table)(projects.map((p) => [
|
|
146
|
+
p.id,
|
|
147
|
+
p.name,
|
|
148
|
+
p.live_url ? 'опубликован' : p.dev_url ? 'только dev' : 'без сборки',
|
|
149
|
+
(0, ui_1.formatAge)(p.updated_at),
|
|
150
|
+
]));
|
|
151
|
+
}
|
|
152
|
+
async function get(args) {
|
|
153
|
+
const root = (0, config_1.findProjectRoot)();
|
|
154
|
+
const config = root ? (0, config_1.readConfig)(root) : undefined;
|
|
155
|
+
const projectId = args.words[0] ?? config?.projectId;
|
|
156
|
+
if (!projectId) {
|
|
157
|
+
throw new errors_1.CliError('Нужен идентификатор проекта', 'Либо запустите команду в папке проекта');
|
|
158
|
+
}
|
|
159
|
+
const client = (0, session_1.connect)(config);
|
|
160
|
+
const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
|
|
161
|
+
(0, ui_1.out)(`${(0, ui_1.bold)(card.name)} ${(0, ui_1.dim)(card.id)}`);
|
|
162
|
+
if (card.description)
|
|
163
|
+
(0, ui_1.out)(card.description);
|
|
164
|
+
(0, ui_1.out)('');
|
|
165
|
+
(0, ui_1.out)(`dev: ${card.dev_url ?? '—'}`);
|
|
166
|
+
(0, ui_1.out)(`live: ${card.live_url ?? '— (не публиковался)'}`);
|
|
167
|
+
(0, ui_1.out)(`база: ${card.database ? `${card.database.name} (${card.database.schema})` : '—'}`);
|
|
168
|
+
if (card.functions.length > 0) {
|
|
169
|
+
(0, ui_1.out)('');
|
|
170
|
+
(0, ui_1.out)('Функции:');
|
|
171
|
+
(0, ui_1.table)(card.functions.map((f) => [` ${f.name}`, f.status, (0, ui_1.formatAge)(f.last_deployed_at)]));
|
|
172
|
+
}
|
|
173
|
+
if (card.schedules.length > 0) {
|
|
174
|
+
(0, ui_1.out)('');
|
|
175
|
+
(0, ui_1.out)('Расписания:');
|
|
176
|
+
(0, ui_1.table)(card.schedules.map((s) => [` ${s.function_name}`, s.cron_expression, s.status]));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function open(args) {
|
|
180
|
+
const { config } = (0, config_1.requireProject)();
|
|
181
|
+
const client = (0, session_1.connect)(config);
|
|
182
|
+
const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}`);
|
|
183
|
+
const live = (0, args_1.flagBool)(args, 'live');
|
|
184
|
+
const url = live ? card.live_url : card.dev_url;
|
|
185
|
+
if (!url) {
|
|
186
|
+
throw new errors_1.CliError(live ? 'Проект не публиковался' : 'У проекта нет сборки', live ? 'Опубликовать текущую версию: xflow publish' : 'Собрать и выложить: xflow deploy');
|
|
187
|
+
}
|
|
188
|
+
(0, ui_1.note)((0, ui_1.dim)(url));
|
|
189
|
+
(0, open_url_1.openUrl)(url);
|
|
190
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.skills = skills;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_os_1 = require("node:os");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const args_1 = require("../args");
|
|
8
|
+
const errors_1 = require("../errors");
|
|
9
|
+
const ui_1 = require("../ui");
|
|
10
|
+
/**
|
|
11
|
+
* Формат SKILL.md общий для инструментов (agentskills.io), а папку каждый ищет
|
|
12
|
+
* свою: Claude Code в .claude/skills, Codex и OpenClaw в .agents/skills.
|
|
13
|
+
* Кладём один и тот же файл в обе, иначе пришлось бы спрашивать, чей агент.
|
|
14
|
+
*/
|
|
15
|
+
const ROOTS = [
|
|
16
|
+
['.claude', 'skills'],
|
|
17
|
+
['.agents', 'skills'],
|
|
18
|
+
];
|
|
19
|
+
/** Скилл едет в пакете рядом с dist: у CLI и у инструкций для агента одна версия. */
|
|
20
|
+
function skillSource() {
|
|
21
|
+
const path = (0, node_path_1.join)(__dirname, '..', '..', 'skills', 'xflow', 'SKILL.md');
|
|
22
|
+
if (!(0, node_fs_1.existsSync)(path)) {
|
|
23
|
+
throw new errors_1.CliError('В пакете CLI нет файла скилла', 'Переустановите @getxflow/cli');
|
|
24
|
+
}
|
|
25
|
+
return (0, node_fs_1.readFileSync)(path, 'utf-8');
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Разложить скилл платформы по папкам, где его найдёт агент.
|
|
29
|
+
*
|
|
30
|
+
* Файлы AGENTS.md и CLAUDE.md не трогаем: они принадлежат пользователю, а наше
|
|
31
|
+
* знание живёт в своей папке и переустанавливается поверх без спора за файл.
|
|
32
|
+
*/
|
|
33
|
+
function skills(args) {
|
|
34
|
+
const content = skillSource();
|
|
35
|
+
const global = (0, args_1.flagBool)(args, 'global');
|
|
36
|
+
const base = global ? (0, node_os_1.homedir)() : process.cwd();
|
|
37
|
+
for (const root of ROOTS) {
|
|
38
|
+
const target = (0, node_path_1.join)(base, ...root, 'xflow', 'SKILL.md');
|
|
39
|
+
const before = (0, node_fs_1.existsSync)(target) ? (0, node_fs_1.readFileSync)(target, 'utf-8') : null;
|
|
40
|
+
if (before === content) {
|
|
41
|
+
(0, ui_1.note)((0, ui_1.dim)(` уже актуален: ${target}`));
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(target), { recursive: true });
|
|
45
|
+
(0, node_fs_1.writeFileSync)(target, content, 'utf-8');
|
|
46
|
+
(0, ui_1.out)(`${before === null ? 'создан' : 'обновлён'}: ${target}`);
|
|
47
|
+
}
|
|
48
|
+
(0, ui_1.ok)(global ? 'Скилл xflow доступен во всех проектах' : 'Скилл xflow на месте');
|
|
49
|
+
(0, ui_1.note)((0, ui_1.dim)(' После обновления CLI повторите команду: скилл обновляется вместе с ним'));
|
|
50
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.latestRevision = latestRevision;
|
|
4
|
+
exports.pushSources = pushSources;
|
|
5
|
+
exports.push = push;
|
|
6
|
+
exports.pull = pull;
|
|
7
|
+
exports.status = status;
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_path_1 = require("node:path");
|
|
10
|
+
const api_1 = require("../api");
|
|
11
|
+
const args_1 = require("../args");
|
|
12
|
+
const config_1 = require("../config");
|
|
13
|
+
const errors_1 = require("../errors");
|
|
14
|
+
const session_1 = require("../session");
|
|
15
|
+
const tree_1 = require("../tree");
|
|
16
|
+
const zip_1 = require("../zip");
|
|
17
|
+
const ui_1 = require("../ui");
|
|
18
|
+
/** Потолок сервера. Проверяем и здесь, чтобы не гнать 25 МБ ради отказа. */
|
|
19
|
+
const MAX_ARCHIVE_BYTES = 25 * 1024 * 1024;
|
|
20
|
+
/** Собрать дерево исходников: что уходит на сервер и с каким хешем. */
|
|
21
|
+
function prepareTree(root, config) {
|
|
22
|
+
const buildDir = config.build?.dir?.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
23
|
+
const extra = [...(config.ignore ?? []), ...(buildDir ? [`${buildDir}/`] : [])];
|
|
24
|
+
const { files, skippedLinks } = (0, tree_1.collectFiles)(root, (0, tree_1.loadIgnoreRules)(root, extra));
|
|
25
|
+
if (files.length === 0) {
|
|
26
|
+
throw new errors_1.CliError('В папке нет файлов для отправки', 'Проверьте .xflowignore: возможно, исключено всё');
|
|
27
|
+
}
|
|
28
|
+
for (const link of skippedLinks) {
|
|
29
|
+
(0, ui_1.warn)(`Пропущена ссылка ${link}: символические ссылки не синхронизируются`);
|
|
30
|
+
}
|
|
31
|
+
const archive = (0, zip_1.zipCreate)(files);
|
|
32
|
+
if (archive.length > MAX_ARCHIVE_BYTES) {
|
|
33
|
+
const top = (0, tree_1.heaviest)(files)
|
|
34
|
+
.map((f) => ` ${f.path} — ${(0, ui_1.formatBytes)(f.content.length)}`)
|
|
35
|
+
.join('\n');
|
|
36
|
+
throw new errors_1.CliError(`Архив ${(0, ui_1.formatBytes)(archive.length)}, потолок ${(0, ui_1.formatBytes)(MAX_ARCHIVE_BYTES)}`, `Самые тяжёлые файлы:\n${top}\n Исключите лишнее в .xflowignore`);
|
|
37
|
+
}
|
|
38
|
+
return { files, hash: (0, tree_1.treeHash)(files), archive };
|
|
39
|
+
}
|
|
40
|
+
async function latestRevision(client, projectId) {
|
|
41
|
+
const { revisions } = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/revisions`);
|
|
42
|
+
return revisions[0] ?? null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Показать, чью работу перетирает `--force`, и спросить подтверждение.
|
|
46
|
+
*
|
|
47
|
+
* Без этого текст «используйте --force» превращается в инструкцию, которую
|
|
48
|
+
* агент выполнит не задумываясь, а чужие правки исчезнут без следа (D23).
|
|
49
|
+
*/
|
|
50
|
+
async function confirmForce(client, projectId, server, local) {
|
|
51
|
+
const card = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}`);
|
|
52
|
+
(0, ui_1.note)('');
|
|
53
|
+
(0, ui_1.warn)(`На сервере ревизия ${server.revision} от ${(0, ui_1.formatAge)(server.created_at)}, ${server.file_count} файлов`);
|
|
54
|
+
try {
|
|
55
|
+
const info = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/pull`);
|
|
56
|
+
const serverPaths = new Set((0, zip_1.zipRead)(await (0, api_1.downloadUrl)(info.download_url)).map((e) => e.path));
|
|
57
|
+
const localPaths = new Set(local.map((f) => f.path));
|
|
58
|
+
const disappearing = [...serverPaths].filter((p) => !localPaths.has(p));
|
|
59
|
+
if (disappearing.length > 0) {
|
|
60
|
+
(0, ui_1.note)(` Исчезнут файлы (${disappearing.length}):`);
|
|
61
|
+
for (const path of disappearing.slice(0, 20))
|
|
62
|
+
(0, ui_1.note)((0, ui_1.dim)(` ${path}`));
|
|
63
|
+
if (disappearing.length > 20)
|
|
64
|
+
(0, ui_1.note)((0, ui_1.dim)(` …и ещё ${disappearing.length - 20}`));
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
(0, ui_1.note)((0, ui_1.dim)(' Файлы не исчезнут, но содержимое серверной ревизии будет заменено вашим'));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
(0, ui_1.warn)('Не удалось прочитать серверную копию: список исчезающих файлов недоступен');
|
|
72
|
+
}
|
|
73
|
+
(0, ui_1.note)('');
|
|
74
|
+
const confirmed = await (0, ui_1.confirmWord)(`Это перетрёт чужую работу без возможности восстановить её из платформы.`, card.name);
|
|
75
|
+
if (!confirmed)
|
|
76
|
+
throw new errors_1.CliError('Отменено');
|
|
77
|
+
}
|
|
78
|
+
/** Отправка исходников. Используется и командой push, и первым шагом deploy. */
|
|
79
|
+
async function pushSources(root, config, client, options) {
|
|
80
|
+
const tree = prepareTree(root, config);
|
|
81
|
+
const state = (0, config_1.readState)(root);
|
|
82
|
+
(0, ui_1.step)(`Отправляю ${tree.files.length} файлов (${(0, ui_1.formatBytes)(tree.archive.length)})`);
|
|
83
|
+
const server = await latestRevision(client, config.projectId);
|
|
84
|
+
// Ревизия сервера есть, а мы не знаем, от какой работали (свежий клон, чужая
|
|
85
|
+
// машина). Совпал хеш — просто синхронизируемся; не совпал — молча заливать
|
|
86
|
+
// нельзя, иначе чужая работа исчезнет без единой ошибки.
|
|
87
|
+
if (server && state.revision === undefined && !options.force) {
|
|
88
|
+
if (server.tree_hash === tree.hash) {
|
|
89
|
+
(0, config_1.writeState)(root, { revision: server.revision, treeHash: server.tree_hash });
|
|
90
|
+
(0, ui_1.ok)(`Уже синхронизировано, ревизия ${server.revision}`);
|
|
91
|
+
return { revision: server.revision, status: 'unchanged' };
|
|
92
|
+
}
|
|
93
|
+
throw new errors_1.CliError(`На сервере ревизия ${server.revision}, а эта папка не помнит, от какой версии работали`, 'Заберите серверную копию рядом и сравните: xflow pull --into ./server-copy');
|
|
94
|
+
}
|
|
95
|
+
if (options.force && server && server.tree_hash !== tree.hash) {
|
|
96
|
+
await confirmForce(client, config.projectId, server, tree.files);
|
|
97
|
+
}
|
|
98
|
+
const headers = {};
|
|
99
|
+
if (state.revision !== undefined)
|
|
100
|
+
headers['X-Base-Revision'] = String(state.revision);
|
|
101
|
+
if (options.force)
|
|
102
|
+
headers['X-Force'] = 'true';
|
|
103
|
+
const result = await (0, api_1.apiUpload)(client, `/api/v1/projects/${config.projectId}/push`, tree.archive, headers);
|
|
104
|
+
(0, config_1.writeState)(root, { revision: result.revision, treeHash: result.tree_hash });
|
|
105
|
+
if (result.status === 'unchanged') {
|
|
106
|
+
(0, ui_1.ok)(`Изменений нет, ревизия ${result.revision}`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
(0, ui_1.ok)(`Ревизия ${result.revision}: ${result.file_count} файлов, ${(0, ui_1.formatBytes)(result.size_bytes)}`);
|
|
110
|
+
}
|
|
111
|
+
return { revision: result.revision, status: result.status };
|
|
112
|
+
}
|
|
113
|
+
async function push(args) {
|
|
114
|
+
const { root, config } = (0, config_1.requireProject)();
|
|
115
|
+
const client = (0, session_1.connect)(config);
|
|
116
|
+
await pushSources(root, config, client, { force: (0, args_1.flagBool)(args, 'force') });
|
|
117
|
+
}
|
|
118
|
+
/** Есть ли в каталоге что-то, кроме служебного. */
|
|
119
|
+
function hasContent(dir) {
|
|
120
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
121
|
+
return false;
|
|
122
|
+
return (0, node_fs_1.readdirSync)(dir).some((name) => name !== '.git' && name !== '.xflow');
|
|
123
|
+
}
|
|
124
|
+
async function pull(args) {
|
|
125
|
+
const { root, config } = (0, config_1.requireProject)();
|
|
126
|
+
const into = (0, args_1.flagString)(args, 'into');
|
|
127
|
+
const target = into ? (0, node_path_1.resolve)(into) : root;
|
|
128
|
+
const client = (0, session_1.connect)(config);
|
|
129
|
+
const revision = (0, args_1.flagNumber)(args, 'revision');
|
|
130
|
+
const query = revision !== undefined ? `?revision=${revision}` : '';
|
|
131
|
+
const info = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/pull${query}`);
|
|
132
|
+
if (hasContent(target) && !(0, args_1.flagBool)(args, 'force')) {
|
|
133
|
+
throw new errors_1.CliError(`Каталог ${target} не пуст`, 'Слить изменения платформа не умеет — это работа git. Заберите серверную копию рядом: ' +
|
|
134
|
+
'xflow pull --into ./server-copy, либо перезапишите папку целиком: xflow pull --force');
|
|
135
|
+
}
|
|
136
|
+
(0, ui_1.step)(`Скачиваю ревизию ${info.revision} (${(0, ui_1.formatBytes)(info.size_bytes)})`);
|
|
137
|
+
const entries = (0, zip_1.zipRead)(await (0, api_1.downloadUrl)(info.download_url));
|
|
138
|
+
const hash = (0, tree_1.treeHash)(entries.map((e) => ({ path: e.path, content: e.content })));
|
|
139
|
+
if (hash !== info.tree_hash) {
|
|
140
|
+
(0, ui_1.warn)('Хеш скачанного дерева не совпал с серверным: содержимое могло измениться при передаче');
|
|
141
|
+
}
|
|
142
|
+
(0, node_fs_1.mkdirSync)(target, { recursive: true });
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
const path = (0, node_path_1.join)(target, entry.path);
|
|
145
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
|
|
146
|
+
(0, node_fs_1.writeFileSync)(path, entry.content);
|
|
147
|
+
}
|
|
148
|
+
// Состояние пишем только для рабочей папки: копия рядом (`--into`) к отсчёту
|
|
149
|
+
// ревизий отношения не имеет, и запись туда сбила бы его у рабочей.
|
|
150
|
+
if (!into) {
|
|
151
|
+
(0, config_1.writeState)(root, { revision: info.revision, treeHash: info.tree_hash });
|
|
152
|
+
}
|
|
153
|
+
(0, ui_1.ok)(`Ревизия ${info.revision}: ${entries.length} файлов в ${target}`);
|
|
154
|
+
}
|
|
155
|
+
async function status() {
|
|
156
|
+
const { root, config } = (0, config_1.requireProject)();
|
|
157
|
+
const client = (0, session_1.connect)(config);
|
|
158
|
+
const tree = prepareTree(root, config);
|
|
159
|
+
const state = (0, config_1.readState)(root);
|
|
160
|
+
const [card, server] = await Promise.all([
|
|
161
|
+
(0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}`),
|
|
162
|
+
latestRevision(client, config.projectId),
|
|
163
|
+
]);
|
|
164
|
+
(0, ui_1.out)(`${(0, ui_1.bold)(card.name)} ${(0, ui_1.dim)(card.id)}`);
|
|
165
|
+
(0, ui_1.out)('');
|
|
166
|
+
(0, ui_1.out)(`Локально: ${tree.files.length} файлов, ${(0, ui_1.formatBytes)(tree.archive.length)} в архиве`);
|
|
167
|
+
if (!server) {
|
|
168
|
+
(0, ui_1.out)('На сервере: исходников ещё нет');
|
|
169
|
+
(0, ui_1.note)((0, ui_1.dim)(' Отправить: xflow push'));
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
(0, ui_1.out)(`На сервере: ревизия ${server.revision}, ${server.file_count} файлов, ${(0, ui_1.formatAge)(server.created_at)}`);
|
|
173
|
+
if (server.tree_hash === tree.hash) {
|
|
174
|
+
(0, ui_1.out)('Состояние: совпадает с сервером');
|
|
175
|
+
}
|
|
176
|
+
else if (state.revision !== undefined && state.revision < server.revision) {
|
|
177
|
+
(0, ui_1.out)(`Состояние: ${(0, ui_1.bold)('расхождение')} — на сервере новее (вы работали от ${state.revision})`);
|
|
178
|
+
(0, ui_1.note)((0, ui_1.dim)(' Забрать серверную копию рядом: xflow pull --into ./server-copy'));
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
(0, ui_1.out)(`Состояние: ${(0, ui_1.bold)('есть локальные изменения')}`);
|
|
182
|
+
(0, ui_1.note)((0, ui_1.dim)(' Отправить: xflow push'));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
(0, ui_1.out)('');
|
|
186
|
+
(0, ui_1.out)(`dev: ${card.dev_url ?? '—'}`);
|
|
187
|
+
(0, ui_1.out)(`live: ${card.live_url ?? '— (не публиковался)'}`);
|
|
188
|
+
}
|