layero 0.8.0 → 0.8.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/README.md CHANGED
@@ -176,10 +176,16 @@ Event types emitted on stdout:
176
176
  {"event":"deploy_started","deploy_id":"…"}
177
177
  {"event":"build_log","line":"…","stream":"…"}
178
178
  {"event":"stage","name":"…"}
179
- {"event":"ready","url":"…","deploy_id":"…"}
179
+ {"event":"ready","url":"…","preview_url":"…","dashboard_url":"…","edge_ready":false,"edge_eta_seconds":N,"deploy_id":"…"}
180
+ {"event":"promoted","url":"…","deploy_id":"…"}
180
181
  {"event":"error","code":"…","next_action":"…","message":"…"}
181
182
  ```
182
183
 
184
+ On `ready`, `url` is the **live public site** (the apex — CLI uploads
185
+ auto-promote to it), `preview_url` is reachable immediately while the apex CDN
186
+ edge warms (`edge_ready=false`), and `dashboard_url` is the management page (not
187
+ the site). Not logged in? `deploy` starts the device-flow itself (`auth_required`).
188
+
183
189
  Errors carry a stable `code` (e.g. `not_logged_in`, `invalid_type`,
184
190
  `project_not_found`, `cli_deploys_disabled`) and a `next_action` hint so
185
191
  your agent can react without parsing prose.
@@ -424,10 +424,20 @@ export async function deployCmd(opts) {
424
424
  const dashboardUrl = projectUrl(cliCfg.apiUrl, project.id);
425
425
  const apexUrl = `https://${project.apex_hostname}`;
426
426
  const probe = await resolveReachability(api, deploy.environment_id);
