layero 0.11.5 → 0.11.7
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 +12 -5
- package/dist/agent.js +1 -1
- package/dist/bin/layero.js +3 -1
- package/dist/commands/claim.js +1 -1
- package/dist/commands/deploy.js +50 -9
- package/dist/detect.js +60 -3
- package/dist/exit-codes.js +1 -0
- package/dist/logs.js +16 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -122,8 +122,10 @@ Run `layero <cmd> --help` for full options.
|
|
|
122
122
|
separate `cli` environment). A project without a repository is always
|
|
123
123
|
published live.
|
|
124
124
|
- `--branch <name>` — **refused** (`branch_unsupported`, exit 4), see above.
|
|
125
|
-
- `--claim` — deploy without an account: a temporary
|
|
126
|
-
plus a `claim_url` for a human to take it over.
|
|
125
|
+
- `--claim` — deploy without an account: a temporary site for 1 hour
|
|
126
|
+
plus a `claim_url` for a human to take it over. Static sites and SPAs only:
|
|
127
|
+
a server app (SSR, fullstack, container) is refused before anything is
|
|
128
|
+
uploaded (`claim_static_only`, exit 4). Turns on by itself when
|
|
127
129
|
there is no token, the run is non-interactive (an agent, not CI), `--yes`
|
|
128
130
|
is passed and the project is new: no `--project`, and the folder is not
|
|
129
131
|
linked to an account project. An existing project without a token means
|
|
@@ -143,11 +145,16 @@ The platform creates a temporary project and a token for it; the CLI deploys
|
|
|
143
145
|
with that token and prints, before `ready`:
|
|
144
146
|
|
|
145
147
|
```
|
|
146
|
-
{"event":"claimable","url":"https://
|
|
148
|
+
{"event":"claimable","url":"https://k3v9q2m8x7w1c4r6t0y5u2ze.layero.app","claim_url":"https://app.layero.ru/claim?code=…","expires_at":"…"}
|
|
147
149
|
```
|
|
148
150
|
|
|
149
|
-
The site lives for
|
|
150
|
-
|
|
151
|
+
The site lives for 1 hour, then stops answering and is deleted. Only static
|
|
152
|
+
sites and SPAs are accepted: a server app needs an account. The address is
|
|
153
|
+
random (the folder name and `--name` do not go into it), and the site is
|
|
154
|
+
closed to search engines: `robots.txt` with `Disallow: /` and
|
|
155
|
+
`X-Robots-Tag: noindex, nofollow`. A human opens `claim_url`, signs in and
|
|
156
|
+
takes the project into their account — the CLI cannot accept a claim by
|
|
157
|
+
itself.
|
|
151
158
|
The claim code is saved in `.layero/project.json`; the temporary token stays
|
|
152
159
|
in `~/.layero/config.json`, so `layero deploy` in the same directory keeps
|
|
153
160
|
updating the same site until the claim expires or is accepted.
|
package/dist/agent.js
CHANGED
|
@@ -343,7 +343,7 @@ function renderHuman(event) {
|
|
|
343
343
|
break;
|
|
344
344
|
case "claimable":
|
|
345
345
|
process.stdout.write(`→ Временный проект ${event.slug}: ${event.url}\n`);
|
|
346
|
-
process.stdout.write(` Сайт живёт до ${event.expires_at} (
|
|
346
|
+
process.stdout.write(` Сайт живёт до ${event.expires_at} (час). Забрать в аккаунт: ${event.claim_url}\n`);
|
|
347
347
|
break;
|
|
348
348
|
case "claim_status":
|
|
349
349
|
process.stdout.write(`Код ${event.code}: ${event.status}${event.claimed ? " (забран)" : ""}` +
|
package/dist/bin/layero.js
CHANGED
|
@@ -453,7 +453,9 @@ async function main() {
|
|
|
453
453
|
.option("--branch <name>", "REFUSED (branch_unsupported, exit 4): archive uploads always land in the " +
|
|
454
454
|
"reserved `cli` environment, so this flag cannot give you a preview. " +
|
|
455
455
|
"Branch previews come from pushing to a connected repository.")
|
|
456
|
-
.option("--claim", "deploy without an account: a temporary
|
|
456
|
+
.option("--claim", "deploy without an account: a temporary site for 1 hour plus a claim_url for a human to take it over. " +
|
|
457
|
+
"Static sites and SPAs only — a server app (SSR, fullstack, container) is refused (claim_static_only, exit 4). " +
|
|
458
|
+
"The address is random and closed to search engines. " +
|
|
457
459
|
"Turns on by itself when there is no token, the run is non-interactive (agent, not CI), --yes is passed " +
|
|
458
460
|
"and the project is new (no --project, folder not linked to an account project). " +
|
|
459
461
|
"Refused together with --project (claim_with_project, exit 4).")
|
package/dist/commands/claim.js
CHANGED
|
@@ -9,7 +9,7 @@ import { dashboardOrigin } from "../urls.js";
|
|
|
9
9
|
*
|
|
10
10
|
* Сценарий: агент работает без аккаунта Layero — токена нет, браузера нет,
|
|
11
11
|
* человек не рядом. Платформа заводит временный проект и выдаёт токен на
|
|
12
|
-
* него; сайт живёт
|
|
12
|
+
* него; сайт живёт час; человек забирает его в свой аккаунт по ссылке
|
|
13
13
|
* `claim_url`. Принять заявку можно ТОЛЬКО в панели: CLI и агент не
|
|
14
14
|
* подтверждают её ни при каких флагах.
|
|
15
15
|
*/
|
package/dist/commands/deploy.js
CHANGED
|
@@ -193,7 +193,43 @@ function isLegacyInitGuess(cfg) {
|
|
|
193
193
|
cfg.build_cmd === "true" &&
|
|
194
194
|
cfg.output_dir === ".");
|
|
195
195
|
}
|
|
196
|
-
|
|
196
|
+
/**
|
|
197
|
+
* 🚨 ПЕСОЧНИЦА — ТОЛЬКО СТАТИКА И SPA (T-20260919-10, решение владельца
|
|
198
|
+
* 19.09.2026). Серверное приложение (SSR, fullstack, контейнер) без аккаунта
|
|
199
|
+
* не выкладывается: это анонимный процесс с выходом в интернет на общем узле.
|
|
200
|
+
* Проверка ДО того, как заведена песочница и уехал архив; платформа и сборщик
|
|
201
|
+
* откажут и сами, но уже потратив чужую минуту.
|
|
202
|
+
*/
|
|
203
|
+
async function assertSandboxServesFiles(cwd, opts, existing, detectFolder) {
|
|
204
|
+
if (opts.prebuilt)
|
|
205
|
+
return; // готовая сборка — это файлы по определению
|
|
206
|
+
const h = hintsFor(cwd, opts, existing);
|
|
207
|
+
const kind = h.asRuntime ?? (await detectFolder(h.detectCwd, h.hint, h.hintSource)).runtime_kind ?? null;
|
|
208
|
+
if (!kind)
|
|
209
|
+
return;
|
|
210
|
+
throw new LayeroError("claim_static_only", `деплой без аккаунта (--claim) выкладывает только статику и SPA, а эта папка — серверное приложение (${kind})`, "sign in and deploy from the account: `npx layero@latest login`, then run the same deploy without --claim");
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* 🚨 ДЕТЕКТ — ОДИН НА ВЫКАТКУ. Гейт core «входов анализа не становится
|
|
214
|
+
* больше» считает запуски детекта в этом файле: каждый лишний — ещё один
|
|
215
|
+
* снимок папки со своей полнотой, и два снимка рано или поздно разойдутся.
|
|
216
|
+
* Проверка песочницы (до заявки) и план сборки (после входа) берут один
|
|
217
|
+
* результат по одинаковым входам.
|
|
218
|
+
*/
|
|
219
|
+
function oneDetectionPerRun() {
|
|
220
|
+
const memo = new Map();
|
|
221
|
+
return (detectCwd, hint, hintSource) => {
|
|
222
|
+
const key = `${detectCwd}\u0000${hint ?? ""}\u0000${hintSource}`;
|
|
223
|
+
let run = memo.get(key);
|
|
224
|
+
if (!run) {
|
|
225
|
+
run = detectProject(detectCwd, { frameworkHint: hint, hintSource });
|
|
226
|
+
memo.set(key, run);
|
|
227
|
+
}
|
|
228
|
+
return run;
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Входы детекта: папка (с `--root`) и подсказка фреймворка с её источником. */
|
|
232
|
+
function hintsFor(cwd, opts, existing, projectFramework) {
|
|
197
233
|
// Honour --root when auto-detecting: the framework signals live in the
|
|
198
234
|
// monorepo subdir, not the repo root.
|
|
199
235
|
const detectCwd = opts.root ? path.join(cwd, opts.root) : cwd;
|
|
@@ -206,10 +242,12 @@ async function resolveSetupConfig(cwd, opts, existing, projectFramework) {
|
|
|
206
242
|
const own = isLegacyInitGuess(existing) ? null : existing;
|
|
207
243
|
const fileHint = own?.framework_hint ?? null;
|
|
208
244
|
const hint = typeHint ?? fileHint ?? projectFramework ?? null;
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
245
|
+
const hintSource = typeHint ? "--type" : fileHint ? ".layero/project.json" : "project settings";
|
|
246
|
+
return { detectCwd, asRuntime, typeHint, own, fileHint, hint, hintSource };
|
|
247
|
+
}
|
|
248
|
+
async function resolveSetupConfig(cwd, opts, existing, projectFramework, detectFolder) {
|
|
249
|
+
const { detectCwd, asRuntime, typeHint, own, fileHint, hint, hintSource } = hintsFor(cwd, opts, existing, projectFramework);
|
|
250
|
+
const detected = await detectFolder(detectCwd, hint, hintSource);
|
|
213
251
|
emit(detectedEvent(detected));
|
|
214
252
|
return {
|
|
215
253
|
detected,
|
|
@@ -405,7 +443,7 @@ async function dryRun(cwd, opts, existing, cliCfg) {
|
|
|
405
443
|
});
|
|
406
444
|
return;
|
|
407
445
|
}
|
|
408
|
-
const setup = await resolveSetupConfig(cwd, { ...opts, root: root ?? undefined }, existing, project?.framework_hint ?? null);
|
|
446
|
+
const setup = await resolveSetupConfig(cwd, { ...opts, root: root ?? undefined }, existing, project?.framework_hint ?? null, oneDetectionPerRun());
|
|
409
447
|
const d = setup.detected;
|
|
410
448
|
let buildCmd = d.build_cmd;
|
|
411
449
|
let outputDir = d.output_dir;
|
|
@@ -485,6 +523,7 @@ export async function waitUntilServing(url, budgetMs = SERVE_WAIT_MS) {
|
|
|
485
523
|
}
|
|
486
524
|
export async function deployCmd(opts) {
|
|
487
525
|
const mode = detectMode();
|
|
526
|
+
const detectFolder = oneDetectionPerRun();
|
|
488
527
|
if (opts.type &&
|
|
489
528
|
!VALID_TYPES.has(opts.type.toLowerCase()) &&
|
|
490
529
|
!runtimeTypeOf(opts.type)) {
|
|
@@ -533,6 +572,7 @@ export async function deployCmd(opts) {
|
|
|
533
572
|
? claimTokenFor(cliCfg, existing?.project_id)
|
|
534
573
|
: undefined;
|
|
535
574
|
if (reuse) {
|
|
575
|
+
await assertSandboxServesFiles(cwd, opts, existing, detectFolder);
|
|
536
576
|
cliCfg = { ...cliCfg, token: reuse };
|
|
537
577
|
// Повторная выкатка песочницы: ссылка «забрать» нужна агенту и здесь —
|
|
538
578
|
// без неё он искал её в `.layero/project.json` (симуляция 18.09.2026).
|
|
@@ -546,8 +586,9 @@ export async function deployCmd(opts) {
|
|
|
546
586
|
(!boundToAccountProject && !mode.interactive && !isCiEnv() && opts.yes)) {
|
|
547
587
|
// 🚨 CI СЮДА НЕ ПОПАДАЕТ НАМЕРЕННО. Раннер без LAYERO_TOKEN — это
|
|
548
588
|
// забытый секрет, и правильный ответ ему — отказ, а не сайт на
|
|
549
|
-
// временном адресе, который через
|
|
550
|
-
//
|
|
589
|
+
// временном адресе, который через час исчезнет вместе с «зелёным»
|
|
590
|
+
// прогоном. Явный `--claim` в CI работает.
|
|
591
|
+
await assertSandboxServesFiles(cwd, opts, existing, detectFolder);
|
|
551
592
|
const r = await createClaimable(cliCfg, cwd, {
|
|
552
593
|
name: opts.name ?? path.basename(cwd),
|
|
553
594
|
});
|
|
@@ -614,7 +655,7 @@ export async function deployCmd(opts) {
|
|
|
614
655
|
// выкатку из исходников.
|
|
615
656
|
const setup = prebuiltDir
|
|
616
657
|
? { framework_hint: null, build_cmd: null, output_dir: null }
|
|
617
|
-
: await resolveSetupConfig(cwd, opts, existing);
|
|
658
|
+
: await resolveSetupConfig(cwd, opts, existing, undefined, detectFolder);
|
|
618
659
|
if (prebuiltDir) {
|
|
619
660
|
emit({ event: "prebuilt", dir: prebuiltDir });
|
|
620
661
|
}
|
package/dist/detect.js
CHANGED
|
@@ -460,6 +460,38 @@ async function findServerEntry(cwd, pkg) {
|
|
|
460
460
|
}
|
|
461
461
|
return null;
|
|
462
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* Сервер, привязанный к localhost: снаружи контейнера до него не достучаться,
|
|
465
|
+
* и запуск оборвётся по таймауту — а текст отказа причину не называет
|
|
466
|
+
* (чистая комната 19.09.2026, T-20260919-9). Сборщик судит по команде запуска;
|
|
467
|
+
* здесь — по коду точки входа, до первого деплоя.
|
|
468
|
+
*/
|
|
469
|
+
const LOOPBACK_LISTEN_RE = /\.listen\s*\([^;\n]*?["'`](127\.0\.0\.1|localhost|::1)["'`]|host\s*[:=]\s*["'](127\.0\.0\.1|localhost)["']|--host[= ]+(127\.0\.0\.1|localhost)\b/;
|
|
470
|
+
/** Файл, который запускает команда `node|tsx|ts-node <file>`. */
|
|
471
|
+
function entryFromStart(start) {
|
|
472
|
+
const m = start?.match(/^(?:node|tsx|ts-node|bun)\s+(?:--[\w-]+\s+)*([^\s&|;]+\.(?:m?js|cjs|ts|mts))/);
|
|
473
|
+
return m?.[1] ?? null;
|
|
474
|
+
}
|
|
475
|
+
function loopbackNote(entry) {
|
|
476
|
+
return `${entry} binds the server to localhost (127.0.0.1): unreachable from outside the container, so the launch times out and the failure text does not say why. Listen on 0.0.0.0 and $PORT.`;
|
|
477
|
+
}
|
|
478
|
+
/** Точка входа, которая слушает только localhost, — или null. */
|
|
479
|
+
async function loopbackEntry(cwd, pkg, start) {
|
|
480
|
+
const names = [
|
|
481
|
+
...(entryFromStart(start) ? [entryFromStart(start)] : []),
|
|
482
|
+
...(typeof pkg?.main === "string" ? [pkg.main] : []),
|
|
483
|
+
...SERVER_ENTRY_CANDIDATES,
|
|
484
|
+
"server/index.ts", "src/index.ts", "src/server.ts", "src/main.ts", "main.py", "app.py",
|
|
485
|
+
];
|
|
486
|
+
for (const n of names) {
|
|
487
|
+
const text = await readText(path.join(cwd, n));
|
|
488
|
+
if (text !== null && LOOPBACK_LISTEN_RE.test(text.slice(0, 64 * 1024)))
|
|
489
|
+
return n;
|
|
490
|
+
}
|
|
491
|
+
if (start && /--host[= ]+(127\.0\.0\.1|localhost)\b/.test(start))
|
|
492
|
+
return "start script";
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
463
495
|
/** Первая папка с index.html, если в корне его нет, — по правилу сборщика
|
|
464
496
|
* (`discover_served_root`): мельчайшая, затем по алфавиту. */
|
|
465
497
|
async function servedSubdir(cwd, maxDepth = 3) {
|
|
@@ -587,6 +619,19 @@ async function detectFromSnapshot(cwd, snap, opts) {
|
|
|
587
619
|
}
|
|
588
620
|
// node_web / python_web / streamlit / gradio / fullstack: the container
|
|
589
621
|
// serves the app, nothing is uploaded as static files from this plan.
|
|
622
|
+
const runtimeNotes = [];
|
|
623
|
+
if (plan.projectKind === "fullstack") {
|
|
624
|
+
runtimeNotes.push("Full-stack layout from layero.json: the frontend is served as files, the backend runs in a container.");
|
|
625
|
+
}
|
|
626
|
+
{
|
|
627
|
+
// Узнанный сервер тоже падает на запуске, если слушает localhost, — а
|
|
628
|
+
// текст отказа причину не называет (T-20260919-9).
|
|
629
|
+
const pkg = snap.packageJson;
|
|
630
|
+
const start = str(layero?.start) ?? str(layero?.startCommand) ?? scriptOf(pkg, "start");
|
|
631
|
+
const loop = await loopbackEntry(cwd, pkg, start);
|
|
632
|
+
if (loop)
|
|
633
|
+
runtimeNotes.push(loopbackNote(loop));
|
|
634
|
+
}
|
|
590
635
|
return {
|
|
591
636
|
framework_hint: plan.framework,
|
|
592
637
|
build_cmd: null,
|
|
@@ -594,9 +639,7 @@ async function detectFromSnapshot(cwd, snap, opts) {
|
|
|
594
639
|
confident: true,
|
|
595
640
|
runtime_kind: plan.projectType,
|
|
596
641
|
sources,
|
|
597
|
-
...(
|
|
598
|
-
? { hint: "Full-stack layout from layero.json: the frontend is served as files, the backend runs in a container." }
|
|
599
|
-
: {}),
|
|
642
|
+
...(runtimeNotes.length ? { hint: runtimeNotes.join(" ") } : {}),
|
|
600
643
|
};
|
|
601
644
|
}
|
|
602
645
|
const framework = plan.framework;
|
|
@@ -681,6 +724,20 @@ async function detectFromSnapshot(cwd, snap, opts) {
|
|
|
681
724
|
outputDir = fileOutput;
|
|
682
725
|
outputSource = "layero.json";
|
|
683
726
|
}
|
|
727
|
+
{
|
|
728
|
+
const pkg = snap.packageJson;
|
|
729
|
+
const buildScript = scriptOf(pkg, "build");
|
|
730
|
+
// Проверка типов внутри сборки: на платформе она упадёт так же, как
|
|
731
|
+
// локально, а деплой ради этого — потраченная сборка (чистая комната 19.09).
|
|
732
|
+
if (buildScript && /(^|[\s&;])tsc\b/.test(buildScript) && !/\bnoEmit\b.*\|\|/.test(buildScript)) {
|
|
733
|
+
notes.push(`The build script runs the TypeScript checker (\`${buildScript}\`): a type error fails the build on the platform exactly as it does locally — run it locally before deploying.`);
|
|
734
|
+
}
|
|
735
|
+
const loop = await loopbackEntry(cwd, pkg, scriptOf(pkg, "start"));
|
|
736
|
+
// Форма узнана верно, поэтому `confident` не трогаем: это дефект кода,
|
|
737
|
+
// а не догадка детекта — и агенту нужен именно этот текст.
|
|
738
|
+
if (loop)
|
|
739
|
+
notes.push(loopbackNote(loop));
|
|
740
|
+
}
|
|
684
741
|
const warning = ssrWarning(snap, framework);
|
|
685
742
|
const hint = [shape?.hint, ...notes].filter(Boolean).join(" ");
|
|
686
743
|
return {
|
package/dist/exit-codes.js
CHANGED
package/dist/logs.js
CHANGED
|
@@ -10,6 +10,7 @@ const POLL_INTERVAL_MS = 1500;
|
|
|
10
10
|
// Теперь: таймаут на запрос (api.ts), до MAX_POLL_FAILURES сбоев ПОДРЯД с
|
|
11
11
|
// растущей паузой, и свой код ошибки с подсказкой, где смотреть итог.
|
|
12
12
|
const MAX_POLL_FAILURES = 8;
|
|
13
|
+
const QUEUED_EVERY_MS = 15_000;
|
|
13
14
|
const RETRY_BASE_MS = 1500;
|
|
14
15
|
const RETRY_CAP_MS = 10_000;
|
|
15
16
|
/** Сбой, который повторять бессмысленно: сервер ответил отказом по существу. */
|
|
@@ -53,10 +54,17 @@ export async function streamDeployLogs(api, deployId) {
|
|
|
53
54
|
let lastStage = null;
|
|
54
55
|
let hiddenNoted = false;
|
|
55
56
|
let failures = 0;
|
|
57
|
+
// Этап объявляется один раз: строки прошлого этапа приходят вперемешку с
|
|
58
|
+
// началом нового, и `announce` по строке дублировал `clone`/`probe`
|
|
59
|
+
// (чистая комната 19.09.2026).
|
|
60
|
+
const announced = new Set();
|
|
61
|
+
const startedAt = Date.now();
|
|
62
|
+
let lastQueuedAt = startedAt;
|
|
56
63
|
const sleep = (ms) => new Promise((res) => setTimeout(res, retryDelayScale() * ms));
|
|
57
64
|
const announce = (name) => {
|
|
58
|
-
if (name === lastStage)
|
|
65
|
+
if (name === lastStage || announced.has(name))
|
|
59
66
|
return;
|
|
67
|
+
announced.add(name);
|
|
60
68
|
lastStage = name;
|
|
61
69
|
if (json) {
|
|
62
70
|
emit({ event: "stage", name });
|
|
@@ -112,6 +120,13 @@ export async function streamDeployLogs(api, deployId) {
|
|
|
112
120
|
}
|
|
113
121
|
if (poll.current_stage)
|
|
114
122
|
announce(poll.current_stage);
|
|
123
|
+
if (json &&
|
|
124
|
+
poll.status === "queued" &&
|
|
125
|
+
announced.size === 0 &&
|
|
126
|
+
Date.now() - lastQueuedAt >= QUEUED_EVERY_MS) {
|
|
127
|
+
lastQueuedAt = Date.now();
|
|
128
|
+
emit({ event: "queued", waited_s: Math.round((Date.now() - startedAt) / 1000) });
|
|
129
|
+
}
|
|
115
130
|
if (poll.terminal) {
|
|
116
131
|
return poll;
|
|
117
132
|
}
|
package/package.json
CHANGED