layero 0.8.22 → 0.8.23

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;
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.")
@@ -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 {
@@ -257,43 +260,46 @@ export async function deployCmd(opts) {
257
260
  const targeting = deployTargeting(opts);
258
261
  // --- Одно обращение вместо пяти: резолв или создание проекта, настройка,
259
262
  // тип рантайма, root и выдача адреса для загрузки архива.
263
+ // Тело вынесено из вызова, чтобы повтор после выбора имени аккаунта уходил
264
+ // РОВНО с тем же payload — собирать его второй раз значит однажды разойтись.
265
+ const sessionPayload = {
266
+ // `--project` ПЕРЕОПРЕДЕЛЯЕТ запись в .layero/project.json: пользователь
267
+ // явно сказал, куда деплоить, и залинкованный проект тут не при чём.
268
+ // Передать оба поля нельзя — сервер выберет project_id и молча уедет
269
+ // не туда, куда просили.
270
+ //
271
+ // Флаг принимает И id, И слаг (так было до переноса оркестрации), а
272
+ // на сервере это разные поля: `project_id` ищется по идентификатору,
273
+ // `name` — по слагу. Отличаем по форме значения, иначе UUID уходит в
274
+ // поиск по слагу и не находится — живой прогон это и показал.
275
+ //
276
+ // Опечатка в слаге при этом обязана дать 404, а не завести лишний
277
+ // проект с похожим именем: `create_if_missing: false`.
278
+ ...(opts.project
279
+ ? UUID_RE.test(opts.project)
280
+ ? { project_id: opts.project }
281
+ : { name: opts.project, create_if_missing: false }
282
+ : existing?.project_id
283
+ ? { project_id: existing.project_id }
284
+ : { name }),
285
+ organization_slug: organizationSlug,
286
+ target: targeting.target,
287
+ branch: targeting.branch,
288
+ promote: Boolean(opts.promote),
289
+ prebuilt: prebuiltDir !== null,
290
+ framework_hint: setup.framework_hint,
291
+ build_cmd: setup.build_cmd,
292
+ output_dir: setup.output_dir,
293
+ runtime_kind: setup.runtime_kind,
294
+ root_directory: opts.root ?? null,
295
+ env_vars: existing?.env_vars ?? {},
296
+ commit_message: prebuiltDir
297
+ ? `CLI prebuilt deploy (${prebuiltDir})`
298
+ : "CLI deploy",
299
+ };
260
300
  let session;
261
301
  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
- });
302
+ session = await api.createDeploySession(sessionPayload);
297
303
  }
298
304
  catch (err) {
299
305
  if (err instanceof ApiError && err.status === 404 && opts.project) {
@@ -305,7 +311,17 @@ export async function deployCmd(opts) {
305
311
  if (err instanceof ApiError && err.status === 403) {
306
312
  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
313
  }
308
- throw err;
314
+ // 412 = у аккаунта нет имени, и платформе некуда положить проект (имя
315
+ // становится слагом личной организации). В интерактивном терминале
316
+ // спрашиваем прямо здесь и повторяем — отправлять в браузер за одним
317
+ // словом значит обрывать ровно тот сценарий, ради которого ставят CLI.
318
+ if (err instanceof ApiError && err.status === 412) {
319
+ await ensureUsername(api);
320
+ session = await api.createDeploySession(sessionPayload);
321
+ }
322
+ else {
323
+ throw err;
324
+ }
309
325
  }
310
326
  const project = session.project;
311
327
  if (project.cli_deploys_enabled === false) {
@@ -404,8 +420,8 @@ export async function deployCmd(opts) {
404
420
  ? await resolveReachability(api, deployRow.environment_id)
405
421
  : null;
406
422
  const liveUrl = promoted || !opts.branch
407
- ? probe?.canonical_url ?? apexUrl
408
- : probe?.preview_url ?? probe?.canonical_url ?? apexUrl;
423
+ ? (probe?.canonical_url ?? apexUrl)
424
+ : (probe?.preview_url ?? probe?.canonical_url ?? apexUrl);
409
425
  emit({
410
426
  event: "ready",
411
427
  url: liveUrl,
@@ -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.23",
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",