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.
- package/README.md +46 -17
- package/dist/agent.js +36 -13
- package/dist/bin/layero.js +26 -14
- package/dist/commands/deploy.js +267 -72
- 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 +440 -20
- package/dist/exit-codes.js +1 -0
- 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,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 =
|
|
531
|
+
const reuse = projectIsLinkedOne
|
|
532
|
+
? claimTokenFor(cliCfg, existing?.project_id)
|
|
533
|
+
: undefined;
|
|
378
534
|
if (reuse) {
|
|
379
535
|
cliCfg = { ...cliCfg, token: reuse };
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
}
|
|
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,
|
|
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 —
|
|
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
|
-
|
|
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:
|
|
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
|
|
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,
|