layero 0.11.3 → 0.11.4

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/agent.js CHANGED
@@ -140,6 +140,8 @@ function renderHuman(event) {
140
140
  process.stdout.write(` ${event.hint}\n`);
141
141
  if (event.next_action)
142
142
  process.stdout.write(` → ${event.next_action}\n`);
143
+ for (const w of event.layero_warnings ?? [])
144
+ process.stdout.write(`! ${w}\n`);
143
145
  break;
144
146
  case "plan": {
145
147
  const src = event.sources;
@@ -158,6 +160,8 @@ function renderHuman(event) {
158
160
  process.stdout.write(` ${event.hint}\n`);
159
161
  if (event.next_action)
160
162
  process.stdout.write(` → ${event.next_action}\n`);
163
+ for (const w of event.layero_warnings ?? [])
164
+ process.stdout.write(`! ${w}\n`);
161
165
  break;
162
166
  }
163
167
  case "prebuilt":
@@ -173,7 +177,11 @@ function renderHuman(event) {
173
177
  // intentionally quiet in human mode — the next stage line takes over
174
178
  break;
175
179
  case "setup_applied":
176
- process.stdout.write(event.framework ? `✓ Setup applied (${event.framework})\n` : `✓ Setup applied\n`);
180
+ // Фреймворк здесь — что увидел детект, а не записанная настройка:
181
+ // сборщик определяет его по репозиторию на каждой сборке.
182
+ process.stdout.write(event.framework
183
+ ? `✓ Setup complete (detected: ${event.framework})\n`
184
+ : `✓ Setup complete\n`);
177
185
  break;
178
186
  case "setup_pending":
179
187
  process.stdout.write(`· Проект ждёт настройки в панели: ${event.url}\n`);
package/dist/api.js CHANGED
@@ -131,8 +131,11 @@ export class ApiClient {
131
131
  * это право: детект ошибается, и репозиторий, который эвристика считает «не
132
132
  * бэкендом», прекрасно работает одним сервисом.
133
133
  */
134
- setRuntimeType(projectId, projectType, force = false) {
135
- return this.request("POST", `/projects/${projectId}/runtime-type`, { project_type: projectType, force });
134
+ setRuntimeType(projectId, projectType, force = false,
135
+ // Кто назвал тип: `user` — человек (`--type`), `platform` — детект. Тип от
136
+ // детекта сборщик вправе уточнить по архиву; выбор человека — нет.
137
+ origin = "user") {
138
+ return this.request("POST", `/projects/${projectId}/runtime-type`, { project_type: projectType, force, origin });
136
139
  }
137
140
  updateProject(projectId, input) {
138
141
  return this.request("PATCH", `/projects/${projectId}`, input);
@@ -44,14 +44,14 @@ async function main() {
44
44
  .option("--debug", "print a full stack trace when a command errors");
45
45
  program
46
46
  .command("login")
47
- .description("Вход через браузер (код из почты или Yandex ID): печатает одноразовый адрес и код.")
48
- .option("--no-browser", "не открывать браузер — только напечатать адрес и код")
49
- .addHelpText("after", "\nПримеры:\n" +
47
+ .description("Sign in through the browser (an emailed code or Yandex ID): prints a one-time URL and code.")
48
+ .option("--no-browser", "do not open the browser — only print the URL and code")
49
+ .addHelpText("after", "\nExamples:\n" +
50
50
  " $ layero login\n" +
51
- " $ layero login --no-browser # SSH, контейнер, среда агента\n" +
52
- "\nДля CI и агентов вход человеком не годится — нужен долгоживущий токен:\n" +
51
+ " $ layero login --no-browser # SSH, container, agent environment\n" +
52
+ "\nA human sign-in does not work for CI and agents — they need a long-lived token:\n" +
53
53
  " $ layero token create ci\n" +
54
- " $ LAYERO_TOKEN=<токен> npx layero@latest deploy")
54
+ " $ LAYERO_TOKEN=<token> npx layero@latest deploy")
55
55
  .action(async (opts) => {
56
56
  await loginCmd(opts);
57
57
  });
@@ -65,14 +65,14 @@ async function main() {
65
65
  .action(whoamiCmd);
66
66
  program
67
67
  .command("username <value>")
68
- .description("Задать имя аккаунта — оно же адрес личной организации. " +
69
- "Без него платформе некуда положить проект. " +
70
- "В интерактивном терминале `login` и `deploy` спросят его сами; " +
71
- "эта команда нужна агентам и CI, где спрашивать некого.")
68
+ .description("Set the account username — it is also the address of your personal organization. " +
69
+ "Without it the platform has nowhere to put a project. " +
70
+ "In an interactive terminal `login` and `deploy` ask for it themselves; " +
71
+ "this command is for agents and CI, where there is nobody to ask.")
72
72
  .addHelpText("after", "\nExamples:\n" +
73
73
  " $ layero username alice\n" +
74
74
  " $ layero username my-team-bot\n\n" +
75
- "Строчные латинские буквы, цифры и дефис; 2–32 символа.")
75
+ "Lowercase Latin letters, digits and hyphens; 2–32 characters.")
76
76
  .action(async (value) => {
77
77
  const cfg = await loadConfig();
78
78
  await usernameSetCmd(value, new ApiClient(cfg));
@@ -87,86 +87,92 @@ async function main() {
87
87
  });
88
88
  const projects = program
89
89
  .command("projects")
90
- .description("Проекты аккаунта: список, создание из репозитория, удаление.");
90
+ .description("The account's projects: list, create from a repository, delete.");
91
91
  projects
92
92
  .command("list")
93
- .description("Список ваших проектов.")
93
+ .description("List your projects.")
94
94
  .action(projectsListCmd);
95
95
  projects
96
96
  .command("create")
97
- .description("Создать проект из репозитория подключённого провайдера — путь (a): push в ветку = превью, push в main = прод. " +
98
- "GitHub — через установленное App, остальные — через `layero sources connect`.")
99
- .requiredOption("--repo <provider:owner/repo>", "репозиторий: github:acme/site, gitverse:acme/site, gitlab:group/sub/project")
100
- .option("--branch <name>", "основная ветка (по умолчанию — ветка репозитория по умолчанию)")
101
- .option("--name <name>", "имя проекта (по умолчанию — имя репозитория)")
102
- .option("--org <slug>", "организация (по умолчанию единственная)")
103
- .option("--no-deploy", "не применять настройки и не запускать первую сборку — проект останется в мастере панели")
104
- .addHelpText("after", "\nПримеры:\n" +
97
+ .description("Create a project from a repository of a connected Git provider — path (a): push to a branch = preview, push to main = production. " +
98
+ "GitHub is connected through the installed Layero GitHub App, other providers through `layero sources connect`.")
99
+ .requiredOption("--repo <provider:owner/repo>", "repository: github:acme/site, gitverse:acme/site, gitlab:group/sub/project")
100
+ .option("--branch <name>", "the project's main branch (default: the repository's default branch)")
101
+ .option("--name <name>", "project name (default: the repository name)")
102
+ .option("--org <slug>", "organization (default: your only one, otherwise your personal organization)")
103
+ .option("--no-deploy", "do not finish the setup or start the first build — the project stays in the setup wizard in the dashboard (app.layero.ru)")
104
+ .addHelpText("after", "\nExamples:\n" +
105
105
  " $ layero projects create --repo github:acme/site\n" +
106
106
  " $ layero projects create --repo gitverse:acme/site --branch develop --json\n" +
107
- "\nПосле привязки команда сама применяет настройки из детекта и запускает первую сборку,\n" +
108
- "как кнопка «Начать деплой» в панели. С --no-deploy проект остаётся в мастере.\n" +
109
- "\nСобытия в --json: project_created, source_connected, webhook_installed | webhook_unavailable,\n" +
110
- "затем setup_applied + deploy_started | setup_pending | setup_failed (проект создан, доделать в панели).\n" +
111
- "Без вебхука push не собирается — заведите его вручную по адресу из webhook_unavailable.")
107
+ "\nOnce the repository is linked, the command finishes the setup and starts the first\n" +
108
+ "build itself. The detected framework, build command and output directory are not\n" +
109
+ "saved in the project: the builder detects them from the repository on every build\n" +
110
+ "(layero.json overrides them). Saved are only the monorepo app folder and the\n" +
111
+ "package manager from layero.json. With --no-deploy the project stays in the setup wizard.\n" +
112
+ "\nEvents with --json: project_created, source_connected, webhook_installed | webhook_unavailable,\n" +
113
+ "then setup_applied + deploy_started | setup_pending |\n" +
114
+ "setup_failed (the project exists; finish the setup in the dashboard).\n" +
115
+ "Without a webhook a push does not start a build — add the webhook by hand\n" +
116
+ "using the URL from webhook_unavailable.")
112
117
  .action(async (opts) => projectsCreateCmd({ ...opts, json: program.opts().json }));
113
118
  projects
114
119
  .command("delete <id_or_slug>")
115
- .description("Удалить проект. НЕОБРАТИМО; требует токена со scope admin (`layero token create <имя> --scope admin`).")
116
- .option("-y, --yes", "не спрашивать подтверждение (вне терминала — обязателен)")
120
+ .description("Delete a project. IRREVERSIBLE; requires a token with the admin scope (`layero token create <name> --scope admin`).")
121
+ .option("-y, --yes", "do not ask for confirmation (required outside a terminal)")
117
122
  .action(async (ref, opts) => projectsDeleteCmd(ref, { ...opts, json: program.opts().json }));
118
123
  const sources = program
119
124
  .command("sources")
120
- .description("Git-провайдеры организации: какие есть, подключить по токену, посмотреть репозитории.");
125
+ .description("The organization's Git providers: see which are available, connect one with a token, browse repositories.");
121
126
  sources
122
127
  .command("list")
123
- .description("Провайдеры платформы и подключения организации.")
124
- .option("--org <slug>", "организация (по умолчанию единственная)")
128
+ .description("Git providers the platform supports and the organization's connections.")
129
+ .option("--org <slug>", "organization (default: your only one, otherwise your personal organization)")
125
130
  .action(async (opts) => sourcesListCmd({ ...opts, json: program.opts().json }));
126
131
  sources
127
132
  .command("connect <provider>")
128
- .description("Подключить провайдера по персональному токену (PAT). Токен проверяется до записи и наружу не возвращается.")
129
- .option("--token <pat>", "токен провайдера (остаётся в истории shell — предпочтительнее --token-stdin)")
130
- .option("--token-stdin", "прочитать токен из stdin: echo \"$PAT\" | layero sources connect gitverse --token-stdin")
131
- .option("--base-url <url>", "адрес собственного инстанса (GitLab, GitFlic)")
132
- .option("--name <label>", "подпись подключения")
133
- .option("--org <slug>", "организация (по умолчанию единственная)")
134
- .addHelpText("after", "\nПримеры:\n" +
133
+ .description("Connect a Git provider with a personal access token (PAT). The token is verified before it is saved and is never returned.")
134
+ .option("--token <pat>", "provider token (stays in the shell history — prefer --token-stdin)")
135
+ .option("--token-stdin", "read the token from stdin: echo \"$PAT\" | layero sources connect gitverse --token-stdin")
136
+ .option("--base-url <url>", "URL of a self-hosted instance (GitLab, GitFlic)")
137
+ .option("--name <label>", "connection label")
138
+ .option("--org <slug>", "organization (default: your only one, otherwise your personal organization)")
139
+ .addHelpText("after", "\nExamples:\n" +
135
140
  " $ echo \"$GITVERSE_TOKEN\" | layero sources connect gitverse --token-stdin\n" +
136
141
  " $ layero sources connect gitlab --token-stdin --base-url https://git.example.com < token.txt\n" +
137
- "\nПровайдеры — `layero sources list`. GitHub подключается установкой App в панели.")
142
+ "\nProviders: `layero sources list`. GitHub is connected by installing\n" +
143
+ "the Layero GitHub App in the dashboard (app.layero.ru).")
138
144
  .action(async (provider, opts) => sourcesConnectCmd(provider, { ...opts, json: program.opts().json }));
139
145
  sources
140
146
  .command("repos <connection_id>")
141
- .description("Репозитории, видимые токену подключения.")
142
- .option("--org <slug>", "организация (по умолчанию единственная)")
147
+ .description("Repositories visible to the connection's token.")
148
+ .option("--org <slug>", "organization (default: your only one, otherwise your personal organization)")
143
149
  .action(async (id, opts) => sourcesReposCmd(id, { ...opts, json: program.opts().json }));
144
150
  const envs = program
145
151
  .command("envs")
146
- .description("Окружения проекта: ветки и их адреса.");
152
+ .description("The project's environments: branches and their addresses.");
147
153
  envs
148
154
  .command("list")
149
- .description("Окружения проекта с адресами. У CLI-проекта одно — `cli`; у проекта с репозиторием — по ветке.")
150
- .option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)")
155
+ .description("The project's environments with their addresses. A CLI project has one — `cli`; a project with a repository has one per branch.")
156
+ .option("--project <id_or_slug>", "project (default: the linked project)")
151
157
  .action(async (opts) => envsListCmd({ ...opts, json: program.opts().json }));
152
158
  const claim = program
153
159
  .command("claim")
154
- .description("Проект без аккаунта (`layero deploy --claim`): статус заявки и ссылка, чтобы забрать сайт.");
160
+ .description("Project without an account (`layero deploy --claim`): the claim status and the link to take the site over.");
155
161
  claim
156
162
  .command("status [code]")
157
- .description("Состояние заявки: жива, забрана, истекла. Без кода — из .layero/project.json.")
163
+ .description("Claim state: alive, claimed or expired. Without a code, the code is taken from .layero/project.json.")
158
164
  .action(async (code) => claimStatusCmd(code, { json: program.opts().json }));
159
165
  claim
160
166
  .command("accept [code]")
161
- .description("Открыть страницу заявки в панели. Принять её может только человек, вошедший в панель, — CLI лишь открывает или печатает ссылку.")
162
- .option("--no-browser", "не открывать браузер — только напечатать ссылку")
167
+ .description("Open the claim page in the dashboard (app.layero.ru). Only a person signed in to the dashboard can accept the claim — the CLI only opens or prints the link.")
168
+ .option("--no-browser", "do not open the browser — only print the link")
163
169
  .action(async (code, opts) => claimAcceptCmd(code, { ...opts, json: program.opts().json }));
164
170
  const orgs = program
165
171
  .command("orgs")
166
- .description("Организации аккаунта: личная и команды.");
172
+ .description("The account's organizations: personal and team ones.");
167
173
  orgs
168
174
  .command("list")
169
- .description("Все организации, в которых вы состоите.")
175
+ .description("All organizations you are a member of.")
170
176
  .action(orgsListCmd);
171
177
  const deploys = program
172
178
  .command("deploys")
@@ -174,7 +180,7 @@ async function main() {
174
180
  deploys
175
181
  .command("list")
176
182
  .description("List recent deploys for the project's default branch (or --branch).")
177
- .option("--project <id_or_slug>", "target project (default: linked .layero/project.json)")
183
+ .option("--project <id_or_slug>", "project (default: the one linked in .layero/project.json)")
178
184
  .option("--branch <name>", "branch to list deploys from (default: project's default_branch)")
179
185
  .option("--limit <n>", "max entries to show (default 20)", (v) => Number(v))
180
186
  .action(async (opts) => {
@@ -186,7 +192,7 @@ async function main() {
186
192
  hooks
187
193
  .command("list")
188
194
  .description("List deploy hooks for the linked project.")
189
- .option("--project <id>", "target project id (default: linked .layero/project.json)")
195
+ .option("--project <id>", "project id (default: the one linked in .layero/project.json)")
190
196
  .action(async (opts) => {
191
197
  await hooksListCmd(opts);
192
198
  });
@@ -194,7 +200,7 @@ async function main() {
194
200
  .command("create <name>")
195
201
  .description("Create a new deploy hook. Prints a URL — paste it into Strapi / Sanity / "
196
202
  + "Contentful / GitHub Actions / cron as a POST webhook.")
197
- .option("--project <id>", "target project id (default: linked .layero/project.json)")
203
+ .option("--project <id>", "project id (default: the one linked in .layero/project.json)")
198
204
  .option("--branch <name>", "branch to deploy when fired (default: project default_branch, evaluated at fire time)")
199
205
  .option("--prod", "fire the hook against the production environment (default: preview)")
200
206
  .addHelpText("after", "\nExamples:\n"
@@ -207,7 +213,7 @@ async function main() {
207
213
  hooks
208
214
  .command("delete <id>")
209
215
  .description("Revoke a deploy hook. The URL stops working immediately.")
210
- .option("--project <id>", "target project id (default: linked .layero/project.json)")
216
+ .option("--project <id>", "project id (default: the one linked in .layero/project.json)")
211
217
  .action(async (id, opts) => {
212
218
  await hooksDeleteCmd(id, opts);
213
219
  });
@@ -215,7 +221,7 @@ async function main() {
215
221
  .command("promote [deploy]")
216
222
  .description("Pin the project apex to a specific deploy (V071 production-pointer). " +
217
223
  "Without [deploy] picks the latest ready build on --branch (defaults to the 'cli' pseudo-branch).")
218
- .option("--project <id_or_slug>", "target project (default: linked .layero/project.json)")
224
+ .option("--project <id_or_slug>", "project (default: the one linked in .layero/project.json)")
219
225
  .option("--branch <name>", "branch to pick latest ready deploy from (default: cli)")
220
226
  .option("-y, --yes", "skip the confirmation prompt (CI)")
221
227
  .addHelpText("after", "\nExamples:\n" +
@@ -232,7 +238,7 @@ async function main() {
232
238
  "moves the production pointer, so the apex comes back too when it is " +
233
239
  "served by this branch. For a SPECIFIC older deploy use " +
234
240
  "`layero promote <commit-sha>`.")
235
- .option("--project <id_or_slug>", "target project (default: linked .layero/project.json)")
241
+ .option("--project <id_or_slug>", "project (default: the one linked in .layero/project.json)")
236
242
  .option("--branch <name>", "branch to roll back (default: project's default_branch)")
237
243
  .option("--deploy <id_or_sha>", "explicit deploy id or commit sha prefix to roll back to")
238
244
  .option("-y, --yes", "skip the confirmation prompt (CI)")
@@ -246,68 +252,68 @@ async function main() {
246
252
  });
247
253
  const env = program
248
254
  .command("env")
249
- .description("Переменные окружения проекта. Значения не показываются — платформа их не отдаёт.");
250
- const envProject = (c) => c.option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)");
251
- envProject(env.command("list").description("Имена переменных и длина значений."))
255
+ .description("The project's environment variables. Values are not shown — the platform does not return them.");
256
+ const envProject = (c) => c.option("--project <id_or_slug>", "project (default: the linked project)");
257
+ envProject(env.command("list").description("Variable names and value lengths."))
252
258
  .action(async (opts) => envListCmd({ ...opts, json: program.opts().json }));
253
259
  envProject(env
254
260
  .command("set <pairs...>")
255
- .description("Задать переменные: KEY=value. Остальные остаются нетронутыми.")).action(async (pairs, opts) => envSetCmd(pairs, { ...opts, json: program.opts().json }));
261
+ .description("Set variables as KEY=value. Other variables stay untouched.")).action(async (pairs, opts) => envSetCmd(pairs, { ...opts, json: program.opts().json }));
256
262
  envProject(env
257
263
  .command("unset <keys...>")
258
- .description("Удалить переменные.")
259
- .option("-y, --yes", "не спрашивать подтверждение")).action(async (keys, opts) => envUnsetCmd(keys, { ...opts, json: program.opts().json }));
264
+ .description("Delete variables.")
265
+ .option("-y, --yes", "do not ask for confirmation")).action(async (keys, opts) => envUnsetCmd(keys, { ...opts, json: program.opts().json }));
260
266
  const data = program
261
267
  .command("data")
262
- .description("Data API базы: ключи, сайты, методы и доступ; адрес и ключ для фронтенда.");
268
+ .description("A database's Data API: keys, allowed sites, methods and access; the URL and key for the frontend.");
263
269
  data
264
270
  .command("env")
265
- .description("Показать VITE_/NEXT_PUBLIC_ переменные Data API; --write кладёт их в .env.local.")
266
- .option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)")
267
- .option("-w, --write", "записать в файл, а не печатать")
268
- .option("--file <path>", "имя файла (по умолчанию .env.local)")
269
- .addHelpText("after", "\nПримеры:\n" +
270
- " $ layero data env # посмотреть\n" +
271
- " $ layero data env --write # положить в .env.local\n" +
272
- "\nОтдаётся только ПУБЛИЧНЫЙ ключ — тот, что и так уезжает в бандл.\n" +
273
- "Секретный ключ платформа не хранит и не отдаёт: он для сервера.")
271
+ .description("Show the VITE_/NEXT_PUBLIC_ variables for the Data API; --write puts them in .env.local.")
272
+ .option("--project <id_or_slug>", "project (default: the linked project)")
273
+ .option("-w, --write", "write to the file instead of printing")
274
+ .option("--file <path>", "file name (default .env.local)")
275
+ .addHelpText("after", "\nExamples:\n" +
276
+ " $ layero data env # view\n" +
277
+ " $ layero data env --write # write to .env.local\n" +
278
+ "\nOnly the PUBLIC key is returned — the one that ends up in the bundle anyway.\n" +
279
+ "The platform neither stores nor returns the secret key: it is for the server.")
274
280
  .action(async (opts) => dataEnvCmd({ ...opts, json: program.opts().json }));
275
281
  registerDataApiCommands(data, program);
276
282
  const db = program
277
283
  .command("db")
278
- .description("Базы организации: завести, посмотреть, подключить к проекту, выполнить SQL.");
279
- const withOrg = (c) => c.option("--org <slug>", "организация (по умолчанию единственная)");
280
- withOrg(db.command("list").description("Базы организации."))
284
+ .description("The organization's databases: create, list, connect to a project, run SQL.");
285
+ const withOrg = (c) => c.option("--org <slug>", "organization (default: your only one, otherwise your personal organization)");
286
+ withOrg(db.command("list").description("The organization's databases."))
281
287
  .action(async (opts) => dbListCmd({ ...opts, json: program.opts().json }));
282
288
  withOrg(db
283
289
  .command("create <name>")
284
- .description("Завести базу. Строка подключения печатается ОДИН раз.")
290
+ .description("Create a database. The connection string is printed ONCE.")
285
291
  // 🚨 ФЛАГ ОСТАВЛЕН РАДИ ЧЕСТНОГО ОТКАЗА, А НЕ РАДИ РАБОТЫ. Он обещал
286
292
  // «объём платной базы в гигабайтах» и не делал НИЧЕГО: сервер поле не
287
293
  // читает ни одним путём создания — у Shared объём задаёт тариф, у
288
294
  // выделенного диск ступени. Убрать его совсем значило бы отвечать
289
295
  // «неизвестный параметр» тому, кто им пользовался, и оставить человека
290
296
  // гадать, куда делся объём. Теперь команда говорит это словами (C9).
291
- .option("--gb <number>", "БОЛЬШЕ НЕ РАБОТАЕТ: объём задаёт тариф или ступень", (v) => parseInt(v, 10))
297
+ .option("--gb <number>", "NO LONGER WORKS: the size is set by the plan or by the dedicated instance's tier", (v) => parseInt(v, 10))
292
298
  // 🚨 ТЕ ЖЕ ФЛАГИ РАДИ ЧЕСТНОГО ОТКАЗА. Выделенный инстанс из терминала
293
299
  // не заказать, и это осознанно: у заказа есть цена и заморозка денег, а
294
300
  // карту в терминале не привяжешь и сумму подтвердить негде. Но человек,
295
301
  // прочитавший про ступени в панели, попробует `--cpu 2` — и без этих
296
302
  // флагов получит «неизвестный параметр», то есть ответ про синтаксис
297
303
  // вместо ответа про причину.
298
- .option("--cpu <number>", "выделенный инстанс: заказывается в панели", (v) => parseInt(v, 10))
299
- .option("--ram <mb>", "выделенный инстанс: заказывается в панели", (v) => parseInt(v, 10))
300
- .option("--dedicated", "выделенный инстанс: заказывается в панели")).action(async (name, opts) => dbCreateCmd(name, { ...opts, json: program.opts().json }));
304
+ .option("--cpu <number>", "dedicated instance: ordered only in the dashboard", (v) => parseInt(v, 10))
305
+ .option("--ram <mb>", "dedicated instance: ordered only in the dashboard", (v) => parseInt(v, 10))
306
+ .option("--dedicated", "dedicated instance: ordered only in the dashboard")).action(async (name, opts) => dbCreateCmd(name, { ...opts, json: program.opts().json }));
301
307
  withOrg(db
302
308
  .command("connect <database>")
303
- .description("Подключить проект к базе: строка подключения приедет в его "
304
- + "переменные, а домены проекта станут разрешёнными для Data API.")
305
- .option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)")).action(async (database, opts) => dbConnectCmd(database, { ...opts, json: program.opts().json }));
309
+ .description("Connect a project to the database: the connection string goes into the project's "
310
+ + "environment variables, and the project's domains become allowed origins of the Data API.")
311
+ .option("--project <id_or_slug>", "project (default: the linked project)")).action(async (database, opts) => dbConnectCmd(database, { ...opts, json: program.opts().json }));
306
312
  withOrg(db
307
313
  .command("disconnect <database>")
308
- .description("Отвязать проект от базы: переменная уйдёт следующим деплоем, "
309
- + "роль проекта удалится, домены перестанут быть разрешёнными для Data API.")
310
- .option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)")).action(async (database, opts) => dbDisconnectCmd(database, { ...opts, json: program.opts().json }));
314
+ .description("Disconnect a project from the database: the variable goes away with the next deploy, "
315
+ + "the project's role is deleted, and the project's domains stop being allowed origins of the Data API.")
316
+ .option("--project <id_or_slug>", "project (default: the linked project)")).action(async (database, opts) => dbDisconnectCmd(database, { ...opts, json: program.opts().json }));
311
317
  withOrg(db
312
318
  // 🚨 ЗАПРОС МОЖНО ПИСАТЬ ПРОСТО СЛЕДОМ ЗА ИМЕНЕМ БАЗЫ. Раньше `-c` был
313
319
  // ОБЯЗАТЕЛЕН, и `layero db sql моя-база "select 1"` отвечал
@@ -315,8 +321,8 @@ async function main() {
315
321
  // самой очевидной форме записи. Так эта команда и записана в приёмке
316
322
  // (C6), и так её пишет всякий, кто помнит psql.
317
323
  .command("sql <database> [sql]")
318
- .description("Выполнить SQL в базе. Скрипт из нескольких операторов — одной транзакцией.")
319
- .option("-c, --command <sql>", "запрос или скрипт")).action(async (database, sql, opts) => dbSqlCmd(database, {
324
+ .description("Run SQL in a database. A script of several statements runs as one transaction.")
325
+ .option("-c, --command <sql>", "query or script")).action(async (database, sql, opts) => dbSqlCmd(database, {
320
326
  ...opts,
321
327
  // Явный `-c` выигрывает: он написан намеренно, а позиционный аргумент
322
328
  // мог прилететь из истории команд.
@@ -325,71 +331,71 @@ async function main() {
325
331
  }));
326
332
  const analytics = program
327
333
  .command("analytics")
328
- .description("Яндекс.Метрика: подключение и статистика сайта.");
329
- const withProject = (c) => c.option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)");
330
- withProject(analytics.command("status").description("Подключена ли Метрика и к какой ветке."))
334
+ .description("Yandex Metrica: connect it and see the site's statistics.");
335
+ const withProject = (c) => c.option("--project <id_or_slug>", "project (default: the linked project)");
336
+ withProject(analytics.command("status").description("Whether Yandex Metrica is connected, and to which branch."))
331
337
  .action(async (opts) => analyticsStatusCmd({ ...opts, json: program.opts().json }));
332
338
  withProject(analytics
333
339
  .command("connect")
334
- .description("Подключить Метрику. Печатает ссылку — открыть её и разрешить доступ должен человек.")
335
- .option("--branch <name>", "ветка, чей адрес получит счётчик (по умолчанию основная)")).action(async (opts) => analyticsConnectCmd({ ...opts, json: program.opts().json }));
340
+ .description("Connect Yandex Metrica. Prints a link — a human must open it and grant access.")
341
+ .option("--branch <name>", "branch whose address gets the counter (default: the main branch)")).action(async (opts) => analyticsConnectCmd({ ...opts, json: program.opts().json }));
336
342
  withProject(analytics
337
343
  .command("stats")
338
- .description("Посещаемость: итоги, тренд и топ источников/устройств/страниц.")
339
- .option("--period <7d|30d|90d>", "период (по умолчанию 7d)")).action(async (opts) => analyticsStatsCmd({ ...opts, json: program.opts().json }));
344
+ .description("Traffic: totals, trend and top sources/devices/pages.")
345
+ .option("--period <7d|30d|90d>", "period (default 7d)")).action(async (opts) => analyticsStatsCmd({ ...opts, json: program.opts().json }));
340
346
  withProject(analytics
341
347
  .command("disconnect")
342
- .description("Отключить Метрику от проекта.")
343
- .option("-y, --yes", "не спрашивать подтверждение")).action(async (opts) => analyticsDisconnectCmd({ ...opts, json: program.opts().json }));
348
+ .description("Disconnect Yandex Metrica from the project.")
349
+ .option("-y, --yes", "do not ask for confirmation")).action(async (opts) => analyticsDisconnectCmd({ ...opts, json: program.opts().json }));
344
350
  const perf = program
345
351
  .command("perf")
346
- .description("Замер производительности сайта со сравнением с предыдущим деплоем.");
352
+ .description("Measure site performance and compare it with the previous deploy.");
347
353
  perf
348
354
  .command("check")
349
- .description("Запустить замер активного деплоя. Прогон асинхронный — с --wait команда дождётся результата.")
350
- .option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)")
351
- .option("--wait", "дождаться результата (до 4 минут)")
355
+ .description("Start a measurement of the active deploy. The run is asynchronous — with --wait the command waits for the result.")
356
+ .option("--project <id_or_slug>", "project (default: the linked project)")
357
+ .option("--wait", "wait for the result (up to 4 minutes)")
352
358
  .action(async (opts) => perfCheckCmd({ ...opts, json: program.opts().json }));
353
359
  perf
354
360
  .command("show")
355
- .description("Показать последний замер и сравнение с предыдущим деплоем.")
356
- .option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)")
361
+ .description("Show the latest measurement and the comparison with the previous deploy.")
362
+ .option("--project <id_or_slug>", "project (default: the linked project)")
357
363
  .action(async (opts) => perfShowCmd({ ...opts, json: program.opts().json }));
358
364
  const domains = program
359
365
  .command("domains")
360
- .description("Свои домены проекта: привязать, проверить DNS, сделать основным, снять.");
361
- const domainOpts = (c) => c.option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)");
362
- domainOpts(domains.command("list").description("Показать домены проекта."))
366
+ .description("The project's custom domains: attach, check DNS, make primary, remove.");
367
+ const domainOpts = (c) => c.option("--project <id_or_slug>", "project (default: the linked project)");
368
+ domainOpts(domains.command("list").description("Show the project's domains."))
363
369
  .action(async (opts) => domainsListCmd({ ...opts, json: program.opts().json }));
364
370
  domainOpts(domains
365
371
  .command("add <domain>")
366
- .description("Привязать домен. Печатает DNS-записи, которые нужно вписать у регистратора; "
367
- + "готовности НЕ ждёт — распространение DNS занимает от минут до часа.")).action(async (domain, opts) => domainsAddCmd(domain, { ...opts, json: program.opts().json }));
368
- domainOpts(domains.command("verify <domain>").description("Проверить DNS сейчас, не дожидаясь фоновой перепроверки.")).action(async (domain, opts) => domainsVerifyCmd(domain, { ...opts, json: program.opts().json }));
369
- domainOpts(domains.command("primary <domain>").description("Сделать домен основным: платформенный адрес станет 301-редиректом на него.")).action(async (domain, opts) => domainsPrimaryCmd(domain, { ...opts, json: program.opts().json }));
372
+ .description("Attach a domain. Prints the DNS records to add at your registrar; "
373
+ + "does NOT wait until the domain is ready — DNS propagation takes from minutes to an hour.")).action(async (domain, opts) => domainsAddCmd(domain, { ...opts, json: program.opts().json }));
374
+ domainOpts(domains.command("verify <domain>").description("Check DNS now instead of waiting for the background re-check.")).action(async (domain, opts) => domainsVerifyCmd(domain, { ...opts, json: program.opts().json }));
375
+ domainOpts(domains.command("primary <domain>").description("Make the domain primary: the platform address becomes a 301 redirect to it.")).action(async (domain, opts) => domainsPrimaryCmd(domain, { ...opts, json: program.opts().json }));
370
376
  domainOpts(domains
371
377
  .command("remove <domain>")
372
- .description("Снять домен с проекта. Необратимо и рвёт живой трафик; требует токена со scope admin.")
373
- .option("-y, --yes", "не спрашивать подтверждение")).action(async (domain, opts) => domainsRemoveCmd(domain, { ...opts, json: program.opts().json }));
378
+ .description("Remove the domain from the project. Irreversible and breaks live traffic; requires a token with the admin scope.")
379
+ .option("-y, --yes", "do not ask for confirmation")).action(async (domain, opts) => domainsRemoveCmd(domain, { ...opts, json: program.opts().json }));
374
380
  program
375
381
  .command("diagnose")
376
- .description("Разобрать, почему деплой в таком состоянии: причина человеческим языком, "
377
- + "окрестность ошибки в логе сборки и состояние приложения. Без --deploy берёт "
378
- + "последний неуспешный деплой проекта.")
379
- .option("--project <id_or_slug>", "проект (по умолчанию — залинкованный в .layero/project.json)")
380
- .option("--deploy <id>", "конкретный деплой")
381
- .addHelpText("after", "\nПримеры:\n $ layero diagnose\n $ layero diagnose --deploy 8da10ee6")
382
+ .description("Explain why a deploy is in its current state: the cause in plain language, "
383
+ + "the build log around the error, and the application's state. Without --deploy takes "
384
+ + "the project's most recent deploy, whatever its status.")
385
+ .option("--project <id_or_slug>", "project (default: the one linked in .layero/project.json)")
386
+ .option("--deploy <id>", "a specific deploy")
387
+ .addHelpText("after", "\nExamples:\n $ layero diagnose\n $ layero diagnose --deploy 8da10ee6")
382
388
  .action(async (opts) => {
383
389
  await diagnoseCmd({ ...opts, json: program.opts().json });
384
390
  });
385
391
  program
386
392
  .command("logs")
387
- .description("Показать логи деплоя: сборки (по умолчанию) или приложения (--runtime).")
388
- .option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)")
389
- .option("--deploy <id>", "конкретный деплой")
390
- .option("--runtime", "логи запущенного приложения вместо логов сборки")
391
- .option("--tail <n>", "сколько последних строк приложения (по умолчанию 100)", (v) => Number(v))
392
- .addHelpText("after", "\nПримеры:\n $ layero logs\n $ layero logs --runtime --tail 200")
393
+ .description("Show a deploy's logs: the build log (default) or the application log (--runtime).")
394
+ .option("--project <id_or_slug>", "project (default: the linked project)")
395
+ .option("--deploy <id>", "a specific deploy")
396
+ .option("--runtime", "the running application's logs instead of build logs")
397
+ .option("--tail <n>", "how many recent application log lines to show (default 100)", (v) => Number(v))
398
+ .addHelpText("after", "\nExamples:\n $ layero logs\n $ layero logs --runtime --tail 200")
393
399
  .action(async (opts) => {
394
400
  await logsCmd({ ...opts, json: program.opts().json });
395
401
  });
@@ -402,25 +408,25 @@ async function main() {
402
408
  .description("Manage the auth token directly (advanced).");
403
409
  token
404
410
  .command("create <name>")
405
- .description("Выпустить долгоживущий токен для CI и агентов. " +
406
- "Показывается ОДИН раз. По умолчанию read+deploy, без необратимого.")
407
- .option("--scope <list>", "через запятую: read, deploy, admin")
408
- .addHelpText("after", "\nПримеры:\n" +
411
+ .description("Issue a long-lived token for CI and agents. " +
412
+ "It is shown ONCE. Default scopes: read+deploy, nothing irreversible.")
413
+ .option("--scope <list>", "comma-separated: read, deploy, admin")
414
+ .addHelpText("after", "\nExamples:\n" +
409
415
  " $ layero token create ci # read+deploy\n" +
410
- " $ layero token create ci --scope read # только чтение\n" +
411
- "\nВ CI: LAYERO_TOKEN=<токен> npx layero@latest deploy")
416
+ " $ layero token create ci --scope read # read only\n" +
417
+ "\nIn CI: LAYERO_TOKEN=<token> npx layero@latest deploy")
412
418
  .action(async (name, opts) => tokenCreateCmd(name, { ...opts, json: program.opts().json }));
413
419
  token
414
420
  .command("list")
415
- .description("Выпущенные токены: имя, подсказка, права, последнее использование.")
421
+ .description("Issued tokens: name, hint, scopes, last use.")
416
422
  .action(async () => tokenListCmd({ json: program.opts().json }));
417
423
  token
418
424
  .command("revoke <id>")
419
- .description("Отозвать токен. Действует немедленно.")
425
+ .description("Revoke a token. Takes effect immediately.")
420
426
  .action(async (id) => tokenRevokeCmd(id, { json: program.opts().json }));
421
427
  token
422
428
  .command("set <jwt>")
423
- .description("Сохранить токен, полученный иначе (например, `layero token create` на другой машине).")
429
+ .description("Save a token obtained elsewhere (for example, with `layero token create` on another machine).")
424
430
  .action(tokenSetCmd);
425
431
  program
426
432
  .command("deploy")
@@ -522,57 +522,57 @@ export async function dataEnableCmd(opts) {
522
522
  */
523
523
  export function registerDataApiCommands(data, program) {
524
524
  const withDb = (c) => c
525
- .option("--db <name>", "база: имя, слаг или id (по умолчанию — единственная подходящая)")
526
- .option("--org <slug>", "организация (по умолчанию единственная)");
525
+ .option("--db <name>", "database: name, slug or id (default: the only matching database)")
526
+ .option("--org <slug>", "organization (default: your only one, otherwise your personal organization)");
527
527
  const json = (opts) => ({ ...opts, json: program.opts().json });
528
- const keys = data.command("keys").description("Ключи Data API: список, выпуск, отзыв.");
529
- withDb(keys.command("list").description("Ключи базы: префикс, срок, последний вызов. Значений нет."))
528
+ const keys = data.command("keys").description("Data API keys: list, issue, revoke.");
529
+ withDb(keys.command("list").description("The database's keys: prefix, expiry, last call. No key values."))
530
530
  .action(async (opts) => dataKeysListCmd(json(opts)));
531
531
  withDb(keys
532
532
  .command("issue")
533
- .description("Выпустить ключ. Значение печатается один раз.")
534
- .option("--kind <kind>", "public — для сайта, secret — для сервера", "public")
535
- .option("--label <text>", "подпись, по которой ключ узнают в списке")
536
- .option("--expires-in <days>", "срок: 30, 90, 365 или never", "never")).action(async (opts) => dataKeysIssueCmd(json(opts)));
533
+ .description("Issue a key. The value is printed once.")
534
+ .option("--kind <kind>", "public — for the site, secret — for the server", "public")
535
+ .option("--label <text>", "label to recognize the key by in the list")
536
+ .option("--expires-in <days>", "lifetime in days: 30, 90, 365 or never", "never")).action(async (opts) => dataKeysIssueCmd(json(opts)));
537
537
  withDb(keys
538
538
  .command("revoke <id>")
539
- .description("Отозвать ключ по id или префиксу. Запросы с ним сразу получают отказ.")
540
- .option("-y, --yes", "не спрашивать подтверждение")).action(async (id, opts) => dataKeysRevokeCmd(id, json(opts)));
541
- const origins = data.command("origins").description("Сайты, которым можно звать базу из браузера.");
542
- withDb(origins.command("list").description("Адреса проектов базы и добавленные вручную."))
539
+ .description("Revoke a key by id or prefix. Requests with it are refused immediately.")
540
+ .option("-y, --yes", "do not ask for confirmation")).action(async (id, opts) => dataKeysRevokeCmd(id, json(opts)));
541
+ const origins = data.command("origins").description("Sites allowed to call the database from the browser.");
542
+ withDb(origins.command("list").description("Addresses of the projects connected to the database, plus sites added by hand."))
543
543
  .action(async (opts) => dataOriginsListCmd(json(opts)));
544
544
  withDb(origins
545
545
  .command("add <url>")
546
- .description("Пустить сайт. Данных не открывает: это решают уровни доступа.")
547
- .option("--note <text>", "зачем добавлен")).action(async (url, opts) => dataOriginsAddCmd(url, json(opts)));
546
+ .description("Allow a site. This opens no data: access levels decide that.")
547
+ .option("--note <text>", "why the site was added")).action(async (url, opts) => dataOriginsAddCmd(url, json(opts)));
548
548
  withDb(origins
549
549
  .command("remove <url>")
550
- .description("Убрать сайт из списка.")
551
- .option("-y, --yes", "не спрашивать подтверждение")).action(async (url, opts) => dataOriginsRemoveCmd(url, json(opts)));
552
- withDb(data.command("methods").description("REST и RPC базы с уровнем доступа на каждый метод."))
550
+ .description("Remove a site from the list.")
551
+ .option("-y, --yes", "do not ask for confirmation")).action(async (url, opts) => dataOriginsRemoveCmd(url, json(opts)));
552
+ withDb(data.command("methods").description("The database's REST and RPC methods with the access level of each."))
553
553
  .action(async (opts) => dataMethodsCmd(json(opts)));
554
554
  withDb(data
555
555
  .command("grant <object>")
556
- .description("Уровень доступа к методам таблицы или функции: показывает SQL, применяет после подтверждения.")
557
- .option("--get <level>", "чтение таблицы")
558
- .option("--post <level>", "добавление строк")
559
- .option("--patch <level>", "изменение строк")
560
- .option("--delete <level>", "удаление строк")
561
- .option("--call <level>", "вызов функции")
562
- .option("-y, --yes", "применить без подтверждения")
563
- .addHelpText("after", "\nУровни: closed — закрыто, visitor — любой посетитель, user — вошедшие, server — только сервер.\n" +
564
- "Неназванные методы таблицы сохраняют текущий уровень.\n" +
565
- "\nПримеры:\n" +
566
- " $ layero data grant app.products --get visitor # каталог виден сайту\n" +
567
- " $ layero data grant app.orders --get user --post user # заказы — вошедшим\n" +
568
- " $ layero data grant api.order_create --call visitor # функция для сайта\n" +
569
- " $ layero data grant 'api.pick(integer)' --call server # перегрузка — по типам\n" +
570
- "\nБез --yes вне терминала команда показывает SQL, ничего не меняет и завершается ошибкой.")).action(async (object, opts) => dataGrantCmd(object, json(opts)));
556
+ .description("Set the access level of a table's or function's methods: shows the SQL, applies it after confirmation.")
557
+ .option("--get <level>", "read the table")
558
+ .option("--post <level>", "insert rows")
559
+ .option("--patch <level>", "update rows")
560
+ .option("--delete <level>", "delete rows")
561
+ .option("--call <level>", "call the function")
562
+ .option("-y, --yes", "apply without confirmation")
563
+ .addHelpText("after", "\nLevels: closed — no access, visitor — any visitor, user — signed-in users, server — server only.\n" +
564
+ "Table methods you do not name keep their current level.\n" +
565
+ "\nExamples:\n" +
566
+ " $ layero data grant app.products --get visitor # the site can read the catalog\n" +
567
+ " $ layero data grant app.orders --get user --post user # orders for signed-in users\n" +
568
+ " $ layero data grant api.order_create --call visitor # a function for the site\n" +
569
+ " $ layero data grant 'api.pick(integer)' --call server # an overload: name it by argument types\n" +
570
+ "\nWithout --yes outside a terminal the command shows the SQL, changes nothing and exits with an error.")).action(async (object, opts) => dataGrantCmd(object, json(opts)));
571
571
  withDb(data
572
572
  .command("enable")
573
- .description("Включить Data API у базы: схема api, роли и публичный ключ. Методы закрыты.")
574
- .option("--with-secret", "выпустить и секретный ключ для сервера")
575
- .option("--repair", "переприменить роли и схему api у базы с включённым Data API; снимает права ролей на схему public")
576
- .option("-y, --yes", "не спрашивать подтверждение для --repair")).action(async (opts) => dataEnableCmd(json(opts)));
573
+ .description("Enable the Data API for a database: sets up the api schema and roles, issues a public key. All methods start closed.")
574
+ .option("--with-secret", "also issue a secret key for the server")
575
+ .option("--repair", "re-apply the roles and the api schema for a database whose Data API is already enabled; revokes the roles' privileges on schema public")
576
+ .option("-y, --yes", "do not ask for confirmation for --repair")).action(async (opts) => dataEnableCmd(json(opts)));
577
577
  registerDataProbeCommand(data, withDb, json);
