layero 0.9.4 → 0.10.0

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.
@@ -11,10 +11,11 @@
11
11
  // командах.
12
12
  import chalk from "chalk";
13
13
  import { ApiClient } from "../api.js";
14
+ import { dashboardOrigin } from "../urls.js";
14
15
  import { loadConfig } from "../config.js";
15
16
  import { loadProjectConfig } from "../project-config.js";
16
17
  import { LayeroError, detectMode, emit } from "../agent.js";
17
- async function orgOf(api, opts) {
18
+ export async function orgOf(api, opts) {
18
19
  if (opts.org)
19
20
  return opts.org;
20
21
  const orgs = await api.listOrganizations();
@@ -26,7 +27,7 @@ async function orgOf(api, opts) {
26
27
  throw new LayeroError("org_unknown", `у вас несколько организаций: ${orgs.map((o) => o.slug).join(", ")}`, "укажите нужную: --org <slug>");
27
28
  }
28
29
  /** База по имени, слагу или id — как её назвал человек. */
29
- async function pick(api, org, ref) {
30
+ export async function pick(api, org, ref) {
30
31
  const list = await api.listDatabases(org);
31
32
  const needle = ref.trim().toLowerCase();
32
33
  const found = list.find((d) => d.id === ref ||
@@ -44,6 +45,40 @@ function gb(bytes) {
44
45
  return "—";
45
46
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} ГБ`;
46
47
  }
48
+ /**
49
+ * Что сказать про деньги у этой базы. Пусто — платить не за что.
50
+ *
51
+ * 🚨 КАЖДОМУ СОСТОЯНИЮ СВОЯ ФРАЗА, И ЭТО НЕ УКРАШЕНИЕ. У базы с закрытым
52
+ * доступом «спишем 3 октября» — неправда: списывать уже пробовали и не вышло.
53
+ * Одна строка на все состояния была бы верной ровно в одном из них.
54
+ *
55
+ * Даты приходят от сервера уже посчитанными: правила лестницы живут там, и
56
+ * вторая их реализация в CLI разошлась бы с панелью молча.
57
+ */
58
+ export function billingLine(d) {
59
+ const b = d.billing;
60
+ if (!b)
61
+ return null;
62
+ const rub = (b.price_month_kopecks / 100).toLocaleString("ru-RU");
63
+ const when = (iso) => iso ? new Date(iso).toLocaleDateString("ru-RU", { day: "numeric", month: "long" }) : null;
64
+ if (b.status === "suspended") {
65
+ const gone = when(b.terminate_at);
66
+ return chalk.red(gone
67
+ ? `${rub} ₽/мес · доступ закрыт за неоплату, удалим ${gone}`
68
+ : `${rub} ₽/мес · доступ закрыт за неоплату`);
69
+ }
70
+ if (b.status === "past_due") {
71
+ const till = when(b.paid_until);
72
+ return chalk.yellow(till
73
+ ? `${rub} ₽/мес · оплата не прошла, работает по ${till}`
74
+ : `${rub} ₽/мес · оплата не прошла`);
75
+ }
76
+ const next = when(b.next_charge_at);
77
+ if (next)
78
+ return chalk.dim(`${rub} ₽/мес · спишем ${next}`);
79
+ const till = when(b.paid_until);
80
+ return chalk.dim(till ? `${rub} ₽/мес · оплачено по ${till}` : `${rub} ₽/мес`);
81
+ }
47
82
  export async function dbListCmd(opts) {
48
83
  const mode = detectMode();
49
84
  const api = new ApiClient(await loadConfig());
@@ -72,6 +107,12 @@ export async function dbListCmd(opts) {
72
107
  console.log(`${chalk.bold(d.name)} ${chalk.dim(d.name_slug ?? "")} ${d.status} ` +
73
108
  `${place} ${version} ` +
74
109
  `${api_on} проектов: ${d.projects_count} ${gb(d.size_bytes)} из ${gb(d.quota_bytes)}`);
110
+ // 🚨 СРОК ОПЛАТЫ — ОТДЕЛЬНОЙ СТРОКОЙ, А НЕ ХВОСТОМ ПЕРВОЙ. Строка и так
111
+ // на пределе ширины терминала, а это единственное, что решает, будет база
112
+ // работать через неделю. Хвост, уезжающий за край, — то же молчание.
113
+ const money = billingLine(d);
114
+ if (money)
115
+ console.log(` ${money}`);
75
116
  }
76
117
  }
77
118
  /** Ждёт, пока база станет рабочей. `false` — не дождались за отведённое время.
@@ -109,7 +150,22 @@ export async function dbCreateCmd(name, opts) {
109
150
  throw new LayeroError("gb_not_supported", "объём базы так не выбирается", "у базы из тарифа объём задан тарифом, у выделенного инстанса — диском " +
110
151
  "ступени: закажите нужную ступень в панели");
111
152
  }
112
- const api = new ApiClient(await loadConfig());
153
+ // 🚨 ПЛАТНЫЙ ЗАКАЗ ИЗ ТЕРМИНАЛА НЕВОЗМОЖЕН, И СКАЗАТЬ ОБ ЭТОМ НАДО СЛОВАМИ.
154
+ // Дело не в лени: у заказа выделенного инстанса есть цена и заморозка денег
155
+ // на карте. Карту в терминале не привяжешь, сумму подтвердить негде, а
156
+ // «списали, потому что вы набрали команду» — не тот способ брать деньги.
157
+ //
158
+ // Отказ называет причину и ведёт туда, где заказ возможен. Молчаливое
159
+ // создание Shared вместо выделенного было бы хуже отказа: человек получил бы
160
+ // не то, что просил, и узнал бы об этом по нехватке мощности.
161
+ const config = await loadConfig();
162
+ if (opts.dedicated || opts.cpu != null || opts.ram != null) {
163
+ throw new LayeroError("dedicated_needs_panel", "выделенный инстанс из терминала не заказывается", "у него есть цена и заморозка денег на карте, а подтвердить сумму в " +
164
+ "терминале негде. Закажите в панели: " +
165
+ `${dashboardOrigin(config.apiUrl)}/databases?new=1 — там видны ` +
166
+ "ступени, цена и дата следующего списания");
167
+ }
168
+ const api = new ApiClient(config);
113
169
  const org = await orgOf(api, opts);
114
170
  const created = await api.createDatabase(org, {
115
171
  name,
@@ -159,8 +215,42 @@ export async function dbConnectCmd(ref, opts) {
159
215
  emit({ event: "database_connected", org, database: db.name, project: project.slug });
160
216
  return;
161
217
  }
218
+ // 🚨 СЛЕДСТВИЙ У СВЯЗИ ДВА, и второе стоило отдельного разбора 09.09.2026.
219
+ // Кроме строки подключения, связь проект↔база — ЕДИНСТВЕННЫЙ источник списка
220
+ // разрешённых доменов Data API: представление `data_api_origins` собирает их
221
+ // из `userdb_roles`. Пока здесь говорилось только про переменные, человек,
222
+ // у которого сайт отбивался по CORS, не имел ни одной причины связать это со
223
+ // своей ненажатой командой — а отказ шлюза именно её и советует.
162
224
  console.log(`${chalk.green("✓")} проект «${project.slug}» подключён к базе «${db.name}»\n` +
163
- chalk.dim(" строка подключения приедет в переменные окружения проекта"));
225
+ chalk.dim(" строка подключения приедет в переменные окружения проекта\n") +
226
+ chalk.dim(` домены проекта разрешены для Data API базы «${db.name}»`));
227
+ }
228
+ export async function dbDisconnectCmd(ref, opts) {
229
+ // 🚨 ОБРАТНОГО ДЕЙСТВИЯ НЕ БЫЛО ВОВСЕ. `db connect` существовал с самого
230
+ // начала, а отвязать проект от базы можно было только в панели — при том
231
+ // что подсказка `env list` прямо звала сделать это командой. Приёмка (A7.6)
232
+ // спрашивает именно её.
233
+ const mode = detectMode();
234
+ const api = new ApiClient(await loadConfig());
235
+ const org = await orgOf(api, opts);
236
+ const linked = await loadProjectConfig(process.cwd());
237
+ const projectRef = opts.project ?? linked?.project_id;
238
+ if (!projectRef) {
239
+ throw new LayeroError("project_unknown", "не понятно, какой проект отвязывать от базы", "запустите из каталога проекта или передайте --project <id|slug>");
240
+ }
241
+ const project = await api.getProject(projectRef);
242
+ const db = await pick(api, org, ref);
243
+ await api.disconnectDatabaseFromProject(org, db.id, project.id);
244
+ if (mode.json || opts.json) {
245
+ emit({ event: "database_disconnected", org, database: db.name, project: project.slug });
246
+ return;
247
+ }
248
+ // Обратная сторона той же связи: домены проекта перестают быть разрешёнными.
249
+ // Молчать об этом — значит отдать человеку сайт, который перестанет отвечать
250
+ // по причине, названной в другой команде.
251
+ console.log(`${chalk.green("✓")} проект «${project.slug}» отвязан от базы «${db.name}»\n` +
252
+ chalk.dim(" переменная уйдёт из окружения следующим деплоем, роль проекта удалена\n") +
253
+ chalk.dim(" домены проекта больше не разрешены для Data API этой базы"));
164
254
  }
165
255
  export async function dbSqlCmd(ref, opts) {
166
256
  const mode = detectMode();
@@ -3,6 +3,8 @@ import path from "node:path";
3
3
  import readline from "node:readline/promises";
4
4
  import chalk from "chalk";
5
5
  import { ApiClient, ApiError, uploadArchive, } from "../api.js";
6
+ import { looksLikeId } from "../project-ref.js";
7
+ import { dashboardOrigin } from "../urls.js";
6
8
  import { loadConfig } from "../config.js";
7
9
  import { loadProjectConfig, persistProjectLinking, } from "../project-config.js";
8
10
  import { packCwd, packDirectory } from "../pack.js";
@@ -11,7 +13,7 @@ import { detectProject } from "../detect.js";
11
13
  import { runDeviceLogin } from "../auth.js";
12
14
  import { LayeroError, detectMode, emit, isCiEnv } from "../agent.js";
13
15
  import { ensureUsername } from "../username.js";
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;
16
+ import { claimTokenFor, createClaimable } from "./claim.js";
15
17
  const VALID_TYPES = new Set([
16
18
  "vite",
17
19
  "next",
@@ -70,19 +72,6 @@ export function runtimeTypeOf(raw) {
70
72
  return null;
71
73
  return RUNTIME_TYPES[raw.trim().toLowerCase()] ?? null;
72
74
  }
73
- function dashboardOrigin(apiUrl) {
74
- const override = process.env.LAYERO_DASHBOARD_URL;
75
- if (override)
76
- return override.replace(/\/+$/, "");
77
- try {
78
- const u = new URL(apiUrl);
79
- u.hostname = u.hostname.replace(/^api\./, "app.");
80
- return u.origin;
81
- }
82
- catch {
83
- return "https://app.layero.ru";
84
- }
85
- }
86
75
  function projectUrl(apiUrl, projectId) {
87
76
  return `${dashboardOrigin(apiUrl)}/projects/${projectId}`;
88
77
  }
@@ -375,21 +364,78 @@ export async function deployCmd(opts) {
375
364
  `runtime kinds: ${[...new Set(Object.values(RUNTIME_TYPES))].join(", ")} ` +
376
365
  `(aliases: ${Object.keys(RUNTIME_TYPES).join(", ")})`);
377
366
  }
367
+ const cwd = process.cwd();
368
+ let existing = await loadProjectConfig(cwd);
378
369
  let cliCfg = await loadConfig();
370
+ // Claimable-проект этого запуска (этап 13): событие `claimable` уходит
371
+ // перед `ready`, когда адрес сайта уже известен.
372
+ let claimable = null;
379
373
  if (!cliCfg.token) {
380
- // In CI nobody can open a browser, so the device flow can only end one
381
- // way: fifteen minutes of a hung job and then `auth_expired`. Fail
382
- // immediately instead, and say what to do burning a quarter of an hour
383
- // of someone's runner to reach a foregone conclusion is not acceptable.
384
- if (isCiEnv()) {
374
+ // Папка уже привязана к claimable-проекту деплоим его же токеном,
375
+ // пока заявка жива. Заявка забрана или истекла платформа ответит 401,
376
+ // и это станет `auth_expired`: честнее, чем молча завести ещё один.
377
+ const reuse = claimTokenFor(cliCfg, existing?.project_id);
378
+ if (reuse) {
379
+ cliCfg = { ...cliCfg, token: reuse };
380
+ }
381
+ else if (opts.claim || (!mode.interactive && !isCiEnv() && opts.yes)) {
382
+ // 🚨 CI СЮДА НЕ ПОПАДАЕТ НАМЕРЕННО. Раннер без LAYERO_TOKEN — это
383
+ // забытый секрет, и правильный ответ ему — отказ, а не сайт на
384
+ // временном адресе, который через 72 часа исчезнет вместе с
385
+ // «зелёным» прогоном. Явный `--claim` в CI работает.
386
+ const r = await createClaimable(cliCfg, cwd, {
387
+ name: opts.name ?? path.basename(cwd),
388
+ });
389
+ cliCfg = r.cfg;
390
+ claimable = {
391
+ claim_url: r.created.claim_url,
392
+ expires_at: r.created.expires_at,
393
+ project_id: r.created.project_id,
394
+ slug: r.created.slug,
395
+ };
396
+ existing = await loadProjectConfig(cwd);
397
+ }
398
+ else if (isCiEnv()) {
399
+ // In CI nobody can open a browser, so the device flow can only end one
400
+ // way: fifteen minutes of a hung job and then `auth_expired`. Fail
401
+ // immediately instead, and say what to do — burning a quarter of an hour
402
+ // of someone's runner to reach a foregone conclusion is not acceptable.
385
403
  throw new LayeroError("auth_required", "No credentials in CI. Create a token at https://app.layero.ru/settings/cli " +
386
404
  "and pass it as the LAYERO_TOKEN environment variable.", "set_layero_token");
387
405
  }
388
- cliCfg = await runDeviceLogin(cliCfg);
406
+ else {
407
+ cliCfg = await runDeviceLogin(cliCfg);
408
+ }
409
+ }
410
+ else if (opts.claim) {
411
+ throw new LayeroError("bad_format", "--claim — деплой без аккаунта, а вход уже выполнен", "уберите --claim: проект создастся в вашем аккаунте; либо `layero logout` перед `--claim`");
389
412
  }
390
413
  const api = new ApiClient(cliCfg);
391
- const cwd = process.cwd();
392
- const existing = await loadProjectConfig(cwd);
414
+ // 🚨 `--branch` У АРХИВНОЙ ЗАГРУЗКИ НЕ РАБОТАЕТ, И МОЛЧАТЬ ОБ ЭТОМ НЕЛЬЗЯ.
415
+ // Платформа кладёт каждый архив в зарезервированное окружение `cli`, что
416
+ // бы ни передали (`projects.py`, «Branch targeting»). До 0.10.0 флаг
417
+ // принимался и игнорировался: агент читал в справке «deploy to a specific
418
+ // branch's environment», делал `--branch=probe` и заменял живой сайт,
419
+ // считая, что выложил превью. Теперь — отказ до упаковки, с разной
420
+ // подсказкой для проекта без репозитория и с ним.
421
+ if (opts.branch) {
422
+ let linked = null;
423
+ try {
424
+ if (opts.project)
425
+ linked = await api.resolveProject(opts.project);
426
+ else if (existing?.project_id)
427
+ linked = await api.getProject(existing.project_id);
428
+ }
429
+ catch {
430
+ linked = null;
431
+ }
432
+ const repo = linked?.repo_full_name && linked.repo_status !== "disconnected" ? linked.repo_full_name : null;
433
+ throw new LayeroError("branch_unsupported", repo
434
+ ? `--branch ${opts.branch}: архивная загрузка не попадает в ветку — платформа кладёт её в окружение «cli»`
435
+ : `--branch ${opts.branch}: у проекта нет подключённого репозитория, а превью-ветки есть только у проектов с репозиторием`, repo
436
+ ? `изолированное превью — push в ветку «${opts.branch}» репозитория ${repo}; \`layero deploy\` без --branch обновит окружение «cli»`
437
+ : "превью-ветки есть только у проектов с репозиторием: подключите его — `layero projects create --repo <provider>:<owner/repo>` — и пушьте в ветку; папку без репозитория выкладывает `layero deploy` без --branch");
438
+ }
393
439
  // --- Всё, что требует файловой системы, делаем сами. Остальное — сервер.
394
440
  const prebuiltDir = await resolvePrebuiltDir(cwd, opts.prebuilt);
395
441
  const setup = prebuiltDir
@@ -448,7 +494,7 @@ export async function deployCmd(opts) {
448
494
  // Опечатка в слаге при этом обязана дать 404, а не завести лишний
449
495
  // проект с похожим именем: `create_if_missing: false`.
450
496
  ...(opts.project
451
- ? UUID_RE.test(opts.project)
497
+ ? looksLikeId(opts.project)
452
498
  ? { project_id: opts.project }
453
499
  : { name: opts.project, create_if_missing: false }
454
500
  : existing?.project_id
@@ -650,6 +696,18 @@ export async function deployCmd(opts) {
650
696
  if (evicted.length > 0) {
651
697
  emit({ event: "preview_evicted", evicted });
652
698
  }
699
+ // Claimable: ссылка «забрать» — ДО `ready`, потому что после `ready`
700
+ // агент не читает, а без этой ссылки сайт исчезнет через 72 часа.
701
+ if (claimable) {
702
+ emit({
703
+ event: "claimable",
704
+ project_id: claimable.project_id,
705
+ slug: claimable.slug,
706
+ url: liveUrl,
707
+ claim_url: claimable.claim_url,
708
+ expires_at: claimable.expires_at,
709
+ });
710
+ }
653
711
  emit({
654
712
  event: "ready",
655
713
  url: liveUrl,
@@ -24,7 +24,7 @@ export async function envListCmd(opts) {
24
24
  emit({
25
25
  event: "env_vars",
26
26
  project: project.slug,
27
- vars: rows.map((r) => ({ key: r.key, length: r.length })),
27
+ vars: rows.map((r) => ({ key: r.key, length: r.length, managed: !!r.managed })),
28
28
  });
29
29
  return;
30
30
  }
@@ -33,10 +33,21 @@ export async function envListCmd(opts) {
33
33
  console.log(chalk.dim("добавить: layero env set KEY=value"));
34
34
  return;
35
35
  }
36
+ // 🚨 ПЕРЕМЕННЫЕ ПЛАТФОРМЫ ПОМЕЧЕНЫ, А НЕ ПЕРЕМЕШАНЫ СО СВОИМИ. Список
37
+ // отвечает на вопрос «что получит моё приложение», и строка подключения к
38
+ // базе — часть ответа. Но менять её нельзя: она пересобирается из связи
39
+ // проекта с базой, и предложить правку значило бы пообещать несбыточное.
36
40
  for (const r of rows) {
37
- console.log(`${r.key}${chalk.dim(` (${r.length} симв.)`)}`);
41
+ const tail = r.managed
42
+ ? chalk.dim(` (${r.length} симв., ставит платформа)`)
43
+ : chalk.dim(` (${r.length} симв.)`);
44
+ console.log(`${r.key}${tail}`);
38
45
  }
39
46
  console.log(chalk.dim("\nзначения не показываются — платформа их не отдаёт"));
47
+ if (rows.some((r) => r.managed)) {
48
+ console.log(chalk.dim("помеченные «ставит платформа» задаются подключением базы к проекту:"));
49
+ console.log(chalk.dim(" layero db connect <база> / layero db disconnect <база>"));
50
+ }
40
51
  }
41
52
  function parsePair(raw) {
42
53
  const i = raw.indexOf("=");
@@ -0,0 +1,44 @@
1
+ import { ApiClient } from "../api.js";
2
+ import { loadConfig } from "../config.js";
3
+ import { loadProjectConfig } from "../project-config.js";
4
+ import { LayeroError, emit } from "../agent.js";
5
+ /**
6
+ * `layero envs list [--project]` — окружения проекта с адресами.
7
+ *
8
+ * Отдельной ручки «environments» в API нет: окружение и ветка — одна
9
+ * сущность (`environments` в базе, `/projects/{id}/branches` наружу), и
10
+ * список приходит оттуда. У CLI-проекта это одно окружение `cli`; у проекта
11
+ * с репозиторием — ветка на окружение. Архивные и снятые с раздачи в список
12
+ * не входят — так же, как в панели.
13
+ */
14
+ export async function envsListCmd(opts) {
15
+ const cfg = await loadConfig();
16
+ if (!cfg.token) {
17
+ throw new LayeroError("auth_required", "вход не выполнен", "выполните `layero login` или задайте LAYERO_TOKEN");
18
+ }
19
+ const api = new ApiClient(cfg);
20
+ let ref = opts.project;
21
+ if (!ref) {
22
+ const linked = await loadProjectConfig(process.cwd());
23
+ ref = linked?.project_id;
24
+ }
25
+ if (!ref) {
26
+ throw new LayeroError("project_unknown", "в этой папке нет привязанного проекта", "запустите из папки проекта или передайте --project <id|slug>");
27
+ }
28
+ const project = await api.resolveProject(ref);
29
+ const branches = await api.listBranches(project.id);
30
+ const productionBranch = project.production_branch_name ?? project.default_branch;
31
+ emit({
32
+ event: "environments",
33
+ project: project.slug,
34
+ environments: branches.map((b) => ({
35
+ id: b.id,
36
+ branch: b.branch_name,
37
+ url: b.preview_url,
38
+ hostname: b.hostname,
39
+ active_deploy_id: b.active_deploy_id,
40
+ active_deploy_at: b.active_deploy_at ?? null,
41
+ production: b.branch_name === productionBranch,
42
+ })),
43
+ });
44
+ }
@@ -1,23 +1,22 @@
1
- import chalk from "chalk";
2
1
  import { ApiClient, ApiError } from "../api.js";
3
2
  import { loadConfig } from "../config.js";
4
3
  import { loadProjectConfig } from "../project-config.js";
4
+ import { LayeroError, emit } from "../agent.js";
5
5
  async function resolveProjectId(opts) {
6
6
  if (opts.project) {
7
- // Accepts an id directly; UUID format check is server-side.
7
+ // Принимает id напрямую; форма UUID проверяется на сервере.
8
8
  return opts.project;
9
9
  }
10
10
  const linked = await loadProjectConfig(process.cwd());
11
11
  if (linked?.project_id) {
12
12
  return linked.project_id;
13
13
  }
14
- throw new Error("no project linked in cwd pass --project <id> or run `layero deploy` "
15
- + "from a project directory once to link it.");
14
+ throw new LayeroError("project_unknown", этой папке нет привязанного проекта", "передайте --project <id> или запустите `layero deploy` из папки проекта, чтобы привязать её");
16
15
  }
17
16
  async function makeClient() {
18
17
  const cfg = await loadConfig();
19
18
  if (!cfg.token) {
20
- throw new Error("not logged in. run `layero login` first.");
19
+ throw new LayeroError("auth_required", "вход не выполнен", "выполните `layero login` или задайте LAYERO_TOKEN");
21
20
  }
22
21
  return new ApiClient(cfg);
23
22
  }
@@ -25,24 +24,22 @@ export async function hooksListCmd(opts) {
25
24
  const api = await makeClient();
26
25
  const projectId = await resolveProjectId(opts);
27
26
  const hooks = await api.listDeployHooks(projectId);
28
- if (hooks.length === 0) {
29
- console.log("no deploy hooks yet — `layero hooks create <name>` to add one.");
30
- return;
31
- }
32
- for (const h of hooks) {
33
- const targetTag = h.target === "production" ? chalk.red("[prod]") : chalk.cyan("[preview]");
34
- const branchTag = h.branch ? chalk.dim(`branch=${h.branch}`) : chalk.dim("branch=default");
35
- const last = h.last_triggered_at
36
- ? `fired ${new Date(h.last_triggered_at).toISOString()}`
37
- : "never fired";
38
- console.log(`${targetTag} ${chalk.bold(h.name)} ${branchTag} ${chalk.dim("(" + last + ")")}`);
39
- console.log(` ${chalk.dim(h.id)}`);
40
- console.log(` ${h.url}`);
41
- }
27
+ emit({
28
+ event: "hooks",
29
+ project: projectId,
30
+ hooks: hooks.map((h) => ({
31
+ id: h.id,
32
+ name: h.name,
33
+ branch: h.branch,
34
+ target: h.target,
35
+ url: h.url,
36
+ last_triggered_at: h.last_triggered_at,
37
+ })),
38
+ });
42
39
  }
43
40
  export async function hooksCreateCmd(name, opts) {
44
41
  if (!name || !name.trim()) {
45
- throw new Error("name is required: `layero hooks create <name>`");
42
+ throw new LayeroError("bad_format", "нужно имя хука", "`layero hooks create <имя>`");
46
43
  }
47
44
  const api = await makeClient();
48
45
  const projectId = await resolveProjectId(opts);
@@ -51,17 +48,19 @@ export async function hooksCreateCmd(name, opts) {
51
48
  branch: opts.branch ?? null,
52
49
  target: opts.prod ? "production" : "preview",
53
50
  });
54
- const targetTag = hook.target === "production" ? chalk.red("[prod]") : chalk.cyan("[preview]");
55
- console.log(`${chalk.green("")} created ${targetTag} ${chalk.bold(hook.name)}`);
56
- console.log(` ${chalk.dim(hook.id)}`);
57
- console.log(` ${chalk.bold(hook.url)}`);
58
- console.log(chalk.dim("\n Paste this URL into your CMS / cron / external CI as a POST webhook. "
59
- + "Anyone with the URL can fire a build — treat it like a secret. "
60
- + "Rotate by deleting and creating a new one."));
51
+ emit({
52
+ event: "hook_created",
53
+ project: projectId,
54
+ id: hook.id,
55
+ name: hook.name,
56
+ branch: hook.branch,
57
+ target: hook.target,
58
+ url: hook.url,
59
+ });
61
60
  }
62
61
  export async function hooksDeleteCmd(hookId, opts) {
63
62
  if (!hookId) {
64
- throw new Error("hook id is required: `layero hooks delete <id>`");
63
+ throw new LayeroError("bad_format", "нужен id хука", "`layero hooks delete <id>`");
65
64
  }
66
65
  const api = await makeClient();
67
66
  const projectId = await resolveProjectId(opts);
@@ -70,11 +69,9 @@ export async function hooksDeleteCmd(hookId, opts) {
70
69
  }
71
70
  catch (err) {
72
71
  if (err instanceof ApiError && err.status === 404) {
73
- console.error(chalk.yellow(`no hook ${hookId} on this project (already deleted?)`));
74
- process.exitCode = 1;
75
- return;
72
+ throw new LayeroError("hook_not_found", проекта нет хука ${hookId} (уже удалён?)`, "`layero hooks list`");
76
73
  }
77
74
  throw err;
78
75
  }
79
- console.log(`${chalk.green("")} hook ${chalk.dim(hookId)} revoked`);
76
+ emit({ event: "hook_deleted", project: projectId, id: hookId });
80
77
  }