@getxflow/cli 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -13
- package/dist/api.js +133 -0
- package/dist/args.js +82 -0
- package/dist/bin.js +138 -0
- package/dist/commands/auth.js +111 -0
- package/dist/commands/deploy.js +123 -0
- package/dist/commands/functions.js +103 -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 +132 -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 +100 -0
- package/index.js +0 -2
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.functionsList = functionsList;
|
|
4
|
+
exports.functionsDeploy = functionsDeploy;
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const api_1 = require("../api");
|
|
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
|
+
const ENTRY_NAMES = ['index.ts', 'index.js', 'index.mjs'];
|
|
13
|
+
/** Папка с функциями внутри проекта. Та же, что была до пивота: менять её незачем. */
|
|
14
|
+
const FUNCTIONS_DIR = 'functions';
|
|
15
|
+
function entryFor(root, name) {
|
|
16
|
+
for (const entry of ENTRY_NAMES) {
|
|
17
|
+
const candidate = (0, node_path_1.join)(root, FUNCTIONS_DIR, name, entry);
|
|
18
|
+
if ((0, node_fs_1.existsSync)(candidate))
|
|
19
|
+
return candidate;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
function discover(root) {
|
|
24
|
+
const dir = (0, node_path_1.join)(root, FUNCTIONS_DIR);
|
|
25
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
26
|
+
return [];
|
|
27
|
+
return (0, node_fs_1.readdirSync)(dir)
|
|
28
|
+
.filter((name) => (0, node_fs_1.statSync)((0, node_path_1.join)(dir, name)).isDirectory())
|
|
29
|
+
.filter((name) => entryFor(root, name) !== null)
|
|
30
|
+
.sort();
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Собрать функцию в один файл.
|
|
34
|
+
*
|
|
35
|
+
* esbuild берём из node_modules проекта, а не тащим зависимостью в CLI: он и так
|
|
36
|
+
* есть в каждом приложении на платформе (внутри Vite), а пакет без зависимостей
|
|
37
|
+
* ставится быстрее и не требует доверия к нашему списку.
|
|
38
|
+
*
|
|
39
|
+
* `pg` оставляем снаружи бандла сознательно: серверная обёртка подставляет схему
|
|
40
|
+
* проекта в каждое соединение, а сделать это можно только с общим драйвером.
|
|
41
|
+
*/
|
|
42
|
+
function bundle(root, entry) {
|
|
43
|
+
let esbuild;
|
|
44
|
+
try {
|
|
45
|
+
esbuild = require(require.resolve('esbuild', { paths: [root] }));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new errors_1.CliError('Не нашёл esbuild в проекте', 'Установите его: npm i -D esbuild. Обычно он уже стоит вместе с Vite');
|
|
49
|
+
}
|
|
50
|
+
const result = esbuild.buildSync({
|
|
51
|
+
entryPoints: [entry],
|
|
52
|
+
bundle: true,
|
|
53
|
+
platform: 'node',
|
|
54
|
+
target: 'node20',
|
|
55
|
+
format: 'cjs',
|
|
56
|
+
external: ['pg'],
|
|
57
|
+
write: false,
|
|
58
|
+
logLevel: 'silent',
|
|
59
|
+
});
|
|
60
|
+
const text = result.outputFiles[0]?.text;
|
|
61
|
+
if (!text)
|
|
62
|
+
throw new errors_1.CliError(`Сборка ${entry} не дала результата`);
|
|
63
|
+
return text;
|
|
64
|
+
}
|
|
65
|
+
async function functionsList() {
|
|
66
|
+
const { config } = (0, config_1.requireProject)();
|
|
67
|
+
const client = (0, session_1.connect)(config);
|
|
68
|
+
const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/functions`);
|
|
69
|
+
if (data.functions.length === 0) {
|
|
70
|
+
(0, ui_1.note)(`Функций нет. Положите код в ${FUNCTIONS_DIR}/<имя>/index.ts и выполните xflow functions deploy`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
(0, ui_1.table)(data.functions.map((fn) => [
|
|
74
|
+
fn.name,
|
|
75
|
+
fn.status === 'deployed' ? 'выложена' : fn.status === 'failed' ? 'ошибка' : fn.status,
|
|
76
|
+
fn.last_deployed_at ? (0, ui_1.formatAge)(fn.last_deployed_at) : '—',
|
|
77
|
+
fn.error_message ?? fn.invoke_url ?? '',
|
|
78
|
+
]));
|
|
79
|
+
}
|
|
80
|
+
async function functionsDeploy(args) {
|
|
81
|
+
const { root, config } = (0, config_1.requireProject)();
|
|
82
|
+
const client = (0, session_1.connect)(config);
|
|
83
|
+
const wanted = args.words[1];
|
|
84
|
+
const names = wanted ? [wanted] : discover(root);
|
|
85
|
+
if (names.length === 0) {
|
|
86
|
+
throw new errors_1.CliError(`В проекте нет функций`, `Создайте ${FUNCTIONS_DIR}/<имя>/index.ts с экспортом handler и повторите`);
|
|
87
|
+
}
|
|
88
|
+
for (const name of names) {
|
|
89
|
+
const entry = entryFor(root, name);
|
|
90
|
+
if (!entry) {
|
|
91
|
+
throw new errors_1.CliError(`Не нашёл ${FUNCTIONS_DIR}/${name}/index.ts`, `Доступные функции: ${discover(root).join(', ') || 'нет ни одной'}`);
|
|
92
|
+
}
|
|
93
|
+
(0, ui_1.step)(`Собираю ${name}`);
|
|
94
|
+
const code = bundle(root, entry);
|
|
95
|
+
(0, ui_1.step)(`Выкладываю ${name}`);
|
|
96
|
+
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' });
|
|
97
|
+
(0, ui_1.ok)(`${(0, ui_1.bold)(result.name)} выложена`);
|
|
98
|
+
(0, ui_1.out)(result.url);
|
|
99
|
+
if (result.secrets.length > 0) {
|
|
100
|
+
(0, ui_1.note)((0, ui_1.dim)(` Секреты организации в окружении: ${result.secrets.join(', ')}`));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ConfigError = exports.CONFIG_FILE = void 0;
|
|
4
|
+
exports.findProjectRoot = findProjectRoot;
|
|
5
|
+
exports.readConfig = readConfig;
|
|
6
|
+
exports.writeConfig = writeConfig;
|
|
7
|
+
exports.requireProject = requireProject;
|
|
8
|
+
exports.apiUrlFor = apiUrlFor;
|
|
9
|
+
exports.readState = readState;
|
|
10
|
+
exports.writeState = writeState;
|
|
11
|
+
exports.ignoreStateInGit = ignoreStateInGit;
|
|
12
|
+
const node_fs_1 = require("node:fs");
|
|
13
|
+
const node_path_1 = require("node:path");
|
|
14
|
+
const version_1 = require("./version");
|
|
15
|
+
exports.CONFIG_FILE = 'xflow.json';
|
|
16
|
+
const STATE_DIR = '.xflow';
|
|
17
|
+
class ConfigError extends Error {
|
|
18
|
+
}
|
|
19
|
+
exports.ConfigError = ConfigError;
|
|
20
|
+
/** Ищем корень проекта вверх по дереву: команду запускают из подкаталога чаще, чем из корня. */
|
|
21
|
+
function findProjectRoot(from = process.cwd()) {
|
|
22
|
+
let dir = (0, node_path_1.resolve)(from);
|
|
23
|
+
for (;;) {
|
|
24
|
+
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(dir, exports.CONFIG_FILE)))
|
|
25
|
+
return dir;
|
|
26
|
+
const parent = (0, node_path_1.dirname)(dir);
|
|
27
|
+
if (parent === dir)
|
|
28
|
+
return null;
|
|
29
|
+
dir = parent;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function readConfig(root) {
|
|
33
|
+
const path = (0, node_path_1.join)(root, exports.CONFIG_FILE);
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf-8'));
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
throw new ConfigError(`Не удалось прочитать ${exports.CONFIG_FILE}: ${e instanceof Error ? e.message : e}`);
|
|
40
|
+
}
|
|
41
|
+
const config = parsed;
|
|
42
|
+
if (!config || typeof config.projectId !== 'string' || !config.projectId) {
|
|
43
|
+
throw new ConfigError(`В ${exports.CONFIG_FILE} нет projectId. Свяжите папку с проектом: xflow link <id>`);
|
|
44
|
+
}
|
|
45
|
+
return config;
|
|
46
|
+
}
|
|
47
|
+
function writeConfig(root, config) {
|
|
48
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, exports.CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
|
|
49
|
+
}
|
|
50
|
+
/** Конфигурация проекта, из которого запущена команда. Бросает, если папка не связана. */
|
|
51
|
+
function requireProject() {
|
|
52
|
+
const root = findProjectRoot();
|
|
53
|
+
if (!root) {
|
|
54
|
+
throw new ConfigError(`Папка не связана с проектом: рядом нет ${exports.CONFIG_FILE}.\n` +
|
|
55
|
+
' Связать существующий: xflow link <id проекта>\n' +
|
|
56
|
+
' Создать новый: xflow init');
|
|
57
|
+
}
|
|
58
|
+
return { root, config: readConfig(root) };
|
|
59
|
+
}
|
|
60
|
+
function apiUrlFor(config) {
|
|
61
|
+
const raw = process.env.XFLOW_API_URL?.trim() || config?.api?.trim() || version_1.DEFAULT_API_URL;
|
|
62
|
+
return raw.replace(/\/+$/, '');
|
|
63
|
+
}
|
|
64
|
+
function readState(root) {
|
|
65
|
+
const path = (0, node_path_1.join)(root, STATE_DIR, 'state.json');
|
|
66
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
67
|
+
return {};
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse((0, node_fs_1.readFileSync)(path, 'utf-8'));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function writeState(root, state) {
|
|
76
|
+
const dir = (0, node_path_1.join)(root, STATE_DIR);
|
|
77
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
78
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, 'state.json'), `${JSON.stringify(state, null, 2)}\n`, 'utf-8');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Состояние в git не нужно никому, но забыть про него легко: дописываем в
|
|
82
|
+
* .gitignore сами. Если файла нет, создаём — проект почти всегда под git.
|
|
83
|
+
*/
|
|
84
|
+
function ignoreStateInGit(root) {
|
|
85
|
+
const path = (0, node_path_1.join)(root, '.gitignore');
|
|
86
|
+
const line = `${STATE_DIR}/`;
|
|
87
|
+
if ((0, node_fs_1.existsSync)(path)) {
|
|
88
|
+
const content = (0, node_fs_1.readFileSync)(path, 'utf-8');
|
|
89
|
+
if (content.split(/\r?\n/).some((l) => l.trim() === line))
|
|
90
|
+
return;
|
|
91
|
+
(0, node_fs_1.appendFileSync)(path, `${content.endsWith('\n') ? '' : '\n'}${line}\n`, 'utf-8');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
(0, node_fs_1.writeFileSync)(path, `${line}\n`, 'utf-8');
|
|
95
|
+
}
|