layero 0.8.22 → 0.8.24

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
@@ -112,6 +112,9 @@ function renderHuman(event) {
112
112
  case "authorized":
113
113
  process.stdout.write(`✓ Authorized as ${event.user}\n`);
114
114
  break;
115
+ case "username_set":
116
+ process.stdout.write(`✓ Имя аккаунта: ${event.username} (организация ${event.organization})\n`);
117
+ break;
115
118
  case "project_created":
116
119
  process.stdout.write(`✓ Created project ${event.slug} (org: ${event.organization})\n`);
117
120
  break;
@@ -145,6 +148,11 @@ function renderHuman(event) {
145
148
  process.stdout.write(`! Failed to set project_type automatically: ${event.error}\n`);
146
149
  process.stdout.write(` Build may fail at detect; accept the suggestion in the dashboard if so.\n`);
147
150
  break;
151
+ case "repeated_failure_guard":
152
+ // В человеческом режиме подробности печатает сама команда: там они с
153
+ // цветом, в stderr и с вопросом. Дублировать их здесь значило бы
154
+ // показать одно и то же дважды.
155
+ break;
148
156
  case "deploy_started":
149
157
  process.stdout.write(`→ Building...\n`);
150
158
  break;
package/dist/auth.js CHANGED
@@ -9,6 +9,7 @@ import open from "open";
9
9
  import { saveConfig } from "./config.js";
10
10
  import { ApiClient } from "./api.js";
11
11
  import { LayeroError, detectMode, emit } from "./agent.js";
12
+ import { ensureUsername } from "./username.js";
12
13
  const MAX_WAIT_MS = 15 * 60 * 1000;
13
14
  /**
14
15
  * Run the browser device-auth flow against `cfg`, persist the resulting
@@ -55,8 +56,25 @@ export async function runDeviceLogin(cfg) {
55
56
  }
56
57
  else {
57
58
  console.log(chalk.green(`Logged in as ${me.username ?? me.email ?? me.id}`));
58
- if (!me.username) {
59
- console.log(chalk.yellow("No username set open https://app.layero.ru/onboarding to pick one."));
59
+ }
60
+ // Имя аккаунта спрашиваем здесь же, а не отправляем в браузер: без него
61
+ // первый же деплой упрётся в 412, а CLI ровно затем и нужен, чтобы в
62
+ // дашборд не ходить. В агентском режиме ensureUsername не спрашивает —
63
+ // отдаёт `username_required` с командой, которую агент выполнит сам.
64
+ if (!me.username) {
65
+ try {
66
+ const picked = await ensureUsername(probe, me);
67
+ cfg.user = { ...cfg.user, username: picked };
68
+ await saveConfig(cfg);
69
+ }
70
+ catch (err) {
71
+ // Вход УЖЕ состоялся и токен сохранён — валить команду из-за
72
+ // невыбранного имени нельзя. В агентском режиме ensureUsername
73
+ // бросает `username_required`: показываем подсказку на stderr
74
+ // (stdout занят JSON-строками) и выходим с успехом. Отказом это
75
+ // станет на первом деплое, где действительно мешает.
76
+ process.stderr.write(`\n ${err instanceof Error ? err.message : String(err)}\n` +
77
+ " Задайте имя аккаунта: layero username <имя>\n\n");
60
78
  }
61
79
  }
62
80
  return cfg;
@@ -19,7 +19,9 @@ import { envListCmd, envSetCmd, envUnsetCmd } from "../commands/env.js";
19
19
  import { analyticsConnectCmd, analyticsDisconnectCmd, analyticsStatsCmd, analyticsStatusCmd, } from "../commands/analytics.js";
20
20
  import { domainsAddCmd, domainsListCmd, domainsPrimaryCmd, domainsRemoveCmd, domainsVerifyCmd, } from "../commands/domains.js";
21
21
  import { LayeroError, detectMode, emit } from "../agent.js";
22
- import { ApiError } from "../api.js";
22
+ import { ApiClient, ApiError } from "../api.js";
23
+ import { loadConfig } from "../config.js";
24
+ import { usernameSetCmd } from "../username.js";
23
25
  import { notifyIfOutdated } from "../update-notifier.js";
24
26
  import { CLI_VERSION } from "../version.js";
25
27
  // Одно чтение package.json на процесс — версия нужна и здесь (`--version`,
@@ -48,6 +50,20 @@ async function main() {
48
50
  .command("whoami")
49
51
  .description("Show the currently logged-in account.")
50
52
  .action(whoamiCmd);
53
+ program
54
+ .command("username <value>")
55
+ .description("Задать имя аккаунта — оно же адрес личной организации. " +
56
+ "Без него платформе некуда положить проект. " +
57
+ "В интерактивном терминале `login` и `deploy` спросят его сами; " +
58
+ "эта команда нужна агентам и CI, где спрашивать некого.")
59
+ .addHelpText("after", "\nExamples:\n" +
60
+ " $ layero username alice\n" +
61
+ " $ layero username my-team-bot\n\n" +
62
+ "Строчные латинские буквы, цифры и дефис; 2–32 символа.")
63
+ .action(async (value) => {
64
+ const cfg = await loadConfig();
65
+ await usernameSetCmd(value, new ApiClient(cfg));
66
+ });
51
67
  program
52
68
  .command("init")
53
69
  .description("Scaffold .layero/project.json from the auto-detected framework, and write a Layero deployment block into your agent-instructions file so future chat sessions know how to deploy. Updates whichever of AGENTS.md / CLAUDE.md / .cursorrules already exist; if none do, creates AGENTS.md.")
@@ -276,6 +292,11 @@ async function main() {
276
292
  "environments come from pushes to a connected repository, not from " +
277
293
  "this flag. Kept for backwards compatibility.")
278
294
  .option("--org <slug>", "Layero organization slug for first-time project creation. Defaults to personal; required when you're a member of multiple orgs and want a non-personal home.")
295
+ // Осознанное подтверждение выкатки, когда предыдущие сборки подряд падают
296
+ // с ОДНОЙ И ТОЙ ЖЕ ошибкой. Намеренно НЕ покрывается `--yes`: смысл стопа
297
+ // в том, чтобы прервать автоматический цикл, а `--yes` в скриптах уже
298
+ // стоит по умолчанию и снял бы стоп, ничего не остановив.
299
+ .option("--confirm-repeated-failure", "proceed even though recent deploys keep failing with the SAME error (the platform stops repeat deploys until you confirm you know what changed)")
279
300
  .addHelpText("after", "\nExamples:\n" +
280
301
  " $ layero deploy # preview deploy (CLI pseudo-branch), auto-detect framework\n" +
281
302
  " $ layero deploy --prod # production deploy (interactive confirm)\n" +
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import readline from "node:readline/promises";
4
4
  import chalk from "chalk";
5
- import { ApiClient, ApiError, uploadArchive } from "../api.js";
5
+ import { ApiClient, ApiError, uploadArchive, } from "../api.js";
6
6
  import { loadConfig } from "../config.js";
7
7
  import { loadProjectConfig, persistProjectLinking, } from "../project-config.js";
8
8
  import { packCwd, packDirectory } from "../pack.js";
@@ -10,6 +10,7 @@ import { streamDeployLogs } from "../logs.js";
10
10
  import { detectProject } from "../detect.js";
11
11
  import { runDeviceLogin } from "../auth.js";
12
12
  import { LayeroError, detectMode, emit, isCiEnv } from "../agent.js";
13
+ import { ensureUsername } from "../username.js";
13
14
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
15
  const VALID_TYPES = new Set([
15
16
  "vite",
@@ -92,7 +93,9 @@ async function confirm(question) {
92
93
  output: process.stdout,
93
94
  });
94
95
  try {
95
- const answer = (await rl.question(`${question} [y/N]: `)).trim().toLowerCase();
96
+ const answer = (await rl.question(`${question} [y/N]: `))
97
+ .trim()
98
+ .toLowerCase();
96
99
  return answer === "y" || answer === "yes";
97
100
  }
98
101
  finally {
@@ -195,6 +198,119 @@ async function resolveSetupConfig(cwd, opts, existing) {
195
198
  ...(detected.runtime_kind ? { runtime_kind: detected.runtime_kind } : {}),
196
199
  };
197
200
  }
201
+ /**
202
+ * Русская форма числительного. Шесть строк вместо зависимости: в CLI своего
203
+ * плюрализатора нет, а «2 сборок» в стоп-сообщении читается как небрежность
204
+ * ровно там, где нужно доверие. Порог настраивается платформой, поэтому
205
+ * подставить одну форму нельзя — при пороге 2 и при пороге 5 они разные.
206
+ */
207
+ function pluralRu(n, one, few, many) {
208
+ const a = Math.abs(n) % 100;
209
+ if (a >= 11 && a <= 14)
210
+ return many;
211
+ const b = a % 10;
212
+ if (b === 1)
213
+ return one;
214
+ if (b >= 2 && b <= 4)
215
+ return few;
216
+ return many;
217
+ }
218
+ function parseRepeatedFailure(err) {
219
+ if (!(err instanceof ApiError) || err.status !== 409)
220
+ return null;
221
+ try {
222
+ const detail = JSON.parse(err.body)?.detail;
223
+ return detail?.code === "repeated_failure" ? detail : null;
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ }
229
+ /**
230
+ * Запуск сборки со стопом на повторяющейся ошибке.
231
+ *
232
+ * СМЫСЛ СТОПА — ОСТАНОВИТЬ ТОГО, КТО ГОНИТ ВЫКАТКУ ВСЛЕПУЮ. Агент, десятый раз
233
+ * собирающий одно и то же, ошибку обычно НЕ читает: она приходит в конце
234
+ * длинного лога сборки, а он смотрит на код возврата и запускает снова. Поэтому
235
+ * здесь текст ошибки печатается ПЕРВЫМ и отдельно от всего остального, и лишь
236
+ * потом объясняется, что делать.
237
+ *
238
+ * В интерактивном терминале спрашиваем прямо; без TTY (агент, CI) — падаем с
239
+ * отдельным кодом ошибки. Автоматически продолжать нельзя: это ровно тот цикл,
240
+ * который правило и разрывает.
241
+ */
242
+ async function startWithRepeatedFailureGuard(api, sessionId, commitSha, opts) {
243
+ try {
244
+ return await api.startDeploySession(sessionId, {
245
+ commit_sha: commitSha,
246
+ confirm_repeated_failure: opts.confirmRepeatedFailure === true,
247
+ });
248
+ }
249
+ catch (err) {
250
+ const detail = parseRepeatedFailure(err);
251
+ if (!detail)
252
+ throw err;
253
+ // Машиночитаемое событие — для агентов в режиме --json.
254
+ // `scope` отвечает на вопрос, который агент задаст первым: «я собирал в
255
+ // этом проекте три раза, откуда десять?». Серия могла набраться суммой по
256
+ // нескольким его проектам — перенос в новый проект правило не обходит.
257
+ const scope = detail.scope === "owner" ? "owner" : "project";
258
+ emit({
259
+ event: "repeated_failure_guard",
260
+ streak: detail.streak,
261
+ threshold: detail.threshold,
262
+ scope,
263
+ failure_stage: detail.failure_stage ?? undefined,
264
+ error: detail.error ?? undefined,
265
+ });
266
+ const errorText = (detail.error || "").trim();
267
+ console.error("");
268
+ console.error(chalk.red.bold(`Стоп: ${detail.streak} ${pluralRu(detail.streak, "сборка", "сборки", "сборок")} ` +
269
+ (scope === "owner"
270
+ ? "в ваших проектах упали с одной и той же ошибкой."
271
+ : "подряд упали с одной и той же ошибкой.")));
272
+ if (errorText) {
273
+ console.error("");
274
+ console.error(chalk.red(errorText));
275
+ }
276
+ if (detail.failure_stage) {
277
+ console.error(chalk.dim(`Стадия: ${detail.failure_stage}`));
278
+ }
279
+ console.error("");
280
+ if (scope === "owner") {
281
+ console.error("Ошибка повторяется в разных проектах — значит, дело не в конкретном");
282
+ console.error("проекте, а в коде приложения или в настройках сборки.");
283
+ console.error("");
284
+ }
285
+ console.error("Повторная выкатка без изменений даст тот же результат. Исправьте причину —");
286
+ console.error("или подтвердите, что изменили что-то, влияющее на неё.");
287
+ console.error("");
288
+ const mode = detectMode();
289
+ if (mode.interactive) {
290
+ const rl = readline.createInterface({
291
+ input: process.stdin,
292
+ output: process.stdout,
293
+ });
294
+ try {
295
+ const answer = (await rl.question("Всё равно выкатить? [y/N]: ")).trim().toLowerCase();
296
+ if (answer !== "y" && answer !== "yes") {
297
+ throw new LayeroError("repeated_failure_declined", "deploy cancelled: the same error keeps failing the build", "fix the error above, then run `layero deploy` again");
298
+ }
299
+ }
300
+ finally {
301
+ rl.close();
302
+ }
303
+ return await api.startDeploySession(sessionId, {
304
+ commit_sha: commitSha,
305
+ confirm_repeated_failure: true,
306
+ });
307
+ }
308
+ throw new LayeroError("repeated_failure", `deploy stopped: the last ${detail.streak} deploys failed with the SAME error` +
309
+ (errorText ? ` — ${errorText}` : ""), "read the error above and fix its cause. Re-running the same deploy will " +
310
+ "fail the same way. If you changed something that affects it, re-run with " +
311
+ "`--confirm-repeated-failure`.");
312
+ }
313
+ }
198
314
  export async function deployCmd(opts) {
199
315
  const mode = detectMode();
200
316
  if (opts.type && !VALID_TYPES.has(opts.type.toLowerCase())) {
@@ -257,43 +373,46 @@ export async function deployCmd(opts) {
257
373
  const targeting = deployTargeting(opts);
258
374
  // --- Одно обращение вместо пяти: резолв или создание проекта, настройка,
259
375
  // тип рантайма, root и выдача адреса для загрузки архива.
376
+ // Тело вынесено из вызова, чтобы повтор после выбора имени аккаунта уходил
377
+ // РОВНО с тем же payload — собирать его второй раз значит однажды разойтись.
378
+ const sessionPayload = {
379
+ // `--project` ПЕРЕОПРЕДЕЛЯЕТ запись в .layero/project.json: пользователь
380
+ // явно сказал, куда деплоить, и залинкованный проект тут не при чём.
381
+ // Передать оба поля нельзя — сервер выберет project_id и молча уедет
382
+ // не туда, куда просили.
383
+ //
384
+ // Флаг принимает И id, И слаг (так было до переноса оркестрации), а
385
+ // на сервере это разные поля: `project_id` ищется по идентификатору,
386
+ // `name` — по слагу. Отличаем по форме значения, иначе UUID уходит в
387
+ // поиск по слагу и не находится — живой прогон это и показал.
388
+ //
389
+ // Опечатка в слаге при этом обязана дать 404, а не завести лишний
390
+ // проект с похожим именем: `create_if_missing: false`.
391
+ ...(opts.project
392
+ ? UUID_RE.test(opts.project)
393
+ ? { project_id: opts.project }
394
+ : { name: opts.project, create_if_missing: false }
395
+ : existing?.project_id
396
+ ? { project_id: existing.project_id }
397
+ : { name }),
398
+ organization_slug: organizationSlug,
399
+ target: targeting.target,
400
+ branch: targeting.branch,
401
+ promote: Boolean(opts.promote),
402
+ prebuilt: prebuiltDir !== null,
403
+ framework_hint: setup.framework_hint,
404
+ build_cmd: setup.build_cmd,
405
+ output_dir: setup.output_dir,
406
+ runtime_kind: setup.runtime_kind,
407
+ root_directory: opts.root ?? null,
408
+ env_vars: existing?.env_vars ?? {},
409
+ commit_message: prebuiltDir
410
+ ? `CLI prebuilt deploy (${prebuiltDir})`
411
+ : "CLI deploy",
412
+ };
260
413
  let session;
261
414
  try {
262
- session = await api.createDeploySession({
263
- // `--project` ПЕРЕОПРЕДЕЛЯЕТ запись в .layero/project.json: пользователь
264
- // явно сказал, куда деплоить, и залинкованный проект тут не при чём.
265
- // Передать оба поля нельзя — сервер выберет project_id и молча уедет
266
- // не туда, куда просили.
267
- //
268
- // Флаг принимает И id, И слаг (так было до переноса оркестрации), а
269
- // на сервере это разные поля: `project_id` ищется по идентификатору,
270
- // `name` — по слагу. Отличаем по форме значения, иначе UUID уходит в
271
- // поиск по слагу и не находится — живой прогон это и показал.
272
- //
273
- // Опечатка в слаге при этом обязана дать 404, а не завести лишний
274
- // проект с похожим именем: `create_if_missing: false`.
275
- ...(opts.project
276
- ? UUID_RE.test(opts.project)
277
- ? { project_id: opts.project }
278
- : { name: opts.project, create_if_missing: false }
279
- : existing?.project_id
280
- ? { project_id: existing.project_id }
281
- : { name }),
282
- organization_slug: organizationSlug,
283
- target: targeting.target,
284
- branch: targeting.branch,
285
- promote: Boolean(opts.promote),
286
- prebuilt: prebuiltDir !== null,
287
- framework_hint: setup.framework_hint,
288
- build_cmd: setup.build_cmd,
289
- output_dir: setup.output_dir,
290
- runtime_kind: setup.runtime_kind,
291
- root_directory: opts.root ?? null,
292
- env_vars: existing?.env_vars ?? {},
293
- commit_message: prebuiltDir
294
- ? `CLI prebuilt deploy (${prebuiltDir})`
295
- : "CLI deploy",
296
- });
415
+ session = await api.createDeploySession(sessionPayload);
297
416
  }
298
417
  catch (err) {
299
418
  if (err instanceof ApiError && err.status === 404 && opts.project) {
@@ -305,7 +424,17 @@ export async function deployCmd(opts) {
305
424
  if (err instanceof ApiError && err.status === 403) {
306
425
  throw new LayeroError("forbidden", `access denied: ${err.body.slice(0, 200)}`, "check that your token has the required scope and that you're a member of the organization");
307
426
  }
308
- throw err;
427
+ // 412 = у аккаунта нет имени, и платформе некуда положить проект (имя
428
+ // становится слагом личной организации). В интерактивном терминале
429
+ // спрашиваем прямо здесь и повторяем — отправлять в браузер за одним
430
+ // словом значит обрывать ровно тот сценарий, ради которого ставят CLI.
431
+ if (err instanceof ApiError && err.status === 412) {
432
+ await ensureUsername(api);
433
+ session = await api.createDeploySession(sessionPayload);
434
+ }
435
+ else {
436
+ throw err;
437
+ }
309
438
  }
310
439
  const project = session.project;
311
440
  if (project.cli_deploys_enabled === false) {
@@ -371,14 +500,20 @@ export async function deployCmd(opts) {
371
500
  expires_in: session.expires_in,
372
501
  }, pack.archivePath);
373
502
  emit({ event: "uploaded", archive_key: session.source_archive_key });
374
- const started = await api.startDeploySession(session.session_id, {
375
- commit_sha: pack.sha256,
376
- });
503
+ const started = await startWithRepeatedFailureGuard(api, session.session_id, pack.sha256, opts);
377
504
  if (!started.deploy_id) {
378
505
  throw new LayeroError("deploy_not_started", `deploy session ended as "${started.status}"${started.error ? `: ${started.error}` : ""}`, "re-run `layero deploy`; if it repeats, check the project in the dashboard");
379
506
  }
380
507
  emit({ event: "deploy_started", deploy_id: started.deploy_id });
381
508
  const final = await streamDeployLogs(api, started.deploy_id);
509
+ // Отмена — не отказ. С выделением 'cancelled' в отдельный статус
510
+ // (08.08.2026) прежняя строка напечатала бы «deploy failed (cancelled)»:
511
+ // деплой одновременно и упал, и отменён. Чаще всего причина — вытеснение
512
+ // более новым пушем, и совет «посмотрите логи» тут не по адресу: смотреть
513
+ // надо на деплой-преемник, а не на этот.
514
+ if (final.status === "cancelled") {
515
+ throw new LayeroError("deploy_cancelled", `deploy cancelled${final.error_message ? `: ${final.error_message}` : ""}`, "a newer deploy superseded this one — check the latest deploy in the dashboard");
516
+ }
382
517
  if (final.status !== "ready") {
383
518
  throw new LayeroError(`deploy_${final.status}`, `deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`, `inspect logs at ${projectUrl(cliCfg.apiUrl, project.id)}`);
384
519
  }
@@ -404,8 +539,8 @@ export async function deployCmd(opts) {
404
539
  ? await resolveReachability(api, deployRow.environment_id)
405
540
  : null;
406
541
  const liveUrl = promoted || !opts.branch
407
- ? probe?.canonical_url ?? apexUrl
408
- : probe?.preview_url ?? probe?.canonical_url ?? apexUrl;
542
+ ? (probe?.canonical_url ?? apexUrl)
543
+ : (probe?.preview_url ?? probe?.canonical_url ?? apexUrl);
409
544
  emit({
410
545
  event: "ready",
411
546
  url: liveUrl,
@@ -20,8 +20,8 @@ is required — Layero packs and uploads the local directory directly.
20
20
 
21
21
  ### First-time auth (one-click device flow)
22
22
 
23
- If you're not logged in yet, \`deploy\` (or \`login\`) starts the browser
24
- device-flow automatically and emits a JSON line:
23
+ If you're not logged in yet, \`npx layero@latest deploy\` (or \`… login\`)
24
+ starts the browser device-flow automatically and emits a JSON line:
25
25
 
26
26
  \`\`\`json
27
27
  {"event":"auth_required","url":"https://app.layero.ru/cli?code=ABCD-1234","user_code":"ABCD-1234"}
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Имя аккаунта (оно же слаг личной организации) — из терминала.
3
+ *
4
+ * WHY (02.08.2026): без имени бэкенд отвечает 412 на создание проекта, а CLI
5
+ * умел только отправить в браузер: «откройте /onboarding и выберите имя». Для
6
+ * инструмента, который весь смысл имеет в том, чтобы не ходить в дашборд, это
7
+ * тупик — и в нём на момент правки сидело 14 активных аккаунтов. Ручка
8
+ * `POST /auth/me/username` была всё это время, `setUsername` лежал в api.ts и
9
+ * не вызывался ниоткуда.
10
+ *
11
+ * Имя НЕ подбирается молча: оно становится слагом личной организации и попадёт
12
+ * в адреса, то есть это выбор человека, а не догадка программы. Мы только
13
+ * предлагаем вариант по почте или логину GitHub — принять или заменить.
14
+ */
15
+ import readline from "node:readline/promises";
16
+ import chalk from "chalk";
17
+ import { LayeroError, detectMode, emit } from "./agent.js";
18
+ /** Правила совпадают с CHECK-ограничением в БД (V044). */
19
+ const MIN_LEN = 2;
20
+ const MAX_LEN = 32;
21
+ /**
22
+ * Привести произвольную строку к допустимому виду: строчные, только
23
+ * `[a-z0-9-]`, без ведущих/замыкающих и сдвоенных дефисов.
24
+ *
25
+ * Это ПОДСКАЗКА для приглашения ввода, а не валидация — последнее слово всегда
26
+ * за сервером (`checkUsername`), иначе правила разъедутся молча.
27
+ */
28
+ export function suggestUsername(me) {
29
+ const raw = me.github_login || (me.email ?? "").split("@")[0] || "";
30
+ const slug = raw
31
+ .toLowerCase()
32
+ .replace(/[^a-z0-9-]+/g, "-")
33
+ .replace(/-{2,}/g, "-")
34
+ .replace(/^-+|-+$/g, "")
35
+ .slice(0, MAX_LEN)
36
+ .replace(/-+$/g, "");
37
+ return slug.length >= MIN_LEN ? slug : "";
38
+ }
39
+ async function ask(question, fallback) {
40
+ const rl = readline.createInterface({
41
+ input: process.stdin,
42
+ output: process.stdout,
43
+ });
44
+ try {
45
+ const hint = fallback ? ` [${fallback}]` : "";
46
+ const answer = (await rl.question(`${question}${hint}: `)).trim();
47
+ return answer || fallback;
48
+ }
49
+ finally {
50
+ rl.close();
51
+ }
52
+ }
53
+ /** Человеческий текст на код отказа от `/auth/me/username/check`. */
54
+ function reasonText(reason, value) {
55
+ switch (reason) {
56
+ case "taken":
57
+ return `Имя «${value}» уже занято.`;
58
+ case "reserved":
59
+ return `Имя «${value}» зарезервировано платформой.`;
60
+ case "too_short":
61
+ return `Слишком коротко — нужно от ${MIN_LEN} символов.`;
62
+ case "too_long":
63
+ return `Слишком длинно — не больше ${MAX_LEN} символов.`;
64
+ default:
65
+ return `Имя «${value}» не подходит: только строчные латинские буквы, цифры и дефис.`;
66
+ }
67
+ }
68
+ /**
69
+ * Вернуть имя аккаунта, спросив его, если не задано.
70
+ *
71
+ * В агентском/неинтерактивном режиме НЕ спрашиваем — там некому отвечать, и
72
+ * приглашение ввода просто повисло бы навсегда. Вместо этого понятный отказ с
73
+ * кодом и командой, которую агент может выполнить сам.
74
+ */
75
+ export async function ensureUsername(api, me) {
76
+ const current = me ?? (await api.me());
77
+ if (current.username)
78
+ return current.username;
79
+ const mode = detectMode();
80
+ if (!mode.interactive) {
81
+ throw new LayeroError("username_required", "У аккаунта не выбрано имя — без него платформе некуда положить проект.", "задайте его: `layero username <имя>` (строчные латинские буквы, цифры и дефис)");
82
+ }
83
+ console.log(chalk.cyan("\nУ аккаунта ещё нет имени.") +
84
+ chalk.dim("\n Оно станет адресом вашей личной организации, поэтому выбираете вы.\n" +
85
+ " Строчные латинские буквы, цифры и дефис.\n"));
86
+ let fallback = suggestUsername(current);
87
+ for (let attempt = 0; attempt < 5; attempt++) {
88
+ const value = await ask("Имя аккаунта", fallback);
89
+ if (!value)
90
+ continue;
91
+ const check = await api.checkUsername(value);
92
+ if (!check.available) {
93
+ console.log(chalk.yellow(` ${reasonText(check.reason, check.normalized || value)}`));
94
+ // Занятое имя не предлагаем повторно — иначе Enter молча упрётся в то же.
95
+ fallback = "";
96
+ continue;
97
+ }
98
+ const saved = await api.setUsername(check.normalized);
99
+ console.log(chalk.green(`✓ Имя аккаунта: ${saved.username}`) +
100
+ chalk.dim(` (организация ${saved.organization_slug})`));
101
+ return saved.username;
102
+ }
103
+ throw new LayeroError("username_required", "Имя аккаунта так и не выбрано.", "задайте его: `layero username <имя>`");
104
+ }
105
+ /** `layero username <value>` — неинтерактивный путь для агентов и CI. */
106
+ export async function usernameSetCmd(value, api) {
107
+ const check = await api.checkUsername(value);
108
+ if (!check.available) {
109
+ throw new LayeroError("username_rejected", reasonText(check.reason, check.normalized || value), "выберите другое имя: строчные латинские буквы, цифры и дефис");
110
+ }
111
+ const saved = await api.setUsername(check.normalized);
112
+ emit({
113
+ event: "username_set",
114
+ username: saved.username,
115
+ organization: saved.organization_slug,
116
+ });
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layero",
3
- "version": "0.8.22",
3
+ "version": "0.8.24",
4
4
  "description": "Layero CLI — publish a local site with one command. No git, no GitHub, agent-friendly (Cursor, Claude Code).",
5
5
  "license": "MIT",
6
6
  "type": "module",