layero 0.8.13 → 0.8.15

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.
@@ -4,12 +4,13 @@ import readline from "node:readline/promises";
4
4
  import chalk from "chalk";
5
5
  import { ApiClient, ApiError, uploadArchive } from "../api.js";
6
6
  import { loadConfig } from "../config.js";
7
- import { loadProjectConfig, persistProjectLinking, projectConfigPath, } from "../project-config.js";
7
+ import { loadProjectConfig, persistProjectLinking, } from "../project-config.js";
8
8
  import { packCwd, packDirectory } from "../pack.js";
9
9
  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
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
14
  const VALID_TYPES = new Set([
14
15
  "vite",
15
16
  "next",
@@ -104,115 +105,6 @@ function deployTargeting(opts) {
104
105
  }
105
106
  return { target: opts.prod ? "production" : "preview" };
106
107
  }
107
- async function resolveOrCreateProject(api, cwd, opts, existing) {
108
- const mode = detectMode();
109
- if (opts.project) {
110
- const all = await api.listProjects();
111
- const match = all.find((p) => p.id === opts.project) ??
112
- all.find((p) => p.slug === opts.project);
113
- if (!match) {
114
- throw new LayeroError("project_not_found", `no project with id/slug "${opts.project}"`, "run `layero projects list` to see available projects");
115
- }
116
- return { project: match, createdNow: false };
117
- }
118
- // Treat the local config as a real link only if it actually carries a
119
- // project_id. `layero init` scaffolds .layero/project.json with the
120
- // detected framework/build/output but NO project_id (the project isn't
121
- // created until the first deploy). Without this guard, deploy would treat
122
- // that scaffold as an existing link and call GET /projects/undefined → 422
123
- // (B1 — the documented init→deploy path was broken). When there's no id,
124
- // fall through to the create path below, which preserves the scaffold's
125
- // setup fields via persistProjectLinking().
126
- if (existing && existing.project_id) {
127
- try {
128
- const project = await api.getProject(existing.project_id);
129
- return { project, createdNow: false };
130
- }
131
- catch (err) {
132
- if (err instanceof ApiError && err.status === 404) {
133
- throw new LayeroError("project_unlinked", `linked project ${existing.project_id} no longer exists or isn't on your account`, `delete ${projectConfigPath(cwd)} and re-run, or run \`layero link <id_or_slug>\``);
134
- }
135
- throw err;
136
- }
137
- }
138
- const me = await api.me();
139
- if (!me.username) {
140
- throw new LayeroError("username_missing", "no username set on your account", "open https://app.layero.ru/onboarding to pick one, then re-run");
141
- }
142
- let organizationSlug = opts.org;
143
- if (organizationSlug) {
144
- const orgs = await api.listOrganizations();
145
- if (!orgs.some((o) => o.slug === organizationSlug)) {
146
- throw new LayeroError("org_membership_missing", `you're not a member of organization "${organizationSlug}"`, `available: ${orgs.map((o) => o.slug).join(", ") || "(none)"}`);
147
- }
148
- }
149
- else {
150
- const orgs = await api.listOrganizations();
151
- if (orgs.length === 0) {
152
- throw new LayeroError("no_organization", "no organization found on your account", "finish onboarding at https://app.layero.ru/onboarding");
153
- }
154
- else if (orgs.length === 1) {
155
- organizationSlug = orgs[0].slug;
156
- }
157
- else if (opts.yes || opts.config || !mode.interactive) {
158
- // Non-interactive: prefer personal, fall back to first.
159
- organizationSlug =
160
- orgs.find((o) => o.kind === "personal")?.slug ?? orgs[0].slug;
161
- }
162
- else {
163
- console.log(chalk.cyan("which organization?"));
164
- orgs.forEach((o, i) => {
165
- const tag = o.kind === "personal" ? "personal" : "team";
166
- console.log(` ${i + 1}. ${o.slug} (${tag}, ${o.my_role})`);
167
- });
168
- const choiceRaw = await prompt("choose number", "1");
169
- const idx = Number(choiceRaw) - 1;
170
- if (Number.isNaN(idx) || idx < 0 || idx >= orgs.length) {
171
- throw new LayeroError("invalid_choice", `invalid org choice "${choiceRaw}"`, "enter a number from the list");
172
- }
173
- organizationSlug = orgs[idx].slug;
174
- }
175
- }
176
- const fallbackName = path.basename(cwd);
177
- const name = opts.name ??
178
- (opts.yes || opts.config || !mode.interactive
179
- ? fallbackName
180
- : await prompt("project name", fallbackName));
181
- const project = await api.createCliProject({
182
- name,
183
- framework_hint: opts.type,
184
- organization_slug: organizationSlug,
185
- });
186
- return { project, createdNow: true };
187
- }
188
- async function packAndUpload(api, cwd, project, prebuiltDir) {
189
- const pack = prebuiltDir
190
- ? await packDirectory(path.resolve(cwd, prebuiltDir), project.slug)
191
- : await packCwd(cwd, project.slug);
192
- emit({
193
- event: "packing",
194
- files: pack.fileCount,
195
- bytes: pack.size,
196
- sha256: pack.sha256,
197
- ...(prebuiltDir ? { prebuilt_dir: prebuiltDir } : {}),
198
- });
199
- // A gitignored lockfile is force-included so the build can do a frozen
200
- // install; warn (to stderr, off the --json stdout stream) that the ignore
201
- // rule was overridden for it.
202
- if (pack.forcedLockfiles?.length) {
203
- process.stderr.write(chalk.yellow(`! including gitignored lockfile(s) so the build uses a frozen install: ` +
204
- `${pack.forcedLockfiles.join(", ")}\n`));
205
- }
206
- const init = await api.initUpload(project.id);
207
- emit({ event: "uploading" });
208
- await uploadArchive(init, pack.archivePath);
209
- emit({ event: "uploaded", archive_key: init.source_archive_key });
210
- return {
211
- archive_key: init.source_archive_key,
212
- commit_sha: pack.sha256,
213
- archivePath: pack.archivePath,
214
- };
215
- }
216
108
  const PREBUILT_AUTO_DIRS = [
217
109
  "dist",
218
110
  "build",
@@ -318,35 +210,13 @@ export async function deployCmd(opts) {
318
210
  throw new LayeroError("auth_required", "No credentials in CI. Create a token at https://app.layero.ru/settings/cli " +
319
211
  "and pass it as the LAYERO_TOKEN environment variable.", "set_layero_token");
320
212
  }
321
- // Not authenticated yet. Kick off the browser device-login flow inline
322
- // and poll, exactly as the docs describe (B5/I4) — no separate `layero
323
- // login` step required. In JSON/agent mode this emits `auth_required`
324
- // so the caller can render the link and keep waiting.
325
213
  cliCfg = await runDeviceLogin(cliCfg);
326
214
  }
327
215
  const api = new ApiClient(cliCfg);
328
216
  const cwd = process.cwd();
329
217
  const existing = await loadProjectConfig(cwd);
330
- const { project: created, createdNow } = await resolveOrCreateProject(api, cwd, opts, existing);
331
- let project = created;
332
- if (project.cli_deploys_enabled === false) {
333
- throw new LayeroError("cli_deploys_disabled", `CLI deploys are disabled on project "${project.slug}"`, "enable them in project settings, or remove --project to use a different project");
334
- }
335
- // Prompt before overwriting production — only in interactive mode.
336
- if (opts.prod && !opts.yes && !opts.branch && mode.interactive) {
337
- const ok = await confirm(`deploy to production (https://${project.apex_hostname})?`);
338
- if (!ok) {
339
- console.log(chalk.yellow("aborted."));
340
- return;
341
- }
342
- }
343
- // Prebuilt deploys: caller has already built the artifact and points at
344
- // its directory. Setup-config detection is skipped (we report a synthetic
345
- // static setup so completeSetup gets *some* values and the dashboard
346
- // shows the project is configured).
218
+ // --- Всё, что требует файловой системы, делаем сами. Остальное сервер.
347
219
  const prebuiltDir = await resolvePrebuiltDir(cwd, opts.prebuilt);
348
- // Resolve setup config (auto-detect + overrides). Done eagerly so the
349
- // user sees what we detected before any network I/O.
350
220
  const setup = prebuiltDir
351
221
  ? {
352
222
  framework_hint: "static",
@@ -358,145 +228,196 @@ export async function deployCmd(opts) {
358
228
  if (prebuiltDir) {
359
229
  emit({ event: "prebuilt", dir: prebuiltDir });
360
230
  }
361
- const persistedCfg = await persistProjectLinking(cwd, {
362
- project_id: project.id,
363
- slug: project.slug,
364
- organization_slug: project.organization.slug,
365
- apex_hostname: project.apex_hostname,
366
- }, setup.framework_hint);
367
- if (createdNow) {
368
- emit({
231
+ // Организацию выбирает человек но только когда есть из чего выбирать и
232
+ // есть кому отвечать. В агентском режиме и в CI спрашивать некого, там
233
+ // политику применяет сервер (одна организация → она, несколько → личная).
234
+ const willCreate = !opts.project && !existing?.project_id;
235
+ let organizationSlug = opts.org;
236
+ if (willCreate && !organizationSlug && mode.interactive && !opts.yes) {
237
+ const orgs = await api.listOrganizations();
238
+ if (orgs.length > 1) {
239
+ console.log(chalk.cyan("which organization?"));
240
+ orgs.forEach((o, i) => {
241
+ const tag = o.kind === "personal" ? "personal" : "team";
242
+ console.log(` ${i + 1}. ${o.slug} (${tag}, ${o.my_role})`);
243
+ });
244
+ const choiceRaw = await prompt("choose number", "1");
245
+ const idx = Number(choiceRaw) - 1;
246
+ if (Number.isNaN(idx) || idx < 0 || idx >= orgs.length) {
247
+ throw new LayeroError("invalid_choice", `invalid org choice "${choiceRaw}"`, "enter a number from the list");
248
+ }
249
+ organizationSlug = orgs[idx].slug;
250
+ }
251
+ }
252
+ const fallbackName = path.basename(cwd);
253
+ const name = opts.name ??
254
+ (willCreate && mode.interactive && !opts.yes && !opts.config
255
+ ? await prompt("project name", fallbackName)
256
+ : fallbackName);
257
+ const targeting = deployTargeting(opts);
258
+ // --- Одно обращение вместо пяти: резолв или создание проекта, настройка,
259
+ // тип рантайма, root и выдача адреса для загрузки архива.
260
+ let session;
261
+ 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
+ });
297
+ }
298
+ catch (err) {
299
+ if (err instanceof ApiError && err.status === 404 && opts.project) {
300
+ // Явно указанный проект не найден. Код ошибки сохраняем прежним
301
+ // (`project_not_found`): на него смотрят агенты и наш Action, и
302
+ // подменять его на `internal` значит ломать их обработку.
303
+ throw new LayeroError("project_not_found", `no project with id/slug "${opts.project}"`, "run `layero projects list` to see available projects");
304
+ }
305
+ if (err instanceof ApiError && err.status === 403) {
306
+ 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
+ }
308
+ throw err;
309
+ }
310
+ const project = session.project;
311
+ if (project.cli_deploys_enabled === false) {
312
+ throw new LayeroError("cli_deploys_disabled", `CLI deploys are disabled on project "${project.slug}"`, "enable them in project settings, or remove --project to use a different project");
313
+ }
314
+ emit(session.created_project
315
+ ? {
369
316
  event: "project_created",
370
317
  project_id: project.id,
371
318
  slug: project.slug,
372
319
  organization: project.organization.slug,
373
- });
374
- }
375
- else {
376
- emit({
320
+ }
321
+ : {
377
322
  event: "project_linked",
378
323
  project_id: project.id,
379
324
  slug: project.slug,
380
325
  });
381
- }
382
- // Apply setup if the project is still in pending_setup. After first
383
- // run, subsequent deploys reuse whatever was set then — the user can
384
- // edit .layero/project.json or use the dashboard to change it.
385
- if (project.status === "pending_setup") {
386
- project = await api.completeSetup(project.id, {
387
- framework_hint: setup.framework_hint,
388
- build_cmd: setup.build_cmd,
389
- output_dir: setup.output_dir,
390
- analytics_enabled: persistedCfg.analytics_enabled ?? false,
391
- env_vars: persistedCfg.env_vars ?? {},
392
- root_directory: opts.root ?? null,
393
- });
394
- emit({ event: "setup_applied" });
395
- // Newly-created projects default to project_type='spa'. If detect
396
- // says the repo is SSR (Next.js without `output: 'export'`), flip
397
- // the type now otherwise the first build crashes at the detect
398
- // stage with "looks like ssr_next but configured as spa", forcing
399
- // the user to open the dashboard and accept the suggestion. Only
400
- // applied on first setup; an explicitly-configured spa project
401
- // that drops a next.config later keeps user's choice.
402
- if (setup.runtime_kind) {
403
- try {
404
- project = await api.setRuntimeType(project.id, setup.runtime_kind);
405
- emit({ event: "runtime_type_applied", project_type: setup.runtime_kind });
406
- }
407
- catch (err) {
408
- // Non-fatal: the build will fall back to the dashboard suggestion
409
- // flow exactly as it did before this CLI fix. Tell the user what
410
- // happened so they don't burn a deploy on a surprise crash.
411
- const msg = err instanceof Error ? err.message : String(err);
412
- emit({ event: "runtime_type_apply_failed", error: msg });
413
- }
326
+ emit({ event: "setup_applied" });
327
+ await persistProjectLinking(cwd, {
328
+ project_id: project.id,
329
+ slug: project.slug,
330
+ organization_slug: project.organization.slug,
331
+ apex_hostname: project.apex_hostname,
332
+ }, setup.framework_hint);
333
+ // Подтверждение спрашиваем ЗДЕСЬ, а не раньше: адрес апекса известен
334
+ // только от сервера, а сборка ещё не запущена — отказ ничего не стоит.
335
+ // Для только что созданного проекта вопрос бессмыслен: перезаписывать
336
+ // нечего.
337
+ if (opts.prod &&
338
+ !opts.yes &&
339
+ !opts.branch &&
340
+ mode.interactive &&
341
+ !session.created_project) {
342
+ const ok = await confirm(`deploy to production (https://${project.apex_hostname})?`);
343
+ if (!ok) {
344
+ console.log(chalk.yellow("aborted."));
345
+ return;
414
346
  }
415
347
  }
416
- else if (opts.root !== undefined) {
417
- // Active project: --root patches the existing row so the *next*
418
- // GitHub-pushed or hook-triggered build uses the new subdir.
419
- project = await api.updateProject(project.id, { root_directory: opts.root });
420
- }
421
- let upload = null;
348
+ // --- Упаковка и загрузка: единственное, что клиент обязан делать сам.
349
+ let archivePath = null;
422
350
  try {
423
- upload = await packAndUpload(api, cwd, project, prebuiltDir);
424
- const targeting = deployTargeting(opts);
425
- const deploy = await api.triggerDeploy(project.id, {
426
- source_archive_key: upload.archive_key,
427
- commit_sha: upload.commit_sha,
428
- commit_message: prebuiltDir ? `CLI prebuilt deploy (${prebuiltDir})` : "CLI deploy",
429
- framework_hint: setup.framework_hint,
430
- target: targeting.target,
431
- branch: targeting.branch,
432
- prebuilt: prebuiltDir !== null,
351
+ const pack = prebuiltDir
352
+ ? await packDirectory(path.resolve(cwd, prebuiltDir), project.slug)
353
+ : await packCwd(cwd, project.slug);
354
+ archivePath = pack.archivePath;
355
+ emit({
356
+ event: "packing",
357
+ files: pack.fileCount,
358
+ bytes: pack.size,
359
+ sha256: pack.sha256,
360
+ ...(prebuiltDir ? { prebuilt_dir: prebuiltDir } : {}),
433
361
  });
434
- emit({ event: "deploy_started", deploy_id: deploy.id });
435
- const final = await streamDeployLogs(api, deploy.id);
362
+ if (pack.forcedLockfiles?.length) {
363
+ process.stderr.write(chalk.yellow(`! including gitignored lockfile(s) so the build uses a frozen install: ` +
364
+ `${pack.forcedLockfiles.join(", ")}\n`));
365
+ }
366
+ emit({ event: "uploading" });
367
+ await uploadArchive({
368
+ upload_url: session.upload_url,
369
+ headers: session.upload_headers,
370
+ source_archive_key: session.source_archive_key,
371
+ expires_in: session.expires_in,
372
+ }, pack.archivePath);
373
+ emit({ event: "uploaded", archive_key: session.source_archive_key });
374
+ const started = await api.startDeploySession(session.session_id, {
375
+ commit_sha: pack.sha256,
376
+ });
377
+ if (!started.deploy_id) {
378
+ 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
+ }
380
+ emit({ event: "deploy_started", deploy_id: started.deploy_id });
381
+ const final = await streamDeployLogs(api, started.deploy_id);
436
382
  if (final.status !== "ready") {
437
383
  throw new LayeroError(`deploy_${final.status}`, `deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`, `inspect logs at ${projectUrl(cliCfg.apiUrl, project.id)}`);
438
384
  }
439
- // --promote: pin apex_hostname to this deploy after a successful
440
- // build. Independent of --prod; --promote lets the typical "CLI
441
- // preview branch" deploy publish straight to production in one
442
- // command. Best-effort: a failure here doesn't fail the deploy
443
- // (the artifact is good; promote can be retried via `layero promote`).
385
+ // --promote: пин апекса на эту сборку. Промоут остаётся на клиенте
386
+ // осознанно сборка асинхронна, а сервер применяет промоут только
387
+ // после активации (см. AGENT-01, известные ограничения).
444
388
  let promoted = false;
445
389
  if (opts.promote) {
446
390
  try {
447
- await api.promoteDeploy(project.id, deploy.id);
391
+ await api.promoteDeploy(project.id, started.deploy_id);
448
392
  promoted = true;
449
393
  }
450
394
  catch (err) {
451
395
  const msg = err instanceof Error ? err.message : String(err);
452
396
  console.warn(chalk.yellow(`warning: deploy succeeded but promote failed — ${msg}.\n` +
453
- ` retry with: layero promote ${deploy.id.slice(0, 8)}`));
397
+ ` retry with: layero promote ${started.deploy_id.slice(0, 8)}`));
454
398
  }
455
399
  }
456
- // Where is this build actually reachable? Ask the backend rather than
457
- // guessing — it knows the live public URL (apex once it's the production
458
- // pointer), the off-CDN preview URL that's reachable immediately, and
459
- // how far along CDN propagation is. Note: a plain `layero deploy` of a
460
- // CLI project auto-promotes to the apex on activate (no `--prod` /
461
- // `promote` needed), so the apex IS the destination for the common case.
462
400
  const dashboardUrl = projectUrl(cliCfg.apiUrl, project.id);
463
401
  const apexUrl = `https://${project.apex_hostname}`;
464
- const probe = await resolveReachability(api, deploy.environment_id);
465
- // Публичный адрес сайта.
466
- //
467
- // Раньше здесь стоял гейт `cdn_ready`: пока YC CDN не подтвердил апекс,
468
- // отдавали off-CDN превью, потому что свежий апекс 10-15 минут отвечал
469
- // 404, а runtime-приложения на апексе вообще не принимали POST (CDN резал
470
- // не-GET). После EDGE-02 CDN перед пользовательскими зонами нет: апекс
471
- // валиден с момента создания проекта (wildcard-запись + wildcard-серт), а
472
- // `cdn_ready` бекенд отдаёт false НАВСЕГДА — строки в `cdn_hostnames` у
473
- // новых проектов не появляется вовсе.
474
- //
475
- // Из-за этого условие не выполнялось никогда, и после успешной публикации
476
- // CLI выдавал ссылку на дашборд вместо адреса сайта. Проверено вживую
477
- // 2026-07-26: `layero deploy` вернул `url: https://app.layero.ru/projects/…`.
478
- //
479
- // Апекс — адрес по умолчанию; превью остаётся для деплоя ветки, которую
480
- // не промоутили: там апекс ведёт на другую сборку.
402
+ const deployRow = await api.getDeploy(started.deploy_id);
403
+ const probe = deployRow.environment_id
404
+ ? await resolveReachability(api, deployRow.environment_id)
405
+ : null;
481
406
  const liveUrl = promoted || !opts.branch
482
407
  ? probe?.canonical_url ?? apexUrl
483
408
  : probe?.preview_url ?? probe?.canonical_url ?? apexUrl;
484
409
  emit({
485
410
  event: "ready",
486
411
  url: liveUrl,
487
- deploy_id: deploy.id,
412
+ deploy_id: started.deploy_id,
488
413
  preview_url: probe?.preview_url ?? undefined,
489
414
  dashboard_url: dashboardUrl,
490
- // `available` от пробы, а НЕ `cdn_ready`: последний после EDGE-02
491
- // остаётся false навсегда, и потребитель JSON-вывода ждал бы события,
492
- // которого не будет. ETA пропагации убран по той же причине —
493
- // распространять больше нечего.
494
415
  edge_ready: probe ? probe.available : undefined,
495
416
  });
496
417
  }
497
418
  finally {
498
- if (upload) {
499
- await fs.unlink(upload.archivePath).catch(() => undefined);
419
+ if (archivePath) {
420
+ await fs.unlink(archivePath).catch(() => undefined);
500
421
  }
501
422
  }
502
423
  }
@@ -0,0 +1,121 @@
1
+ import chalk from "chalk";
2
+ import { ApiClient, ApiError } from "../api.js";
3
+ import { loadConfig } from "../config.js";
4
+ import { loadProjectConfig } from "../project-config.js";
5
+ import { LayeroError, detectMode, emit } from "../agent.js";
6
+ async function resolveDeployId(api, opts, cwd) {
7
+ if (opts.deploy) {
8
+ // Явно указанный деплой: проект знать не обязательно, ручка работает
9
+ // по самому id.
10
+ return { deployId: opts.deploy, projectId: "" };
11
+ }
12
+ const linked = await loadProjectConfig(cwd);
13
+ const ref = opts.project ?? linked?.project_id;
14
+ if (!ref) {
15
+ throw new LayeroError("project_unknown", "не понятно, какой проект смотреть", "запусти из каталога проекта, либо передай --project <id|slug> или --deploy <id>");
16
+ }
17
+ const project = await api.getProject(ref).catch((err) => {
18
+ if (err instanceof ApiError && err.status === 404) {
19
+ throw new LayeroError("project_not_found", `нет проекта с id/slug "${ref}"`, "посмотри список: layero projects list");
20
+ }
21
+ throw err;
22
+ });
23
+ const deploys = await api.listProjectDeploys(project.id);
24
+ if (!deploys.length) {
25
+ throw new LayeroError("no_deploys", `у проекта "${project.slug}" ещё нет ни одного деплоя`, "запусти `layero deploy`");
26
+ }
27
+ // Берём САМЫЙ СВЕЖИЙ деплой — он и есть текущее состояние проекта.
28
+ //
29
+ // Соблазнительно искать «последний неуспешный»: вопрос-то звучит как
30
+ // «почему упало». Но так команда врёт после починки: пользователь
31
+ // исправил код, передеплоил успешно, спрашивает — и снова видит старую
32
+ // ошибку, которой уже нет. Поймано живым прогоном.
33
+ //
34
+ // Разобрать конкретную старую сборку по-прежнему можно: --deploy <id>.
35
+ const target = deploys[0];
36
+ return { deployId: target.id, projectId: project.id };
37
+ }
38
+ export async function diagnoseCmd(opts) {
39
+ const mode = detectMode();
40
+ const cfg = await loadConfig();
41
+ if (!cfg.token) {
42
+ throw new LayeroError("auth_required", "нужен вход", "выполни `layero login` или задай LAYERO_TOKEN");
43
+ }
44
+ const api = new ApiClient(cfg);
45
+ const { deployId } = await resolveDeployId(api, opts, process.cwd());
46
+ const d = await api.getDeployDiagnosis(deployId);
47
+ if (mode.json) {
48
+ emit({ event: "diagnosis", ...d });
49
+ return;
50
+ }
51
+ const head = d.status === "ready"
52
+ ? chalk.green(`деплой ${deployId.slice(0, 8)} — успешен`)
53
+ : chalk.red(`деплой ${deployId.slice(0, 8)} — ${d.status ?? "неизвестно"}`);
54
+ console.log(head + (d.stage ? chalk.dim(` (этап: ${d.stage})`) : ""));
55
+ if (d.verdict) {
56
+ console.log(`\n${chalk.bold("причина:")} ${d.verdict}`);
57
+ }
58
+ if (d.runtime_state) {
59
+ const idle = d.runtime_state === "paused" || d.runtime_state === "suspended";
60
+ console.log(`${chalk.bold("рантайм:")} ${d.runtime_state}` +
61
+ (idle ? chalk.dim(" — приложение спит, это штатно (scale-to-zero)") : ""));
62
+ }
63
+ if (d.build_log_excerpt?.length) {
64
+ console.log(`\n${chalk.bold("лог сборки")}${d.build_error_found ? chalk.dim(" (вокруг ошибки)") : chalk.dim(" (хвост)")}:`);
65
+ for (const line of d.build_log_excerpt)
66
+ console.log(" " + line);
67
+ }
68
+ if (d.runtime_log_excerpt?.length) {
69
+ console.log(`\n${chalk.bold("лог приложения")}:`);
70
+ for (const line of d.runtime_log_excerpt)
71
+ console.log(" " + line);
72
+ }
73
+ if (d.truncated) {
74
+ console.log(chalk.dim("\n(вывод укорочен; полный лог — в панели проекта)"));
75
+ }
76
+ if (d.status !== "ready") {
77
+ console.log(chalk.dim(`\nчто дальше: ${(d.next_actions ?? []).join(", ")}`));
78
+ }
79
+ }
80
+ export async function logsCmd(opts) {
81
+ const mode = detectMode();
82
+ const cfg = await loadConfig();
83
+ if (!cfg.token) {
84
+ throw new LayeroError("auth_required", "нужен вход", "выполни `layero login` или задай LAYERO_TOKEN");
85
+ }
86
+ const api = new ApiClient(cfg);
87
+ const { deployId } = await resolveDeployId(api, opts, process.cwd());
88
+ if (opts.runtime) {
89
+ const rt = await api.getRuntimeLogs(deployId, opts.tail ?? 100);
90
+ if (mode.json) {
91
+ emit({ event: "runtime_logs", status: rt.status, lines: rt.lines });
92
+ return;
93
+ }
94
+ if (!rt.lines.length) {
95
+ console.log(chalk.dim(rt.status === "cold"
96
+ ? "приложение ещё ни разу не запускалось — логов нет"
97
+ : "логов нет"));
98
+ return;
99
+ }
100
+ for (const l of rt.lines) {
101
+ console.log(l.ts ? `${chalk.dim(l.ts)} ${l.text}` : l.text);
102
+ }
103
+ return;
104
+ }
105
+ // Лог сборки. Читаем разом, без long-poll: `layero deploy` уже стримит
106
+ // живую сборку, а эта команда нужна, когда всё закончилось.
107
+ const out = await api.pollLogs(deployId, 0);
108
+ if (mode.json) {
109
+ emit({ event: "build_logs", status: out.status, lines: out.lines });
110
+ return;
111
+ }
112
+ if (!out.lines.length) {
113
+ console.log(chalk.dim("логов нет"));
114
+ return;
115
+ }
116
+ for (const l of out.lines)
117
+ console.log(l.line);
118
+ if (out.error_message) {
119
+ console.log(chalk.red(`\n${out.error_message}`));
120
+ }
121
+ }