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/detect.js
CHANGED
|
@@ -17,7 +17,27 @@ async function readText(p) {
|
|
|
17
17
|
return null;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
async function exists(p) {
|
|
21
|
+
try {
|
|
22
|
+
await fs.access(p);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
20
29
|
const HTML_SKIP_DIRS = new Set(["node_modules", ".git", ".cache", "dist", "build", "out", "public"]);
|
|
30
|
+
/**
|
|
31
|
+
* Есть ли в дереве html, который можно отдать КАК ЕСТЬ.
|
|
32
|
+
*
|
|
33
|
+
* 🚨 Папка со своим `package.json` — это чужое приложение, и её `index.html`
|
|
34
|
+
* — исходник, а не статика. Сборщик различает это с 23.08.2026
|
|
35
|
+
* (`detect_core._dir_has_html`), а CLI — нет: у монорепо `apps/web/index.html`
|
|
36
|
+
* поднимал флаг, и корень с одним README уверенно становился «static,
|
|
37
|
+
* confident: true». Эту догадку CLI затем записывал в проект, и сборщик
|
|
38
|
+
* выкладывал исходники Vite-приложения без сборки (симуляция 18.09.2026,
|
|
39
|
+
* T-20260918-7).
|
|
40
|
+
*/
|
|
21
41
|
async function dirHasHtml(base, depth = 0) {
|
|
22
42
|
if (depth > 6)
|
|
23
43
|
return false;
|
|
@@ -28,6 +48,8 @@ async function dirHasHtml(base, depth = 0) {
|
|
|
28
48
|
catch {
|
|
29
49
|
return false;
|
|
30
50
|
}
|
|
51
|
+
if (depth > 0 && entries.some((e) => e.isFile() && e.name === "package.json"))
|
|
52
|
+
return false;
|
|
31
53
|
for (const e of entries) {
|
|
32
54
|
if (e.isFile() && (e.name.endsWith(".html") || e.name.endsWith(".htm")))
|
|
33
55
|
return true;
|
|
@@ -40,6 +62,14 @@ async function dirHasHtml(base, depth = 0) {
|
|
|
40
62
|
}
|
|
41
63
|
return false;
|
|
42
64
|
}
|
|
65
|
+
async function rootHasIndexHtml(cwd) {
|
|
66
|
+
try {
|
|
67
|
+
return (await fs.readdir(cwd)).some((n) => n.toLowerCase() === "index.html");
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
43
73
|
/** Build a detect_core Snapshot from the local filesystem (the CLI's disk
|
|
44
74
|
* fidelity — the analogue of detect_core.py's snapshot_from_dir). */
|
|
45
75
|
async function snapshotFromDir(cwd) {
|
|
@@ -57,13 +87,8 @@ async function snapshotFromDir(cwd) {
|
|
|
57
87
|
".vitepress/config.ts", ".vitepress/config.js", ".vitepress/config.mts", ".vitepress/config.mjs",
|
|
58
88
|
"docs/.vitepress/config.ts", "docs/.vitepress/config.js", "docs/.vitepress/config.mts", "docs/.vitepress/config.mjs",
|
|
59
89
|
]) {
|
|
60
|
-
|
|
61
|
-
await fs.access(path.join(cwd, nested));
|
|
90
|
+
if (await exists(path.join(cwd, nested)))
|
|
62
91
|
files.add(nested);
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
/* absent */
|
|
66
|
-
}
|
|
67
92
|
}
|
|
68
93
|
const packageJson = (await readJson(path.join(cwd, "package.json")));
|
|
69
94
|
const requirementsTxt = await readText(path.join(cwd, "requirements.txt"));
|
|
@@ -132,47 +157,442 @@ function ssrWarning(snap, framework) {
|
|
|
132
157
|
}
|
|
133
158
|
return undefined;
|
|
134
159
|
}
|
|
160
|
+
// ── Форма папки ─────────────────────────────────────────────────────────────
|
|
161
|
+
//
|
|
162
|
+
// Детект отвечает, ЧТО лежит в папке. Он не отвечает, почему в ней ничего не
|
|
163
|
+
// узнано: приложение в подпапке, две половины рядом, свой скрипт сборки или
|
|
164
|
+
// сервер без известного фреймворка. До 18.09.2026 на все эти формы CLI
|
|
165
|
+
// отвечал одним `static, confident: true` — и агенту оставалось верить.
|
|
166
|
+
// Ниже — подсказки для этих форм. Вердикта они не меняют: решает сборщик.
|
|
167
|
+
// Зеркало `_SCAN_SKIP` / `_APP_MANIFESTS` сборщика (`frameworks/base.py`,
|
|
168
|
+
// `find_app_subdirs`): кандидатов CLI называет по тому же правилу, по
|
|
169
|
+
// которому сборщик сам выбирает единственную папку приложения.
|
|
170
|
+
const SCAN_SKIP = new Set([
|
|
171
|
+
"node_modules", ".git", ".github", ".cache", ".turbo", ".vercel",
|
|
172
|
+
"coverage", "__pycache__", ".angular", ".next", ".nuxt", ".svelte-kit",
|
|
173
|
+
"vendor", ".gradle",
|
|
174
|
+
]);
|
|
175
|
+
const APP_MANIFESTS = ["package.json", "requirements.txt", "pyproject.toml", "layero.json", "go.mod", "Gemfile"];
|
|
176
|
+
const WORKSPACE_FILES = ["pnpm-workspace.yaml", "lerna.json", "rush.json", "turbo.json", "nx.json"];
|
|
177
|
+
const MAX_CANDIDATES = 12;
|
|
178
|
+
async function hasAppManifest(dir) {
|
|
179
|
+
for (const m of APP_MANIFESTS)
|
|
180
|
+
if (await exists(path.join(dir, m)))
|
|
181
|
+
return true;
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
/** Папки с манифестом приложения на глубине 1–2, мельчайшие первыми. */
|
|
185
|
+
async function findAppSubdirs(base, maxDepth = 2) {
|
|
186
|
+
const found = [];
|
|
187
|
+
async function walk(rel, depth) {
|
|
188
|
+
let entries;
|
|
189
|
+
try {
|
|
190
|
+
entries = await fs.readdir(path.join(base, rel), { withFileTypes: true });
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
for (const e of entries) {
|
|
196
|
+
if (!e.isDirectory() || SCAN_SKIP.has(e.name) || e.name.startsWith("."))
|
|
197
|
+
continue;
|
|
198
|
+
const child = rel ? `${rel}/${e.name}` : e.name;
|
|
199
|
+
if (await hasAppManifest(path.join(base, child))) {
|
|
200
|
+
found.push(child);
|
|
201
|
+
continue; // вложенные манифесты приложения — не отдельные приложения
|
|
202
|
+
}
|
|
203
|
+
if (depth + 1 < maxDepth)
|
|
204
|
+
await walk(child, depth + 1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
await walk("", 0);
|
|
208
|
+
found.sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
|
|
209
|
+
return found;
|
|
210
|
+
}
|
|
211
|
+
const LOCAL_DEP_RE = /^(workspace:|file:|link:|portal:)/;
|
|
212
|
+
function scriptOf(pkg, name) {
|
|
213
|
+
const v = pkg?.scripts?.[name];
|
|
214
|
+
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
215
|
+
}
|
|
216
|
+
function isWorkspaceContainer(snap) {
|
|
217
|
+
const pkg = snap.packageJson;
|
|
218
|
+
return Boolean(pkg && pkg.workspaces) || WORKSPACE_FILES.some((f) => snap.files.has(f));
|
|
219
|
+
}
|
|
220
|
+
async function describeCandidate(base, dir, siblingNames) {
|
|
221
|
+
const abs = path.join(base, dir);
|
|
222
|
+
const snap = await snapshotFromDir(abs);
|
|
223
|
+
const plan = dc.detect(snap);
|
|
224
|
+
const pkg = snap.packageJson;
|
|
225
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
226
|
+
const workspaceDeps = Object.entries(deps).some(([name, v]) => (typeof v === "string" && LOCAL_DEP_RE.test(v)) || siblingNames.has(name));
|
|
227
|
+
let role = "unknown";
|
|
228
|
+
if (plan.runtimeKind !== null && plan.projectType !== "ssr_next")
|
|
229
|
+
role = "server";
|
|
230
|
+
else if (plan.runtimeKind !== null)
|
|
231
|
+
role = "app";
|
|
232
|
+
else if (plan.framework !== "static" && plan.framework !== "generic")
|
|
233
|
+
role = "app";
|
|
234
|
+
else if (await rootHasIndexHtml(abs))
|
|
235
|
+
role = "app";
|
|
236
|
+
else if (isWorkspaceContainer(snap) && !scriptOf(pkg, "build"))
|
|
237
|
+
role = "workspace";
|
|
238
|
+
else if (pkg && (pkg.exports || pkg.main || pkg.module || pkg.types) && !scriptOf(pkg, "start"))
|
|
239
|
+
role = "library";
|
|
240
|
+
else if (scriptOf(pkg, "build") || scriptOf(pkg, "start"))
|
|
241
|
+
role = "app";
|
|
242
|
+
return {
|
|
243
|
+
dir,
|
|
244
|
+
role,
|
|
245
|
+
framework: plan.framework,
|
|
246
|
+
outputDir: plan.runtimeKind === null ? plan.outputDir : null,
|
|
247
|
+
packageName: typeof pkg?.name === "string" ? pkg.name : undefined,
|
|
248
|
+
workspaceDeps,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
async function describeCandidates(base, dirs) {
|
|
252
|
+
const names = new Set();
|
|
253
|
+
for (const d of dirs) {
|
|
254
|
+
const pkg = (await readJson(path.join(base, d, "package.json")));
|
|
255
|
+
if (pkg && typeof pkg.name === "string")
|
|
256
|
+
names.add(pkg.name);
|
|
257
|
+
}
|
|
258
|
+
const out = [];
|
|
259
|
+
for (const d of dirs.slice(0, MAX_CANDIDATES)) {
|
|
260
|
+
const own = (await readJson(path.join(base, d, "package.json")));
|
|
261
|
+
const siblings = new Set([...names].filter((n) => n !== own?.name));
|
|
262
|
+
out.push(await describeCandidate(base, d, siblings));
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
const FRONT_NAMES = ["frontend", "client", "web", "app", "site", "ui"];
|
|
267
|
+
const BACK_NAMES = ["backend", "server", "api"];
|
|
268
|
+
function pickByName(list, names) {
|
|
269
|
+
return list.find((c) => names.includes(path.basename(c.dir).toLowerCase())) ?? list[0];
|
|
270
|
+
}
|
|
271
|
+
function label(c) {
|
|
272
|
+
return `${c.dir} (${c.framework})`;
|
|
273
|
+
}
|
|
274
|
+
function fullstackRecipe(front, back) {
|
|
275
|
+
const fe = { root: front.dir };
|
|
276
|
+
if (front.outputDir && front.outputDir !== "." && front.outputDir !== "dist")
|
|
277
|
+
fe.output = front.outputDir;
|
|
278
|
+
const be = { root: back.dir };
|
|
279
|
+
if (back.framework && back.framework !== "generic")
|
|
280
|
+
be.framework = back.framework;
|
|
281
|
+
return JSON.stringify({ frontend: fe, backend: be, apiPrefix: "/api" });
|
|
282
|
+
}
|
|
283
|
+
function workspaceBuildCmd(snap, c) {
|
|
284
|
+
const pnpm = snap.files.has("pnpm-workspace.yaml") || snap.files.has("pnpm-lock.yaml");
|
|
285
|
+
if (pnpm && c.packageName)
|
|
286
|
+
return `pnpm --filter ${c.packageName}... build`;
|
|
287
|
+
return "npm run build --workspaces --if-present";
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Что сказать про папку, которую детект не узнал.
|
|
291
|
+
*
|
|
292
|
+
* Порядок — от самой частой формы в отказах прода (монорепо и не та папка —
|
|
293
|
+
* 14,8 % отказов, серии до 61 подряд) к редким.
|
|
294
|
+
*/
|
|
295
|
+
async function shapeOf(cwd, snap, plan) {
|
|
296
|
+
const pkg = snap.packageJson;
|
|
297
|
+
const dirs = await findAppSubdirs(cwd);
|
|
298
|
+
const described = await describeCandidates(cwd, dirs);
|
|
299
|
+
const apps = described.filter((c) => c.role === "app" || c.role === "server");
|
|
300
|
+
const candidates = apps.map((c) => c.dir);
|
|
301
|
+
const servers = apps.filter((c) => c.role === "server");
|
|
302
|
+
const fronts = apps.filter((c) => c.role === "app");
|
|
303
|
+
if (servers.length >= 1 && fronts.length >= 1) {
|
|
304
|
+
const front = pickByName(fronts, FRONT_NAMES);
|
|
305
|
+
const back = pickByName(servers, BACK_NAMES);
|
|
306
|
+
return {
|
|
307
|
+
hint: `${label(front)} looks like the site and ${label(back)} like its server. ` +
|
|
308
|
+
"Without layero.json the platform builds only one app folder, so the other half would be missing. " +
|
|
309
|
+
"Describe both halves in layero.json at the root of this folder.",
|
|
310
|
+
next_action: `create layero.json here: ${fullstackRecipe(front, back)} — then deploy from this folder`,
|
|
311
|
+
candidates,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
if (apps.length === 1) {
|
|
315
|
+
const c = apps[0];
|
|
316
|
+
const workspace = isWorkspaceContainer(snap);
|
|
317
|
+
if (c.workspaceDeps) {
|
|
318
|
+
const out = `${c.dir}/${c.outputDir && c.outputDir !== "." ? c.outputDir : "dist"}`;
|
|
319
|
+
const recipe = { framework: "generic", buildCommand: workspaceBuildCmd(snap, c), outputDirectory: out };
|
|
320
|
+
return {
|
|
321
|
+
hint: `The app is ${label(c)}, and it imports a neighbour package of the workspace. ` +
|
|
322
|
+
`--root ${c.dir} would upload the app without that package, so build it from this folder: ` +
|
|
323
|
+
"a buildCommand that builds the neighbours first, and the app's output folder.",
|
|
324
|
+
next_action: `create layero.json here: ${JSON.stringify(recipe)} — then deploy from this folder`,
|
|
325
|
+
candidates,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
hint: `This folder has no app of its own${workspace ? " (it is a workspace root)" : ""}; the app is ${label(c)}. ` +
|
|
330
|
+
"Deploy that folder.",
|
|
331
|
+
next_action: `npx layero@latest deploy --root ${c.dir}`,
|
|
332
|
+
candidates,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (apps.length > 1) {
|
|
336
|
+
return {
|
|
337
|
+
hint: `Several app folders: ${apps.map(label).join(", ")}. ` +
|
|
338
|
+
"One project deploys one of them — pick it with --root.",
|
|
339
|
+
next_action: `npx layero@latest deploy --root ${apps[0].dir}`,
|
|
340
|
+
candidates,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
const build = scriptOf(pkg, "build");
|
|
344
|
+
if (build && plan.framework === "static") {
|
|
345
|
+
return {
|
|
346
|
+
hint: `package.json has a build script (\`${build}\`), but no known framework was recognised and there is no index.html at the root. ` +
|
|
347
|
+
"As it stands the platform serves these files as they are and does NOT run the build (framework \"static\").",
|
|
348
|
+
next_action: snap.layeroJson
|
|
349
|
+
? 'if the site must be built, add "framework": "generic" to layero.json ' +
|
|
350
|
+
"(buildCommand runs your script; outputDirectory is the folder that holds index.html after the build)"
|
|
351
|
+
: "if the site must be built, create layero.json: " +
|
|
352
|
+
'{"framework":"generic","buildCommand":"npm run build","outputDirectory":"<folder that holds index.html after the build>"}',
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
if (build) {
|
|
356
|
+
return {
|
|
357
|
+
hint: `No known framework: the platform will run the build script (\`${build}\`) and serve the folder where index.html appears after the build. ` +
|
|
358
|
+
"If it lands somewhere unexpected, set outputDirectory in layero.json.",
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const start = scriptOf(pkg, "start");
|
|
362
|
+
const serverEntry = await findServerEntry(cwd, pkg);
|
|
363
|
+
if (start || serverEntry) {
|
|
364
|
+
const cmd = start ? "npm start" : `node ${serverEntry}`;
|
|
365
|
+
return {
|
|
366
|
+
hint: `This looks like a Node server (${start ? `start script \`${start}\`` : `${serverEntry} calls listen()`}), ` +
|
|
367
|
+
"but no known server framework was recognised. A server has to be run in a container, not served as files.",
|
|
368
|
+
next_action: `npx layero@latest deploy -t node_web (or layero.json: {"runtime":"node_web","startCommand":"${cmd}"})`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
if (snap.requirementsTxt !== null || snap.files.has("pyproject.toml")) {
|
|
372
|
+
return {
|
|
373
|
+
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 main:app --host 0.0.0.0 --port $PORT"})',
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
hint: "Nothing to build or serve was recognised here: no index.html, no build script, no server, no app in a subfolder.",
|
|
379
|
+
next_action: "run the command from the app's folder (or pass --root <dir>); ready-made files: npx layero@latest deploy --prebuilt <dir>",
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const SERVER_ENTRY_CANDIDATES = ["server.js", "index.js", "app.js", "main.js", "server.mjs", "index.mjs"];
|
|
383
|
+
const LISTEN_RE = /\.listen\s*\(|createServer\s*\(/;
|
|
384
|
+
async function findServerEntry(cwd, pkg) {
|
|
385
|
+
const names = [...(typeof pkg?.main === "string" ? [pkg.main] : []), ...SERVER_ENTRY_CANDIDATES];
|
|
386
|
+
for (const n of names) {
|
|
387
|
+
const text = await readText(path.join(cwd, n));
|
|
388
|
+
if (text !== null && LISTEN_RE.test(text.slice(0, 64 * 1024)))
|
|
389
|
+
return n;
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
/** Первая папка с index.html, если в корне его нет, — по правилу сборщика
|
|
394
|
+
* (`discover_served_root`): мельчайшая, затем по алфавиту. */
|
|
395
|
+
async function servedSubdir(cwd, maxDepth = 3) {
|
|
396
|
+
let best = null;
|
|
397
|
+
async function walk(rel, depth) {
|
|
398
|
+
if (depth > maxDepth)
|
|
399
|
+
return;
|
|
400
|
+
let entries;
|
|
401
|
+
try {
|
|
402
|
+
entries = await fs.readdir(path.join(cwd, rel), { withFileTypes: true });
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (depth > 0 && entries.some((e) => e.isFile() && e.name === "package.json"))
|
|
408
|
+
return;
|
|
409
|
+
if (depth > 0 && entries.some((e) => e.isFile() && e.name.toLowerCase() === "index.html")) {
|
|
410
|
+
if (!best || depth < best.depth || (depth === best.depth && rel.localeCompare(best.rel) < 0)) {
|
|
411
|
+
best = { rel, depth };
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
for (const e of entries) {
|
|
416
|
+
if (e.isDirectory() && !SCAN_SKIP.has(e.name) && !e.name.startsWith(".")) {
|
|
417
|
+
await walk(rel ? `${rel}/${e.name}` : e.name, depth + 1);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
await walk("", 0);
|
|
422
|
+
return best ? best.rel : null;
|
|
423
|
+
}
|
|
424
|
+
function str(v) {
|
|
425
|
+
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
426
|
+
}
|
|
427
|
+
const BUILD_SOURCE = {
|
|
428
|
+
detected: "package.json",
|
|
429
|
+
default: "framework default",
|
|
430
|
+
guess: "framework default",
|
|
431
|
+
unknown: "none",
|
|
432
|
+
};
|
|
433
|
+
const OUTPUT_SOURCE = {
|
|
434
|
+
detected: "framework config",
|
|
435
|
+
default: "framework default",
|
|
436
|
+
guess: "framework default",
|
|
437
|
+
unknown: "none",
|
|
438
|
+
};
|
|
135
439
|
/**
|
|
136
440
|
* Detect framework / build command / output directory from the project shape.
|
|
137
441
|
* Identity now comes from the unified detect_core (the same spec the builder
|
|
138
|
-
* and backend wizard use); this function
|
|
139
|
-
*
|
|
140
|
-
*
|
|
442
|
+
* and backend wizard use); this function adapts the resulting BuildPlan into
|
|
443
|
+
* the CLI's `Detected` shape and says honestly how sure it is.
|
|
444
|
+
*
|
|
445
|
+
* 🚨 ЭТО ПОДСКАЗКА, А НЕ РЕШЕНИЕ. Решает сборщик: он видит архив целиком и на
|
|
446
|
+
* первой сборке применяет правила, которых в TS-ядре нет. До 18.09.2026 CLI
|
|
447
|
+
* отправлял свою догадку в настройки проекта, и она перебивала сборщик на
|
|
448
|
+
* каждой следующей сборке (`(from hint)`, `(from dashboard)` в логе у проекта,
|
|
449
|
+
* который никто не настраивал). Теперь `deploy` отправляет только то, что
|
|
450
|
+
* назвал человек, а этот результат — только показывает.
|
|
141
451
|
*/
|
|
142
|
-
export async function detectProject(cwd) {
|
|
452
|
+
export async function detectProject(cwd, opts = {}) {
|
|
143
453
|
const snap = await snapshotFromDir(cwd);
|
|
144
|
-
const
|
|
454
|
+
const layero = snap.layeroJson;
|
|
455
|
+
const fileFramework = str(layero?.framework);
|
|
456
|
+
const outsideHint = fileFramework ? null : str(opts.frameworkHint);
|
|
457
|
+
const plan = dc.detect(snap, fileFramework ?? outsideHint);
|
|
458
|
+
const frameworkSource = fileFramework
|
|
459
|
+
? "layero.json"
|
|
460
|
+
: outsideHint
|
|
461
|
+
? (opts.hintSource ?? "--type")
|
|
462
|
+
: "detected";
|
|
145
463
|
if (plan.runtimeKind !== null) {
|
|
464
|
+
const fromFile = layero !== null && (str(layero.runtime) !== null || plan.projectKind === "fullstack");
|
|
465
|
+
const sources = {
|
|
466
|
+
framework: (fromFile ? "layero.json" : frameworkSource),
|
|
467
|
+
build_cmd: "none",
|
|
468
|
+
output_dir: "none",
|
|
469
|
+
};
|
|
146
470
|
if (plan.projectType === "ssr_next") {
|
|
147
471
|
// SSR Next builds in the runtime-builder; surface the framework + the
|
|
148
472
|
// `.next` dir the CLI has always reported (vestigial but expected).
|
|
149
473
|
const [unit] = dc.detectFramework(snap, "nextjs");
|
|
474
|
+
const script = snap.packageJson?.scripts?.build;
|
|
150
475
|
return {
|
|
151
476
|
framework_hint: "nextjs",
|
|
152
477
|
build_cmd: unit.buildCmd ?? "npx next build",
|
|
153
478
|
output_dir: ".next",
|
|
154
479
|
confident: true,
|
|
155
480
|
runtime_kind: "ssr_next",
|
|
481
|
+
sources: { ...sources, build_cmd: script ? "package.json" : "framework default", output_dir: "framework default" },
|
|
156
482
|
};
|
|
157
483
|
}
|
|
158
|
-
// node_web / python_web / streamlit / gradio
|
|
159
|
-
//
|
|
484
|
+
// node_web / python_web / streamlit / gradio / fullstack: the container
|
|
485
|
+
// serves the app, nothing is uploaded as static files from this plan.
|
|
160
486
|
return {
|
|
161
|
-
framework_hint:
|
|
162
|
-
build_cmd:
|
|
163
|
-
output_dir:
|
|
487
|
+
framework_hint: plan.framework,
|
|
488
|
+
build_cmd: null,
|
|
489
|
+
output_dir: null,
|
|
164
490
|
confident: true,
|
|
165
491
|
runtime_kind: plan.projectType,
|
|
492
|
+
sources,
|
|
493
|
+
...(plan.projectKind === "fullstack"
|
|
494
|
+
? { hint: "Full-stack layout from layero.json: the frontend is served as files, the backend runs in a container." }
|
|
495
|
+
: {}),
|
|
166
496
|
};
|
|
167
497
|
}
|
|
168
498
|
const framework = plan.framework;
|
|
169
|
-
const
|
|
499
|
+
const fileBuild = str(layero?.build);
|
|
500
|
+
const fileOutput = str(layero?.output);
|
|
501
|
+
let buildCmd = plan.buildCmd;
|
|
502
|
+
let buildSource = BUILD_SOURCE[plan.fieldSources.build_cmd ?? "unknown"] ?? "none";
|
|
503
|
+
let outputDir = plan.outputDir;
|
|
504
|
+
let outputSource = OUTPUT_SOURCE[plan.fieldSources.output_dir ?? "unknown"] ?? "none";
|
|
505
|
+
// Фреймворк назван человеком (файл, `--type`, настройки) — это не догадка.
|
|
506
|
+
const declared = Boolean(fileFramework || outsideHint);
|
|
507
|
+
let confident = declared || plan.confident;
|
|
508
|
+
let shape = null;
|
|
509
|
+
const notes = [];
|
|
510
|
+
if (framework === "static") {
|
|
511
|
+
// Статика не собирается НИКОГДА: команда сборки у неё не «true», а
|
|
512
|
+
// отсутствует. Заглушка `true` читалась как команда и уезжала в проект.
|
|
513
|
+
buildCmd = null;
|
|
514
|
+
buildSource = "none";
|
|
515
|
+
outputDir = ".";
|
|
516
|
+
outputSource = "detected";
|
|
517
|
+
const pkg = snap.packageJson;
|
|
518
|
+
const buildScript = scriptOf(pkg, "build");
|
|
519
|
+
if (await rootHasIndexHtml(cwd)) {
|
|
520
|
+
confident = true;
|
|
521
|
+
if (buildScript && !declared) {
|
|
522
|
+
notes.push(`package.json has a build script (\`${buildScript}\`), but a static site is served as it is and the script is not run. ` +
|
|
523
|
+
'If it must run: layero.json {"framework":"generic","buildCommand":"npm run build","outputDirectory":"<folder>"}.');
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
const sub = buildScript || fileOutput ? null : await servedSubdir(cwd);
|
|
528
|
+
if (sub) {
|
|
529
|
+
confident = true;
|
|
530
|
+
outputDir = sub;
|
|
531
|
+
notes.push(`index.html is not at the root: the platform serves ${sub}/.`);
|
|
532
|
+
}
|
|
533
|
+
else if (declared) {
|
|
534
|
+
if (!fileOutput) {
|
|
535
|
+
notes.push("There is no index.html at the root of this folder, and a static site is served as it is: " +
|
|
536
|
+
"point outputDirectory at the folder with index.html, or deploy ready-made files with --prebuilt <dir>.");
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
confident = false;
|
|
541
|
+
shape = await shapeOf(cwd, snap, plan);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (fileBuild) {
|
|
545
|
+
notes.push('layero.json sets buildCommand, but the framework is "static", which never builds: the command will NOT run. ' +
|
|
546
|
+
'Set "framework" in layero.json (for a custom build script: "generic").');
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
if (fileBuild) {
|
|
551
|
+
buildCmd = fileBuild;
|
|
552
|
+
buildSource = "layero.json";
|
|
553
|
+
}
|
|
554
|
+
if (framework === "generic" && !declared) {
|
|
555
|
+
confident = false;
|
|
556
|
+
shape = await shapeOf(cwd, snap, plan);
|
|
557
|
+
}
|
|
558
|
+
if (framework === "generic" && buildCmd === null) {
|
|
559
|
+
// Собирать нечем — и каталог результата у такой папки не «dist по
|
|
560
|
+
// умолчанию», а неизвестен: умолчание фреймворка «Other» здесь вымысел.
|
|
561
|
+
outputDir = null;
|
|
562
|
+
outputSource = "none";
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (fileOutput) {
|
|
566
|
+
outputDir = fileOutput;
|
|
567
|
+
outputSource = "layero.json";
|
|
568
|
+
}
|
|
170
569
|
const warning = ssrWarning(snap, framework);
|
|
570
|
+
const hint = [shape?.hint, ...notes].filter(Boolean).join(" ");
|
|
171
571
|
return {
|
|
172
572
|
framework_hint: framework,
|
|
173
|
-
build_cmd,
|
|
174
|
-
output_dir:
|
|
175
|
-
confident
|
|
573
|
+
build_cmd: buildCmd,
|
|
574
|
+
output_dir: outputDir,
|
|
575
|
+
confident,
|
|
576
|
+
sources: { framework: frameworkSource, build_cmd: buildSource, output_dir: outputSource },
|
|
577
|
+
...(hint ? { hint } : {}),
|
|
578
|
+
...(shape?.next_action ? { next_action: shape.next_action } : {}),
|
|
579
|
+
...(shape?.candidates?.length ? { candidates: shape.candidates } : {}),
|
|
176
580
|
...(warning ? { ssr_warning: warning } : {}),
|
|
177
581
|
};
|
|
178
582
|
}
|
|
583
|
+
/** Событие `detected` — одно на `init`, `deploy` и `deploy --dry-run`. */
|
|
584
|
+
export function detectedEvent(d) {
|
|
585
|
+
return {
|
|
586
|
+
event: "detected",
|
|
587
|
+
framework: d.framework_hint,
|
|
588
|
+
build_cmd: d.build_cmd,
|
|
589
|
+
output_dir: d.output_dir,
|
|
590
|
+
confident: d.confident,
|
|
591
|
+
sources: d.sources,
|
|
592
|
+
...(d.runtime_kind ? { runtime_kind: d.runtime_kind } : {}),
|
|
593
|
+
...(d.hint ? { hint: d.hint } : {}),
|
|
594
|
+
...(d.next_action ? { next_action: d.next_action } : {}),
|
|
595
|
+
...(d.candidates?.length ? { candidates: d.candidates } : {}),
|
|
596
|
+
...(d.ssr_warning ? { ssr_warning: d.ssr_warning } : {}),
|
|
597
|
+
};
|
|
598
|
+
}
|
package/dist/exit-codes.js
CHANGED
package/dist/logs.js
CHANGED
|
@@ -11,6 +11,15 @@ function colorForStream(stream) {
|
|
|
11
11
|
return (s) => s;
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Строки npm о каждом скачанном пакете (`npm http fetch …`, `npm http cache …`).
|
|
16
|
+
*
|
|
17
|
+
* На проде это почти четверть строк логов сборки: 92 тыс. из последних
|
|
18
|
+
* 400 тыс. строк у 154 сборок (замер 18.09.2026). В `--json` каждая уходила
|
|
19
|
+
* отдельным событием `build_log`, и агент тонул в них, не видя ошибки. Полный
|
|
20
|
+
* лог по-прежнему у `layero logs`.
|
|
21
|
+
*/
|
|
22
|
+
const NPM_HTTP_RE = /^npm http (fetch|cache) /;
|
|
14
23
|
export async function streamDeployLogs(api, deployId) {
|
|
15
24
|
// In JSON mode stdout must stay a clean JSON-lines stream: build logs go
|
|
16
25
|
// out as structured `build_log` / `stage` events (N2), never as raw text
|
|
@@ -19,22 +28,42 @@ export async function streamDeployLogs(api, deployId) {
|
|
|
19
28
|
const json = detectMode().json;
|
|
20
29
|
let afterId = 0;
|
|
21
30
|
let lastStage = null;
|
|
31
|
+
let hiddenNoted = false;
|
|
32
|
+
const announce = (name) => {
|
|
33
|
+
if (name === lastStage)
|
|
34
|
+
return;
|
|
35
|
+
lastStage = name;
|
|
36
|
+
if (json) {
|
|
37
|
+
emit({ event: "stage", name });
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
process.stderr.write(chalk.bold.blue(`▶ stage: ${name}\n`));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
22
43
|
// Loop until terminal — the server's per-stage timeouts bound duration.
|
|
23
44
|
// eslint-disable-next-line no-constant-condition
|
|
24
45
|
while (true) {
|
|
25
46
|
const poll = await api.pollLogs(deployId, afterId);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
else {
|
|
32
|
-
process.stderr.write(chalk.bold.blue(`▶ stage: ${poll.current_stage}\n`));
|
|
33
|
-
}
|
|
34
|
-
}
|
|
47
|
+
// 🚨 Этап объявляем по СТРОКЕ, а не по текущему этапу деплоя. Опрос
|
|
48
|
+
// приносит пачку строк, где хвост прошлого этапа идёт вперемешку с
|
|
49
|
+
// началом нового, а `current_stage` — это уже новый: объявленный до
|
|
50
|
+
// пачки, он приписывал ему строки прошлого (симуляция 18.09.2026).
|
|
35
51
|
for (const line of poll.lines) {
|
|
36
52
|
afterId = Math.max(afterId, line.id);
|
|
53
|
+
if (line.stage)
|
|
54
|
+
announce(line.stage);
|
|
37
55
|
if (json) {
|
|
56
|
+
if (NPM_HTTP_RE.test(line.line)) {
|
|
57
|
+
if (!hiddenNoted) {
|
|
58
|
+
hiddenNoted = true;
|
|
59
|
+
emit({
|
|
60
|
+
event: "build_log",
|
|
61
|
+
line: `[layero] "npm http" download lines are hidden in --json; full log: npx layero@latest logs --deploy ${deployId}`,
|
|
62
|
+
stream: "meta",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
38
67
|
emit({ event: "build_log", line: line.line, stream: line.stream });
|
|
39
68
|
}
|
|
40
69
|
else {
|
|
@@ -42,6 +71,8 @@ export async function streamDeployLogs(api, deployId) {
|
|
|
42
71
|
process.stdout.write(paint(line.line) + "\n");
|
|
43
72
|
}
|
|
44
73
|
}
|
|
74
|
+
if (poll.current_stage)
|
|
75
|
+
announce(poll.current_stage);
|
|
45
76
|
if (poll.terminal) {
|
|
46
77
|
return poll;
|
|
47
78
|
}
|
package/package.json
CHANGED