@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/dist/session.js CHANGED
@@ -5,22 +5,18 @@ exports.anonymous = anonymous;
5
5
  const config_1 = require("./config");
6
6
  const credentials_1 = require("./credentials");
7
7
  const errors_1 = require("./errors");
8
- /**
9
- * Соединение с платформой: адрес плюс ключ.
10
- *
11
- * Адрес берётся из XFLOW_API_URL, потом из xflow.json, потом умолчание —
12
- * порядок такой, чтобы разработчик мог указать локальную платформу одной
13
- * переменной, не правя конфигурацию проекта.
14
- */
8
+ /** Platform connection: address plus key. */
15
9
  function connect(config) {
16
10
  const apiUrl = (0, config_1.apiUrlFor)(config);
17
- const token = (0, credentials_1.resolveToken)(apiUrl);
11
+ // XFLOW_TOKEN never follows an address taken from the repo's xflow.json:
12
+ // a cloned config must not be able to redirect the key elsewhere.
13
+ const token = (0, credentials_1.resolveToken)(apiUrl, apiUrl === (0, config_1.apiUrlFor)());
18
14
  if (!token) {
19
- throw new errors_1.CliError(`Нет ключа доступа для ${apiUrl}`, 'Войдите: xflow login. В CI передайте ключ переменной XFLOW_TOKEN');
15
+ throw new errors_1.CliError(`No access key for ${apiUrl}`, 'Sign in: xflow login. In CI pass the key in the XFLOW_TOKEN variable');
20
16
  }
21
17
  return { apiUrl, token };
22
18
  }
23
- /** Соединение без ключа: только для входа. */
19
+ /** Sign-in only. */
24
20
  function anonymous(config) {
25
21
  return { apiUrl: (0, config_1.apiUrlFor)(config), token: '' };
26
22
  }
package/dist/template.js CHANGED
@@ -15,23 +15,23 @@ dist/
15
15
  `;
16
16
  const README = `# %NAME%
17
17
 
18
- Приложение на XFlow.
18
+ An application on XFlow.
19
19
 
20
20
  \`\`\`bash
21
- npm install # зависимости
22
- npm run dev # разработка на localhost:3000
23
- xflow deploy # отправить код и собрать на платформе
24
- xflow publish # показать dev-версию посетителям
21
+ npm install # dependencies
22
+ npm run dev # develop on localhost:3000
23
+ xflow deploy # send the code and build it on the platform
24
+ xflow publish # show the dev version to visitors
25
25
  \`\`\`
26
26
 
27
- Интерфейс собран на компонентах шаблона: \`src/components/ui\` (кнопки, поля, диалоги)
28
- и \`src/components/blocks\` (таблица, форма, фильтры, канбан, графики). Своя вёрстка
29
- поверх них выглядит как остальная платформа, а собственные цвета вместо токенов из
30
- \`src/index.css\` эту связность ломают.
27
+ The interface is built on the template components: \`src/components/ui\` (buttons, fields,
28
+ dialogs) and \`src/components/blocks\` (table, form, filters, kanban, charts). Layout built
29
+ on top of them looks like the rest of the platform, while custom colors in place of the
30
+ tokens from \`src/index.css\` break that consistency.
31
31
 
32
- Полезное: \`xflow status\` что на сервере, \`xflow deployments\` история версий.
32
+ Useful: \`xflow status\` for what is on the server, \`xflow deployments\` for version history.
33
33
  `;
34
- /** Адреса облачных функций для сборки: имя → URL, одной строкой JSON. */
34
+ /** Function name → URL map, one JSON line. */
35
35
  exports.FUNCTIONS_ENV_KEY = 'VITE_XFLOW_FUNCTIONS';
36
36
  function functionsEnvValue(functions) {
37
37
  const map = {};
@@ -41,10 +41,7 @@ function functionsEnvValue(functions) {
41
41
  }
42
42
  return JSON.stringify(map);
43
43
  }