578
578
  }
@@ -46,6 +46,8 @@ const ANSWER_HEADERS = [
46
46
  ];
47
47
  /** Что откат пробы не возвращает — теми же словами, что панель. */
48
48
  const NOT_UNDONE = "номера последовательностей, внешние вызовы из базы, сессионные блокировки и суточная квота вызовов";
49
+ /** Тот же список по-английски — для `--help` (справка для агентов на английском). Правите один — правьте оба. */
50
+ const NOT_UNDONE_HELP = "sequence numbers, outbound calls made by the database, session locks and the daily call quota";
49
51
  /** Ручка ждёт UUID (`UUID(user_id)`). */
50
52
  const UUID = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
51
53
  const CONTACTS = "https://docs.layero.ru/contacts/";
@@ -515,27 +517,27 @@ export function registerDataProbeCommand(data, withDb, json) {
515
517
  const collect = (value, prev = []) => [...prev, value];
516
518
  withDb(data
517
519
  .command("probe <method> <path>")
518
- .description("Проба метода настоящим запросом через шлюз: права и правила строк настоящие, запись откатывается.")
519
- .option("--as <who>", "от чьего имени: visitor — посетитель (по умолчанию), user — вошедший, server — сервер")
520
- .option("--user <id>", "для --as user: id пользователя приложения (UUID)")
521
- .option("--query <name=value>", "параметр запроса в синтаксисе PostgREST; флаг повторяется", collect)
522
- .option("--body <json>", "тело POST и PATCH: объект или массив JSON")
523
- .option("--body-file <file>", "тело из файла с JSON")
524
- .option("--schema <schema>", "схема таблицы: api, public или app; без флага шлюз ищет в api, затем в public, затем в app")
525
- .option("--expect <status>", "ожидаемый статус: 200, 2xx или несколько через запятую; не совпал — ошибка")
526
- .addHelpText("after", "\nПути: /rest/v1/<таблица>, /rest/v1/rpc/<функция> (GET или POST), /whoami (только GET).\n" +
527
- "Параметры — флагами --query, не через «?» в пути.\n" +
528
- "\nПримеры:\n" +
520
+ .description("Probe a method with a real request through the gateway: real grants and RLS policies apply, writes are rolled back.")
521
+ .option("--as <who>", "who makes the request: visitor — a site visitor (default), user — a signed-in user, server — the server")
522
+ .option("--user <id>", "for --as user: the app user's id (UUID)")
523
+ .option("--query <name=value>", "query parameter in PostgREST syntax; repeat the flag for more", collect)
524
+ .option("--body <json>", "body for POST and PATCH: a JSON object or array")
525
+ .option("--body-file <file>", "body from a JSON file")
526
+ .option("--schema <schema>", "table schema: api, public or app; without the flag the gateway looks in api, then public, then app")
527
+ .option("--expect <status>", "expected status: 200, 2xx or a comma-separated list; a mismatch is an error")
528
+ .addHelpText("after", "\nPaths: /rest/v1/<table>, /rest/v1/rpc/<function> (GET or POST), /whoami (GET only).\n" +
529
+ "Pass parameters as --query flags, not with a \"?\" in the path.\n" +
530
+ "\nExamples:\n" +
529
531
  " $ layero data probe GET /rest/v1/products --query select=id,title --query limit=5\n" +
530
532
  " $ layero data probe POST /rest/v1/cart --as user --user <id> --body '{\"qty\":1}'\n" +
531
533
  " $ layero data probe POST /rest/v1/rpc/order_create --body-file order.json --as server\n" +
532
- " $ layero data probe GET /rest/v1/orders --expect 401,403 # закрыто ли посетителю\n" +
534
+ " $ layero data probe GET /rest/v1/orders --expect 401,403 # is it closed to visitors?\n" +
533
535
  " $ layero data probe GET /whoami\n" +
534
- `\nЗапись откатывается. Не откатываются: ${NOT_UNDONE}.\n` +
535
- "\nКод выхода — по порядку:\n" +
536
- " 1. откат записи не подтверждён — ошибка data_probe_not_rolled_back;\n" +
537
- " 2. статус совпал с --expect — 0;\n" +
538
- " 3. шлюз ответил 5xx — ошибка data_probe_gateway_failed;\n" +
539
- " 4. статус не совпал с --expect — ошибка data_probe_unexpected_status;\n" +
540
- " 5. иначе 0, в том числе на отказ 4xx: 401, 403, 404 — ответ пробы, а не сбой.")).action(async (method, path, opts) => dataProbeCmd(method, path, json(opts)));
536
+ `\nWrites are rolled back. Not rolled back: ${NOT_UNDONE_HELP}.\n` +
537
+ "\nExit code, decided in this order:\n" +
538
+ " 1. the write rollback was not confirmed — error data_probe_not_rolled_back;\n" +
539
+ " 2. the status matches --expect — 0;\n" +
540
+ " 3. the gateway answered 5xx — error data_probe_gateway_failed;\n" +
541
+ " 4. the status does not match --expect — error data_probe_unexpected_status;\n" +
542
+ " 5. otherwise 0, including a 4xx refusal: 401, 403, 404 are the probe's answer, not a failure.")).action(async (method, path, opts) => dataProbeCmd(method, path, json(opts)));
541
543
  }
