layero 0.11.0 → 0.11.2
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/dist/bin/layero.js +2 -2
- package/dist/commands/claim.js +21 -0
- package/dist/commands/deploy.js +7 -1
- package/dist/commands/deploys.js +5 -3
- package/dist/commands/diagnose.js +1 -22
- package/dist/detect.js +94 -6
- package/package.json +1 -1
package/dist/bin/layero.js
CHANGED
|
@@ -79,7 +79,7 @@ async function main() {
|
|
|
79
79
|
});
|
|
80
80
|
program
|
|
81
81
|
.command("init")
|
|
82
|
-
.description("
|
|
82
|
+
.description("Optional. Write a Layero deployment block into your agent-instructions file so future chat sessions know how to deploy (updates whichever of AGENTS.md / CLAUDE.md / .cursorrules already exist; if none do, creates AGENTS.md), and create .layero/project.json — this folder's link to its Layero project, filled in by the first deploy. No detected settings are recorded: build settings live in layero.json (committed with the code) or in the project settings. `deploy` links the folder by itself, so init is never required.")
|
|
83
83
|
.option("-y, --yes", "non-interactive: accept all defaults")
|
|
84
84
|
.option("--skip-agent-docs", "do not touch AGENTS.md / CLAUDE.md / .cursorrules")
|
|
85
85
|
.action(async (opts) => {
|
|
@@ -429,7 +429,7 @@ async function main() {
|
|
|
429
429
|
"Framework, build command and output directory are detected by the platform; --dry-run shows the plan first.")
|
|
430
430
|
.option("--dry-run", "show how the platform will build this folder (framework, build command, output folder, where each came from, " +
|
|
431
431
|
"hints for monorepos / frontend+backend / custom build scripts) and exit; uploads nothing, needs no login")
|
|
432
|
-
.option("-t, --type <preset>", "type override — static preset (vite | vitepress | next | astro | cra | sveltekit | nuxt | gatsby | docusaurus | storybook | eleventy | hugo | static) " +
|
|
432
|
+
.option("-t, --type <preset>", "type override — static preset (vite | vitepress | next | astro | cra | sveltekit | nuxt | gatsby | docusaurus | storybook | eleventy | hugo | static | generic) " +
|
|
433
433
|
"or runtime kind for apps the platform RUNS (node_web | python_web | flask | streamlit | gradio | ssr_next; aliases: express, fastapi, django, node, python). " +
|
|
434
434
|
"`static` serves the files as they are and never runs a build; `generic` runs your own build command " +
|
|
435
435
|
"(layero.json buildCommand or the package.json build script) and serves the folder with index.html")
|
package/dist/commands/claim.js
CHANGED
|
@@ -76,6 +76,27 @@ export function claimTokenFor(cfg, projectId) {
|
|
|
76
76
|
return undefined;
|
|
77
77
|
return cfg.claim_tokens?.[projectId];
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Конфиг с токеном, которым можно ЧИТАТЬ проект этой папки.
|
|
81
|
+
*
|
|
82
|
+
* 🚨 Песочница (`deploy --claim`) — тоже вход. Её токен лежит в
|
|
83
|
+
* `~/.layero/config.json` по id проекта, и прав на чтение хватает; без этой
|
|
84
|
+
* ветки агент без аккаунта получал на упавшей сборке совет «выполни
|
|
85
|
+
* `layero login`» и не мог узнать причину отказа ничем, кроме панели, которая
|
|
86
|
+
* ему недоступна (T-20260918-8). Для `diagnose`, `logs`, `deploys list` — то,
|
|
87
|
+
* что читает; действия, меняющие сайт, этим путём не ходят.
|
|
88
|
+
*/
|
|
89
|
+
export async function configForFolder(opts, cwd) {
|
|
90
|
+
const cfg = await loadConfig();
|
|
91
|
+
if (cfg.token)
|
|
92
|
+
return cfg;
|
|
93
|
+
const linked = await loadProjectConfig(cwd);
|
|
94
|
+
const sameProject = !opts.project || opts.project === linked?.project_id || opts.project === linked?.slug;
|
|
95
|
+
const claim = sameProject ? claimTokenFor(cfg, linked?.project_id) : undefined;
|
|
96
|
+
if (claim)
|
|
97
|
+
return { ...cfg, token: claim };
|
|
98
|
+
throw new LayeroError("auth_required", "нужен вход", "выполни `layero login` или задай LAYERO_TOKEN");
|
|
99
|
+
}
|
|
79
100
|
function claimUrlFor(cfg, code) {
|
|
80
101
|
return `${dashboardOrigin(cfg.apiUrl)}/claim?code=${encodeURIComponent(code)}`;
|
|
81
102
|
}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -572,7 +572,13 @@ export async function deployCmd(opts) {
|
|
|
572
572
|
}
|
|
573
573
|
}
|
|
574
574
|
else if (opts.claim) {
|
|
575
|
-
|
|
575
|
+
// 🚨 Совет НЕ предлагает `logout`. Агенты исполняют `next_action`
|
|
576
|
+
// дословно, а `logout` стирает сохранённый вход и токены песочниц — войти
|
|
577
|
+
// заново может только человек. Прогон evals 19.09: агент с чужим входом на
|
|
578
|
+
// машине сделал ровно это и оставил владельца без CLI.
|
|
579
|
+
throw new LayeroError("bad_format", "--claim — деплой без аккаунта, а на этой машине уже выполнен вход", "run the same command without --claim: the project is created in the signed-in account " +
|
|
580
|
+
"(`npx layero@latest whoami` shows which). Do not run `layero logout` for someone else — " +
|
|
581
|
+
"it deletes the saved login, and only a person can sign in again");
|
|
576
582
|
}
|
|
577
583
|
const api = new ApiClient(cliCfg);
|
|
578
584
|
// 🚨 `--branch` У АРХИВНОЙ ЗАГРУЗКИ НЕ РАБОТАЕТ, И МОЛЧАТЬ ОБ ЭТОМ НЕЛЬЗЯ.
|
package/dist/commands/deploys.js
CHANGED
|
@@ -2,6 +2,7 @@ import readline from "node:readline/promises";
|
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { ApiClient, ApiError } from "../api.js";
|
|
4
4
|
import { loadConfig } from "../config.js";
|
|
5
|
+
import { configForFolder } from "./claim.js";
|
|
5
6
|
import { loadProjectConfig } from "../project-config.js";
|
|
6
7
|
import { detectMode, LayeroError } from "../agent.js";
|
|
7
8
|
async function resolveProjectId(api, cwd, override) {
|
|
@@ -52,9 +53,10 @@ function fmtSource(d) {
|
|
|
52
53
|
return chalk.dim("(push)");
|
|
53
54
|
}
|
|
54
55
|
export async function deploysListCmd(opts) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
// Токеном песочницы тоже: совет при отменённой выкатке ведёт сюда, и агент
|
|
57
|
+
// без аккаунта получал «not logged in» там, где `logs` и `diagnose` уже
|
|
58
|
+
// работали (прогон evals 19.09).
|
|
59
|
+
const cliCfg = await configForFolder(opts, process.cwd());
|
|
58
60
|
const api = new ApiClient(cliCfg);
|
|
59
61
|
const projectId = await resolveProjectId(api, process.cwd(), opts.project);
|
|
60
62
|
const deploys = await api.listProjectDeploys(projectId, opts.branch);
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { ApiClient, ApiError } from "../api.js";
|
|
3
|
-
import { loadConfig } from "../config.js";
|
|
4
3
|
import { loadProjectConfig } from "../project-config.js";
|
|
5
4
|
import { LayeroError, detectMode, emit } from "../agent.js";
|
|
6
|
-
import {
|
|
5
|
+
import { configForFolder } from "./claim.js";
|
|
7
6
|
async function resolveDeployId(api, opts, cwd) {
|
|
8
7
|
if (opts.deploy) {
|
|
9
8
|
// Явно указанный деплой: проект знать не обязательно, ручка работает
|
|
@@ -36,26 +35,6 @@ async function resolveDeployId(api, opts, cwd) {
|
|
|
36
35
|
const target = deploys[0];
|
|
37
36
|
return { deployId: target.id, projectId: project.id };
|
|
38
37
|
}
|
|
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
|
-
}
|
|
59
38
|
export async function diagnoseCmd(opts) {
|
|
60
39
|
const mode = detectMode();
|
|
61
40
|
const cfg = await configForFolder(opts, process.cwd());
|
package/dist/detect.js
CHANGED
|
@@ -361,17 +361,34 @@ async function shapeOf(cwd, snap, plan) {
|
|
|
361
361
|
const start = scriptOf(pkg, "start");
|
|
362
362
|
const serverEntry = await findServerEntry(cwd, pkg);
|
|
363
363
|
if (start || serverEntry) {
|
|
364
|
-
|
|
364
|
+
// Без скрипта `start` сборщик приложений перебирает свой список точек
|
|
365
|
+
// входа (server.js, index.js, …) и `server.mjs` не находит — одного
|
|
366
|
+
// `-t node_web` мало, команду запуска надо назвать (кейс a3).
|
|
365
367
|
return {
|
|
366
368
|
hint: `This looks like a Node server (${start ? `start script \`${start}\`` : `${serverEntry} calls listen()`}), ` +
|
|
367
369
|
"but no known server framework was recognised. A server has to be run in a container, not served as files.",
|
|
368
|
-
next_action:
|
|
370
|
+
next_action: start
|
|
371
|
+
? `npx layero@latest deploy -t node_web (the start script runs the server)`
|
|
372
|
+
: `create layero.json: {"runtime":"node_web","startCommand":"node ${serverEntry}"}`,
|
|
369
373
|
};
|
|
370
374
|
}
|
|
371
375
|
if (snap.requirementsTxt !== null || snap.files.has("pyproject.toml")) {
|
|
376
|
+
const app = await findPythonApp(cwd);
|
|
377
|
+
if (app) {
|
|
378
|
+
// Детект платформы ищет точку входа только в корне (main.py, app.py…);
|
|
379
|
+
// приложение в пакете (`service/web.py`) он не видит. Кейс b3.
|
|
380
|
+
const cmd = app.kind === "wsgi"
|
|
381
|
+
? `gunicorn ${app.target} --bind 0.0.0.0:$PORT`
|
|
382
|
+
: `uvicorn ${app.target} --host 0.0.0.0 --port $PORT`;
|
|
383
|
+
return {
|
|
384
|
+
hint: `A Python web app (${app.framework}) whose entry is not at the root: \`${app.target}\`. ` +
|
|
385
|
+
"The platform looks for main.py / app.py at the root, so name the app and how to start it.",
|
|
386
|
+
next_action: `create layero.json: {"runtime":"python_web","startCommand":"${cmd}"}`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
372
389
|
return {
|
|
373
390
|
hint: "A Python project, but no known web framework or entry file was recognised.",
|
|
374
|
-
next_action: 'npx layero@latest deploy -t python_web (or layero.json: {"runtime":"python_web","startCommand":"uvicorn
|
|
391
|
+
next_action: 'npx layero@latest deploy -t python_web (or layero.json: {"runtime":"python_web","startCommand":"uvicorn <module>:<app> --host 0.0.0.0 --port $PORT"})',
|
|
375
392
|
};
|
|
376
393
|
}
|
|
377
394
|
return {
|
|
@@ -379,7 +396,60 @@ async function shapeOf(cwd, snap, plan) {
|
|
|
379
396
|
next_action: "run the command from the app's folder (or pass --root <dir>); ready-made files: npx layero@latest deploy --prebuilt <dir>",
|
|
380
397
|
};
|
|
381
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* Каталог результата, заданный ФЛАГОМ в скрипте сборки (`vite build --outDir
|
|
401
|
+
* public_html`). Детект читает конфиги, а не флаги, — сборщик без
|
|
402
|
+
* `outputDirectory` ищет в умолчании фреймворка и может отдать исходный
|
|
403
|
+
* `index.html` из корня вместо собранного. Кейс «слепого» агента b1.
|
|
404
|
+
*/
|
|
405
|
+
const OUTDIR_FLAG_RE = /(?:^|\s)--(?:outDir|out-dir|outdir|output-dir|dist-dir)(?:=|\s+)(["']?)([^\s"'&|;]+)\1/;
|
|
406
|
+
function outDirFlag(pkg) {
|
|
407
|
+
const m = OUTDIR_FLAG_RE.exec(scriptOf(pkg, "build") ?? "");
|
|
408
|
+
return m ? m[2].replace(/^\.\//, "").replace(/\/+$/, "") || null : null;
|
|
409
|
+
}
|
|
382
410
|
const SERVER_ENTRY_CANDIDATES = ["server.js", "index.js", "app.js", "main.js", "server.mjs", "index.mjs"];
|
|
411
|
+
// `api = FastAPI(`, `app = Flask(__name__)` — объект приложения на верхнем
|
|
412
|
+
// уровне модуля. ASGI запускает uvicorn, WSGI — gunicorn.
|
|
413
|
+
const PY_APP_RE = /^(\w+)\s*=\s*(FastAPI|Starlette|Flask|Quart|Litestar)\s*\(/m;
|
|
414
|
+
const PY_ASGI = new Set(["FastAPI", "Starlette", "Quart", "Litestar"]);
|
|
415
|
+
const PY_SKIP = new Set([".venv", "venv", "env", "node_modules", ".git", "__pycache__", "site-packages", "tests", "test"]);
|
|
416
|
+
async function findPythonApp(cwd, maxDepth = 3) {
|
|
417
|
+
const found = [];
|
|
418
|
+
async function walk(rel, depth) {
|
|
419
|
+
if (depth > maxDepth || found.length > 3)
|
|
420
|
+
return;
|
|
421
|
+
let entries;
|
|
422
|
+
try {
|
|
423
|
+
entries = await fs.readdir(path.join(cwd, rel), { withFileTypes: true });
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
for (const e of entries) {
|
|
429
|
+
const child = rel ? `${rel}/${e.name}` : e.name;
|
|
430
|
+
if (e.isFile() && e.name.endsWith(".py")) {
|
|
431
|
+
const text = await readText(path.join(cwd, child));
|
|
432
|
+
const m = text ? PY_APP_RE.exec(text.slice(0, 64 * 1024)) : null;
|
|
433
|
+
if (m)
|
|
434
|
+
found.push({ rel: child, name: m[1], framework: m[2] });
|
|
435
|
+
}
|
|
436
|
+
else if (e.isDirectory() && !PY_SKIP.has(e.name) && !e.name.startsWith(".")) {
|
|
437
|
+
await walk(child, depth + 1);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
await walk("", 0);
|
|
442
|
+
// Одно приложение — называем; несколько — гадать, какое главное, не берёмся.
|
|
443
|
+
if (found.length !== 1)
|
|
444
|
+
return null;
|
|
445
|
+
const f = found[0];
|
|
446
|
+
const module = f.rel.replace(/\.py$/, "").replace(/\//g, ".").replace(/\.__init__$/, "");
|
|
447
|
+
return {
|
|
448
|
+
target: `${module}:${f.name}`,
|
|
449
|
+
framework: f.framework,
|
|
450
|
+
kind: PY_ASGI.has(f.framework) ? "asgi" : "wsgi",
|
|
451
|
+
};
|
|
452
|
+
}
|
|
383
453
|
const LISTEN_RE = /\.listen\s*\(|createServer\s*\(/;
|
|
384
454
|
async function findServerEntry(cwd, pkg) {
|
|
385
455
|
const names = [...(typeof pkg?.main === "string" ? [pkg.main] : []), ...SERVER_ENTRY_CANDIDATES];
|
|
@@ -461,11 +531,18 @@ export async function detectProject(cwd, opts = {}) {
|
|
|
461
531
|
? (opts.hintSource ?? "--type")
|
|
462
532
|
: "detected";
|
|
463
533
|
if (plan.runtimeKind !== null) {
|
|
464
|
-
|
|
534
|
+
// Файл объявил, КАК запускать (`runtime`, фуллстек-блоки), — это не то же
|
|
535
|
+
// самое, что назвать фреймворк: имя (`fastapi`, `express`) детект берёт из
|
|
536
|
+
// зависимостей. Без этого различия `sources.framework` приписывал файлу
|
|
537
|
+
// ключ, которого в нём нет (прогон evals 19.09, кейс b3).
|
|
538
|
+
const kindFromFile = layero !== null && (str(layero.runtime) !== null || plan.projectKind === "fullstack");
|
|
539
|
+
const nameFromFile = fileFramework !== null ||
|
|
540
|
+
(plan.projectKind === "fullstack" && str(layero?.backend?.framework) !== null);
|
|
465
541
|
const sources = {
|
|
466
|
-
framework: (
|
|
542
|
+
framework: (nameFromFile ? "layero.json" : frameworkSource),
|
|
467
543
|
build_cmd: "none",
|
|
468
544
|
output_dir: "none",
|
|
545
|
+
runtime_kind: (kindFromFile ? "layero.json" : "detected"),
|
|
469
546
|
};
|
|
470
547
|
if (plan.projectType === "ssr_next") {
|
|
471
548
|
// SSR Next builds in the runtime-builder; surface the framework + the
|
|
@@ -507,6 +584,7 @@ export async function detectProject(cwd, opts = {}) {
|
|
|
507
584
|
let confident = declared || plan.confident;
|
|
508
585
|
let shape = null;
|
|
509
586
|
const notes = [];
|
|
587
|
+
let flagNextAction;
|
|
510
588
|
if (framework === "static") {
|
|
511
589
|
// Статика не собирается НИКОГДА: команда сборки у неё не «true», а
|
|
512
590
|
// отсутствует. Заглушка `true` читалась как команда и уезжала в проект.
|
|
@@ -555,6 +633,16 @@ export async function detectProject(cwd, opts = {}) {
|
|
|
555
633
|
confident = false;
|
|
556
634
|
shape = await shapeOf(cwd, snap, plan);
|
|
557
635
|
}
|
|
636
|
+
const flagDir = fileOutput ? null : outDirFlag(snap.packageJson);
|
|
637
|
+
if (flagDir && flagDir !== outputDir) {
|
|
638
|
+
// План платформы положит результат не туда: она не читает флаги скрипта.
|
|
639
|
+
confident = false;
|
|
640
|
+
notes.push(`The build script sets the output folder with a flag (\`${flagDir}\`), which detection does not read: ` +
|
|
641
|
+
`without outputDirectory the platform looks in "${outputDir ?? "dist"}" and may serve the source index.html instead of the build.`);
|
|
642
|
+
flagNextAction = snap.layeroJson
|
|
643
|
+
? `add "outputDirectory": "${flagDir}" to layero.json`
|
|
644
|
+
: `create layero.json: {"outputDirectory":"${flagDir}"}`;
|
|
645
|
+
}
|
|
558
646
|
if (framework === "generic" && buildCmd === null) {
|
|
559
647
|
// Собирать нечем — и каталог результата у такой папки не «dist по
|
|
560
648
|
// умолчанию», а неизвестен: умолчание фреймворка «Other» здесь вымысел.
|
|
@@ -575,7 +663,7 @@ export async function detectProject(cwd, opts = {}) {
|
|
|
575
663
|
confident,
|
|
576
664
|
sources: { framework: frameworkSource, build_cmd: buildSource, output_dir: outputSource },
|
|
577
665
|
...(hint ? { hint } : {}),
|
|
578
|
-
...(shape?.next_action ? { next_action: shape
|
|
666
|
+
...(shape?.next_action ?? flagNextAction ? { next_action: shape?.next_action ?? flagNextAction } : {}),
|
|
579
667
|
...(shape?.candidates?.length ? { candidates: shape.candidates } : {}),
|
|
580
668
|
...(warning ? { ssr_warning: warning } : {}),
|
|
581
669
|
};
|
package/package.json
CHANGED