@getxflow/cli 0.1.8 → 0.1.10

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,6 +1,6 @@
1
1
  # @getxflow/cli
2
2
 
3
- CLI платформы [XFlow](https://getxflow.com): синхронизация кода, сборка и деплой приложений.
3
+ CLI for the [XFlow](https://getxflow.com) platform: source sync, builds and releases.
4
4
 
5
5
  ```bash
6
6
  npm i -g @getxflow/cli
@@ -11,34 +11,35 @@ xflow deploy
11
11
  xflow publish
12
12
  ```
13
13
 
14
- Требуется Node 20+. Зависимостей у пакета нет: он держит в себе ключ доступа, и чужого кода
15
- рядом с ним быть не должно.
14
+ Requires Node 20+. The package has no dependencies: it holds an access key, and nobody
15
+ else's code belongs next to it.
16
16
 
17
- | Команда | Что делает |
17
+ | Command | What it does |
18
18
  |---|---|
19
- | `login` / `logout` / `whoami` | вход через браузер, выход, чей ключ и что может |
20
- | `init` / `link` | новый проект, связать папку с существующим |
21
- | `status` / `push` / `pull` | состояние, отправка и получение исходников |
22
- | `deploy` / `publish` / `rollback` / `deployments` | сборка, публикация, откат, история версий |
23
- | `db status` / `db migrate` | миграции из `migrations/*.sql` с гейтом на разрушающие |
24
- | `functions list` / `functions deploy` | облачные функции проекта из `functions/<имя>/index.ts` |
25
- | `functions invoke` / `functions logs` | вызвать функцию, посмотреть её падения со стеком |
26
- | `schedules list` / `set` / `rm` | запуск функций по времени (таймер-триггеры) |
27
- | `env` / `env check` / `env set` | переменные окружения функций, значения наружу не отдаются |
28
- | `logs` | ошибки выложенного приложения в браузере |
29
- | `projects list` / `projects get` / `open` | проекты организации и адреса приложения |
30
- | `skills` | инструкция о платформе для ИИ-агента (формат [Agent Skills](https://agentskills.io)) |
31
- | `mcp install` | подключить агента к платформе напрямую, без терминала |
32
-
33
- Справка по команде: `xflow help <команда>`.
34
-
35
- Приложение вы пишете сами или своим агентом: платформа в код не лезет. `xflow skills` кладёт
36
- рядом описание платформы, которое Claude Code, Codex и OpenClaw подхватывают сами, когда задача
37
- касается XFlow. Ваши `AGENTS.md` и `CLAUDE.md` команда не трогает.
38
-
39
- `xflow mcp install` подключает агента к платформе напрямую: база, миграции, функции, расписания,
40
- переменные и версии становятся его инструментами, без разбора вывода команд. Код, сборка и выкладка
41
- остаются в CLI.
42
-
43
- Ключ хранится в `~/.xflow/credentials.json`, конфигурация проекта — в `xflow.json`.
44
- Для CI передайте ключ переменной `XFLOW_TOKEN`.
19
+ | `login` / `logout` / `whoami` | sign in through the browser, sign out, whose key this is and what it can do |
20
+ | `init` / `link` | new project, link a folder to an existing one |
21
+ | `status` / `push` / `pull` | state, sending and fetching sources |
22
+ | `deploy` / `publish` / `rollback` / `deployments` | build, publish, roll back, version history |
23
+ | `db status` / `db migrate` | migrations from `migrations/*.sql` with a gate on destructive ones |
24
+ | `functions list` / `functions deploy` | cloud functions of the project from `functions/<name>/index.ts` |
25
+ | `functions invoke` / `functions logs` | call a function, look at its crashes with the stack |
26
+ | `schedules list` / `set` / `rm` | running functions on a timer (timer triggers) |
27
+ | `env` / `env check` / `env set` | environment variables of the functions, values are never handed back |
28
+ | `logs` | browser errors from the released application |
29
+ | `projects list` / `projects get` / `open` | projects of the organization and the application addresses |
30
+ | `skills` | platform instructions for an AI agent ([Agent Skills](https://agentskills.io) format) |
31
+ | `mcp install` | connect the agent to the platform directly, without a terminal |
32
+
33
+ Help for one command: `xflow help <command>`.
34
+
35
+ You write the application yourself or with your agent: the platform stays out of the code.
36
+ `xflow skills` puts a description of the platform alongside it, which Claude Code, Codex and
37
+ OpenClaw pick up on their own when a task touches XFlow. Your `AGENTS.md` and `CLAUDE.md` are
38
+ left alone.
39
+
40
+ `xflow mcp install` connects the agent to the platform directly: the database, migrations,
41
+ functions, schedules, variables and versions become its tools, with no command output to parse.
42
+ Code, builds and releases stay in the CLI.
43
+
44
+ The key is stored in `~/.xflow/credentials.json`, the project configuration in `xflow.json`.
45
+ For CI, pass the key in the `XFLOW_TOKEN` variable.
package/dist/api.js CHANGED
@@ -6,32 +6,27 @@ exports.apiBinary = apiBinary;
6
6
  exports.apiUpload = apiUpload;
7
7
  exports.downloadUrl = downloadUrl;
8
8
  const version_1 = require("./version");
9
- /**
10
- * Клиент публичного API платформы.
11
- *
12
- * Ошибка сервера приходит в одном формате (`error`, `code`, `hint`) — здесь она
13
- * превращается в исключение, у которого есть что показать человеку. Плюс
14
- * проверка версии протокола: старый CLI обязан сказать «обновись», а не падать
15
- * на разборе неизвестного ответа.
16
- */
17
9
  class ApiError extends Error {
18
10
  code;
19
11
  status;
20
12
  hint;
21
- /** Разбор отказа, если он есть: например, нарушения, из-за которых не пошла сборка. */
13
+ /** Check violations, e.g. why a build was rejected. */
22
14
  issues;
23
- constructor(message, code, status, hint, issues) {
15
+ /** Set when a plan limit was hit. */
16
+ limit;
17
+ constructor(message, code, status, hint, issues, limit) {
24
18
  super(message);
25
19
  this.name = 'ApiError';
26
20
  this.code = code;
27
21
  this.status = status;
28
22
  this.hint = hint;
29
23
  this.issues = issues;
24
+ this.limit = limit;
30
25
  }
31
26
  }
32
27
  exports.ApiError = ApiError;
33
28
  const DEFAULT_TIMEOUT_MS = 30_000;
34
- /** Заливка и скачивание архивов: десятки мегабайт по обычному домашнему каналу. */
29
+ /** Archive uploads and downloads. */
35
30
  const TRANSFER_TIMEOUT_MS = 5 * 60_000;
36
31
  function parseVersion(value) {
37
32
  return value
@@ -53,7 +48,7 @@ function isOlder(current, required) {
53
48
  function assertProtocol(response) {
54
49
  const required = response.headers.get('x-xflow-cli-min');
55
50
  if (required && isOlder(version_1.CLI_VERSION, required)) {
56
- throw new ApiError(`Платформа требует CLI не ниже ${required}, установлен ${version_1.CLI_VERSION}`, 'outdated_cli', response.status, 'Обновите: npm i -g @getxflow/cli');
51
+ throw new ApiError(`The platform requires CLI ${required} or newer, ${version_1.CLI_VERSION} is installed`, 'outdated_cli', response.status, 'Update: npm i -g @getxflow/cli');
57
52
  }
58
53
  }
59
54
  async function send(client, path, options = {}) {
@@ -63,13 +58,11 @@ async function send(client, path, options = {}) {
63
58
  'X-Xflow-Cli': version_1.CLI_VERSION,
64
59
  ...headers,
65
60
  };
66
- // Вход по device flow идёт без ключа: пустой Bearer выглядел бы как испорченный.
67
61
  if (client.token)
68
62
  requestHeaders.Authorization = `Bearer ${client.token}`;
69
63
  let payload;
70
64
  if (Buffer.isBuffer(body)) {
71
- // Именно ArrayBuffer: Buffer живёт в общем пуле Node, и его срез это чужие
72
- // байты вокруг наших. Плюс тип понятен и DOM-, и node-типизации fetch.
65
+ // Copy out of Node's shared Buffer pool before handing the bytes to fetch.
73
66
  payload = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
74
67
  requestHeaders['Content-Type'] = requestHeaders['Content-Type'] ?? 'application/zip';
75
68
  }
@@ -88,7 +81,7 @@ async function send(client, path, options = {}) {
88
81
  }
89
82
  catch (e) {
90
83
  const reason = e instanceof Error ? e.message : String(e);
91
- throw new ApiError(`Платформа недоступна: ${reason}`, 'network', 0, `Проверьте соединение и адрес ${client.apiUrl}`);
84
+ throw new ApiError(`The platform is unreachable: ${reason}`, 'network', 0, `Check the connection and the address ${client.apiUrl}`);
92
85
  }
93
86
  assertProtocol(response);
94
87
  return response;
@@ -97,10 +90,10 @@ async function readError(response) {
97
90
  const text = await response.text().catch(() => '');
98
91
  try {
99
92
  const parsed = JSON.parse(text);
100
- return new ApiError(parsed.error || `Запрос отклонён (${response.status})`, parsed.code || 'unknown', response.status, parsed.hint, parsed.issues);
93
+ return new ApiError(parsed.error || `Request rejected (${response.status})`, parsed.code || 'unknown', response.status, parsed.hint, parsed.issues, parsed.limit);
101
94
  }
102
95
  catch {
103
- return new ApiError(`Запрос отклонён (${response.status})`, 'unknown', response.status, text.slice(0, 200) || undefined);
96
+ return new ApiError(`Request rejected (${response.status})`, 'unknown', response.status, text.slice(0, 200) || undefined);
104
97
  }
105
98
  }
106
99
  async function apiJson(client, path, options = {}) {
@@ -109,28 +102,26 @@ async function apiJson(client, path, options = {}) {
109
102
  throw await readError(response);
110
103
  return (await response.json());
111
104
  }
112
- /** Ответ-файл: архив шаблона отдаётся телом, а не ссылкой (он лежит в базе, а не в S3). */
113
105
  async function apiBinary(client, path) {
114
106
  const response = await send(client, path, { timeoutMs: TRANSFER_TIMEOUT_MS });
115
107
  if (!response.ok)
116
108
  throw await readError(response);
117
109
  return Buffer.from(await response.arrayBuffer());
118
110
  }
119
- /** Загрузка архива на сервер: тот же разбор ошибок, но с длинным таймаутом. */
120
111
  async function apiUpload(client, path, body, headers = {}) {
121
112
  return apiJson(client, path, { method: 'POST', body, headers, timeoutMs: TRANSFER_TIMEOUT_MS });
122
113
  }
123
- /** Скачивание по подписанной ссылке. Ключ сюда не подставляем: ссылка сама и есть пропуск. */
114
+ /** Signed URL download: no Authorization header attached. */
124
115
  async function downloadUrl(url) {
125
116
  let response;
126
117
  try {
127
118
  response = await fetch(url, { signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) });
128
119
  }
129
120
  catch (e) {
130
- throw new ApiError(`Не удалось скачать архив: ${e instanceof Error ? e.message : e}`, 'network', 0);
121
+ throw new ApiError(`Could not download the archive: ${e instanceof Error ? e.message : e}`, 'network', 0);
131
122
  }
132
123
  if (!response.ok) {
133
- throw new ApiError(`Не удалось скачать архив (${response.status})`, 'download_failed', response.status);
124
+ throw new ApiError(`Could not download the archive (${response.status})`, 'download_failed', response.status);
134
125
  }
135
126
  return Buffer.from(await response.arrayBuffer());
136
127
  }
package/dist/args.js CHANGED
@@ -1,19 +1,11 @@
1
1
  "use strict";
2
- /**
3
- * Разбор аргументов.
4
- *
5
- * Своими руками, без зависимостей: пакет с ключом доступа внутри не должен
6
- * тянуть за собой чужой код ради двадцати строк логики.
7
- */
2
+ /** Argument parsing, dependency-free. */
8
3
  Object.defineProperty(exports, "__esModule", { value: true });
9
4
  exports.parseArgs = parseArgs;
10
5
  exports.flagString = flagString;
11
6
  exports.flagBool = flagBool;
12
7
  exports.flagNumber = flagNumber;
13
- /**
14
- * Флаги без значения. Список явный, потому что иначе `--force ./dir` съедает
15
- * путь как значение флага, и разработчик получает загадочную ошибку.
16
- */
8
+ /** Flags that never take a value, so `--force ./dir` keeps the path as a word. */
17
9
  const BOOLEAN_FLAGS = new Set([
18
10
  'force',
19
11
  'yes',
@@ -80,7 +72,7 @@ function flagNumber(args, name) {
80
72
  return undefined;
81
73
  const parsed = Number(value);
82
74
  if (!Number.isInteger(parsed) || parsed < 0) {
83
- throw new Error(`Значение --${name} должно быть целым числом, получено «${value}»`);
75
+ throw new Error(`The value of --${name} has to be an integer, got "${value}"`);
84
76
  }
85
77
  return parsed;
86
78
  }
package/dist/bin.js CHANGED
@@ -6,6 +6,7 @@ const args_1 = require("./args");
6
6
  const config_1 = require("./config");
7
7
  const errors_1 = require("./errors");
8
8
  const help_1 = require("./help");
9
+ const limits_1 = require("./limits");
9
10
  const ui_1 = require("./ui");
10
11
  const zip_1 = require("./zip");
11
12
  const version_1 = require("./version");
@@ -20,7 +21,6 @@ const schedules_1 = require("./commands/schedules");
20
21
  const skills_1 = require("./commands/skills");
21
22
  const sources_1 = require("./commands/sources");
22
23
  const deploy_1 = require("./commands/deploy");
23
- /** Одно место, где команда превращается в действие. Слова, а не флаги: `projects list`. */
24
24
  async function run(args) {
25
25
  const [first, second] = args.words;
26
26
  const rest = { ...args, words: args.words.slice(1) };
@@ -58,7 +58,7 @@ async function run(args) {
58
58
  await (0, projects_1.list)();
59
59
  return;
60
60
  }
61
- throw new errors_1.CliError(`Неизвестная команда: projects ${second}`, 'Есть list и get');
61
+ throw new errors_1.CliError(`Unknown command: projects ${second}`, 'Available: list and get');
62
62
  case 'skills':
63
63
  (0, skills_1.skills)(rest);
64
64
  return;
@@ -67,7 +67,7 @@ async function run(args) {
67
67
  await (0, mcp_1.mcpInstall)(rest);
68
68
  return;
69
69
  }
70
- throw new errors_1.CliError(`Неизвестная команда: mcp ${second}`, 'Есть install');
70
+ throw new errors_1.CliError(`Unknown command: mcp ${second}`, 'Available: install');
71
71
  case 'functions':
72
72
  if (second === 'deploy') {
73
73
  await (0, functions_1.functionsDeploy)(rest);
@@ -85,7 +85,7 @@ async function run(args) {
85
85
  await (0, functions_1.functionsList)();
86
86
  return;
87
87
  }
88
- throw new errors_1.CliError(`Неизвестная команда: functions ${second}`, 'Есть list, deploy, invoke и logs');
88
+ throw new errors_1.CliError(`Unknown command: functions ${second}`, 'Available: list, deploy, invoke and logs');
89
89
  case 'logs':
90
90
  await (0, logs_1.logs)(rest);
91
91
  return;
@@ -106,7 +106,7 @@ async function run(args) {
106
106
  await (0, env_1.envList)();
107
107
  return;
108
108
  }
109
- throw new errors_1.CliError(`Неизвестная команда: env ${second}`, 'Есть list, check, set и rm');
109
+ throw new errors_1.CliError(`Unknown command: env ${second}`, 'Available: list, check, set and rm');
110
110
  case 'schedules':
111
111
  if (second === 'set') {
112
112
  await (0, schedules_1.schedulesSet)(rest);
@@ -120,7 +120,7 @@ async function run(args) {
120
120
  await (0, schedules_1.schedulesList)();
121
121
  return;
122
122
  }
123
- throw new errors_1.CliError(`Неизвестная команда: schedules ${second}`, 'Есть list, set и rm');
123
+ throw new errors_1.CliError(`Unknown command: schedules ${second}`, 'Available: list, set and rm');
124
124
  case 'db':
125
125
  if (second === 'migrate') {
126
126
  await (0, db_1.dbMigrate)(rest);
@@ -130,7 +130,7 @@ async function run(args) {
130
130
  await (0, db_1.dbStatus)();
131
131
  return;
132
132
  }
133
- throw new errors_1.CliError(`Неизвестная команда: db ${second}`, 'Есть status и migrate');
133
+ throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status and migrate');
134
134
  case 'status':
135
135
  await (0, sources_1.status)();
136
136
  return;
@@ -153,7 +153,7 @@ async function run(args) {
153
153
  await (0, deploy_1.deployments)();
154
154
  return;
155
155
  default:
156
- throw new errors_1.CliError(`Неизвестная команда: ${first}`, 'Список команд: xflow help');
156
+ throw new errors_1.CliError(`Unknown command: ${first}`, 'The list of commands: xflow help');
157
157
  }
158
158
  }
159
159
  async function main() {
@@ -173,6 +173,8 @@ async function main() {
173
173
  catch (e) {
174
174
  if (e instanceof api_1.ApiError) {
175
175
  (0, ui_1.fail)(e.message);
176
+ if (e.limit)
177
+ (0, ui_1.note)((0, ui_1.dim)(` ${(0, limits_1.limitLine)(e.limit)}`));
176
178
  if (e.hint)
177
179
  (0, ui_1.note)((0, ui_1.dim)(` ${e.hint}`));
178
180
  return 1;
@@ -185,7 +187,6 @@ async function main() {
185
187
  return 1;
186
188
  }
187
189
  (0, ui_1.fail)(e instanceof Error ? e.message : String(e));
188
- // Стек прячем: он полезен только нам, и то по запросу.
189
190
  if (process.env.XFLOW_DEBUG && e instanceof Error && e.stack)
190
191
  (0, ui_1.note)((0, ui_1.dim)(e.stack));
191
192
  return 1;
@@ -8,6 +8,7 @@ const api_1 = require("../api");
8
8
  const config_1 = require("../config");
9
9
  const credentials_1 = require("../credentials");
10
10
  const errors_1 = require("../errors");
11
+ const limits_1 = require("../limits");
11
12
  const session_1 = require("../session");
12
13
  const ui_1 = require("../ui");
13
14
  function localConfig() {
@@ -22,34 +23,38 @@ function localConfig() {
22
23
  }
23
24
  }
24
25
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
25
- /**
26
- * Вход человека: device flow.
27
- *
28
- * Ключ не проходит ни через копипаст, ни через контекст агента — он выпускается
29
- * на сервере после подтверждения в браузере и приезжает сюда ровно один раз.
30
- */
26
+ /** Device flow: the key is issued after confirmation in the browser, never typed. */
31
27
  async function login() {
32
28
  const config = localConfig();
33
29
  const client = (0, session_1.anonymous)(config);
34
- // Имя машины уходит на сервер и становится названием ключа: иначе в списке
35
- // доступов лежат одинаковые строки и непонятно, какой компьютер отзывать.
30
+ // The machine name becomes the key label on the platform.
36
31
  const start = await (0, api_1.apiJson)(client, '/api/v1/auth/device', {
37
32
  method: 'POST',
38
33
  body: { client_name: (0, node_os_1.hostname)() },
39
34
  });
40
35
  (0, ui_1.note)('');
41
- (0, ui_1.note)(` Код подтверждения: ${(0, ui_1.bold)(start.user_code)}`);
42
- (0, ui_1.note)(` Компьютер: ${(0, node_os_1.hostname)()}`);
43
- (0, ui_1.note)(` Ссылка для входа: ${start.verification_uri_complete}`);
36
+ (0, ui_1.note)(` Confirmation code: ${(0, ui_1.bold)(start.user_code)}`);
37
+ (0, ui_1.note)(` Computer: ${(0, node_os_1.hostname)()}`);
38
+ (0, ui_1.note)(` Sign-in link: ${start.verification_uri_complete}`);
44
39
  (0, ui_1.note)('');
45
- // Браузер не открываем сами: команду часто запускает агент, и всплывшее окно
46
- // забирает у человека внимание, не объяснив, что он подтверждает.
47
- (0, ui_1.step)('Откройте ссылку и сверьте код на странице с показанным выше');
40
+ (0, ui_1.step)('Open the link and check the code on the page against the one above');
41
+ const stopOffer = (0, ui_1.offerBrowser)(start.verification_uri_complete);
42
+ if (process.stdin.isTTY)
43
+ (0, ui_1.note)((0, ui_1.dim)(' Or press ENTER to open it in the browser'));
44
+ try {
45
+ await awaitConfirmation(client, start);
46
+ }
47
+ finally {
48
+ stopOffer();
49
+ }
50
+ }
51
+ /** Poll until the code is confirmed or expires. */
52
+ async function awaitConfirmation(client, start) {
48
53
  const deadline = Date.now() + start.expires_in * 1000;
49
54
  let intervalMs = Math.max(start.interval, 1) * 1000;
50
55
  for (;;) {
51
56
  if (Date.now() > deadline) {
52
- throw new errors_1.CliError('Срок кода истёк', 'Повторите: xflow login');
57
+ throw new errors_1.CliError('The code has expired', 'Try again: xflow login');
53
58
  }
54
59
  await sleep(intervalMs);
55
60
  let poll;
@@ -60,7 +65,7 @@ async function login() {
60
65
  });
61
66
  }
62
67
  catch (e) {
63
- // Слишком частый опрос не повод прерывать вход, замедляемся.
68
+ // Slow down on rate limits instead of failing the sign-in.
64
69
  if (e instanceof api_1.ApiError && e.code === 'rate_limited') {
65
70
  intervalMs = Math.min(intervalMs * 2, 30_000);
66
71
  continue;
@@ -74,54 +79,56 @@ async function login() {
74
79
  organizationId: poll.organization_id,
75
80
  projectId: poll.project_id,
76
81
  });
77
- (0, ui_1.ok)(`Вход выполнен, ключ сохранён для ${client.apiUrl}`);
82
+ (0, ui_1.ok)(`Signed in, the key is stored for ${client.apiUrl}`);
78
83
  return;
79
84
  }
80
85
  }
81
86
  function logout() {
82
87
  const apiUrl = (0, config_1.apiUrlFor)(localConfig());
83
88
  if ((0, credentials_1.forgetCredential)(apiUrl)) {
84
- (0, ui_1.ok)(`Ключ для ${apiUrl} удалён`);
89
+ (0, ui_1.ok)(`The key for ${apiUrl} is deleted`);
85
90
  }
86
91
  else {
87
- (0, ui_1.warn)(`Сохранённого ключа для ${apiUrl} нет`);
92
+ (0, ui_1.warn)(`There is no stored key for ${apiUrl}`);
88
93
  }
89
94
  }
90
- /** Приписка к тарифу. У живой подписки её нет: молчание и значит «всё в порядке». */
95
+ /** Suffix for a non-active subscription. */
91
96
  const SUBSCRIPTION_STATE = {
92
- past_due: ', период кончился',
93
- suspended: ', работа приостановлена',
94
- terminated: ', подписка прекращена',
95
- };
96
- const QUOTA_LABELS = {
97
- projects: 'проекты',
98
- functions: 'функции',
99
- developers: 'разработчики',
100
- members: 'участники',
97
+ past_due: ', the period has ended',
98
+ suspended: ', work is suspended',
99
+ terminated: ', the subscription is terminated',
101
100
  };
102
101
  async function whoami() {
103
102
  const config = localConfig();
104
103
  const client = (0, session_1.connect)(config);
105
104
  const me = await (0, api_1.apiJson)(client, '/api/v1/me');
106
- (0, ui_1.out)(`Организация: ${me.organization.name ?? me.organization.id}`);
107
- (0, ui_1.out)(`Ключ: xfk_${me.key.prefix}… (${me.key.scopes.join(', ')})`);
108
- (0, ui_1.out)(`Платформа: ${client.apiUrl}`);
109
- if (process.env.XFLOW_TOKEN) {
110
- (0, ui_1.note)((0, ui_1.dim)(' Ключ взят из XFLOW_TOKEN, сохранённый в ~/.xflow не используется'));
105
+ (0, ui_1.out)(`Organization: ${me.organization.name ?? me.organization.id}`);
106
+ (0, ui_1.out)(`Key: xfk_${me.key.prefix}… (${me.key.scopes.join(', ')})`);
107
+ (0, ui_1.out)(`Platform: ${client.apiUrl}`);
108
+ // Same condition as in connect: for an address out of xflow.json the env key is ignored.
109
+ if (process.env.XFLOW_TOKEN?.trim() && client.apiUrl === (0, config_1.apiUrlFor)()) {
110
+ (0, ui_1.note)((0, ui_1.dim)(' The key comes from XFLOW_TOKEN, the one stored in ~/.xflow is not used'));
111
111
  }
112
112
  else if (!(0, credentials_1.readCredential)(client.apiUrl)) {
113
- (0, ui_1.note)((0, ui_1.dim)(' Ключ не сохранён'));
113
+ (0, ui_1.note)((0, ui_1.dim)(' The key is not stored'));
114
114
  }
115
115
  if (me.billing) {
116
+ const pkg = me.billing.package_title ? `, ${me.billing.package_title}` : '';
116
117
  (0, ui_1.out)('');
117
- (0, ui_1.out)(`Тариф: ${me.billing.plan_title}${SUBSCRIPTION_STATE[me.billing.status] ?? ''}`);
118
- (0, ui_1.out)(`Баланс: ${me.billing.balance ?? '—'}`);
118
+ (0, ui_1.out)(`Plan: ${me.billing.plan_title}${pkg}${SUBSCRIPTION_STATE[me.billing.status] ?? ''}`);
119
119
  }
120
120
  if (me.quotas) {
121
121
  (0, ui_1.out)('');
122
- (0, ui_1.out)('Лимиты тарифа:');
123
- for (const [name, value] of Object.entries(me.quotas)) {
124
- (0, ui_1.out)(` ${QUOTA_LABELS[name] ?? name}: ${value.used} из ${value.limit}`);
125
- }
122
+ (0, ui_1.out)('Plan limits:');
123
+ (0, ui_1.table)((0, limits_1.quotaRows)(me.quotas));
124
+ }
125
+ if (me.features) {
126
+ const interval = me.features.schedule_min_interval_min;
127
+ (0, ui_1.out)('');
128
+ (0, ui_1.out)(`Schedules: ${me.features.schedules
129
+ ? interval
130
+ ? `yes, no more often than one run every ${interval} min`
131
+ : 'yes'
132
+ : 'no'}`);
126
133
  }
127
134
  }
@@ -11,18 +11,12 @@ const config_1 = require("../config");
11
11
  const errors_1 = require("../errors");
12
12
  const session_1 = require("../session");
13
13
  const ui_1 = require("../ui");
14
- /**
15
- * Миграции базы проекта.
16
- *
17
- * Файлы лежат в репозитории (`migrations/0001_init.sql`), историю применённых
18
- * помнит сама база. Отсюда правило: локально мы только читаем и сортируем, а что
19
- * из этого новое, решает сервер.
20
- */
14
+ /** Migration files live in the repo; the applied history lives in the database. */
21
15
  const MIGRATIONS_DIR = 'migrations';
22
16
  function checksum(sql) {
23
17
  return (0, node_crypto_1.createHash)('sha256').update(sql).digest('hex').slice(0, 16);
24
18
  }
25
- /** Порядок по имени файла: `0001_` идёт раньше `0002_`, это и есть очередь. */
19
+ /** File-name order is the queue. */
26
20
  function readMigrations(root) {
27
21
  const dir = (0, node_path_1.join)(root, MIGRATIONS_DIR);
28
22
  if (!(0, node_fs_1.existsSync)(dir))
@@ -38,7 +32,7 @@ function readMigrations(root) {
38
32
  function requireMigrations(root) {
39
33
  const migrations = readMigrations(root);
40
34
  if (migrations.length === 0) {
41
- throw new errors_1.CliError( проекте нет миграций`, `Положите SQL в ${MIGRATIONS_DIR}/0001_init.sql и повторите`);
35
+ throw new errors_1.CliError(`The project has no migrations`, `Put the SQL in ${MIGRATIONS_DIR}/0001_init.sql and try again`);
42
36
  }
43
37
  return migrations;
44
38
  }
@@ -48,37 +42,36 @@ async function dbStatus() {
48
42
  const local = readMigrations(root);
49
43
  const history = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/db/migrate`);
50
44
  const applied = new Map(history.applied.map((row) => [row.name, row]));
51
- (0, ui_1.out)(`Схема: ${(0, ui_1.bold)(history.schema)}`);
45
+ (0, ui_1.out)(`Schema: ${(0, ui_1.bold)(history.schema)}`);
52
46
  (0, ui_1.out)('');
53
47
  if (local.length === 0 && applied.size === 0) {
54
- (0, ui_1.note)(`Миграций нет. Первая: ${MIGRATIONS_DIR}/0001_init.sql`);
48
+ (0, ui_1.note)(`No migrations. The first one: ${MIGRATIONS_DIR}/0001_init.sql`);
55
49
  return;
56
50
  }
57
51
  const rows = [];
58
52
  for (const migration of local) {
59
53
  const row = applied.get(migration.name);
60
54
  if (!row) {
61
- rows.push([migration.name, 'ждёт применения', '']);
55
+ rows.push([migration.name, 'waiting', '']);
62
56
  continue;
63
57
  }
64
58
  const changed = row.checksum !== checksum(migration.sql);
65
59
  rows.push([
66
60
  migration.name,
67
- changed ? 'применена, файл изменён' : 'применена',
61
+ changed ? 'applied, file changed' : 'applied',
68
62
  (0, ui_1.formatAge)(row.applied_at),
69
63
  ]);
70
64
  }
71
- // Применённое, чего нет локально: чаще всего чужая миграция из другого проекта
72
- // на той же логической базе.
65
+ // Applied but missing locally: usually another project on the same logical database.
73
66
  const localNames = new Set(local.map((migration) => migration.name));
74
67
  for (const row of history.applied) {
75
68
  if (!localNames.has(row.name))
76
- rows.push([row.name, 'применена, файла нет', (0, ui_1.formatAge)(row.applied_at)]);
69
+ rows.push([row.name, 'applied, file missing', (0, ui_1.formatAge)(row.applied_at)]);
77
70
  }
78
71
  (0, ui_1.table)(rows);
79
- if (rows.some((row) => row[1] === 'применена, файл изменён')) {
72
+ if (rows.some((row) => row[1] === 'applied, file changed')) {
80
73
  (0, ui_1.out)('');
81
- (0, ui_1.warn)('Изменённый файл повторно не применится: заведите новую миграцию');
74
+ (0, ui_1.warn)('A changed file will not be applied again: write a new migration');
82
75
  }
83
76
  }
84
77
  async function dbMigrate(args) {
@@ -86,7 +79,7 @@ async function dbMigrate(args) {
86
79
  const client = (0, session_1.connect)(config);
87
80
  const migrations = requireMigrations(root);
88
81
  const dryRun = (0, args_1.flagBool)(args, 'dry-run');
89
- (0, ui_1.step)(dryRun ? `Проверяю миграции (${migrations.length} в каталоге)` : `Применяю миграции (${migrations.length} в каталоге)`);
82
+ (0, ui_1.step)(dryRun ? `Checking the migrations (${migrations.length} in the directory)` : `Applying the migrations (${migrations.length} in the directory)`);
90
83
  const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/db/migrate`, {
91
84
  method: 'POST',
92
85
  body: {
@@ -97,34 +90,34 @@ async function dbMigrate(args) {
97
90
  timeoutMs: 300_000,
98
91
  });
99
92
  if (result.destructive.length > 0) {
100
- (0, ui_1.warn)('Разрушающие операции:');
93
+ (0, ui_1.warn)('Destructive operations:');
101
94
  for (const hit of result.destructive)
102
95
  (0, ui_1.note)((0, ui_1.dim)(` ${hit}`));
103
96
  }
104
97
  if (result.dry_run) {
105
98
  if (result.pending.length === 0) {
106
- (0, ui_1.ok)('Применять нечего: все миграции уже в базе');
99
+ (0, ui_1.ok)('Nothing to apply: every migration is already in the database');
107
100
  return;
108
101
  }
109
102
  (0, ui_1.out)('');
110
- (0, ui_1.out)(`Будут применены (${result.pending.length}):`);
103
+ (0, ui_1.out)(`Will be applied (${result.pending.length}):`);
111
104
  for (const name of result.pending)
112
105
  (0, ui_1.out)(` ${name}`);
113
- (0, ui_1.note)((0, ui_1.dim)(' Пробный прогон: база не менялась'));
106
+ (0, ui_1.note)((0, ui_1.dim)(' Dry run: the database was not changed'));
114
107
  return;
115
108
  }
116
109
  for (const dump of result.dumps) {
117
- (0, ui_1.note)((0, ui_1.dim)(` Дамп ${dump.table}: строк ${dump.rows}${dump.truncated ? ' (обрезан по потолку)' : ''}, хранится ${result.dump_retention_days} суток`));
110
+ (0, ui_1.note)((0, ui_1.dim)(` Dump of ${dump.table}: ${dump.rows} rows${dump.truncated ? ' (cut at the cap)' : ''}, kept for ${result.dump_retention_days} days`));
118
111
  }
119
112
  if (result.applied.length > 0) {
120
- (0, ui_1.ok)(`Применено: ${result.applied.join(', ')}`);
113
+ (0, ui_1.ok)(`Applied: ${result.applied.join(', ')}`);
121
114
  }
122
115
  else if (!result.failed) {
123
- (0, ui_1.ok)('Применять нечего: все миграции уже в базе');
116
+ (0, ui_1.ok)('Nothing to apply: every migration is already in the database');
124
117
  }
125
118
  if (result.failed) {
126
119
  throw new errors_1.CliError(`${result.failed.name}: ${result.failed.error}`, result.applied.length > 0
127
- ? `Применённое до неё осталось в базе (${result.applied.join(', ')}), остальное не запускалось`
128
- : 'База не изменилась: миграция откатилась целиком');
120
+ ? `What ran before it stays in the database (${result.applied.join(', ')}), the rest did not run`
121
+ : 'The database is unchanged: the migration rolled back as a whole');
129
122
  }
130
123
  }