@@ -442,6 +442,7 @@ async function dryRun(cwd, opts, existing, cliCfg) {
442
442
  ...(hint ? { hint } : {}),
443
443
  ...(d.next_action ? { next_action: d.next_action } : {}),
444
444
  ...(d.candidates?.length ? { candidates: d.candidates } : {}),
445
+ ...(d.layero_warnings?.length ? { layero_warnings: d.layero_warnings } : {}),
445
446
  });
446
447
  }
447
448
  // Сколько ждём, пока живой адрес начнёт отвечать приложением. Статика
@@ -861,8 +862,11 @@ export async function deployCmd(opts) {
861
862
  }
862
863
  const dashboardUrl = projectUrl(cliCfg.apiUrl, project.id);
863
864
  const apexUrl = `https://${project.apex_hostname}`;
864
- const deployRow = await api.getDeploy(started.deploy_id);
865
- const probe = deployRow.environment_id
865
+ // Сборка уже готова: сбой этих запросов не повод объявлять её упавшей
866
+ // (`internal` при живом сайте, T-20260918-16). Без строки деплоя — адрес
867
+ // апекса и никаких чужих превью.
868
+ const deployRow = await api.getDeploy(started.deploy_id).catch(() => null);
869
+ const probe = deployRow?.environment_id
866
870
  ? await resolveReachability(api, deployRow.environment_id)