44
- /**
45
- * `.env` в исходники не уходит (его пропускают и push, и сервер), поэтому в свежем
46
- * клоне его восстанавливает `xflow link`.
47
- */
44
+ /** .env is never pushed; xflow link recreates it in a fresh clone. */
48
45
  function envFile(projectToken, apiUrl, functions = []) {
49
46
  return [
50
47
  `VITE_XFLOW_PROJECT_TOKEN=${projectToken}`,
@@ -53,12 +50,7 @@ function envFile(projectToken, apiUrl, functions = []) {
53
50
  '',
54
51
  ].join('\n');
55
52
  }
56
- /**
57
- * Обновить одну переменную в `.env`, не трогая остальные.
58
- *
59
- * Файл наш, но живёт у разработчика: рядом с нашими тремя строками у него может
60
- * лежать что угодно своё, и переписывать файл целиком мы не вправе.
61
- */
53
+ /** Update one variable in .env without touching the rest of the file. */
62
54
  function writeEnvValue(root, key, value) {
63
55
  const path = (0, node_path_1.join)(root, '.env');
64
56
  const line = `${key}=${value}`;
package/dist/tree.js CHANGED
@@ -7,12 +7,7 @@ exports.heaviest = heaviest;
7
7
  const node_crypto_1 = require("node:crypto");
8
8
  const node_fs_1 = require("node:fs");
9
9
  const node_path_1 = require("node:path");
10
- /**
11
- * Не отправляется никогда. Первые пять сервер отклоняет и сам, но узнать об
12
- * этом лучше до заливки 200 МБ node_modules. Секреты (`.env*`) шире серверного
13
- * списка сознательно: значения переменных живут в настройках организации,
14
- * а не в репозитории.
15
- */
10
+ /** Never sent. */
16
11
  const DEFAULT_IGNORE = [
17
12
  'node_modules/',
18
13
  '.git/',
@@ -60,7 +55,7 @@ function compileRule(raw) {
60
55
  const suffix = dirOnly ? '/' : '($|/)';
61
56
  return { re: new RegExp(`${prefix}${source}${suffix}`) };
62
57
  }
63
- /** Правила исключения: умолчания, файл .xflowignore и поле ignore в xflow.json. */
58
+ /** Defaults, the .xflowignore file and the ignore field of xflow.json. */
64
59
  function loadIgnoreRules(root, extra = []) {
65
60
  const lines = [...DEFAULT_IGNORE, ...extra];
66
61
  const ignoreFile = (0, node_path_1.join)(root, IGNORE_FILE);
@@ -73,7 +68,7 @@ function isIgnored(relativePath, rules, isDirectory) {
73
68
  const probe = isDirectory ? `${relativePath}/` : relativePath;
74
69
  return rules.some((rule) => rule.re.test(probe));
75
70
  }
76
- /** Двоичный файл: ищем нулевой байт в начале, как это делает git. */
71
+ /** NUL byte in the head, same heuristic as git. */
77
72
  function isBinary(content) {
78
73
  const limit = Math.min(content.length, 8000);
79
74
  for (let i = 0; i < limit; i++) {
@@ -82,12 +77,7 @@ function isBinary(content) {
82
77
  }
83
78
  return false;
84
79
  }
85
- /**
86
- * Приводим переводы строк к LF.
87
- *
88
- * Иначе одно и то же дерево на Windows и на Linux даёт разные хеши, и статус
89
- * вечно показывает изменения, которых никто не делал. Двоичные файлы не трогаем.
90
- */
80
+ /** CRLF and LF must hash the same on every OS. Binary files stay untouched. */
91
81
  function normalizeEol(content) {
92
82
  if (isBinary(content) || !content.includes(0x0d))
93
83
  return content;
@@ -100,8 +90,6 @@ function collectFiles(root, rules) {
100
90
  for (const entry of (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })) {
101
91
  const full = (0, node_path_1.join)(dir, entry.name);
102
92
  const rel = (0, node_path_1.relative)(root, full).split(node_path_1.sep).join('/');
103
- // Ссылки не отправляем: в архиве их не представить, а пойти по ним значит
104
- // утащить чужое дерево целиком.
105
93
  if (entry.isSymbolicLink()) {
106
94
  skippedLinks.push(rel);
107
95
  continue;
@@ -123,13 +111,12 @@ function collectFiles(root, rules) {
123
111
  files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
124
112
  return { files, skippedLinks };
125
113
  }
126
- /** Хеш дерева. Считается ровно так же на сервере — иначе push «менялось/не менялось» врал бы. */
114
+ /** Computed identically on the server. */
127
115
  function treeHash(files) {
128
116
  const parts = files.map((file) => `${file.path}\0${(0, node_crypto_1.createHash)('sha256').update(file.content).digest('hex')}`);
129
117
  parts.sort();
130
118
  return (0, node_crypto_1.createHash)('sha256').update(parts.join('\n')).digest('hex');
131
119
  }
132
- /** Самые тяжёлые файлы: показываем их, когда архив не влез в лимит. */
133
120
  function heaviest(files, count = 5) {
134
121
  return [...files].sort((a, b) => b.content.length - a.content.length).slice(0, count);
135
122
  }
package/dist/types.js CHANGED
@@ -1,10 +1,3 @@
1
1
  "use strict";
2
- /**
3
- * Ответы публичного API, которые разбирает CLI.
4
- *
5
- * Адресов собранного приложения здесь нет: платформа их наружу не отдаёт. В них
6
- * зашит номер версии, и после следующей публикации такая ссылка продолжает
7
- * отвечать старой копией, а не ломается. Версии называем номерами, ссылка одна
8
- * и ведёт на страницу проекта.
9
- */
2
+ /** Public API responses parsed by the CLI. */
10
3
  Object.defineProperty(exports, "__esModule", { value: true });
package/dist/ui.js CHANGED
@@ -10,14 +10,13 @@ exports.fail = fail;
10
10
  exports.table = table;
11
11
  exports.formatBytes = formatBytes;
12
12
  exports.formatAge = formatAge;
13
+ exports.offerBrowser = offerBrowser;
13
14
  exports.confirmWord = confirmWord;
15
+ const node_child_process_1 = require("node:child_process");
14
16
  const node_readline_1 = require("node:readline");
15
17
  /**
16
- * Вывод CLI.
17
- *
18
- * Всё пишется через один модуль, а не через console: человек читает stdout,
19
- * машина парсит его же, а служебные сообщения (шаги, предупреждения) обязаны
20
- * уходить в stderr, иначе `xflow pull > file` сложит их в файл вместе с данными.
18
+ * All CLI output. Data goes to stdout, progress and warnings to stderr,
19
+ * so `xflow pull > file` captures data only.
21
20
  */
22
21
  const color = process.stdout.isTTY === true && !process.env.NO_COLOR;
23
22
  function paint(code, text) {
@@ -30,11 +29,9 @@ exports.dim = dim;
30
29
  const red = (t) => paint('31', t);
31
30
  const green = (t) => paint('32', t);
32
31
  const yellow = (t) => paint('33', t);
33
- /** Данные: то, ради чего команду запускали. */
34
32
  function out(line = '') {
35
33
  process.stdout.write(`${line}\n`);
36
34
  }
37
- /** Ход работы: шаги, предупреждения, ошибки. */
38
35
  function note(line = '') {
39
36
  process.stderr.write(`${line}\n`);
40
37
  }
@@ -50,7 +47,7 @@ function warn(message) {
50
47
  function fail(message) {
51
48
  note(red(`✗ ${message}`));
52
49
  }
53
- /** Таблица с выравниванием по колонкам. Заголовок не рисуем: он не нужен на 3 строки. */
50
+ /** Column-aligned rows, no header. */
54
51
  function table(rows) {
55
52
  if (rows.length === 0)
56
53
  return;
@@ -66,44 +63,69 @@ function table(rows) {
66
63
  }
67
64
  function formatBytes(bytes) {
68
65
  if (bytes < 1024)
69
- return `${bytes} Б`;
66
+ return `${bytes} B`;
70
67
  if (bytes < 1024 * 1024)
71
- return `${(bytes / 1024).toFixed(1)} КБ`;
72
- return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
68
+ return `${(bytes / 1024).toFixed(1)} KB`;
69
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
73
70
  }
74
71
  function formatAge(iso) {
75
72
  if (!iso)
76
- return '';
73
+ return '-';
77
74
  const ms = Date.now() - new Date(iso).getTime();
78
75
  if (!Number.isFinite(ms) || ms < 0)
79
- return '';
76
+ return '-';
80
77
  const minutes = Math.floor(ms / 60000);
81
78
  if (minutes < 1)
82
- return 'только что';
79
+ return 'just now';
83
80
  if (minutes < 60)
84
- return `${minutes} мин назад`;
81
+ return `${minutes} min ago`;
85
82
  const hours = Math.floor(minutes / 60);
86
83
  if (hours < 24)
87
- return `${hours} ч назад`;
88
- return `${Math.floor(hours / 24)} дн назад`;
84
+ return `${hours} h ago`;
85
+ return `${Math.floor(hours / 24)} d ago`;
86
+ }
87
+ /** Open a link with the system handler. Failures are ignored: the link is already printed. */
88
+ function openUrl(url) {
89
+ // The URL reaches a shell on Windows: allow only plain http(s) links.
90
+ if (!/^https?:\/\/[A-Za-z0-9.-]+(:\d+)?(\/[A-Za-z0-9._~/?=#-]*)?$/.test(url))
91
+ return;
92
+ const [command, args] = process.platform === 'win32'
93
+ ? ['cmd', ['/c', 'start', '', url]]
94
+ : process.platform === 'darwin'
95
+ ? ['open', [url]]
96
+ : ['xdg-open', [url]];
97
+ try {
98
+ (0, node_child_process_1.spawn)(command, args, { stdio: 'ignore', detached: true }).unref();
99
+ }
100
+ catch {
101
+ // No browser at all (ssh session): the user opens the link elsewhere.
102
+ }
89
103
  }
90
104
  /**
91
- * Подтверждение опасного действия.
92
- *
93
- * Требуем ввести конкретное слово, а не «y»: `--force` перетирает чужую работу,
94
- * и подтверждение должно стоить осознанного действия. В неинтерактивном режиме
95
- * (CI, запуск из агента) подтвердить нельзя вовсе — это сознательно (D23).
105
+ * Offer to open the link on Enter. TTY only, so agent-driven runs are unaffected.
106
+ * Non-blocking: polling continues, Enter is just a shortcut. Returns a cleanup.
96
107
  */
108
+ function offerBrowser(url) {
109
+ if (!process.stdin.isTTY)
110
+ return () => { };
111
+ const rl = (0, node_readline_1.createInterface)({ input: process.stdin, output: process.stderr });
112
+ rl.on('line', () => {
113
+ openUrl(url);
114
+ rl.close();
115
+ });
116
+ return () => rl.close();
117
+ }
118
+ /** Dangerous-action confirmation: type the exact word. Unavailable without a TTY. */
97
119
  async function confirmWord(question, expected) {
98
120
  if (!process.stdin.isTTY) {
99
- fail('Подтверждение возможно только в интерактивном терминале');
100
- note((0, exports.dim)(' В CI это правильное поведение: расхождение версий должно ронять сборку, а не перетирать чужую работу'));
121
+ fail('Confirmation is only possible in an interactive terminal');
122
+ note((0, exports.dim)(' In CI this is the right behaviour: a version conflict has to fail the build rather than overwrite work that is not yours'));
101
123
  return false;
102
124
  }
103
125
  const rl = (0, node_readline_1.createInterface)({ input: process.stdin, output: process.stderr });
104
126
  try {
105
127
  const answer = await new Promise((resolve) => {
106
- rl.question(`${question}\n Введите «${expected}» для подтверждения: `, resolve);
128
+ rl.question(`${question}\n Type "${expected}" to confirm: `, resolve);
107
129
  });
108
130
  return answer.trim() === expected;
109
131
  }
package/dist/version.js CHANGED
@@ -1,11 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_API_URL = exports.CLI_VERSION = void 0;
4
- /**
5
- * Версия CLI. Держать синхронно с cli/package.json: читать package.json в
6
- * рантайме нельзя, после сборки он лежит на уровень выше dist и в бандл не
7
- * попадает.
8
- */
9
- exports.CLI_VERSION = '0.1.7';
10
- /** Адрес платформы по умолчанию. Переопределяется XFLOW_API_URL и полем `api` в xflow.json. */
4
+ /** Keep in sync with cli/package.json. */
5
+ exports.CLI_VERSION = '0.1.10';
6
+ /** Overridden by XFLOW_API_URL or the `api` field in xflow.json. */
11
7
  exports.DEFAULT_API_URL = 'https://app.getxflow.com';
package/dist/zip.js CHANGED
@@ -10,9 +10,8 @@ exports.ZipError = ZipError;
10
10
  const LOCAL_SIG = 0x04034b50;
11
11
  const CENTRAL_SIG = 0x02014b50;
12
12
  const EOCD_SIG = 0x06054b50;
13
- /** Флаг «имена в UTF-8»: без него кириллица в путях читается как мохибейк. */
14
13
  const FLAG_UTF8 = 0x0800;
15
- /** 1980-01-01 в формате DOS: время сборки в архив не пишем, чтобы он был воспроизводим. */
14
+ /** Fixed timestamp keeps archives reproducible. */
16
15
  const DOS_DATE = 0x0021;
17
16
  const DOS_TIME = 0x0000;
18
17
  const CRC_TABLE = (() => {
@@ -47,13 +46,11 @@ function zipCreate(entries) {
47
46
  const locals = [];
48
47
  const centrals = [];
49
48
  let offset = 0;
50
- // Порядок фиксируем: одно и то же дерево должно давать один и тот же архив.
49
+ // Same tree, same archive: fixed order.
51
50
  const sorted = [...entries].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
52
51
  for (const entry of sorted) {
53
52
  const name = Buffer.from(entry.path, 'utf-8');
54
53
  const crc = crc32(entry.content);
55
- // Сжимаем, но только если это что-то даёт: у уже сжатых файлов (png, zip)
56
- // deflate добавляет байты, и хранение как есть честнее.
57
54
  const deflated = (0, node_zlib_1.deflateRawSync)(entry.content, { level: 6 });
58
55
  const compressed = deflated.length < entry.content.length;
59
56
  const method = compressed ? 8 : 0;
@@ -110,7 +107,7 @@ function zipCreate(entries) {
110
107
  return Buffer.concat([...locals, central, eocd]);
111
108
  }
112
109
  function findEocd(buffer) {
113
- // Комментарий архива может занимать до 64 КБ, поэтому ищем сигнатуру с конца.
110
+ // The archive comment can be up to 64 KB long: scan from the end.
114
111
  const from = Math.max(0, buffer.length - 66_000);
115
112
  for (let i = buffer.length - 22; i >= from; i--) {
116
113
  if (buffer.readUInt32LE(i) === EOCD_SIG)
@@ -118,18 +115,25 @@ function findEocd(buffer) {
118
115
  }
119
116
  return -1;
120
117
  }
118
+ /** Entries are written to disk by the caller: reject paths that escape the target. */
119
+ function isSafeEntryPath(path) {
120
+ if (path.length === 0 || path.startsWith('/') || /^[A-Za-z]:/.test(path) || path.includes('\0')) {
121
+ return false;
122
+ }
123
+ return path.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
124
+ }
121
125
  function zipRead(buffer) {
122
126
  if (buffer.length < 22)
123
- throw new ZipError('Файл слишком мал для zip');
127
+ throw new ZipError('The file is too small to be a zip');
124
128
  const eocd = findEocd(buffer);
125
129
  if (eocd === -1)
126
- throw new ZipError('Это не zip: не найден конец архива');
130
+ throw new ZipError('This is not a zip: the end of the archive was not found');
127
131
  const count = buffer.readUInt16LE(eocd + 10);
128
132
  let pointer = buffer.readUInt32LE(eocd + 16);
129
133
  const entries = [];
130
134
  for (let i = 0; i < count; i++) {
131
135
  if (pointer + 46 > buffer.length || buffer.readUInt32LE(pointer) !== CENTRAL_SIG) {
132
- throw new ZipError('Повреждён оглавление архива');
136
+ throw new ZipError('The archive directory is damaged');
133
137
  }
134
138
  const method = buffer.readUInt16LE(pointer + 10);
135
139
  const compressedSize = buffer.readUInt32LE(pointer + 20);
@@ -143,10 +147,10 @@ function zipRead(buffer) {
143
147
  if (name.endsWith('/'))
144
148
  continue;
145
149
  if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff) {
146
- throw new ZipError(`Архив в формате zip64 не поддерживается: ${name}`);
150
+ throw new ZipError(`The zip64 format is not supported: ${name}`);
147
151
  }
148
- if (buffer.readUInt32LE(localOffset) !== LOCAL_SIG) {
149
- throw new ZipError(`Повреждена запись архива: ${name}`);
152
+ if (localOffset + 30 > buffer.length || buffer.readUInt32LE(localOffset) !== LOCAL_SIG) {
153
+ throw new ZipError(`A damaged archive entry: ${name}`);
150
154
  }
151
155
  const localNameLength = buffer.readUInt16LE(localOffset + 26);
152
156
  const localExtraLength = buffer.readUInt16LE(localOffset + 28);
@@ -161,13 +165,16 @@ function zipRead(buffer) {
161
165
  content = (0, node_zlib_1.inflateRawSync)(raw);
162
166
  }
163
167
  catch (e) {
164
- throw new ZipError(`Не удалось распаковать ${name}: ${e instanceof Error ? e.message : e}`);
168
+ throw new ZipError(`Could not unpack ${name}: ${e instanceof Error ? e.message : e}`);
165
169
  }
166
170
  }
167
171
  else {
168
- throw new ZipError(`Неизвестный метод сжатия (${method}) у ${name}`);
172
+ throw new ZipError(`Unknown compression method (${method}) on ${name}`);
169
173
  }
170
- entries.push({ path: name.replace(/\\/g, '/'), content });
174
+ const path = name.replace(/\\/g, '/');
175
+ if (!isSafeEntryPath(path))
176
+ throw new ZipError(`Unsafe path in the archive: ${name}`);
177
+ entries.push({ path, content });
171
178
  }
172
179
  return entries;
173
180
  }
package/package.json CHANGED
@@ -1,22 +1,22 @@
1
- {
2
- "name": "@getxflow/cli",
3
- "version": "0.1.8",
4
- "description": "CLI платформы XFlow: синхронизация кода, деплой и публикация приложений",
5
- "license": "UNLICENSED",
6
- "engines": {
7
- "node": ">=20"
8
- },
9
- "bin": {
10
- "xflow": "dist/bin.js"
11
- },
12
- "files": [
13
- "dist",
14
- "skills"
15
- ],
16
- "scripts": {
17
- "build": "tsc -p tsconfig.json"
18
- },
19
- "publishConfig": {
20
- "access": "public"
21
- }
22
- }
1
+ {
2
+ "name": "@getxflow/cli",
3
+ "version": "0.1.10",
4
+ "description": "CLI for the XFlow platform: source sync, deployment and publishing of applications",
5
+ "license": "UNLICENSED",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "bin": {
10
+ "xflow": "dist/bin.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "skills"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ }
22
+ }
@@ -14,7 +14,25 @@ A platform project is recognized by the `xflow.json` file in its root.
14
14
  Check commands and flags against `xflow help` and `xflow help <command>`, not against
15
15
  memory. If a command is not in the help output, it does not exist: guessing flags is
16
16
  pointless. The CLI prints a hint with almost every error, read it in full, it usually
17
- contains the fix. Help output is in Russian.
17
+ contains the fix.
18
+
19
+ ## Plan limits
20
+
21
+ The organization runs on a plan with finite limits: projects, cloud functions, developer
22
+ and staff seats, database and file storage, function minutes per month, plus the right to
23
+ use schedules. `xflow whoami` prints every one of them next to what is already used, and
24
+ reading it before a long task is cheaper than hitting a wall mid-way.
25
+
26
+ A limit refusal is not a bad request. The CLI prints a line starting with `Plan limit:`,
27
+ the API answers `code: "forbidden"` with a `limit` object (`code`, `used`, `limit`), and
28
+ MCP tools carry the same field. Retrying the command, renaming things or rewriting the
29
+ code changes nothing: tell the user what ran out and stop. Only the owner or an admin
30
+ lifts it, in the web interface, by freeing the resource or moving to a bigger plan.
31
+
32
+ One refusal looks like a code error but is not: when the database is over its plan size,
33
+ Postgres itself rejects inserts (`db_write_locked`). Reads and deletes still work, so the
34
+ fix is a migration that deletes data, never a rewrite of the failing SQL. Write is
35
+ restored within an hour of the data going back under the limit.
18
36
 
19
37
  ## Workflow
20
38