layero 0.10.4 → 0.11.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.
@@ -9,7 +9,7 @@ import { loadConfig } from "../config.js";
9
9
  import { loadProjectConfig, persistProjectLinking, } from "../project-config.js";
10
10
  import { packCwd, packDirectory } from "../pack.js";
11
11
  import { streamDeployLogs } from "../logs.js";
12
- import { detectProject } from "../detect.js";
12
+ import { detectProject, detectedEvent } from "../detect.js";
13
13
  import { runDeviceLogin } from "../auth.js";
14
14
  import { LayeroError, detectMode, emit, isCiEnv } from "../agent.js";
15
15
  import { ensureUsername } from "../username.js";
@@ -76,31 +76,23 @@ function projectUrl(apiUrl, projectId) {
76
76
  return `${dashboardOrigin(apiUrl)}/projects/${projectId}`;
77
77
  }
78
78
  /**
79
- * After a deploy reaches `ready`, ask the backend where it's actually
80
- * reachable. The builder marks status=ready *before* calling /activate
81
- * (which runs auto-promote + schedules CDN warmup), so a probe fired the
82
- * instant we see `ready` can race ahead of the apex pointer being set.
79
+ * After a deploy reaches `ready`, ask the backend for the canonical address
80
+ * of its environment (apex for the production env, the env host otherwise).
83
81
  *
84
- * Poll the probe briefly (bounded) and return as soon as the env is
85
- * reachable (`available` — the preview host is up) or we learn the CDN
86
- * edge is already warm. Best-effort: any error returns null and the caller
87
- * falls back to apex/dashboard URLs.
82
+ * One request, not a poll. The probe used to be polled for up to 15 s until
83
+ * its `available` flag turned true, and that flag also fed `edge_ready` — but
84
+ * it is the API's own view of the host, and the simulation of 18.09.2026 saw
85
+ * it stay false for 15 s on a static site that answered 200 at once. Whether
86
+ * the address serves the site is now measured directly (`waitUntilServing`).
87
+ * Best-effort: any error returns null and the caller falls back to the apex.
88
88
  */