867
871
  : null;
868
872
  const liveUrl = promoted || !opts.branch
@@ -876,7 +880,7 @@ export async function deployCmd(opts) {
876
880
  // Пустой список — обычный случай и молчит. Поля может не быть вовсе, если
877
881
  // платформа старше него: `?? []` и никаких предупреждений о том, чего не
878
882
  // знаем.
879
- const evicted = deployRow.preview_evicted ?? [];
883
+ const evicted = deployRow?.preview_evicted ?? [];
880
884
  if (evicted.length > 0) {
881
885
  emit({ event: "preview_evicted", evicted });
882
886
  }
@@ -151,26 +151,24 @@ async function setupUrl(project) {
151
151
  }
152
152
  /** Рантайм, который панель ставит проекту ДО первой сборки; статика — как есть. */
153
153
  const RUNTIME_KINDS = new Set(["ssr_next", "streamlit", "gradio", "flask", "python_web", "node_web"]);
154
- /** Приложения, у которых `framework_hint` — рудимент: панель шлёт им `static`. */
155
- const HINT_IS_STATIC = new Set(["ssr_next", "streamlit", "gradio"]);
156
154
  const PACKAGE_MANAGERS = new Set(["npm", "yarn", "pnpm", "bun"]);
157
155
  /**
158
- * Тело `/setup` из подсказки детекта — так же, как его собирает мастер панели.
156
+ * Тело `/setup` при импорте репозитория без человека.
159
157
  *
160
- * Пишем только то, что детект действительно дал: пустое поле — не заглушка,
161
- * а «решит сборщик по репозиторию» (правило 3 `core/docs/BUILD-CONFIG.md`).
162
- * Менеджер пакетов закрепляем только из `layero.json`: это выбор владельца,
163
- * а вывод по лок-файлу сборщик и так повторит сам.
158
+ * 🚨 ФРЕЙМВОРК, КОМАНДА И КАТАЛОГ ИЗ ДЕТЕКТА В ПРОЕКТ НЕ УХОДЯТ. Настройки
159
+ * проекта сборщик исполняет дословно на КАЖДОЙ сборке (`BUILD-CONFIG.md`,
160
+ * правило 3), и догадка, записанная туда, становилась «выбором владельца»:
161
+ * репозиторий сменит сборщик — проект продолжит собирать старым. Панель
162
+ * перестала так делать 25.08 (T-20260824-16), а `projects create --repo`
163
+ * 17.09 повторил старое поведение (T-20260918-19). Пусто = «решит сборщик по
164
+ * клону» — тем же детектом, но на полном дереве и на каждой сборке заново.
165
+ *
166
+ * Остаётся то, что детект не угадывает, а читает как решение: менеджер пакетов
167
+ * из `layero.json` (выбор владельца) и папка приложения в монорепо (без неё
168
+ * сборщик при нескольких кандидатах откажет).
164
169
  */
165
170
  export function setupPayloadFromDetect(d) {
166
- const kind = d.runtime_kind ?? null;
167
- const payload = {
168
- framework_hint: kind && HINT_IS_STATIC.has(kind) ? "static" : d.framework,
169
- };
170
- if (d.build_cmd)
171
- payload.build_cmd = d.build_cmd;
172
- if (d.output_dir)
173
- payload.output_dir = d.output_dir;
171
+ const payload = {};
174
172
  if (d.layero_found && d.package_manager && PACKAGE_MANAGERS.has(d.package_manager)) {
175
173
  payload.package_manager = d.package_manager;
176
174
  }
@@ -211,12 +209,13 @@ async function finishSetup(api, project, opts) {
211
209
  });
212
210
  return;
213
211
  }
