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 +7 -1
- package/dist/commands/deploy.js +14 -4
- package/dist/detect.js +137 -501
- package/package.json +2 -1
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.
|
package/dist/commands/deploy.js
CHANGED
|
@@ -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
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
23
|
-
return JSON.parse(raw);
|
|
6
|
+
return JSON.parse(await fs.readFile(p, "utf-8"));
|
|
24
7
|
}
|
|
25
|
-
catch
|
|
26
|
-
|
|
27
|
-
return null;
|
|
28
|
-
throw err;
|
|
8
|
+
catch {
|
|
9
|
+
return null;
|
|
29
10
|
}
|
|
30
11
|
}
|
|
31
|
-
function
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
/* empty / unreadable cwd */
|
|
70
55
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
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
|
|
159
|
-
output_dir:
|
|
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
|
-
|
|
222
|
-
|
|
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:
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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:
|
|
425
|
-
build_cmd
|
|
426
|
-
output_dir: ".",
|
|
427
|
-
confident:
|
|
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.
|
|
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
|
},
|