layero 0.10.5 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- try {
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,523 @@ 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
+ // Без скрипта `start` сборщик приложений перебирает свой список точек
365
+ // входа (server.js, index.js, …) и `server.mjs` не находит — одного
366
+ // `-t node_web` мало, команду запуска надо назвать (кейс a3).
367
+ return {
368
+ hint: `This looks like a Node server (${start ? `start script \`${start}\`` : `${serverEntry} calls listen()`}), ` +
369
+ "but no known server framework was recognised. A server has to be run in a container, not served as files.",
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}"}`,
373
+ };
374
+ }
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
+ }
389
+ return {
390
+ hint: "A Python project, but no known web framework or entry file was recognised.",
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"})',
392
+ };
393
+ }
394
+ return {
395
+ hint: "Nothing to build or serve was recognised here: no index.html, no build script, no server, no app in a subfolder.",
396
+ next_action: "run the command from the app's folder (or pass --root <dir>); ready-made files: npx layero@latest deploy --prebuilt <dir>",
397
+ };
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
+ }
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
+ }
453
+ const LISTEN_RE = /\.listen\s*\(|createServer\s*\(/;
454
+ async function findServerEntry(cwd, pkg) {
455
+ const names = [...(typeof pkg?.main === "string" ? [pkg.main] : []), ...SERVER_ENTRY_CANDIDATES];
456
+ for (const n of names) {
457
+ const text = await readText(path.join(cwd, n));
458
+ if (text !== null && LISTEN_RE.test(text.slice(0, 64 * 1024)))
459
+ return n;
460
+ }
461
+ return null;
462
+ }
463
+ /** Первая папка с index.html, если в корне его нет, — по правилу сборщика
464
+ * (`discover_served_root`): мельчайшая, затем по алфавиту. */
465
+ async function servedSubdir(cwd, maxDepth = 3) {
466
+ let best = null;
467
+ async function walk(rel, depth) {
468
+ if (depth > maxDepth)
469
+ return;
470
+ let entries;
471
+ try {
472
+ entries = await fs.readdir(path.join(cwd, rel), { withFileTypes: true });
473
+ }
474
+ catch {
475
+ return;
476
+ }
477
+ if (depth > 0 && entries.some((e) => e.isFile() && e.name === "package.json"))
478
+ return;
479
+ if (depth > 0 && entries.some((e) => e.isFile() && e.name.toLowerCase() === "index.html")) {
480
+ if (!best || depth < best.depth || (depth === best.depth && rel.localeCompare(best.rel) < 0)) {
481
+ best = { rel, depth };
482
+ }
483
+ return;
484
+ }
485
+ for (const e of entries) {
486
+ if (e.isDirectory() && !SCAN_SKIP.has(e.name) && !e.name.startsWith(".")) {
487
+ await walk(rel ? `${rel}/${e.name}` : e.name, depth + 1);
488
+ }
489
+ }
490
+ }
491
+ await walk("", 0);
492
+ return best ? best.rel : null;
493
+ }
494
+ function str(v) {
495
+ return typeof v === "string" && v.trim() ? v.trim() : null;
496
+ }
497
+ const BUILD_SOURCE = {
498
+ detected: "package.json",
499
+ default: "framework default",
500
+ guess: "framework default",
501
+ unknown: "none",
502
+ };
503
+ const OUTPUT_SOURCE = {
504
+ detected: "framework config",
505
+ default: "framework default",
506
+ guess: "framework default",
507
+ unknown: "none",
508
+ };
135
509
  /**
136
510
  * Detect framework / build command / output directory from the project shape.
137
511
  * Identity now comes from the unified detect_core (the same spec the builder
138
- * and backend wizard use); this function only adapts the resulting BuildPlan
139
- * into the CLI's `Detected` shape and its conventions (SSR Next reports `.next`,
140
- * runtime apps report a `static` placeholder + runtime_kind).
512
+ * and backend wizard use); this function adapts the resulting BuildPlan into
513
+ * the CLI's `Detected` shape and says honestly how sure it is.
514
+ *
515
+ * 🚨 ЭТО ПОДСКАЗКА, А НЕ РЕШЕНИЕ. Решает сборщик: он видит архив целиком и на
516
+ * первой сборке применяет правила, которых в TS-ядре нет. До 18.09.2026 CLI
517
+ * отправлял свою догадку в настройки проекта, и она перебивала сборщик на
518
+ * каждой следующей сборке (`(from hint)`, `(from dashboard)` в логе у проекта,
519
+ * который никто не настраивал). Теперь `deploy` отправляет только то, что
520
+ * назвал человек, а этот результат — только показывает.
141
521
  */
142
- export async function detectProject(cwd) {
522
+ export async function detectProject(cwd, opts = {}) {
143
523
  const snap = await snapshotFromDir(cwd);
144
- const plan = dc.detect(snap);
524
+ const layero = snap.layeroJson;
525
+ const fileFramework = str(layero?.framework);
526
+ const outsideHint = fileFramework ? null : str(opts.frameworkHint);
527
+ const plan = dc.detect(snap, fileFramework ?? outsideHint);
528
+ const frameworkSource = fileFramework
529
+ ? "layero.json"
530
+ : outsideHint
531
+ ? (opts.hintSource ?? "--type")
532
+ : "detected";
145
533
  if (plan.runtimeKind !== null) {
534
+ const fromFile = layero !== null && (str(layero.runtime) !== null || plan.projectKind === "fullstack");
535
+ const sources = {
536
+ framework: (fromFile ? "layero.json" : frameworkSource),
537
+ build_cmd: "none",
538
+ output_dir: "none",
539
+ };
146
540
  if (plan.projectType === "ssr_next") {
147
541
  // SSR Next builds in the runtime-builder; surface the framework + the
148
542
  // `.next` dir the CLI has always reported (vestigial but expected).
149
543
  const [unit] = dc.detectFramework(snap, "nextjs");
544
+ const script = snap.packageJson?.scripts?.build;
150
545
  return {
151
546
  framework_hint: "nextjs",
152
547
  build_cmd: unit.buildCmd ?? "npx next build",
153
548
  output_dir: ".next",
154
549
  confident: true,
155
550
  runtime_kind: "ssr_next",
551
+ sources: { ...sources, build_cmd: script ? "package.json" : "framework default", output_dir: "framework default" },
156
552
  };
157
553
  }
158
- // node_web / python_web / streamlit / gradio → static placeholder; the
159
- // container serves the app, the SPA pipeline is a no-op.
554
+ // node_web / python_web / streamlit / gradio / fullstack: the container
555
+ // serves the app, nothing is uploaded as static files from this plan.
160
556
  return {
161
- framework_hint: "static",
162
- build_cmd: "true",
163
- output_dir: ".",
557
+ framework_hint: plan.framework,
558
+ build_cmd: null,
559
+ output_dir: null,
164
560
  confident: true,
165
561
  runtime_kind: plan.projectType,
562
+ sources,
563
+ ...(plan.projectKind === "fullstack"
564
+ ? { hint: "Full-stack layout from layero.json: the frontend is served as files, the backend runs in a container." }
565
+ : {}),
166
566
  };
167
567
  }
168
568
  const framework = plan.framework;
169
- const build_cmd = plan.buildCmd ?? (framework === "static" ? "true" : "npm run build");
569
+ const fileBuild = str(layero?.build);
570
+ const fileOutput = str(layero?.output);
571
+ let buildCmd = plan.buildCmd;
572
+ let buildSource = BUILD_SOURCE[plan.fieldSources.build_cmd ?? "unknown"] ?? "none";
573
+ let outputDir = plan.outputDir;
574
+ let outputSource = OUTPUT_SOURCE[plan.fieldSources.output_dir ?? "unknown"] ?? "none";
575
+ // Фреймворк назван человеком (файл, `--type`, настройки) — это не догадка.
576
+ const declared = Boolean(fileFramework || outsideHint);
577
+ let confident = declared || plan.confident;
578
+ let shape = null;
579
+ const notes = [];
580
+ let flagNextAction;
581
+ if (framework === "static") {
582
+ // Статика не собирается НИКОГДА: команда сборки у неё не «true», а
583
+ // отсутствует. Заглушка `true` читалась как команда и уезжала в проект.
584
+ buildCmd = null;
585
+ buildSource = "none";
586
+ outputDir = ".";
587
+ outputSource = "detected";
588
+ const pkg = snap.packageJson;
589
+ const buildScript = scriptOf(pkg, "build");
590
+ if (await rootHasIndexHtml(cwd)) {
591
+ confident = true;
592
+ if (buildScript && !declared) {
593
+ notes.push(`package.json has a build script (\`${buildScript}\`), but a static site is served as it is and the script is not run. ` +
594
+ 'If it must run: layero.json {"framework":"generic","buildCommand":"npm run build","outputDirectory":"<folder>"}.');
595
+ }
596
+ }
597
+ else {
598
+ const sub = buildScript || fileOutput ? null : await servedSubdir(cwd);
599
+ if (sub) {
600
+ confident = true;
601
+ outputDir = sub;
602
+ notes.push(`index.html is not at the root: the platform serves ${sub}/.`);
603
+ }
604
+ else if (declared) {
605
+ if (!fileOutput) {
606
+ notes.push("There is no index.html at the root of this folder, and a static site is served as it is: " +
607
+ "point outputDirectory at the folder with index.html, or deploy ready-made files with --prebuilt <dir>.");
608
+ }
609
+ }
610
+ else {
611
+ confident = false;
612
+ shape = await shapeOf(cwd, snap, plan);
613
+ }
614
+ }
615
+ if (fileBuild) {
616
+ notes.push('layero.json sets buildCommand, but the framework is "static", which never builds: the command will NOT run. ' +
617
+ 'Set "framework" in layero.json (for a custom build script: "generic").');
618
+ }
619
+ }
620
+ else {
621
+ if (fileBuild) {
622
+ buildCmd = fileBuild;
623
+ buildSource = "layero.json";
624
+ }
625
+ if (framework === "generic" && !declared) {
626
+ confident = false;
627
+ shape = await shapeOf(cwd, snap, plan);
628
+ }
629
+ const flagDir = fileOutput ? null : outDirFlag(snap.packageJson);
630
+ if (flagDir && flagDir !== outputDir) {
631
+ // План платформы положит результат не туда: она не читает флаги скрипта.
632
+ confident = false;
633
+ notes.push(`The build script sets the output folder with a flag (\`${flagDir}\`), which detection does not read: ` +
634
+ `without outputDirectory the platform looks in "${outputDir ?? "dist"}" and may serve the source index.html instead of the build.`);
635
+ flagNextAction = snap.layeroJson
636
+ ? `add "outputDirectory": "${flagDir}" to layero.json`
637
+ : `create layero.json: {"outputDirectory":"${flagDir}"}`;
638
+ }
639
+ if (framework === "generic" && buildCmd === null) {
640
+ // Собирать нечем — и каталог результата у такой папки не «dist по
641
+ // умолчанию», а неизвестен: умолчание фреймворка «Other» здесь вымысел.
642
+ outputDir = null;
643
+ outputSource = "none";
644
+ }
645
+ }
646
+ if (fileOutput) {
647
+ outputDir = fileOutput;
648
+ outputSource = "layero.json";
649
+ }
170
650
  const warning = ssrWarning(snap, framework);
651
+ const hint = [shape?.hint, ...notes].filter(Boolean).join(" ");
171
652
  return {
172
653
  framework_hint: framework,
173
- build_cmd,
174
- output_dir: plan.outputDir ?? ".",
175
- confident: plan.confident,
654
+ build_cmd: buildCmd,
655
+ output_dir: outputDir,
656
+ confident,
657
+ sources: { framework: frameworkSource, build_cmd: buildSource, output_dir: outputSource },
658
+ ...(hint ? { hint } : {}),
659
+ ...(shape?.next_action ?? flagNextAction ? { next_action: shape?.next_action ?? flagNextAction } : {}),
660
+ ...(shape?.candidates?.length ? { candidates: shape.candidates } : {}),
176
661
  ...(warning ? { ssr_warning: warning } : {}),
177
662
  };
178
663
  }
664
+ /** Событие `detected` — одно на `init`, `deploy` и `deploy --dry-run`. */
665
+ export function detectedEvent(d) {
666
+ return {
667
+ event: "detected",
668
+ framework: d.framework_hint,
669
+ build_cmd: d.build_cmd,
670
+ output_dir: d.output_dir,
671
+ confident: d.confident,
672
+ sources: d.sources,
673
+ ...(d.runtime_kind ? { runtime_kind: d.runtime_kind } : {}),
674
+ ...(d.hint ? { hint: d.hint } : {}),
675
+ ...(d.next_action ? { next_action: d.next_action } : {}),
676
+ ...(d.candidates?.length ? { candidates: d.candidates } : {}),
677
+ ...(d.ssr_warning ? { ssr_warning: d.ssr_warning } : {}),
678
+ };
679
+ }