212
+ // Что увидел детект — для сведения: в проект эти значения не записаны.
214
213
  emit({
215
214
  event: "setup_applied",
216
215
  project: project.slug,
217
- framework: payload.framework_hint ?? undefined,
218
- build_cmd: payload.build_cmd ?? null,
219
- output_dir: payload.output_dir ?? null,
216
+ framework: detected.framework ?? undefined,
217
+ build_cmd: detected.build_cmd || null,
218
+ output_dir: detected.output_dir || null,
220
219
  layero_found: detected.layero_found,
221
220
  });
222
221
  // Приложение, а не статика: тип — до первой сборки, иначе сборщик падает на
@@ -225,7 +224,7 @@ async function finishSetup(api, project, opts) {
225
224
  const kind = detected.runtime_kind ?? null;
226
225
  if (kind && RUNTIME_KINDS.has(kind) && project.project_type !== kind) {
227
226
  try {
228
- await api.setRuntimeType(project.id, kind);
227
+ await api.setRuntimeType(project.id, kind, false, "platform");
229
228
  emit({ event: "runtime_type_applied", project_type: kind });
230
229
  }
231
230
  catch {
package/dist/detect.js CHANGED
@@ -521,6 +521,33 @@ const OUTPUT_SOURCE = {
521
521
  */
522
522
  export async function detectProject(cwd, opts = {}) {
523
523
  const snap = await snapshotFromDir(cwd);
524
+ const warnings = layeroKeyWarnings(snap.layeroJson);
525
+ const d = await detectFromSnapshot(cwd, snap, opts);
526
+ return warnings.length ? { ...d, layero_warnings: warnings } : d;
527
+ }
528
+ // Значения ключа `runtime` — зеркало `layero_keys.RUNTIME_VALUES` в core.
529
+ const RUNTIME_VALUES = new Set(["ssr_next", "node_web", "python_web", "streamlit", "gradio", "flask"]);
530
+ /**
531
+ * Ключи layero.json, которые платформа НЕ применит, — с верным именем.
532
+ *
533
+ * 🚨 `"type": "vite"` пишут по аналогии с флагом `--type` (прогон evals 19.09,
534
+ * T-20260918-21). Сборщик ключ молча пропускал, и узнать об этом можно было
535
+ * только из строки в логе сборки. Синонимом не принимаем: `type` двусмыслен —
536
+ * `--type` принимает и фреймворк, и рантайм, — поэтому имя подсказываем по
537
+ * значению, как `layero_keys.suggest_key` у сборщика и панели.
538
+ */
539
+ export function layeroKeyWarnings(layero) {
540
+ if (!layero || typeof layero !== "object" || !("type" in layero))
541
+ return [];
542
+ const value = layero.type;
543
+ const v = typeof value === "string" ? value.trim().toLowerCase() : "";
544
+ const key = RUNTIME_VALUES.has(v) ? "runtime" : "framework";
545
+ return [
546
+ `layero.json: key "type" is not applied — did you mean "${key}"? ` +
547
+ `Write {"${key}": ${JSON.stringify(value ?? "")}} instead.`,
548
+ ];
549
+ }
550
+ async function detectFromSnapshot(cwd, snap, opts) {
524
551
  const layero = snap.layeroJson;
525
552
  const fileFramework = str(layero?.framework);
526
553
  const outsideHint = fileFramework ? null : str(opts.frameworkHint);
@@ -682,5 +709,6 @@ export function detectedEvent(d) {
682
709
  ...(d.next_action ? { next_action: d.next_action } : {}),
683
710
  ...(d.candidates?.length ? { candidates: d.candidates } : {}),
684
711
  ...(d.ssr_warning ? { ssr_warning: d.ssr_warning } : {}),
712
+ ...(d.layero_warnings?.length ? { layero_warnings: d.layero_warnings } : {}),
685
713
  };
686
714
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layero",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
4
4
  "description": "Layero CLI — deploy a directory or a repo to hosting with build servers in Russia. JSON events and exit codes for AI agents and CI.",
5
5
  "license": "MIT",
6
6
  "type": "module",