89
89
  async function resolveReachability(api, environmentId) {
90
- const deadline = Date.now() + 15_000;
91
- let last = null;
92
- while (Date.now() < deadline) {
93
- try {
94
- last = await api.probeEnvironment(environmentId);
95
- }
96
- catch {
97
- return last; // permission/network — caller falls back
98
- }
99
- if (last.available || last.cdn_ready)
100
- return last;
101
- await new Promise((r) => setTimeout(r, 1500));
90
+ try {
91
+ return await api.probeEnvironment(environmentId);
92
+ }
93
+ catch {
94
+ return null; // permission/network — caller falls back
102
95
  }
103
- return last;
104
96
  }
105
97
  async function prompt(question, fallback) {
106
98
  const rl = readline.createInterface({
@@ -188,54 +180,48 @@ async function resolvePrebuiltDir(cwd, raw) {
188
180
  }
189
181
  return dir;
190
182
  }
191
- // Resolve the framework / build / output config to send to completeSetup.
192
- // Precedence: explicit CLI args > .layero/project.json > auto-detect.
193
- async function resolveSetupConfig(cwd, opts, existing) {
183
+ /**
184
+ * Заготовка `layero init` до 0.11: `static` / `true` / `.` без project_id.
185
+ * Эту тройку писал CLI, а не человек, — и она та самая догадка, от которой
186
+ * эта версия отказалась. Читать её как выбор значит тащить старую ошибку в
187
+ * новый проект.
188
+ */
189
+ function isLegacyInitGuess(cfg) {
190
+ return Boolean(cfg &&
191
+ !cfg.project_id &&
192
+ cfg.framework_hint === "static" &&
193
+ cfg.build_cmd === "true" &&
194
+ cfg.output_dir === ".");
195
+ }
196
+ async function resolveSetupConfig(cwd, opts, existing, projectFramework) {
194
197
  // Honour --root when auto-detecting: the framework signals live in the
195
- // monorepo subdir, not the repo root. Without this the detector sees
196
- // a bare workspace package.json and falls through to "static".
198
+ // monorepo subdir, not the repo root.
197
199
  const detectCwd = opts.root ? path.join(cwd, opts.root) : cwd;
198
- const detected = await detectProject(detectCwd);
199
- emit({
200
- event: "detected",
201
- framework: detected.framework_hint,
202
- build_cmd: detected.build_cmd,
203
- output_dir: detected.output_dir,
204
- confident: detected.confident,
205
- ...(detected.runtime_kind ? { runtime_kind: detected.runtime_kind } : {}),
206
- ...(detected.ssr_warning ? { ssr_warning: detected.ssr_warning } : {}),
207
- });
208
200
  // ⚠️ Runtime-тип в `framework_hint` не уходит: это разные вопросы. Хинт
209
201
  // отвечает «чем собирать» (vite, next, …), а runtime-kind — «запускать или
210
202
  // раздавать файлами». Положи мы сюда `node_web`, сборщик получил бы имя
211
203
  // фреймворка, которого не существует.
212
204
  const asRuntime = runtimeTypeOf(opts.type);
213
- const framework_hint = (asRuntime ? undefined : opts.type) ??
214
- existing?.framework_hint ??
215
- detected.framework_hint;
216
- const build_cmd = existing?.build_cmd ?? detected.build_cmd;
217
- const output_dir = existing?.output_dir ?? detected.output_dir;
218
- let source;
219
- if (existing?.build_cmd || existing?.output_dir || existing?.framework_hint) {
220
- source =
221
- existing?.build_cmd && existing?.output_dir && existing?.framework_hint
222
- ? "config"
223
- : "hybrid";
224
- }
225
- else {
226
- source = "detected";
227
- }
205
+ const typeHint = asRuntime ? null : (opts.type ?? null);
206
+ const own = isLegacyInitGuess(existing) ? null : existing;
207
+ const fileHint = own?.framework_hint ?? null;
208
+ const hint = typeHint ?? fileHint ?? projectFramework ?? null;
209
+ const detected = await detectProject(detectCwd, {
210
+ frameworkHint: hint,
211
+ hintSource: typeHint ? "--type" : fileHint ? ".layero/project.json" : "project settings",
212
+ });
213
+ emit(detectedEvent(detected));
228
214
  return {
229
- framework_hint,
230
- build_cmd,
231
- output_dir,
232
- source,
215
+ detected,
216
+ framework_hint: typeHint ?? fileHint,
217
+ build_cmd: own?.build_cmd ?? null,
218
+ output_dir: own?.output_dir ?? null,
233
219
  // Явный `--type` сильнее детекта: человек уже видел, как детект ошибся,
234
220
  // иначе бы не писал флаг.
235
221
  ...(asRuntime
236
- ? { runtime_kind: asRuntime }
222
+ ? { runtime_kind: asRuntime, runtime_kind_origin: "user" }
237
223
  : detected.runtime_kind
238
- ? { runtime_kind: detected.runtime_kind }
224
+ ? { runtime_kind: detected.runtime_kind, runtime_kind_origin: "detected" }
239
225
  : {}),
240
226
  };
241
227
  }
@@ -352,6 +338,150 @@ async function startWithRepeatedFailureGuard(api, sessionId, commitSha, opts) {
352
338
  "`--confirm-repeated-failure`.");
353
339
  }
354
340
  }
341
+ const RUNTIME_PROJECT_TYPES = new Set(["ssr_next", "streamlit", "gradio", "flask", "python_web", "node_web"]);
342
+ /** У проекта подключён репозиторий: выкатка архива без `--prod` ляжет в
343
+ * окружение `cli`, а не на живой адрес. */
344
+ function hasRepo(p) {
345
+ return Boolean(p?.repo_full_name && p.repo_status !== "disconnected");
346
+ }
347
+ /**
348
+ * `deploy --dry-run`: как платформа соберёт эту папку, если выкатить сейчас.
349
+ * Ничего не пакуется, не выгружается и не создаётся.
350
+ *
351
+ * 🚨 До 0.11 сухого прогона не было: единственным доказательством того, что
352
+ * платформа поняла папку, был лог настоящей сборки. Агент проверял форму
353
+ * приложения выкатками — а у проекта без репозитория каждая из них заменяет
354
+ * живой сайт (симуляция 18.09.2026, T-20260918-7).
355
+ *
356
+ * Порядок тот же, что у сборщика: `layero.json` > настройки проекта > детект.
357
+ * Настройки читаются, только если папка привязана к проекту и вход есть; без
358
+ * них план показывает то, что говорят файлы, и говорит об этом полем.
359
+ */
360
+ async function dryRun(cwd, opts, existing, cliCfg) {
361
+ const ref = opts.project ?? (existing?.project_id || undefined);
362
+ let project = null;
363
+ let settings = "not linked";
364
+ if (ref) {
365
+ const token = cliCfg.token ?? claimTokenFor(cliCfg, existing?.project_id);
366
+ if (!token) {
367
+ settings = "not read: not logged in";
368
+ }
369
+ else {
370
+ try {
371
+ const api = new ApiClient({ ...cliCfg, token });
372
+ project = opts.project ? await api.resolveProject(opts.project) : await api.getProject(ref);
373
+ settings = "read";
374
+ }
375
+ catch (err) {
376
+ settings = `not read: ${err instanceof ApiError ? `API ${err.status}` : "network error"}`;
377
+ }
378
+ }
379
+ }
380
+ const root = opts.root ?? project?.root_directory ?? null;
381
+ const replacesLive = !project || !hasRepo(project) || Boolean(opts.prod || opts.promote);
382
+ const branchNote = opts.branch
383
+ ? `A real deploy refuses --branch (branch_unsupported): an upload always lands in the "cli" environment.`
384
+ : "";
385
+ const projectInfo = project
386
+ ? { id: project.id, slug: project.slug, project_type: project.project_type, repo: project.repo_full_name ?? null }
387
+ : null;
388
+ const prebuiltDir = await resolvePrebuiltDir(cwd, opts.prebuilt);
389
+ if (prebuiltDir) {
390
+ emit({
391
+ event: "plan",
392
+ framework: "static",
393
+ build_cmd: null,
394
+ output_dir: prebuiltDir,
395
+ runtime_kind: null,
396
+ root: null,
397
+ confident: true,
398
+ sources: { framework: "--prebuilt", build_cmd: "none", output_dir: "--prebuilt" },
399
+ project: projectInfo,
400
+ project_settings: settings,
401
+ creates_project: !ref,
402
+ replaces_live_site: replacesLive,
403
+ prebuilt_dir: prebuiltDir,
404
+ ...(branchNote ? { hint: branchNote } : {}),
405
+ });
406
+ return;
407
+ }
408
+ const setup = await resolveSetupConfig(cwd, { ...opts, root: root ?? undefined }, existing, project?.framework_hint ?? null);
409
+ const d = setup.detected;
410
+ let buildCmd = d.build_cmd;
411
+ let outputDir = d.output_dir;
412
+ const sources = { ...d.sources };
413
+ let runtimeKind = setup.runtime_kind ?? null;
414
+ // Настройки проекта исполняются дословно — поверх детекта, но под файлом.
415
+ // Статика не собирается, что бы ни лежало в поле команды.
416
+ if (project && !d.runtime_kind) {
417
+ if (project.build_cmd && d.framework_hint !== "static" && sources.build_cmd !== "layero.json") {
418
+ buildCmd = project.build_cmd;
419
+ sources.build_cmd = "project settings";
420
+ }
421
+ if (project.output_dir && sources.output_dir !== "layero.json") {
422
+ outputDir = project.output_dir;
423
+ sources.output_dir = "project settings";
424
+ }
425
+ if (RUNTIME_PROJECT_TYPES.has(project.project_type))
426
+ runtimeKind = project.project_type;
427
+ }
428
+ const hint = [d.hint, branchNote].filter(Boolean).join(" ");
429
+ emit({
430
+ event: "plan",
431
+ framework: d.framework_hint,
432
+ build_cmd: runtimeKind ? null : buildCmd,
433
+ output_dir: runtimeKind ? null : outputDir,
434
+ runtime_kind: runtimeKind,
435
+ root,
436
+ confident: d.confident,
437
+ sources,
438
+ project: projectInfo,
439
+ project_settings: settings,
440
+ creates_project: !ref,
441
+ replaces_live_site: replacesLive,
442
+ ...(hint ? { hint } : {}),
443
+ ...(d.next_action ? { next_action: d.next_action } : {}),
444
+ ...(d.candidates?.length ? { candidates: d.candidates } : {}),
445
+ });
446
+ }
447
+ // Сколько ждём, пока живой адрес начнёт отвечать приложением. Статика
448
+ // отвечает с первого запроса; контейнеру нужно до ~20 с на запуск, и
449
+ // симуляция 18.09.2026 видела 404-заглушку платформы уже после `ready`.
450
+ const SERVE_WAIT_MS = 90_000;
451
+ const SERVE_POLL_MS = 2_000;
452
+ /**
453
+ * Дождаться, пока адрес перестанет отдавать экран платформы.
454
+ *
455
+ * Признак — заголовок `X-Layero-Screen`: его ставят ТОЛЬКО наши экраны
456
+ * («запускается», «здесь пока ничего нет», …), ответ сайта его не несёт.
457
+ * Тот же признак ждёт проба отклика платформы (`deploy_probe._await_edge`);
458
+ * время само по себе ничего не доказывает. До 0.11 CLI отдавал `ready`, как
459
+ * только сборка закончилась, и агенту приходилось опрашивать адрес самому —
460
+ * он узнавал это только из навыка (T-20260918-8).
461
+ */
462
+ export async function waitUntilServing(url, budgetMs = SERVE_WAIT_MS) {
463
+ const deadline = Date.now() + budgetMs;
464
+ let screen;
465
+ for (;;) {
466
+ try {
467
+ const res = await fetch(url, {
468
+ headers: { "cache-control": "no-cache" },
469
+ redirect: "follow",
470
+ signal: AbortSignal.timeout(10_000),
471
+ });
472
+ screen = res.headers.get("x-layero-screen") ?? undefined;
473
+ await res.body?.cancel().catch(() => undefined);
474
+ if (!screen)
475
+ return { serving: true };
476
+ }
477
+ catch {
478
+ screen = "no response";
479
+ }
480
+ if (Date.now() + SERVE_POLL_MS > deadline)
481
+ return { serving: false, screen };
482
+ await new Promise((r) => setTimeout(r, SERVE_POLL_MS));
483
+ }
484
+ }
355
485
  export async function deployCmd(opts) {
356
486
  const mode = detectMode();
357
487
  if (opts.type &&
@@ -367,18 +497,52 @@ export async function deployCmd(opts) {
367
497
  const cwd = process.cwd();
368
498
  let existing = await loadProjectConfig(cwd);
369
499
  let cliCfg = await loadConfig();
500
+ if (opts.dryRun) {
501
+ await dryRun(cwd, opts, existing, cliCfg);
502
+ return;
503
+ }
370
504
  // Claimable-проект этого запуска (этап 13): событие `claimable` уходит
371
505
  // перед `ready`, когда адрес сайта уже известен.
372
506
  let claimable = null;
507
+ let reusedClaim = null;
508
+ // 🚨 ПЕСОЧНИЦА — ТОЛЬКО ДЛЯ НОВОГО ПРОЕКТА. `--project` или папка,
509
+ // привязанная к проекту аккаунта, значат «выкатить в существующий», а у
510
+ // токена песочницы прав на него нет: до 0.10.5 авто-режим всё равно
511
+ // включался, и платформа отвечала `username_required` про держателя
512
+ // песочницы — человек читал совет выбрать имя аккаунта, которого у него
513
+ // нет. Теперь существующий проект без токена — это вход (`auth_required`).
514
+ if (opts.claim && opts.project) {
515
+ throw new LayeroError("claim_with_project", `--claim вместе с --project ${opts.project}: песочница создаёт новый проект, в существующий она не выкатывает`, "песочница создаёт новый проект; для существующего войдите: `layero login` — и повторите без --claim");
516
+ }
517
+ // Папка привязана к claimable-проекту, и `--project` (если задан) называет
518
+ // его же. Любой другой `--project` — проект аккаунта, и токен песочницы
519
+ // из конфига к нему не подходит.
520
+ const projectIsLinkedOne = !opts.project ||
521
+ (existing?.project_id !== undefined &&
522
+ (opts.project === existing.project_id || opts.project === existing.slug));
523
+ const linkedIsClaimable = Boolean(existing?.project_id && existing.claim);
524
+ const boundToAccountProject = opts.project
525
+ ? !(projectIsLinkedOne && linkedIsClaimable)
526
+ : Boolean(existing?.project_id) && !linkedIsClaimable;
373
527
  if (!cliCfg.token) {
374
528
  // Папка уже привязана к claimable-проекту — деплоим его же токеном,
375
529
  // пока заявка жива. Заявка забрана или истекла — платформа ответит 401,
376
530
  // и это станет `auth_expired`: честнее, чем молча завести ещё один.
377
- const reuse = claimTokenFor(cliCfg, existing?.project_id);
531
+ const reuse = projectIsLinkedOne
532
+ ? claimTokenFor(cliCfg, existing?.project_id)
533
+ : undefined;
378
534
  if (reuse) {
379
535
  cliCfg = { ...cliCfg, token: reuse };
380
- }
381
- else if (opts.claim || (!mode.interactive && !isCiEnv() && opts.yes)) {
536
+ // Повторная выкатка песочницы: ссылка «забрать» нужна агенту и здесь —
537
+ // без неё он искал её в `.layero/project.json` (симуляция 18.09.2026).
538
+ if (existing?.claim)
539
+ reusedClaim = existing.claim;
540
+ }
541
+ else if (
542
+ // Явный `--claim` — всегда новая песочница (с `--project` он отклонён
543
+ // выше). Сам режим включается только для нового проекта.
544
+ opts.claim ||
545
+ (!boundToAccountProject && !mode.interactive && !isCiEnv() && opts.yes)) {
382
546
  // 🚨 CI СЮДА НЕ ПОПАДАЕТ НАМЕРЕННО. Раннер без LAYERO_TOKEN — это
383
547
  // забытый секрет, и правильный ответ ему — отказ, а не сайт на
384
548
  // временном адресе, который через 72 часа исчезнет вместе с
@@ -438,13 +602,11 @@ export async function deployCmd(opts) {
438
602
  }
439
603
  // --- Всё, что требует файловой системы, делаем сами. Остальное — сервер.
440
604
  const prebuiltDir = await resolvePrebuiltDir(cwd, opts.prebuilt);
605
+ // Готовая сборка настроек проекту не пишет: сборщик у такой выкатки не
606
+ // запускается, а записанная `static` пережила бы её и заглушила следующую
607
+ // выкатку из исходников.
441
608
  const setup = prebuiltDir
442
- ? {
443
- framework_hint: "static",
444
- build_cmd: "true",
445
- output_dir: ".",
446
- source: "prebuilt",
447
- }
609
+ ? { framework_hint: null, build_cmd: null, output_dir: null }
448
610
  : await resolveSetupConfig(cwd, opts, existing);
449
611
  if (prebuiltDir) {
450
612
  emit({ event: "prebuilt", dir: prebuiltDir });
@@ -509,6 +671,7 @@ export async function deployCmd(opts) {
509
671
  build_cmd: setup.build_cmd,
510
672
  output_dir: setup.output_dir,
511
673
  runtime_kind: setup.runtime_kind,
674
+ runtime_kind_origin: setup.runtime_kind_origin,
512
675
  root_directory: opts.root ?? null,
513
676
  env_vars: existing?.env_vars ?? {},
514
677
  commit_message: prebuiltDir
@@ -596,12 +759,22 @@ export async function deployCmd(opts) {
596
759
  }
597
760
  emit({ event: "runtime_type_applied", project_type: wantType });
598
761
  }
762
+ else if (setup.runtime_kind &&
763
+ project.project_type === setup.runtime_kind &&
764
+ (session.created_project || claimable !== null)) {
765
+ // Новый проект получил тип в самой сессии, флипать нечего — но событие
766
+ // обещано документацией, и без него агент не узнаёт, что приложение будут
767
+ // ЗАПУСКАТЬ, а не раздавать файлами (симуляция 18.09.2026, `--type node_web`).
768
+ emit({ event: "runtime_type_applied", project_type: setup.runtime_kind });
769
+ }
770
+ // Только связка папки с проектом. Фреймворк сюда больше не пишется: поле
771
+ // этого файла CLI читает как выбор человека (см. `resolveSetupConfig`).
599
772
  await persistProjectLinking(cwd, {
600
773
  project_id: project.id,
601
774
  slug: project.slug,
602
775
  organization_slug: project.organization.slug,
603
776
  apex_hostname: project.apex_hostname,
604
- }, setup.framework_hint);
777
+ });
605
778
  // Подтверждение спрашиваем ЗДЕСЬ, а не раньше: адрес апекса известен
606
779
  // только от сервера, а сборка ещё не запущена — отказ ничего не стоит.
607
780
  // Для только что созданного проекта вопрос бессмыслен: перезаписывать
@@ -645,7 +818,7 @@ export async function deployCmd(opts) {
645
818
  emit({ event: "uploaded", archive_key: session.source_archive_key });
646
819
  const started = await startWithRepeatedFailureGuard(api, session.session_id, pack.sha256, opts);
647
820
  if (!started.deploy_id) {
648
- 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");
821
+ throw new LayeroError("deploy_not_started", `deploy session ended as "${started.status}"${started.error ? `: ${started.error}` : ""}`, "re-run `npx layero@latest deploy`; if it repeats, `npx layero@latest diagnose` shows the last deploy");
649
822
  }
650
823
  emit({ event: "deploy_started", deploy_id: started.deploy_id });
651
824
  const final = await streamDeployLogs(api, started.deploy_id);
@@ -655,10 +828,15 @@ export async function deployCmd(opts) {
655
828
  // более новым пушем, и совет «посмотрите логи» тут не по адресу: смотреть
656
829
  // надо на деплой-преемник, а не на этот.
657
830
  if (final.status === "cancelled") {
658
- 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");
831
+ throw new LayeroError("deploy_cancelled", `deploy cancelled${final.error_message ? `: ${final.error_message}` : ""}`, "a newer deploy superseded this one — `npx layero@latest deploys list` shows it");
659
832
  }
660
833
  if (final.status !== "ready") {
661
- throw new LayeroError(`deploy_${final.status}`, `deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`, `inspect logs at ${projectUrl(cliCfg.apiUrl, project.id)}`);
834
+ // 🚨 Совет — КОМАНДА, а не ссылка на панель. Агенту без аккаунта
835
+ // (песочница `--claim`) панель недоступна вовсе, а `diagnose` работает
836
+ // и с токеном песочницы: причина словами плюс окрестность ошибки в логе
837
+ // (T-20260918-8).
838
+ throw new LayeroError(`deploy_${final.status}`, `deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`, `read the cause: \`npx layero@latest diagnose --deploy ${started.deploy_id}\` ` +
839
+ `(full build log: \`npx layero@latest logs --deploy ${started.deploy_id}\`)`);
662
840
  }
663
841
  // --promote: пин апекса на эту сборку. Промоут остаётся на клиенте
664
842
  // осознанно — сборка асинхронна, а сервер применяет промоут только
@@ -708,13 +886,30 @@ export async function deployCmd(opts) {
708
886
  expires_at: claimable.expires_at,
709
887
  });
710
888
  }
889
+ else if (reusedClaim) {
890
+ emit({
891
+ event: "claimable",
892
+ project_id: project.id,
893
+ slug: project.slug,
894
+ url: liveUrl,
895
+ claim_url: reusedClaim.claim_url,
896
+ expires_at: reusedClaim.expires_at,
897
+ });
898
+ }
899
+ // `ready` — когда адрес уже отвечает сайтом, а не когда кончилась сборка.
900
+ // Выкатка в окружение `cli` проекта с репозиторием живой адрес не меняет —
901
+ // ждать там нечего.
902
+ const serving = promoted || !hasRepo(project) || opts.prod
903
+ ? await waitUntilServing(liveUrl)
904
+ : { serving: probe ? probe.available : true };
711
905
  emit({
712
906
  event: "ready",
713
907
  url: liveUrl,
714
908
  deploy_id: started.deploy_id,
715
909
  preview_url: probe?.preview_url ?? undefined,
716
910
  dashboard_url: dashboardUrl,
717
- edge_ready: probe ? probe.available : undefined,
911
+ edge_ready: serving.serving,
912
+ ...(serving.serving ? {} : { screen: serving.screen }),
718
913
  });
719
914
  }
720
915
  finally {
@@ -3,6 +3,7 @@ import { ApiClient, ApiError } from "../api.js";
3
3
  import { loadConfig } from "../config.js";
4
4
  import { loadProjectConfig } from "../project-config.js";
5
5
  import { LayeroError, detectMode, emit } from "../agent.js";
6
+ import { claimTokenFor } from "./claim.js";
6
7
  async function resolveDeployId(api, opts, cwd) {
7
8
  if (opts.deploy) {
8
9
  // Явно указанный деплой: проект знать не обязательно, ручка работает
@@ -35,12 +36,29 @@ async function resolveDeployId(api, opts, cwd) {
35
36
  const target = deploys[0];
36
37
  return { deployId: target.id, projectId: project.id };
37
38
  }
39
+ /**
40
+ * Конфиг с токеном, которым можно читать деплои этой папки.
41
+ *
42
+ * 🚨 Песочница (`deploy --claim`) — тоже вход. Её токен лежит в
43
+ * `~/.layero/config.json` по id проекта и прав на чтение хватает; без этой
44
+ * ветки агент без аккаунта получал на упавшей сборке совет «выполни
45
+ * `layero login`» — то есть не мог узнать причину отказа ничем, кроме панели,
46
+ * которая ему недоступна (T-20260918-8).
47
+ */
48
+ async function configForFolder(opts, cwd) {
49
+ const cfg = await loadConfig();
50
+ if (cfg.token)
51
+ return cfg;
52
+ const linked = await loadProjectConfig(cwd);
53
+ const sameProject = !opts.project || opts.project === linked?.project_id || opts.project === linked?.slug;
54
+ const claim = sameProject ? claimTokenFor(cfg, linked?.project_id) : undefined;
55
+ if (claim)
56
+ return { ...cfg, token: claim };
57
+ throw new LayeroError("auth_required", "нужен вход", "выполни `layero login` или задай LAYERO_TOKEN");
58
+ }
38
59
  export async function diagnoseCmd(opts) {
39
60
  const mode = detectMode();
40
- const cfg = await loadConfig();
41
- if (!cfg.token) {
42
- throw new LayeroError("auth_required", "нужен вход", "выполни `layero login` или задай LAYERO_TOKEN");
43
- }
61
+ const cfg = await configForFolder(opts, process.cwd());
44
62
  const api = new ApiClient(cfg);
45
63
  const { deployId } = await resolveDeployId(api, opts, process.cwd());
46
64
  const d = await api.getDeployDiagnosis(deployId);
@@ -79,10 +97,7 @@ export async function diagnoseCmd(opts) {
79
97
  }
80
98
  export async function logsCmd(opts) {
81
99
  const mode = detectMode();
82
- const cfg = await loadConfig();
83
- if (!cfg.token) {
84
- throw new LayeroError("auth_required", "нужен вход", "выполни `layero login` или задай LAYERO_TOKEN");
85
- }
100
+ const cfg = await configForFolder(opts, process.cwd());
86
101
  const api = new ApiClient(cfg);
87
102
  const { deployId } = await resolveDeployId(api, opts, process.cwd());
88
103
  if (opts.runtime) {
@@ -1,10 +1,25 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
- import { detectProject } from "../detect.js";
3
+ import { detectProject, detectedEvent } from "../detect.js";
4
4
  import { emit } from "../agent.js";
5
5
  const AGENT_BLOCK_MARKER_START = "<!-- layero:start -->";
6
6
  const AGENT_BLOCK_MARKER_END = "<!-- layero:end -->";
7
- function agentDocBlock(framework) {
7
+ /**
8
+ * Строка про эту папку в блоке для агентов.
9
+ *
10
+ * 🚨 Только то, в чём детект уверен. До 18.09.2026 сюда писалось имя
11
+ * фреймворка всегда — и `static` для монорепо, собственного скрипта сборки
12
+ * или фронта с бэкендом ложился в AGENTS.md как факт о проекте: следующая
13
+ * сессия агента читала его раньше, чем смотрела на папку (T-20260918-7).
14
+ */
15
+ function folderLine(d) {
16
+ if (d.confident) {
17
+ const what = d.runtime_kind ? `${d.framework_hint}, runs as ${d.runtime_kind}` : d.framework_hint;
18
+ return `this project, detected by \`init\` as **${what}**`;
19
+ }
20
+ return "this project; `init` could not recognise the app here — run `npx layero@latest deploy --dry-run --json` and read `hint`";
21
+ }
22
+ function agentDocBlock(d) {
8
23
  // Компактный индекс, а не ссылка на навык: по evals Vercel блок в
9
24
  // AGENTS.md агент выполняет надёжнее (100 % против 79 % у навыка).
10
25
  // Полная версия — навык `layero` в LayeroInfra/layero-agents; здесь —
@@ -24,7 +39,7 @@ Docs for agents: https://docs.layero.ru/agents/
24
39
  1. **Repository connected** (GitHub, GitVerse, GitLab, GitFlic, SourceCraft) —
25
40
  push to a branch = preview, push to \`main\` = production. Connect one with
26
41
  \`npx layero@latest projects create --repo <provider>:<owner/repo>\`.
27
- 2. **A directory with code** (this project, framework: **${framework}**) —
42
+ 2. **A directory with code** (${folderLine(d)}) —
28
43
  \`npx layero@latest deploy --json\`. The CLI packs the directory, the
29
44
  platform builds it. No git repository is needed for this path.
30
45
  3. **A site already on Layero** — \`npx layero@latest diagnose\`, \`logs\`,
@@ -34,9 +49,15 @@ Docs for agents: https://docs.layero.ru/agents/
34
49
  ### Deploy from this directory
35
50
 
36
51
  \`\`\`bash
52
+ npx layero@latest deploy --dry-run --json # how the platform will build it; uploads nothing
37
53
  npx layero@latest deploy --json
38
54
  \`\`\`
39
55
 
56
+ \`--dry-run\` needs no login. If its \`confident\` is \`false\`, follow its
57
+ \`hint\` / \`next_action\` (app in a subfolder → \`--root <dir>\`, frontend +
58
+ backend → \`layero.json\` with both halves, custom build → \`"framework":
59
+ "generic"\`) before the first deploy.
60
+
40
61
  Not logged in? The command starts the browser device flow itself and prints
41
62
  \`{"event":"auth_required","url":"…","user_code":"…"}\` — show \`url\` as a
42
63
  clickable link and keep waiting; the CLI polls every 2 s. No localhost
@@ -46,11 +67,12 @@ No account at all? \`npx layero@latest deploy --claim\` publishes to a
46
67
  temporary project for 72 hours and prints a \`claim_url\` for a human to
47
68
  take it over.
48
69
 
49
- Key JSON events: \`detected\` (framework), \`project_created\` /
70
+ Key JSON events: \`detected\` (framework, \`confident\`, \`hint\`), \`project_created\` /
50
71
  \`project_linked\`, \`build_log\` (forward only lines with errors),
51
72
  \`claimable\` (\`claim_url\`, \`expires_at\`), \`ready\` — \`url\` is the live
52
- site: show it as-is and stop; \`dashboard_url\` is the panel, not the site.
53
- \`error\` — follow \`next_action\` verbatim.
73
+ site, already answering (\`edge_ready: true\`): show it as-is and stop;
74
+ \`dashboard_url\` is the panel, not the site. \`error\` — follow
75
+ \`next_action\` verbatim.
54
76
 
55
77
  Exit codes: 0 ok · 2 auth (\`auth_required\`, \`auth_expired\`, \`auth_timeout\`) ·
56
78
  3 not found (\`project_unknown\`, \`project_not_found\`) · 4 invalid input
@@ -110,7 +132,7 @@ async function upsertAgentDoc(cwd, filename, content) {
110
132
  await fs.writeFile(filePath, merged, "utf-8");
111
133
  return "updated";
112
134
  }
113
- async function ensureProjectJson(cwd, framework, build_cmd, output_dir) {
135
+ async function ensureProjectJson(cwd) {
114
136
  const dir = path.join(cwd, ".layero");
115
137
  const file = path.join(dir, "project.json");
116
138
  try {
@@ -121,10 +143,12 @@ async function ensureProjectJson(cwd, framework, build_cmd, output_dir) {
121
143
  // missing → create
122
144
  }
123
145
  await fs.mkdir(dir, { recursive: true });
146
+ // 🚨 Без framework_hint / build_cmd / output_dir. Поля в этом файле CLI
147
+ // читает как ВЫБОР ЧЕЛОВЕКА и отправляет в проект; положи сюда догадку
148
+ // детекта — и она станет «настройкой», которую никто не выбирал, а сборщик
149
+ // перестанет решать сам (T-20260918-7). Человек дописывает их руками, если
150
+ // хочет закрепить.
124
151
  const scaffold = {
125
- framework_hint: framework,
126
- build_cmd,
127
- output_dir,
128
152
  analytics_enabled: false,
129
153
  env_vars: {},
130
154
  };
@@ -150,14 +174,8 @@ async function ensureGitignore(cwd) {
150
174
  export async function initCmd(opts) {
151
175
  const cwd = process.cwd();
152
176
  const detected = await detectProject(cwd);
153
- emit({
154
- event: "detected",
155
- framework: detected.framework_hint,
156
- build_cmd: detected.build_cmd,
157
- output_dir: detected.output_dir,
158
- confident: detected.confident,
159
- });
160
- const block = agentDocBlock(detected.framework_hint);
177
+ emit(detectedEvent(detected));
178
+ const block = agentDocBlock(detected);
161
179
  const agentDocs = [];
162
180
  if (!opts.skipAgentDocs) {
163
181
  // Touch every agent-doc convention we know about. If one already
@@ -181,11 +199,12 @@ export async function initCmd(opts) {
181
199
  agentDocs.push({ file: f, result });
182
200
  }
183
201
  }
184
- const pjResult = await ensureProjectJson(cwd, detected.framework_hint, detected.build_cmd, detected.output_dir);
202
+ const pjResult = await ensureProjectJson(cwd);
185
203
  await ensureGitignore(cwd);
186
204
  emit({
187
205
  event: "init_done",
188
206
  framework: detected.framework_hint,
207
+ confident: detected.confident,
189
208
  agent_docs: agentDocs,
190
209
  project_json: pjResult,
191
210
  });
@@ -214,7 +214,7 @@ async function finishSetup(api, project, opts) {
214
214
  emit({
215
215
  event: "setup_applied",
216
216
  project: project.slug,
217
- framework: payload.framework_hint,
217
+ framework: payload.framework_hint ?? undefined,
218
218
  build_cmd: payload.build_cmd ?? null,
219
219
  output_dir: payload.output_dir ?? null,
220
220
  layero_found: detected.layero_found,