@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 CHANGED
@@ -1,13 +1,34 @@
1
- # @getxflow/cli
2
-
3
- Аналитика кабинетов маркетплейсов для вашего ИИ-агента.
4
-
5
- Подключаете кабинеты — агент через MCP или CLI получает нормализованные ответы по всем кабинетам сразу и собирает живые дашборды.
6
-
7
- ```bash
8
- npm i -g @getxflow/cli
9
- xflow auth:login
10
- xflow stats --today
11
- ```
12
-
13
- Пакет в разработке.
1
+ # @getxflow/cli
2
+
3
+ CLI платформы [XFlow](https://getxflow.com): синхронизация кода, сборка и деплой приложений.
4
+
5
+ ```bash
6
+ npm i -g @getxflow/cli
7
+ xflow login
8
+ xflow init my-app
9
+ cd my-app && npm install
10
+ xflow deploy
11
+ xflow publish
12
+ ```
13
+
14
+ Требуется Node 20+. Зависимостей у пакета нет: он держит в себе ключ доступа, и чужого кода
15
+ рядом с ним быть не должно.
16
+
17
+ | Команда | Что делает |
18
+ |---|---|
19
+ | `login` / `logout` / `whoami` | вход через браузер, выход, чей ключ и что может |
20
+ | `init` / `link` | новый проект, связать папку с существующим |
21
+ | `status` / `push` / `pull` | состояние, отправка и получение исходников |
22
+ | `deploy` / `publish` / `rollback` / `deployments` | сборка, публикация, откат, история версий |
23
+ | `functions list` / `functions deploy` | облачные функции проекта из `functions/<имя>/index.ts` |
24
+ | `projects list` / `projects get` / `open` | проекты организации и адреса приложения |
25
+ | `skills` | инструкция о платформе для ИИ-агента (формат [Agent Skills](https://agentskills.io)) |
26
+
27
+ Справка по команде: `xflow help <команда>`.
28
+
29
+ Приложение вы пишете сами или своим агентом: платформа в код не лезет. `xflow skills` кладёт
30
+ рядом описание платформы, которое Claude Code, Codex и OpenClaw подхватывают сами, когда задача
31
+ касается XFlow. Ваши `AGENTS.md` и `CLAUDE.md` команда не трогает.
32
+
33
+ Ключ хранится в `~/.xflow/credentials.json`, конфигурация проекта — в `xflow.json`.
34
+ Для CI передайте ключ переменной `XFLOW_TOKEN`.
package/dist/api.js ADDED
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ApiError = void 0;
4
+ exports.apiJson = apiJson;
5
+ exports.apiBinary = apiBinary;
6
+ exports.apiUpload = apiUpload;
7
+ exports.downloadUrl = downloadUrl;
8
+ const version_1 = require("./version");
9
+ /**
10
+ * Клиент публичного API платформы.
11
+ *
12
+ * Ошибка сервера приходит в одном формате (`error`, `code`, `hint`) — здесь она
13
+ * превращается в исключение, у которого есть что показать человеку. Плюс
14
+ * проверка версии протокола: старый CLI обязан сказать «обновись», а не падать
15
+ * на разборе неизвестного ответа.
16
+ */
17
+ class ApiError extends Error {
18
+ code;
19
+ status;
20
+ hint;
21
+ constructor(message, code, status, hint) {
22
+ super(message);
23
+ this.name = 'ApiError';
24
+ this.code = code;
25
+ this.status = status;
26
+ this.hint = hint;
27
+ }
28
+ }
29
+ exports.ApiError = ApiError;
30
+ const DEFAULT_TIMEOUT_MS = 30_000;
31
+ /** Заливка и скачивание архивов: десятки мегабайт по обычному домашнему каналу. */
32
+ const TRANSFER_TIMEOUT_MS = 5 * 60_000;
33
+ function parseVersion(value) {
34
+ return value
35
+ .trim()
36
+ .split('.')
37
+ .map((part) => Number.parseInt(part, 10) || 0);
38
+ }
39
+ function isOlder(current, required) {
40
+ const a = parseVersion(current);
41
+ const b = parseVersion(required);
42
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
43
+ const left = a[i] ?? 0;
44
+ const right = b[i] ?? 0;
45
+ if (left !== right)
46
+ return left < right;
47
+ }
48
+ return false;
49
+ }
50
+ function assertProtocol(response) {
51
+ const required = response.headers.get('x-xflow-cli-min');
52
+ if (required && isOlder(version_1.CLI_VERSION, required)) {
53
+ throw new ApiError(`Платформа требует CLI не ниже ${required}, установлен ${version_1.CLI_VERSION}`, 'outdated_cli', response.status, 'Обновите: npm i -g @getxflow/cli');
54
+ }
55
+ }
56
+ async function send(client, path, options = {}) {
57
+ const { method = 'GET', body, headers = {}, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
58
+ const requestHeaders = {
59
+ 'User-Agent': `xflow-cli/${version_1.CLI_VERSION}`,
60
+ 'X-Xflow-Cli': version_1.CLI_VERSION,
61
+ ...headers,
62
+ };
63
+ // Вход по device flow идёт без ключа: пустой Bearer выглядел бы как испорченный.
64
+ if (client.token)
65
+ requestHeaders.Authorization = `Bearer ${client.token}`;
66
+ let payload;
67
+ if (Buffer.isBuffer(body)) {
68
+ // Именно ArrayBuffer: Buffer живёт в общем пуле Node, и его срез — это чужие
69
+ // байты вокруг наших. Плюс тип понятен и DOM-, и node-типизации fetch.
70
+ payload = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
71
+ requestHeaders['Content-Type'] = requestHeaders['Content-Type'] ?? 'application/zip';
72
+ }
73
+ else if (body) {
74
+ payload = JSON.stringify(body);
75
+ requestHeaders['Content-Type'] = 'application/json';
76
+ }
77
+ let response;
78
+ try {
79
+ response = await fetch(`${client.apiUrl}${path}`, {
80
+ method,
81
+ headers: requestHeaders,
82
+ body: payload,
83
+ signal: AbortSignal.timeout(timeoutMs),
84
+ });
85
+ }
86
+ catch (e) {
87
+ const reason = e instanceof Error ? e.message : String(e);
88
+ throw new ApiError(`Платформа недоступна: ${reason}`, 'network', 0, `Проверьте соединение и адрес ${client.apiUrl}`);
89
+ }
90
+ assertProtocol(response);
91
+ return response;
92
+ }
93
+ async function readError(response) {
94
+ const text = await response.text().catch(() => '');
95
+ try {
96
+ const parsed = JSON.parse(text);
97
+ return new ApiError(parsed.error || `Запрос отклонён (${response.status})`, parsed.code || 'unknown', response.status, parsed.hint);
98
+ }
99
+ catch {
100
+ return new ApiError(`Запрос отклонён (${response.status})`, 'unknown', response.status, text.slice(0, 200) || undefined);
101
+ }
102
+ }
103
+ async function apiJson(client, path, options = {}) {
104
+ const response = await send(client, path, options);
105
+ if (!response.ok)
106
+ throw await readError(response);
107
+ return (await response.json());
108
+ }
109
+ /** Ответ-файл: архив шаблона отдаётся телом, а не ссылкой (он лежит в базе, а не в S3). */
110
+ async function apiBinary(client, path) {
111
+ const response = await send(client, path, { timeoutMs: TRANSFER_TIMEOUT_MS });
112
+ if (!response.ok)
113
+ throw await readError(response);
114
+ return Buffer.from(await response.arrayBuffer());
115
+ }
116
+ /** Загрузка архива на сервер: тот же разбор ошибок, но с длинным таймаутом. */
117
+ async function apiUpload(client, path, body, headers = {}) {
118
+ return apiJson(client, path, { method: 'POST', body, headers, timeoutMs: TRANSFER_TIMEOUT_MS });
119
+ }
120
+ /** Скачивание по подписанной ссылке. Ключ сюда не подставляем: ссылка сама и есть пропуск. */
121
+ async function downloadUrl(url) {
122
+ let response;
123
+ try {
124
+ response = await fetch(url, { signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) });
125
+ }
126
+ catch (e) {
127
+ throw new ApiError(`Не удалось скачать архив: ${e instanceof Error ? e.message : e}`, 'network', 0);
128
+ }
129
+ if (!response.ok) {
130
+ throw new ApiError(`Не удалось скачать архив (${response.status})`, 'download_failed', response.status);
131
+ }
132
+ return Buffer.from(await response.arrayBuffer());
133
+ }
package/dist/args.js ADDED
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ /**
3
+ * Разбор аргументов.
4
+ *
5
+ * Своими руками, без зависимостей: пакет с ключом доступа внутри не должен
6
+ * тянуть за собой чужой код ради двадцати строк логики.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.parseArgs = parseArgs;
10
+ exports.flagString = flagString;
11
+ exports.flagBool = flagBool;
12
+ exports.flagNumber = flagNumber;
13
+ /**
14
+ * Флаги без значения. Список явный, потому что иначе `--force ./dir` съедает
15
+ * путь как значение флага, и разработчик получает загадочную ошибку.
16
+ */
17
+ const BOOLEAN_FLAGS = new Set([
18
+ 'force',
19
+ 'yes',
20
+ 'json',
21
+ 'help',
22
+ 'version',
23
+ 'open',
24
+ 'no-push',
25
+ 'skip-build',
26
+ 'all',
27
+ ]);
28
+ function parseArgs(argv) {
29
+ const words = [];
30
+ const flags = {};
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const token = argv[i];
33
+ if (token === '--') {
34
+ words.push(...argv.slice(i + 1));
35
+ break;
36
+ }
37
+ if (token.startsWith('--')) {
38
+ const body = token.slice(2);
39
+ const eq = body.indexOf('=');
40
+ if (eq !== -1) {
41
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
42
+ continue;
43
+ }
44
+ const next = argv[i + 1];
45
+ if (BOOLEAN_FLAGS.has(body) || next === undefined || next.startsWith('-')) {
46
+ flags[body] = true;
47
+ }
48
+ else {
49
+ flags[body] = next;
50
+ i++;
51
+ }
52
+ continue;
53
+ }
54
+ if (token === '-h') {
55
+ flags.help = true;
56
+ continue;
57
+ }
58
+ if (token === '-v') {
59
+ flags.version = true;
60
+ continue;
61
+ }
62
+ words.push(token);
63
+ }
64
+ return { words, flags };
65
+ }
66
+ function flagString(args, name) {
67
+ const value = args.flags[name];
68
+ return typeof value === 'string' ? value : undefined;
69
+ }
70
+ function flagBool(args, name) {
71
+ return args.flags[name] === true || args.flags[name] === 'true';
72
+ }
73
+ function flagNumber(args, name) {
74
+ const value = flagString(args, name);
75
+ if (value === undefined)
76
+ return undefined;
77
+ const parsed = Number(value);
78
+ if (!Number.isInteger(parsed) || parsed < 0) {
79
+ throw new Error(`Значение --${name} должно быть целым числом, получено «${value}»`);
80
+ }
81
+ return parsed;
82
+ }
package/dist/bin.js ADDED
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const api_1 = require("./api");
5
+ const args_1 = require("./args");
6
+ const config_1 = require("./config");
7
+ const errors_1 = require("./errors");
8
+ const help_1 = require("./help");
9
+ const ui_1 = require("./ui");
10
+ const zip_1 = require("./zip");
11
+ const version_1 = require("./version");
12
+ const auth_1 = require("./commands/auth");
13
+ const projects_1 = require("./commands/projects");
14
+ const functions_1 = require("./commands/functions");
15
+ const skills_1 = require("./commands/skills");
16
+ const sources_1 = require("./commands/sources");
17
+ const deploy_1 = require("./commands/deploy");
18
+ /** Одно место, где команда превращается в действие. Слова, а не флаги: `projects list`. */
19
+ async function run(args) {
20
+ const [first, second] = args.words;
21
+ const rest = { ...args, words: args.words.slice(1) };
22
+ switch (first) {
23
+ case undefined:
24
+ (0, help_1.help)();
25
+ return;
26
+ case 'help':
27
+ (0, help_1.help)(second);
28
+ return;
29
+ case 'login':
30
+ await (0, auth_1.login)();
31
+ return;
32
+ case 'logout':
33
+ (0, auth_1.logout)();
34
+ return;
35
+ case 'whoami':
36
+ await (0, auth_1.whoami)();
37
+ return;
38
+ case 'templates':
39
+ await (0, projects_1.templates)();
40
+ return;
41
+ case 'init':
42
+ await (0, projects_1.init)(rest);
43
+ return;
44
+ case 'link':
45
+ await (0, projects_1.link)(rest);
46
+ return;
47
+ case 'open':
48
+ await (0, projects_1.open)(rest);
49
+ return;
50
+ case 'projects':
51
+ if (second === 'get') {
52
+ await (0, projects_1.get)({ ...args, words: args.words.slice(2) });
53
+ return;
54
+ }
55
+ if (second === undefined || second === 'list') {
56
+ await (0, projects_1.list)();
57
+ return;
58
+ }
59
+ throw new errors_1.CliError(`Неизвестная команда: projects ${second}`, 'Есть list и get');
60
+ case 'skills':
61
+ (0, skills_1.skills)(rest);
62
+ return;
63
+ case 'functions':
64
+ if (second === 'deploy') {
65
+ await (0, functions_1.functionsDeploy)(rest);
66
+ return;
67
+ }
68
+ if (second === undefined || second === 'list') {
69
+ await (0, functions_1.functionsList)();
70
+ return;
71
+ }
72
+ throw new errors_1.CliError(`Неизвестная команда: functions ${second}`, 'Есть list и deploy');
73
+ case 'status':
74
+ await (0, sources_1.status)();
75
+ return;
76
+ case 'push':
77
+ await (0, sources_1.push)(rest);
78
+ return;
79
+ case 'pull':
80
+ await (0, sources_1.pull)(rest);
81
+ return;
82
+ case 'deploy':
83
+ await (0, deploy_1.deploy)(rest);
84
+ return;
85
+ case 'publish':
86
+ await (0, deploy_1.publish)();
87
+ return;
88
+ case 'rollback':
89
+ await (0, deploy_1.rollback)(rest);
90
+ return;
91
+ case 'deployments':
92
+ await (0, deploy_1.deployments)();
93
+ return;
94
+ default:
95
+ throw new errors_1.CliError(`Неизвестная команда: ${first}`, 'Список команд: xflow help');
96
+ }
97
+ }
98
+ async function main() {
99
+ const args = (0, args_1.parseArgs)(process.argv.slice(2));
100
+ if ((0, args_1.flagBool)(args, 'version')) {
101
+ (0, ui_1.out)(version_1.CLI_VERSION);
102
+ return 0;
103
+ }
104
+ if ((0, args_1.flagBool)(args, 'help')) {
105
+ (0, help_1.help)(args.words[0]);
106
+ return 0;
107
+ }
108
+ try {
109
+ await run(args);
110
+ return 0;
111
+ }
112
+ catch (e) {
113
+ if (e instanceof api_1.ApiError) {
114
+ (0, ui_1.fail)(e.message);
115
+ if (e.hint)
116
+ (0, ui_1.note)((0, ui_1.dim)(` ${e.hint}`));
117
+ return 1;
118
+ }
119
+ if (e instanceof errors_1.CliError || e instanceof config_1.ConfigError || e instanceof zip_1.ZipError) {
120
+ (0, ui_1.fail)(e.message);
121
+ const hint = e instanceof errors_1.CliError ? e.hint : undefined;
122
+ if (hint)
123
+ (0, ui_1.note)((0, ui_1.dim)(` ${hint}`));
124
+ return 1;
125
+ }
126
+ (0, ui_1.fail)(e instanceof Error ? e.message : String(e));
127
+ // Стек прячем: он полезен только нам, и то по запросу.
128
+ if (process.env.XFLOW_DEBUG && e instanceof Error && e.stack)
129
+ (0, ui_1.note)((0, ui_1.dim)(e.stack));
130
+ return 1;
131
+ }
132
+ }
133
+ main().then((code) => {
134
+ process.exitCode = code;
135
+ }, (e) => {
136
+ (0, ui_1.fail)(e instanceof Error ? e.message : String(e));
137
+ process.exitCode = 1;
138
+ });
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.login = login;
4
+ exports.logout = logout;
5
+ exports.whoami = whoami;
6
+ const api_1 = require("../api");
7
+ const config_1 = require("../config");
8
+ const credentials_1 = require("../credentials");
9
+ const errors_1 = require("../errors");
10
+ const open_url_1 = require("../open-url");
11
+ const session_1 = require("../session");
12
+ const ui_1 = require("../ui");
13
+ function localConfig() {
14
+ const root = (0, config_1.findProjectRoot)();
15
+ if (!root)
16
+ return undefined;
17
+ try {
18
+ return (0, config_1.readConfig)(root);
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
25
+ /**
26
+ * Вход человека: device flow.
27
+ *
28
+ * Ключ не проходит ни через копипаст, ни через контекст агента — он выпускается
29
+ * на сервере после подтверждения в браузере и приезжает сюда ровно один раз.
30
+ */
31
+ async function login() {
32
+ const config = localConfig();
33
+ const client = (0, session_1.anonymous)(config);
34
+ const start = await (0, api_1.apiJson)(client, '/api/v1/auth/device', { method: 'POST' });
35
+ (0, ui_1.note)('');
36
+ (0, ui_1.note)(` Код подтверждения: ${(0, ui_1.bold)(start.user_code)}`);
37
+ (0, ui_1.note)(` Страница входа: ${start.verification_uri}`);
38
+ (0, ui_1.note)('');
39
+ (0, ui_1.step)('Открываю браузер, код там надо сверить с показанным выше');
40
+ (0, open_url_1.openUrl)(start.verification_uri_complete);
41
+ const deadline = Date.now() + start.expires_in * 1000;
42
+ let intervalMs = Math.max(start.interval, 1) * 1000;
43
+ for (;;) {
44
+ if (Date.now() > deadline) {
45
+ throw new errors_1.CliError('Срок кода истёк', 'Повторите: xflow login');
46
+ }
47
+ await sleep(intervalMs);
48
+ let poll;
49
+ try {
50
+ poll = await (0, api_1.apiJson)(client, '/api/v1/auth/device/token', {
51
+ method: 'POST',
52
+ body: { device_code: start.device_code },
53
+ });
54
+ }
55
+ catch (e) {
56
+ // Слишком частый опрос — не повод прерывать вход, замедляемся.
57
+ if (e instanceof api_1.ApiError && e.code === 'rate_limited') {
58
+ intervalMs = Math.min(intervalMs * 2, 30_000);
59
+ continue;
60
+ }
61
+ throw e;
62
+ }
63
+ if (poll.status === 'pending')
64
+ continue;
65
+ (0, credentials_1.saveCredential)(client.apiUrl, {
66
+ token: poll.token,
67
+ organizationId: poll.organization_id,
68
+ projectId: poll.project_id,
69
+ });
70
+ (0, ui_1.ok)(`Вход выполнен, ключ сохранён для ${client.apiUrl}`);
71
+ if (poll.project_id) {
72
+ (0, ui_1.note)((0, ui_1.dim)(` Ключ выдан на проект ${poll.project_id}: другие проекты им недоступны`));
73
+ }
74
+ return;
75
+ }
76
+ }
77
+ function logout() {
78
+ const apiUrl = (0, config_1.apiUrlFor)(localConfig());
79
+ if ((0, credentials_1.forgetCredential)(apiUrl)) {
80
+ (0, ui_1.ok)(`Ключ для ${apiUrl} удалён`);
81
+ }
82
+ else {
83
+ (0, ui_1.warn)(`Сохранённого ключа для ${apiUrl} нет`);
84
+ }
85
+ }
86
+ async function whoami() {
87
+ const config = localConfig();
88
+ const client = (0, session_1.connect)(config);
89
+ const me = await (0, api_1.apiJson)(client, '/api/v1/me');
90
+ (0, ui_1.out)(`Организация: ${me.organization.name ?? me.organization.id}`);
91
+ (0, ui_1.out)(`Ключ: xfk_${me.key.prefix}… (${me.key.scopes.join(', ')})`);
92
+ (0, ui_1.out)(`Проект ключа: ${me.key.project_id ?? 'вся организация'}`);
93
+ (0, ui_1.out)(`Платформа: ${client.apiUrl}`);
94
+ if (process.env.XFLOW_TOKEN) {
95
+ (0, ui_1.note)((0, ui_1.dim)(' Ключ взят из XFLOW_TOKEN, сохранённый в ~/.xflow не используется'));
96
+ }
97
+ else if (!(0, credentials_1.readCredential)(client.apiUrl)) {
98
+ (0, ui_1.note)((0, ui_1.dim)(' Ключ не сохранён'));
99
+ }
100
+ if (me.billing) {
101
+ (0, ui_1.out)('');
102
+ (0, ui_1.out)(`Баланс: ${me.billing.balance ?? '—'} (статус ${me.billing.status})`);
103
+ }
104
+ if (me.quotas) {
105
+ (0, ui_1.out)('');
106
+ (0, ui_1.out)('Квоты:');
107
+ for (const [name, value] of Object.entries(me.quotas)) {
108
+ (0, ui_1.out)(` ${name}: ${value.used} из ${value.limit}`);
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deploy = deploy;
4
+ exports.publish = publish;
5
+ exports.rollback = rollback;
6
+ exports.deployments = deployments;
7
+ const node_child_process_1 = require("node:child_process");
8
+ const node_path_1 = require("node:path");
9
+ const api_1 = require("../api");
10
+ const args_1 = require("../args");
11
+ const config_1 = require("../config");
12
+ const errors_1 = require("../errors");
13
+ const session_1 = require("../session");
14
+ const tree_1 = require("../tree");
15
+ const zip_1 = require("../zip");
16
+ const ui_1 = require("../ui");
17
+ const sources_1 = require("./sources");
18
+ /** Потолок артефакта на сервере. */
19
+ const MAX_ARTIFACT_BYTES = 100 * 1024 * 1024;
20
+ /**
21
+ * Запустить сборку проекта.
22
+ *
23
+ * Через оболочку: команда задана разработчиком в xflow.json (`npm run build`),
24
+ * и разбирать её самим значит спорить с тем, что он написал. Вывод идёт прямо
25
+ * в терминал, поэтому кодировки и прогресс-бары выглядят как обычно.
26
+ */
27
+ function runBuild(command, cwd) {
28
+ return new Promise((done, reject) => {
29
+ const child = (0, node_child_process_1.spawn)(command, { cwd, shell: true, stdio: 'inherit' });
30
+ child.on('error', (e) => reject(new errors_1.CliError(`Не удалось запустить сборку: ${e.message}`)));
31
+ child.on('close', (code) => {
32
+ if (code === 0) {
33
+ done();
34
+ return;
35
+ }
36
+ reject(new errors_1.CliError(`Сборка завершилась с кодом ${code}`, 'Деплой остановлен: на сервер уходит только то, что собралось'));
37
+ });
38
+ });
39
+ }
40
+ async function deploy(args) {
41
+ const { root, config } = (0, config_1.requireProject)();
42
+ const client = (0, session_1.connect)(config);
43
+ const buildCommand = config.build?.command ?? 'npm run build';
44
+ const buildDir = (0, node_path_1.resolve)(root, config.build?.dir ?? 'dist');
45
+ let revision;
46
+ if ((0, args_1.flagBool)(args, 'no-push')) {
47
+ const server = await (0, sources_1.latestRevision)(client, config.projectId);
48
+ if (!server) {
49
+ throw new errors_1.CliError('На сервере нет исходников', 'Отправьте их: xflow push');
50
+ }
51
+ revision = server.revision;
52
+ (0, ui_1.warn)(`Исходники не отправлены (--no-push), деплой пойдёт от ревизии ${revision}`);
53
+ }
54
+ else {
55
+ revision = (await (0, sources_1.pushSources)(root, config, client, { force: (0, args_1.flagBool)(args, 'force') })).revision;
56
+ }
57
+ if (!(0, args_1.flagBool)(args, 'skip-build')) {
58
+ (0, ui_1.step)(`Сборка: ${buildCommand}`);
59
+ await runBuild(buildCommand, root);
60
+ }
61
+ const files = (0, tree_1.collectArtifact)(buildDir);
62
+ if (files.length === 0) {
63
+ throw new errors_1.CliError(`Каталог сборки пуст: ${buildDir}`, 'Проверьте build.dir в xflow.json: там должен быть каталог, куда сборщик кладёт результат');
64
+ }
65
+ // Платформа раздаёт статику. Ловим здесь, а не после заливки: ошибка о том,
66
+ // что сборка серверная, полезна до того, как проект отдаст пустую страницу.
67
+ if (!files.some((f) => f.path === 'index.html')) {
68
+ throw new errors_1.CliError(`В корне ${buildDir} нет index.html`, 'Похоже на серверную сборку (SSR), а платформа раздаёт статику: соберите приложение как SPA');
69
+ }
70
+ const archive = (0, zip_1.zipCreate)(files);
71
+ if (archive.length > MAX_ARTIFACT_BYTES) {
72
+ throw new errors_1.CliError(`Артефакт ${(0, ui_1.formatBytes)(archive.length)}, потолок ${(0, ui_1.formatBytes)(MAX_ARTIFACT_BYTES)}`, 'Уберите из сборки исходные карты (sourcemaps) и неиспользуемые ассеты');
73
+ }
74
+ (0, ui_1.step)(`Выкладываю ${files.length} файлов (${(0, ui_1.formatBytes)(archive.length)})`);
75
+ const result = await (0, api_1.apiUpload)(client, `/api/v1/projects/${config.projectId}/deploy`, archive, { 'X-Revision': String(revision) });
76
+ (0, ui_1.ok)(`Версия ${result.deploy_id} собрана из ревизии ${result.revision}`);
77
+ (0, ui_1.out)(result.url);
78
+ (0, ui_1.note)((0, ui_1.dim)(' Показать посетителям: xflow publish'));
79
+ }
80
+ async function publish() {
81
+ const { config } = (0, config_1.requireProject)();
82
+ const client = (0, session_1.connect)(config);
83
+ (0, ui_1.step)('Публикую текущую dev-версию');
84
+ const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/publish`, { method: 'POST', timeoutMs: 120_000 });
85
+ if (result.already_published) {
86
+ (0, ui_1.ok)('Эта версия уже опубликована');
87
+ }
88
+ else {
89
+ (0, ui_1.ok)(`Опубликована версия ${result.deploy_id}`);
90
+ }
91
+ (0, ui_1.out)(result.url);
92
+ }
93
+ async function rollback(args) {
94
+ const { config } = (0, config_1.requireProject)();
95
+ const deployId = args.words[0];
96
+ if (!deployId) {
97
+ throw new errors_1.CliError('Нужен номер версии', 'Посмотреть версии: xflow deployments. Пример: xflow rollback 481203');
98
+ }
99
+ const client = (0, session_1.connect)(config);
100
+ const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/rollback`, { method: 'POST', body: { deploy_id: deployId } });
101
+ (0, ui_1.ok)(`dev-адрес переключён на версию ${result.deploy_id}`);
102
+ (0, ui_1.out)(result.url);
103
+ if (result.revision !== null) {
104
+ (0, ui_1.note)((0, ui_1.dim)(` Код этой версии: xflow pull --revision ${result.revision} --into ./v${result.deploy_id}`));
105
+ }
106
+ (0, ui_1.note)((0, ui_1.dim)(' Посетители увидят её только после xflow publish'));
107
+ }
108
+ async function deployments() {
109
+ const { config } = (0, config_1.requireProject)();
110
+ const client = (0, session_1.connect)(config);
111
+ const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/deployments`);
112
+ if (data.deployments.length === 0) {
113
+ (0, ui_1.note)('Версий пока нет. Собрать и выложить: xflow deploy');
114
+ return;
115
+ }
116
+ (0, ui_1.table)(data.deployments.map((d) => [
117
+ d.deploy_id ?? '—',
118
+ d.revision !== null ? `ревизия ${d.revision}` : 'без ревизии',
119
+ d.status === 'deployed' ? 'готова' : d.status,
120
+ (0, ui_1.formatAge)(d.deployed_at ?? d.created_at),
121
+ [d.is_dev ? (0, ui_1.bold)('dev') : '', d.is_live ? (0, ui_1.bold)('live') : ''].filter(Boolean).join(' '),
122
+ ]));
123
+ }