427
- // Public site URL: prefer the backend's canonical_url; fall back to the
428
- // apex for the no-branch case (CLI auto-promote), else the dashboard.
429
- const liveUrl = probe?.canonical_url ??
430
- (promoted || !opts.branch ? apexUrl : dashboardUrl);
427
+ // Public site URL honour the preview-first contract the dashboard uses
428
+ // (probe states B/C): until the CDN apex is verified live (`cdn_ready`),
429
+ // the reachable address is the off-CDN preview domain. A fresh apex
430
+ // 404/000s for ~10-15min while CDN propagates, and for runtime apps the
431
+ // preview domain is the *permanent* address (the CDN apex can't serve
432
+ // POST/WebSocket). So only surface the canonical apex once it's warm;
433
+ // before that, hand back the preview. `edge_ready` / `edge_eta_seconds`
434
+ // below tell the caller the apex is on its way.
435
+ const cdnWarm = probe?.cdn_ready === true;
436
+ const liveUrl = cdnWarm
437
+ ? probe?.canonical_url ?? apexUrl
438
+ : probe?.preview_url ??
439
+ probe?.canonical_url ??
440
+ (promoted || !opts.branch ? apexUrl : dashboardUrl);
431
441
  emit({
432
442
  event: "ready",
433
443
  url: liveUrl,
package/dist/detect.js CHANGED
@@ -1,542 +1,178 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
- // package.json deps marking a Node web backend → node_web. Lockstep with
4
- // builder/src/runtime_detect.py NODE_WEB_SIGNALS / NODE_FRONTEND_DEPS.
5
- const NODE_WEB_SIGNALS = [
6
- "express", "fastify", "koa", "@nestjs/core", "@hapi/hapi", "hapi",
7
- "hono", "@adonisjs/core", "restify", "polka", "@feathersjs/feathers",
8
- "sails", "h3", "json-server",
9
- ];
10
- const NODE_FRONTEND_DEPS = [
11
- "next", "nuxt", "vite", "react-scripts", "@angular/core", "@sveltejs/kit",
12
- "gatsby", "astro", "@docusaurus/core", "@11ty/eleventy", "vitepress",
13
- ];
14
- // A Node backend: a server framework AND no frontend/SSG framework (which
15
- // would build to static and belong to the SPA pipeline instead).
16
- function detectNodeRuntime(pkg) {
17
- return (NODE_WEB_SIGNALS.some((d) => hasDep(pkg, d)) &&
18
- !NODE_FRONTEND_DEPS.some((d) => hasDep(pkg, d)));
19
- }
20
- async function readPkg(cwd) {
3
+ import * as dc from "layero-detection";
4
+ async function readJson(p) {
21
5
  try {
22
- const raw = await fs.readFile(path.join(cwd, "package.json"), "utf-8");
23
- return JSON.parse(raw);
6
+ return JSON.parse(await fs.readFile(p, "utf-8"));
24
7
  }
25
- catch (err) {
26
- if (err?.code === "ENOENT")
27
- return null;
28
- throw err;
8
+ catch {
9
+ return null;
29
10
  }
30
11
  }
31
- function hasDep(pkg, name) {
32
- return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]);
12
+ async function readText(p) {
13
+ try {
14
+ return await fs.readFile(p, "utf-8");
15
+ }
16
+ catch {
17
+ return null;
18
+ }
33
19
  }
34
- async function fileExists(cwd, ...candidates) {
35
- for (const c of candidates) {
36
- try {
37
- await fs.access(path.join(cwd, c));
20
+ const HTML_SKIP_DIRS = new Set(["node_modules", ".git", ".cache", "dist", "build", "out", "public"]);
21
+ async function dirHasHtml(base, depth = 0) {
22
+ if (depth > 6)
23
+ return false;
24
+ let entries;
25
+ try {
26
+ entries = await fs.readdir(base, { withFileTypes: true });
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ for (const e of entries) {
32
+ if (e.isFile() && (e.name.endsWith(".html") || e.name.endsWith(".htm")))
38
33
  return true;
39
- }
40
- catch {
41
- // try next
34
+ }
35
+ for (const e of entries) {
36
+ if (e.isDirectory() && !HTML_SKIP_DIRS.has(e.name)) {
37
+ if (await dirHasHtml(path.join(base, e.name), depth + 1))
38
+ return true;
42
39
  }
43
40
  }
44
41
  return false;
45
42
  }
46
- // Python runtime: `app.py`/`main.py` entry + a runtime lib in requirements.txt.
47
- // Lockstep with detect_runtime() (backend) / runtime_detect.py (builder):
48
- // Streamlit/Gradio keep their self-contained runtimes; Flask/FastAPI and any
49
- // WSGI|ASGI server route to the generic python_web runtime.
50
- // Lockstep with builder/src/runtime_detect.py PY_WEB_SIGNALS + framework_detector.py.
51
- const PY_WEB_SIGNALS = [
52
- "fastapi", "starlette", "litestar", "starlite", "quart", "sanic",
53
- "blacksheep", "hypercorn", "daphne", "uvicorn",
54
- "flask", "falcon", "bottle", "pyramid", "cherrypy", "werkzeug", "gunicorn",
55
- "aiohttp", "tornado", "django",
56
- ];
57
- async function detectPythonRuntime(cwd) {
58
- const appPy = await fileExists(cwd, "app.py");
59
- const mainPy = await fileExists(cwd, "main.py");
60
- // Django's entrypoint is manage.py (no app.py/main.py at the root).
61
- const managePy = await fileExists(cwd, "manage.py");
62
- if (!appPy && !mainPy && !managePy)
63
- return null;
64
- let reqs;
43
+ /** Build a detect_core Snapshot from the local filesystem (the CLI's disk
44
+ * fidelity the analogue of detect_core.py's snapshot_from_dir). */
45
+ async function snapshotFromDir(cwd) {
46
+ const files = new Set();
47
+ const dirs = new Set();
65
48
  try {
66
- reqs = (await fs.readFile(path.join(cwd, "requirements.txt"), "utf-8")).toLowerCase();
49
+ for (const e of await fs.readdir(cwd, { withFileTypes: true })) {
50
+ (e.isDirectory() ? dirs : files).add(e.name);
51
+ }
67
52
  }
68
53
  catch {
69
- return null;
54
+ /* empty / unreadable cwd */
70
55
  }
71
- if (reqs.includes("streamlit") && appPy)
72
- return "streamlit";
73
- if (reqs.includes("gradio") && appPy)
74
- return "gradio";
75
- if (PY_WEB_SIGNALS.some((s) => reqs.includes(s)))
76
- return "python_web";
77
- return null;
78
- }
79
- // Mirrors `frameworks/nextjs.py:_NEXT_STATIC_EXPORT_RE` and
80
- // `next_config_static_export()` so CLI and builder can't disagree on
81
- // what counts as a static-export Next.js project. The class-of-bug we
82
- // hit on 2026-05-22 (builder detector v72 fix) and 2026-05-26 (CLI side)
83
- // was exactly this kind of dual detector drift.
84
- const NEXT_STATIC_EXPORT_RE = /output\s*:\s*['"]export['"]/m;
85
- // Strip JS line and block comments before regex matching. Without this,
86
- // a comment like `// see output: 'export'` falsely matches as
87
- // static-export — observed 2026-05-26 on the SSR smoke canary fixture.
88
- // Naive (does not understand strings that contain comment-like tokens),
89
- // but real next.config files don't have that shape.
90
- const JS_COMMENT_RE = /\/\/[^\n]*|\/\*[\s\S]*?\*\//gm;
91
- const stripJsComments = (text) => text.replace(JS_COMMENT_RE, "");
92
- async function readNextConfigText(cwd) {
93
- for (const name of ["next.config.mjs", "next.config.ts", "next.config.js", "next.config.cjs"]) {
56
+ for (const nested of [
57
+ ".vitepress/config.ts", ".vitepress/config.js", ".vitepress/config.mts", ".vitepress/config.mjs",
58
+ "docs/.vitepress/config.ts", "docs/.vitepress/config.js", "docs/.vitepress/config.mts", "docs/.vitepress/config.mjs",
59
+ ]) {
94
60
  try {
95
- return await fs.readFile(path.join(cwd, name), "utf-8");
96
- }
97
- catch (err) {
98
- if (err?.code === "ENOENT")
99
- continue;
100
- return null;
61
+ await fs.access(path.join(cwd, nested));
62
+ files.add(nested);
101
63
  }
102
- }
103
- return null;
64
+ catch {
65
+ /* absent */
66
+ }
67
+ }
68
+ const packageJson = (await readJson(path.join(cwd, "package.json")));
69
+ const requirementsTxt = await readText(path.join(cwd, "requirements.txt"));
70
+ const layeroJson = await readJson(path.join(cwd, "layero.json"));
71
+ const configTexts = {};
72
+ const candidates = new Set(dc.CONFIG_TEXT_FILES);
73
+ for (const bn of dc.CONFIG_TEXT_BASENAMES)
74
+ for (const ext of dc.CONFIG_EXTENSIONS)
75
+ candidates.add(bn + ext);
76
+ // nuxt.config / svelte.config drive the SSR warning below.
77
+ for (const n of ["nuxt.config.mjs", "nuxt.config.ts", "nuxt.config.js", "svelte.config.js", "svelte.config.ts"])
78
+ candidates.add(n);
79
+ for (const name of candidates) {
80
+ const t = await readText(path.join(cwd, name));
81
+ if (t !== null)
82
+ configTexts[name] = t.slice(0, 64 * 1024);
83
+ }
84
+ return dc.snapshotFromInputs({
85
+ packageJson,
86
+ requirementsTxt,
87
+ files,
88
+ dirs,
89
+ configTexts,
90
+ layeroJson: layeroJson && typeof layeroJson === "object" ? layeroJson : null,
91
+ hasHtml: await dirHasHtml(cwd),
92
+ });
104
93
  }
105
- // True -> config has `output: 'export'` (static-export SPA).
106
- // False -> config exists but no export marker (SSR Next.js).
107
- // null -> no next.config file at all; caller treats as static-default.
108
- async function nextConfigStaticExport(cwd) {
109
- const text = await readNextConfigText(cwd);
110
- if (text === null)
111
- return null;
112
- return NEXT_STATIC_EXPORT_RE.test(stripJsComments(text));
94
+ const NUXT_STATIC_SSR_RE = /\bssr\s*:\s*false\b/m;
95
+ const NUXT_STATIC_PRESET_RE = /preset\s*:\s*['"]static['"]/m;
96
+ const JS_COMMENT_RE = /\/\/[^\n]*|\/\*[\s\S]*?\*\//gm;
97
+ function hasDep(pkg, name) {
98
+ return Boolean(pkg && ((pkg.dependencies ?? {})[name] ?? (pkg.devDependencies ?? {})[name]));
113
99
  }
114
- async function hasAnyHtml(cwd) {
115
- // Shallow check only top-level. Recursive walk is too expensive here
116
- // and the static fallback is already permissive enough.
117
- try {
118
- const entries = await fs.readdir(cwd);
119
- return entries.some((e) => e.endsWith(".html") || e.endsWith(".htm"));
100
+ /** Mirror of the builder's static-host pre-flight warning for the two
101
+ * frameworks that build a server by default (Nuxt, SvelteKit). */
102
+ function ssrWarning(snap, framework) {
103
+ const pkg = snap.packageJson;
104
+ if (framework === "nuxt") {
105
+ const scripts = pkg?.scripts ?? {};
106
+ const hasGenerate = Object.values(scripts).some((v) => typeof v === "string" && v.includes("nuxt generate"));
107
+ let declaresStatic = false;
108
+ for (const n of ["nuxt.config.mjs", "nuxt.config.ts", "nuxt.config.js"]) {
109
+ const txt = snap.configTexts[n];
110
+ if (txt !== undefined) {
111
+ const stripped = txt.replace(JS_COMMENT_RE, "");
112
+ declaresStatic = NUXT_STATIC_SSR_RE.test(stripped) || NUXT_STATIC_PRESET_RE.test(stripped);
113
+ break;
114
+ }
115
+ }
116
+ if (!hasGenerate && !declaresStatic) {
117
+ return ("Nuxt без `nuxt generate` или `ssr: false` собирается как SSR-сервер; " +
118
+ "Layero хостит только статику Nuxt. Добавьте `\"generate\": \"nuxt generate\"` " +
119
+ "в scripts или поставьте `ssr: false` в nuxt.config.");
120
+ }
120
121
  }
121
- catch {
122
- return false;
122
+ if (framework === "sveltekit") {
123
+ const hasStatic = hasDep(pkg, "@sveltejs/adapter-static");
124
+ if (hasStatic)
125
+ return undefined;
126
+ const allDeps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
127
+ const serverAdapter = Object.keys(allDeps).find((d) => d.startsWith("@sveltejs/adapter-") && d !== "@sveltejs/adapter-static");
128
+ if (serverAdapter) {
129
+ return `SvelteKit использует ${serverAdapter} (серверный адаптер). Layero хостит только статику — поставьте @sveltejs/adapter-static.`;
130
+ }
131
+ return "SvelteKit без явного адаптера — поставьте @sveltejs/adapter-static для статического хостинга.";
123
132
  }
124
- }
125
- // Build command preference:
126
- // 1. If `scripts.build` exists in package.json, use `npm run build` —
127
- // it respects whatever the user set up (custom flags, monorepo
128
- // filters, etc.). `npm run` works regardless of package manager
129
- // installed on the build VM, because the builder runs `npm` itself.
130
- // 2. Otherwise, use the framework's CLI directly via `npx`.
131
- function buildCmd(pkg, fallback) {
132
- if (pkg?.scripts?.build)
133
- return "npm run build";
134
- return fallback;
133
+ return undefined;
135
134
  }
136
135
  /**
137
- * Detect framework / build command / output directory from the project
138
- * shape. Mirrors the builder's detector ([core/builder/src/frameworks])
139
- * so the values we ship to `completeSetup` match what the builder will
140
- * actually do at build time.
141
- *
142
- * Order matters: more specific signals first (Next, Nuxt, SvelteKit,
143
- * Gatsby, Astro, Docusaurus) before catch-alls (Vite, CRA, static).
136
+ * Detect framework / build command / output directory from the project shape.
137
+ * 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).
144
141
  */
145
142
  export async function detectProject(cwd) {
146
- const pkg = await readPkg(cwd);
147
- if (pkg) {
148
- if (hasDep(pkg, "next") || (await fileExists(cwd, "next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"))) {
149
- // A Next.js repo is SSR unless next.config explicitly declares
150
- // `output: 'export'`. We only flag ssr_next when the config file
151
- // exists AND lacks the export marker same rule the builder uses
152
- // in `runtime_detect.py:46`. Without a next.config file at all we
153
- // err on the side of static (legacy `next export` workflow).
154
- const exportFlag = await nextConfigStaticExport(cwd);
155
- const isSsr = exportFlag === false;
143
+ const snap = await snapshotFromDir(cwd);
144
+ const plan = dc.detect(snap);
145
+ if (plan.runtimeKind !== null) {
146
+ if (plan.projectType === "ssr_next") {
147
+ // SSR Next builds in the runtime-builder; surface the framework + the
148
+ // `.next` dir the CLI has always reported (vestigial but expected).
149
+ const [unit] = dc.detectFramework(snap, "nextjs");
156
150
  return {
157
151
  framework_hint: "nextjs",
158
- build_cmd: buildCmd(pkg, "npx next build"),
159
- output_dir: isSsr ? ".next" : "out",
160
- confident: true,
161
- ...(isSsr ? { runtime_kind: "ssr_next" } : {}),
162
- };
163
- }
164
- // Node web backend (Express/Fastify/Koa/NestJS/Hapi/Hono/…) → node_web.
165
- // Build (TypeScript compile) runs inside the container image, so the SPA
166
- // pipeline is a no-op here.
167
- if (detectNodeRuntime(pkg)) {
168
- return {
169
- framework_hint: "static",
170
- build_cmd: "true",
171
- output_dir: ".",
172
- confident: true,
173
- runtime_kind: "node_web",
174
- };
175
- }
176
- if (hasDep(pkg, "nuxt") || hasDep(pkg, "nuxt3") || (await fileExists(cwd, "nuxt.config.ts", "nuxt.config.js", "nuxt.config.mjs"))) {
177
- const generateScript = pkg.scripts?.generate;
178
- const buildScript = pkg.scripts?.build;
179
- const cmd = generateScript
180
- ? "npm run generate"
181
- : buildScript
182
- ? "npm run build"
183
- : "npx nuxt generate";
184
- // Nuxt defaults to SSR. Layero hosts only static for Nuxt today,
185
- // so a project without an explicit static signal (`nuxt generate`
186
- // script, or `ssr: false` / `nitro.preset='static'` in config)
187
- // will build but the resulting `.output/server/` is useless to us.
188
- // Mirrors builder's `frameworks/nuxt.py` SSR hint.
189
- const nuxtStatic = !!generateScript || (await nuxtConfigDeclaresStatic(cwd));
190
- return {
191
- framework_hint: "nuxt",
192
- build_cmd: cmd,
193
- output_dir: ".output/public",
194
- confident: true,
195
- ...(nuxtStatic ? {} : {
196
- ssr_warning: "Nuxt без `nuxt generate` или `ssr: false` собирается как SSR-сервер; " +
197
- "Layero хостит только статику Nuxt. Добавьте `\"generate\": \"nuxt generate\"` " +
198
- "в scripts или поставьте `ssr: false` в nuxt.config.",
199
- }),
200
- };
201
- }
202
- // Remix / React Router v7 before SvelteKit/Vite — RR7 ships Vite
203
- // internally, so the Vite-dep check would otherwise win. Mirrors the
204
- // builder's RemixFramework + backend `remix` table entry (output
205
- // build/client). Was missing from the CLI entirely — same dual-detector
206
- // gap class as Angular: a Remix repo deployed via the CLI fell through
207
- // to the static fallback and shipped raw sources.
208
- if (hasDep(pkg, "@remix-run/dev") ||
209
- hasDep(pkg, "@remix-run/react") ||
210
- hasDep(pkg, "@remix-run/node") ||
211
- hasDep(pkg, "@react-router/dev") ||
212
- hasDep(pkg, "@react-router/node") ||
213
- (await fileExists(cwd, "react-router.config.ts", "react-router.config.js"))) {
214
- return {
215
- framework_hint: "remix",
216
- build_cmd: buildCmd(pkg, "npx react-router build"),
217
- output_dir: "build/client",
152
+ build_cmd: unit.buildCmd ?? "npx next build",
153
+ output_dir: ".next",
218
154
  confident: true,
155
+ runtime_kind: "ssr_next",
219
156
  };
220
157
  }
221
- if (hasDep(pkg, "@sveltejs/kit") || (await fileExists(cwd, "svelte.config.js"))) {
222
- // SvelteKit needs an adapter. adapter-static = SPA path; anything
223
- // else (adapter-node, adapter-auto, …) is server-side. Mirrors
224
- // builder's `frameworks/svelte.py:validate_static`.
225
- const hasStaticAdapter = hasDep(pkg, "@sveltejs/adapter-static");
226
- const serverAdapter = !hasStaticAdapter
227
- ? Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) })
228
- .find((d) => d.startsWith("@sveltejs/adapter-") && d !== "@sveltejs/adapter-static")
229
- : undefined;
230
- let ssrWarning;
231
- if (serverAdapter) {
232
- ssrWarning =
233
- `SvelteKit использует ${serverAdapter} (серверный адаптер). ` +
234
- "Layero хостит только статику — поставьте `@sveltejs/adapter-static`.";
235
- }
236
- else if (!hasStaticAdapter) {
237
- ssrWarning =
238
- "SvelteKit без явного адаптера — поставьте `@sveltejs/adapter-static` " +
239
- "для статического хостинга на Layero.";
240
- }
241
- return {
242
- framework_hint: "sveltekit",
243
- build_cmd: buildCmd(pkg, "npx vite build"),
244
- output_dir: "build",
245
- confident: true,
246
- ...(ssrWarning ? { ssr_warning: ssrWarning } : {}),
247
- };
248
- }
249
- if (hasDep(pkg, "gatsby") || (await fileExists(cwd, "gatsby-config.js", "gatsby-config.ts"))) {
250
- return {
251
- framework_hint: "gatsby",
252
- build_cmd: buildCmd(pkg, "npx gatsby build"),
253
- output_dir: "public",
254
- confident: true,
255
- };
256
- }
257
- if (hasDep(pkg, "astro") || (await fileExists(cwd, "astro.config.mjs", "astro.config.ts", "astro.config.js"))) {
258
- return {
259
- framework_hint: "astro",
260
- build_cmd: buildCmd(pkg, "npx astro build"),
261
- output_dir: "dist",
262
- confident: true,
263
- };
264
- }
265
- if (hasDep(pkg, "@docusaurus/core") || (await fileExists(cwd, "docusaurus.config.js", "docusaurus.config.ts", "docusaurus.config.mjs"))) {
266
- return {
267
- framework_hint: "docusaurus",
268
- build_cmd: buildCmd(pkg, "npx docusaurus build"),
269
- output_dir: "build",
270
- confident: true,
271
- };
272
- }
273
- // Storybook before Vite/CRA: Storybook 7+ uses Vite or webpack
274
- // internally, so `vite` / `react-scripts` is in deps. Without
275
- // listing it first the SPA Vite path would output_dir=dist, which
276
- // doesn't exist after `build-storybook`.
277
- const storybookDeps = [
278
- "@storybook/cli",
279
- "@storybook/react",
280
- "@storybook/react-vite",
281
- "@storybook/react-webpack5",
282
- "@storybook/vue",
283
- "@storybook/vue3",
284
- "@storybook/vue3-vite",
285
- "@storybook/svelte",
286
- "@storybook/svelte-vite",
287
- "@storybook/web-components",
288
- "@storybook/web-components-vite",
289
- "@storybook/preact",
290
- "@storybook/angular",
291
- "@storybook/nextjs",
292
- "@storybook/html",
293
- "@storybook/html-vite",
294
- "storybook",
295
- ];
296
- const hasStorybookDep = storybookDeps.some((d) => hasDep(pkg, d));
297
- const hasStorybookScript = pkg.scripts?.["build-storybook"] !== undefined
298
- || Object.values(pkg.scripts ?? {}).some((s) => typeof s === "string" && s.includes("storybook build"));
299
- const hasStorybookDir = await fileExists(cwd, ".storybook/main.js", ".storybook/main.ts", ".storybook/main.cjs", ".storybook/main.mjs");
300
- if (hasStorybookDep || hasStorybookScript || hasStorybookDir) {
301
- const cmd = pkg.scripts?.["build-storybook"]
302
- ? "npm run build-storybook"
303
- : (pkg.scripts?.build && pkg.scripts.build.includes("storybook"))
304
- ? "npm run build"
305
- : "npx storybook build";
306
- return {
307
- framework_hint: "storybook",
308
- build_cmd: cmd,
309
- output_dir: "storybook-static",
310
- confident: true,
311
- };
312
- }
313
- // VitePress before generic Vite: VitePress repos often pull `vite`
314
- // transitively, and the SPA Vite path would set output_dir=dist —
315
- // wrong for VitePress, which writes to `.vitepress/dist/` (or
316
- // `docs/.vitepress/dist/` when the config lives under docs/).
317
- if (hasDep(pkg, "vitepress") || (await hasVitepressConfig(cwd))) {
318
- const docsLayout = await hasVitepressConfig(cwd, "docs/.vitepress");
319
- const outputDir = docsLayout ? "docs/.vitepress/dist" : ".vitepress/dist";
320
- const cmd = pkg.scripts?.["docs:build"]
321
- ? "npm run docs:build"
322
- : buildCmd(pkg, "npx vitepress build");
323
- return {
324
- framework_hint: "vitepress",
325
- build_cmd: cmd,
326
- output_dir: outputDir,
327
- confident: true,
328
- };
329
- }
330
- if (hasDep(pkg, "vite") || (await fileExists(cwd, "vite.config.ts", "vite.config.js", "vite.config.mjs"))) {
331
- return {
332
- framework_hint: "vite",
333
- build_cmd: buildCmd(pkg, "npx vite build"),
334
- output_dir: "dist",
335
- confident: true,
336
- };
337
- }
338
- // Angular after Vite — mirrors builder ALL order. Angular ships its
339
- // own CLI (`ng build`) and writes to `dist/{project}/` (Angular <17)
340
- // or `dist/{project}/browser/` (17+ `application` builder), NOT bare
341
- // `dist`. Until this branch existed the CLI fell through to the
342
- // static fallback (`output_dir='.'`), so the first deploy of an
343
- // Angular repo shipped the raw sources — the dist/<project> S3 404
344
- // incident. `angularOutputDir` parses angular.json to pin the path
345
- // (lockstep with `frameworks/angular.py:extract_output_dir`).
346
- if (hasDep(pkg, "@angular/core") || (await fileExists(cwd, "angular.json"))) {
347
- return {
348
- framework_hint: "angular",
349
- build_cmd: buildCmd(pkg, "npx ng build"),
350
- output_dir: await angularOutputDir(cwd),
351
- confident: true,
352
- };
353
- }
354
- if (hasDep(pkg, "react-scripts")) {
355
- return {
356
- framework_hint: "cra",
357
- build_cmd: buildCmd(pkg, "npx react-scripts build"),
358
- output_dir: "build",
359
- confident: true,
360
- };
361
- }
362
- // Eleventy late in the package.json branch — `@11ty/eleventy` is
363
- // unique enough not to collide with other frameworks, but Vite /
364
- // CRA / etc. should win if both are present (a project that pulls
365
- // both is probably using Vite as the runtime and 11ty just for one
366
- // build step).
367
- if (hasDep(pkg, "@11ty/eleventy") ||
368
- hasDep(pkg, "eleventy") ||
369
- (await fileExists(cwd, ".eleventy.js", "eleventy.config.js", "eleventy.config.mjs", "eleventy.config.cjs"))) {
370
- return {
371
- framework_hint: "eleventy",
372
- build_cmd: buildCmd(pkg, "npx @11ty/eleventy"),
373
- output_dir: "_site",
374
- confident: true,
375
- };
376
- }
377
- // package.json present but no framework matched → a custom Node build,
378
- // NOT a static site. Mirrors the backend's detect(): `pkg is None →
379
- // _STATIC`, otherwise `_GENERIC` (npm run build → dist). Falling through
380
- // to the static fallback here shipped the unbuilt sources — exactly the
381
- // `diplomtest` case (Express app + custom build script detected as static
382
- // by the CLI but `generic` by the backend).
383
- return {
384
- framework_hint: "generic",
385
- build_cmd: buildCmd(pkg, "npm run build"),
386
- output_dir: "dist",
387
- confident: false,
388
- };
389
- }
390
- // Python runtimes (Streamlit / Gradio / Flask): an `app.py` entry plus a
391
- // runtime lib in requirements.txt. Mirrors detect_runtime() / runtime_detect.py.
392
- // No package.json, so without this they fall to the static fallback and ship
393
- // `app.py` as a static file (never runs) — the `streamlit-hello` case.
394
- const pyRuntime = await detectPythonRuntime(cwd);
395
- if (pyRuntime) {
158
+ // node_web / python_web / streamlit / gradio → static placeholder; the
159
+ // container serves the app, the SPA pipeline is a no-op.
396
160
  return {
397
161
  framework_hint: "static",
398
162
  build_cmd: "true",
399
163
  output_dir: ".",
400
164
  confident: true,
401
- runtime_kind: pyRuntime,
402
- };
403
- }
404
- // Non-Node SSGs (Hugo today). Recognise repos without package.json
405
- // before we fall back to "static" with a no-op build command — Hugo
406
- // needs `hugo --gc --minify` and writes to `public/`, the static
407
- // fallback would just upload the raw .md sources.
408
- if ((await fileExists(cwd, "hugo.toml", "hugo.yaml", "hugo.json")) ||
409
- ((await fileExists(cwd, "config.toml", "config.yaml", "config.json")) &&
410
- (await hasHugoConfigMarker(cwd)))) {
411
- return {
412
- framework_hint: "hugo",
413
- build_cmd: "hugo --gc --minify",
414
- output_dir: "public",
415
- confident: true,
165
+ runtime_kind: plan.projectType,
416
166
  };
417
167
  }
418
- // No (or unrecognised) package.json. If there's HTML on disk we treat
419
- // the current directory as a static site. Otherwise we still fall back
420
- // to static it's the most permissive option and the builder will
421
- // ship whatever is on disk verbatim.
422
- const _staticHtml = await hasAnyHtml(cwd);
168
+ const framework = plan.framework;
169
+ const build_cmd = plan.buildCmd ?? (framework === "static" ? "true" : "npm run build");
170
+ const warning = ssrWarning(snap, framework);
423
171
  return {
424
- framework_hint: "static",
425
- build_cmd: "true",
426
- output_dir: ".",
427
- confident: _staticHtml,
172
+ framework_hint: framework,
173
+ build_cmd,
174
+ output_dir: plan.outputDir ?? ".",
175
+ confident: plan.confident,
176
+ ...(warning ? { ssr_warning: warning } : {}),
428
177
  };
429
178
  }
430
- const HUGO_CONFIG_TOKENS = [
431
- "baseURL",
432
- "baseurl",
433
- "languageCode",
434
- "languagecode",
435
- "[params]",
436
- "[markup]",
437
- "[menu",
438
- "[taxonomies]",
439
- "hugoVersion",
440
- "minVersion",
441
- "theme",
442
- ];
443
- // Match `ssr: false` or `nitro: { preset: 'static' }` in nuxt.config —
444
- // either marker keeps the project on the SPA pipeline. Mirrors the same
445
- // "string-search before AST" approach used for Next.js.
446
- const NUXT_STATIC_SSR_RE = /\bssr\s*:\s*false\b/m;
447
- const NUXT_STATIC_PRESET_RE = /preset\s*:\s*['"]static['"]/m;
448
- async function nuxtConfigDeclaresStatic(cwd) {
449
- for (const name of ["nuxt.config.mjs", "nuxt.config.ts", "nuxt.config.js"]) {
450
- try {
451
- const txt = stripJsComments(await fs.readFile(path.join(cwd, name), "utf-8"));
452
- if (NUXT_STATIC_SSR_RE.test(txt) || NUXT_STATIC_PRESET_RE.test(txt))
453
- return true;
454
- return false; // config found, no static marker → SSR
455
- }
456
- catch (err) {
457
- if (err?.code === "ENOENT")
458
- continue;
459
- return false;
460
- }
461
- }
462
- return false; // no config at all → defaults to SSR
463
- }
464
- async function hasVitepressConfig(cwd, prefix = ".vitepress") {
465
- for (const name of ["config.ts", "config.js", "config.mts", "config.mjs"]) {
466
- try {
467
- await fs.access(path.join(cwd, prefix, name));
468
- return true;
469
- }
470
- catch {
471
- // try next
472
- }
473
- }
474
- return false;
475
- }
476
- async function hasHugoConfigMarker(cwd) {
477
- for (const fn of ["config.toml", "config.yaml", "config.json"]) {
478
- try {
479
- const head = await fs.readFile(path.join(cwd, fn), "utf-8");
480
- if (HUGO_CONFIG_TOKENS.some((t) => head.includes(t)))
481
- return true;
482
- }
483
- catch {
484
- // try next
485
- }
486
- }
487
- return false;
488
- }
489
- // Resolve Angular's real build output directory from angular.json.
490
- // Lockstep with `core/builder/src/frameworks/angular.py:extract_output_dir`
491
- // — same dual-detector-drift class as Next.js static-export, so the two
492
- // implementations must agree (parity fixtures in
493
- // core/tests/fixtures/framework-detect/angular-*).
494
- //
495
- // Resolution:
496
- // 1. defaultProject if set & present, else first project in `projects`
497
- // 2. project.architect.build.options.outputPath, else the CLI default
498
- // `dist/{projectName}`
499
- // 3. strip leading `./`
500
- // 4. append `/browser` for the Angular 17+ `application` builder
501
- // (builder id ends with `:application`) — it writes the served
502
- // assets one level deeper. We also probe disk in case the repo was
503
- // built locally before `layero deploy`.
504
- // Returns the framework default `dist` on any parse error — matches
505
- // AngularFramework.default_output_dir, and the builder re-derives the
506
- // exact path at upload time anyway.
507
- async function angularOutputDir(cwd) {
508
- const FALLBACK = "dist";
509
- let cfg;
510
- try {
511
- cfg = JSON.parse(await fs.readFile(path.join(cwd, "angular.json"), "utf-8"));
512
- }
513
- catch {
514
- return FALLBACK;
515
- }
516
- const projects = cfg?.projects;
517
- if (!projects || typeof projects !== "object")
518
- return FALLBACK;
519
- const firstName = Object.keys(projects)[0];
520
- if (!firstName)
521
- return FALLBACK;
522
- const defaultName = cfg.defaultProject;
523
- const projectName = defaultName && projects[defaultName] ? defaultName : firstName;
524
- const buildCfg = projects[projectName]?.architect?.build;
525
- if (!buildCfg || typeof buildCfg !== "object")
526
- return FALLBACK;
527
- const opts = (buildCfg.options ?? {});
528
- const rawOut = typeof opts === "object" && opts ? opts.outputPath : undefined;
529
- let out = typeof rawOut === "string" && rawOut.trim() ? rawOut.trim() : `dist/${projectName}`;
530
- if (out.startsWith("./"))
531
- out = out.slice(2);
532
- const builderId = typeof buildCfg.builder === "string" ? buildCfg.builder : "";
533
- const isApplicationBuilder = builderId.endsWith(":application");
534
- let browserExists = false;
535
- try {
536
- browserExists = (await fs.stat(path.join(cwd, out, "browser"))).isDirectory();
537
- }
538
- catch {
539
- // not built locally — rely on the builder-id signal below
540
- }
541
- return browserExists || isApplicationBuilder ? `${out}/browser` : out;
542
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layero",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Layero CLI — publish a local site with one command. No git, no GitHub, agent-friendly (Cursor, Claude Code).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,6 +49,7 @@
49
49
  "chalk": "^5.3.0",
50
50
  "commander": "^12.1.0",
51
51
  "ignore": "^5.3.2",
52
+ "layero-detection": "^0.1.0",
52
53
  "open": "^10.1.0",
53
54
  "tar": "^7.4.3"
54
55
  },