layero 0.10.5 → 0.11.1
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/README.md +41 -15
- package/dist/agent.js +36 -13
- package/dist/bin/layero.js +23 -13
- package/dist/commands/deploy.js +239 -69
- package/dist/commands/diagnose.js +23 -8
- package/dist/commands/init.js +38 -19
- package/dist/commands/projects.js +1 -1
- package/dist/detect.js +521 -20
- package/dist/logs.js +40 -9
- package/package.json +1 -1
package/dist/commands/deploy.js
CHANGED
|
@@ -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
|
|
80
|
-
*
|
|
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
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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.
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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,9 +497,14 @@ 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;
|
|
373
508
|
// 🚨 ПЕСОЧНИЦА — ТОЛЬКО ДЛЯ НОВОГО ПРОЕКТА. `--project` или папка,
|
|
374
509
|
// привязанная к проекту аккаунта, значат «выкатить в существующий», а у
|
|
375
510
|
// токена песочницы прав на него нет: до 0.10.5 авто-режим всё равно
|
|
@@ -398,6 +533,10 @@ export async function deployCmd(opts) {
|
|
|
398
533
|
: undefined;
|
|
399
534
|
if (reuse) {
|
|
400
535
|
cliCfg = { ...cliCfg, token: reuse };
|
|
536
|
+
// Повторная выкатка песочницы: ссылка «забрать» нужна агенту и здесь —
|
|
537
|
+
// без неё он искал её в `.layero/project.json` (симуляция 18.09.2026).
|
|
538
|
+
if (existing?.claim)
|
|
539
|
+
reusedClaim = existing.claim;
|
|
401
540
|
}
|
|
402
541
|
else if (
|
|
403
542
|
// Явный `--claim` — всегда новая песочница (с `--project` он отклонён
|
|
@@ -463,13 +602,11 @@ export async function deployCmd(opts) {
|
|
|
463
602
|
}
|
|
464
603
|
// --- Всё, что требует файловой системы, делаем сами. Остальное — сервер.
|
|
465
604
|
const prebuiltDir = await resolvePrebuiltDir(cwd, opts.prebuilt);
|
|
605
|
+
// Готовая сборка настроек проекту не пишет: сборщик у такой выкатки не
|
|
606
|
+
// запускается, а записанная `static` пережила бы её и заглушила следующую
|
|
607
|
+
// выкатку из исходников.
|
|
466
608
|
const setup = prebuiltDir
|
|
467
|
-
? {
|
|
468
|
-
framework_hint: "static",
|
|
469
|
-
build_cmd: "true",
|
|
470
|
-
output_dir: ".",
|
|
471
|
-
source: "prebuilt",
|
|
472
|
-
}
|
|
609
|
+
? { framework_hint: null, build_cmd: null, output_dir: null }
|
|
473
610
|
: await resolveSetupConfig(cwd, opts, existing);
|
|
474
611
|
if (prebuiltDir) {
|
|
475
612
|
emit({ event: "prebuilt", dir: prebuiltDir });
|
|
@@ -534,6 +671,7 @@ export async function deployCmd(opts) {
|
|
|
534
671
|
build_cmd: setup.build_cmd,
|
|
535
672
|
output_dir: setup.output_dir,
|
|
536
673
|
runtime_kind: setup.runtime_kind,
|
|
674
|
+
runtime_kind_origin: setup.runtime_kind_origin,
|
|
537
675
|
root_directory: opts.root ?? null,
|
|
538
676
|
env_vars: existing?.env_vars ?? {},
|
|
539
677
|
commit_message: prebuiltDir
|
|
@@ -621,12 +759,22 @@ export async function deployCmd(opts) {
|
|
|
621
759
|
}
|
|
622
760
|
emit({ event: "runtime_type_applied", project_type: wantType });
|
|
623
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`).
|
|
624
772
|
await persistProjectLinking(cwd, {
|
|
625
773
|
project_id: project.id,
|
|
626
774
|
slug: project.slug,
|
|
627
775
|
organization_slug: project.organization.slug,
|
|
628
776
|
apex_hostname: project.apex_hostname,
|
|
629
|
-
}
|
|
777
|
+
});
|
|
630
778
|
// Подтверждение спрашиваем ЗДЕСЬ, а не раньше: адрес апекса известен
|
|
631
779
|
// только от сервера, а сборка ещё не запущена — отказ ничего не стоит.
|
|
632
780
|
// Для только что созданного проекта вопрос бессмыслен: перезаписывать
|
|
@@ -670,7 +818,7 @@ export async function deployCmd(opts) {
|
|
|
670
818
|
emit({ event: "uploaded", archive_key: session.source_archive_key });
|
|
671
819
|
const started = await startWithRepeatedFailureGuard(api, session.session_id, pack.sha256, opts);
|
|
672
820
|
if (!started.deploy_id) {
|
|
673
|
-
throw new LayeroError("deploy_not_started", `deploy session ended as "${started.status}"${started.error ? `: ${started.error}` : ""}`, "re-run `layero deploy`; if it repeats,
|
|
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");
|
|
674
822
|
}
|
|
675
823
|
emit({ event: "deploy_started", deploy_id: started.deploy_id });
|
|
676
824
|
const final = await streamDeployLogs(api, started.deploy_id);
|
|
@@ -680,10 +828,15 @@ export async function deployCmd(opts) {
|
|
|
680
828
|
// более новым пушем, и совет «посмотрите логи» тут не по адресу: смотреть
|
|
681
829
|
// надо на деплой-преемник, а не на этот.
|
|
682
830
|
if (final.status === "cancelled") {
|
|
683
|
-
throw new LayeroError("deploy_cancelled", `deploy cancelled${final.error_message ? `: ${final.error_message}` : ""}`, "a newer deploy superseded this one —
|
|
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");
|
|
684
832
|
}
|
|
685
833
|
if (final.status !== "ready") {
|
|
686
|
-
|
|
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}\`)`);
|
|
687
840
|
}
|
|
688
841
|
// --promote: пин апекса на эту сборку. Промоут остаётся на клиенте
|
|
689
842
|
// осознанно — сборка асинхронна, а сервер применяет промоут только
|
|
@@ -733,13 +886,30 @@ export async function deployCmd(opts) {
|
|
|
733
886
|
expires_at: claimable.expires_at,
|
|
734
887
|
});
|
|
735
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 };
|
|
736
905
|
emit({
|
|
737
906
|
event: "ready",
|
|
738
907
|
url: liveUrl,
|
|
739
908
|
deploy_id: started.deploy_id,
|
|
740
909
|
preview_url: probe?.preview_url ?? undefined,
|
|
741
910
|
dashboard_url: dashboardUrl,
|
|
742
|
-
edge_ready:
|
|
911
|
+
edge_ready: serving.serving,
|
|
912
|
+
...(serving.serving ? {} : { screen: serving.screen }),
|
|
743
913
|
});
|
|
744
914
|
}
|
|
745
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
|
|
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
|
|
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) {
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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** (
|
|
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;
|
|
53
|
-
\`
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|