layero 0.7.5 → 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/agent.js +18 -1
- package/dist/api.js +6 -0
- package/dist/auth.js +69 -0
- package/dist/bin/layero.js +16 -4
- package/dist/commands/deploy.js +71 -6
- package/dist/commands/deploys.js +20 -0
- package/dist/commands/init.js +28 -6
- package/dist/commands/login.js +3 -52
- package/dist/commands/promote.js +27 -9
- package/dist/detect.js +137 -458
- package/dist/logs.js +19 -3
- 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/agent.js
CHANGED
|
@@ -143,7 +143,24 @@ function renderHuman(event) {
|
|
|
143
143
|
process.stdout.write(`${event.line}\n`);
|
|
144
144
|
break;
|
|
145
145
|
case "ready":
|
|
146
|
-
|
|
146
|
+
if (event.edge_ready === false) {
|
|
147
|
+
// Built & published, but the CDN edge for the apex is still
|
|
148
|
+
// propagating (first deploy of a new hostname can take a few
|
|
149
|
+
// minutes on YC CDN). The preview host is reachable immediately.
|
|
150
|
+
const eta = typeof event.edge_eta_seconds === "number" && event.edge_eta_seconds > 0
|
|
151
|
+
? ` (edge propagating, ~${event.edge_eta_seconds}s)`
|
|
152
|
+
: " (edge propagating)";
|
|
153
|
+
process.stdout.write(`✓ Built. Live at ${event.url}${eta}\n`);
|
|
154
|
+
if (event.preview_url) {
|
|
155
|
+
process.stdout.write(` Reachable now: ${event.preview_url}\n`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
process.stdout.write(`✓ Live at ${event.url}\n`);
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
case "promoted":
|
|
163
|
+
process.stdout.write(`✓ Published apex → ${event.url}\n`);
|
|
147
164
|
break;
|
|
148
165
|
case "error":
|
|
149
166
|
process.stderr.write(`Error: ${event.code}\n`);
|
package/dist/api.js
CHANGED
|
@@ -77,6 +77,12 @@ export class ApiClient {
|
|
|
77
77
|
triggerDeploy(projectId, input) {
|
|
78
78
|
return this.request("POST", `/projects/${projectId}/deploy`, input);
|
|
79
79
|
}
|
|
80
|
+
// Edge/URL readiness for an environment. Used after a deploy reaches
|
|
81
|
+
// `ready` to report the real public URL + a reachable preview link
|
|
82
|
+
// instead of the management dashboard.
|
|
83
|
+
probeEnvironment(environmentId) {
|
|
84
|
+
return this.request("GET", `/environments/${environmentId}/probe`);
|
|
85
|
+
}
|
|
80
86
|
pollLogs(deployId, afterId) {
|
|
81
87
|
return this.request("GET", `/deploys/${deployId}/logs?after_id=${afterId}`);
|
|
82
88
|
}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Shared device-auth flow.
|
|
2
|
+
//
|
|
3
|
+
// Used by `layero login` (always) and by `layero deploy` when no token is
|
|
4
|
+
// saved yet (B5/I4 — the documented happy path is `deploy` kicking off the
|
|
5
|
+
// device login inline and polling, rather than erroring out to a separate
|
|
6
|
+
// `login` command).
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import open from "open";
|
|
9
|
+
import { saveConfig } from "./config.js";
|
|
10
|
+
import { ApiClient } from "./api.js";
|
|
11
|
+
import { LayeroError, detectMode, emit } from "./agent.js";
|
|
12
|
+
const MAX_WAIT_MS = 15 * 60 * 1000;
|
|
13
|
+
/**
|
|
14
|
+
* Run the browser device-auth flow against `cfg`, persist the resulting
|
|
15
|
+
* token, and return the updated config. Emits `auth_required` (so agents can
|
|
16
|
+
* render the link) and `authorized` in JSON mode; prints a friendly block and
|
|
17
|
+
* auto-opens the browser in interactive mode.
|
|
18
|
+
*
|
|
19
|
+
* Throws LayeroError("auth_expired" | "auth_timeout") if the user doesn't
|
|
20
|
+
* approve in time.
|
|
21
|
+
*/
|
|
22
|
+
export async function runDeviceLogin(cfg) {
|
|
23
|
+
const mode = detectMode();
|
|
24
|
+
const api = new ApiClient(cfg);
|
|
25
|
+
const device = await api.startDeviceAuth();
|
|
26
|
+
const { device_code, user_code, verification_url, poll_interval } = device;
|
|
27
|
+
const pollMs = (poll_interval ?? 2) * 1000;
|
|
28
|
+
if (mode.json) {
|
|
29
|
+
emit({ event: "auth_required", url: verification_url, user_code });
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.log(chalk.cyan("\nOpen this URL to sign in:"));
|
|
33
|
+
console.log(chalk.bold(` ${verification_url}`));
|
|
34
|
+
console.log(chalk.dim(`\n Confirmation code: `) + chalk.white.bold(user_code));
|
|
35
|
+
console.log(chalk.dim(` (expires in ${device.expires_in}s)\n`));
|
|
36
|
+
try {
|
|
37
|
+
await open(verification_url);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// non-fatal — user can paste the URL manually
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const deadline = Date.now() + MAX_WAIT_MS;
|
|
44
|
+
while (Date.now() < deadline) {
|
|
45
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
46
|
+
const poll = await api.pollDeviceAuth(device_code);
|
|
47
|
+
if (poll.status === "approved" && poll.token) {
|
|
48
|
+
cfg.token = poll.token;
|
|
49
|
+
const probe = new ApiClient(cfg);
|
|
50
|
+
const me = await probe.me();
|
|
51
|
+
cfg.user = { id: me.id, username: me.username, email: me.email };
|
|
52
|
+
await saveConfig(cfg);
|
|
53
|
+
if (mode.json) {
|
|
54
|
+
emit({ event: "authorized", user: me.username ?? me.email ?? me.id });
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
console.log(chalk.green(`Logged in as ${me.username ?? me.email ?? me.id}`));
|
|
58
|
+
if (!me.username) {
|
|
59
|
+
console.log(chalk.yellow("No username set — open https://app.layero.ru/onboarding to pick one."));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return cfg;
|
|
63
|
+
}
|
|
64
|
+
if (poll.status === "expired") {
|
|
65
|
+
throw new LayeroError("auth_expired", "Login timed out — code expired. Run `layero login` again.", "run_login");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new LayeroError("auth_timeout", "Login timed out. Run `layero login` again.", "run_login");
|
|
69
|
+
}
|
package/dist/bin/layero.js
CHANGED
|
@@ -26,7 +26,8 @@ async function main() {
|
|
|
26
26
|
.name("layero")
|
|
27
27
|
.description("Layero CLI — publish a local directory with one command. No git required.")
|
|
28
28
|
.version(VERSION)
|
|
29
|
-
.option("--json", "emit machine-readable JSON-lines on stdout (for agents and CI)")
|
|
29
|
+
.option("--json", "emit machine-readable JSON-lines on stdout (for agents and CI)")
|
|
30
|
+
.option("--debug", "print a full stack trace when a command errors");
|
|
30
31
|
program
|
|
31
32
|
.command("login")
|
|
32
33
|
.description("Authenticate via browser (GitHub / Yandex). Opens a one-time URL — no localhost server required.")
|
|
@@ -44,9 +45,9 @@ async function main() {
|
|
|
44
45
|
.action(whoamiCmd);
|
|
45
46
|
program
|
|
46
47
|
.command("init")
|
|
47
|
-
.description("Scaffold .layero/project.json from auto-detected framework, and write a Layero deployment block into AGENTS.md / CLAUDE.md / .cursorrules
|
|
48
|
+
.description("Scaffold .layero/project.json from the auto-detected framework, and write a Layero deployment block into your agent-instructions file so future chat sessions know how to deploy. Updates whichever of AGENTS.md / CLAUDE.md / .cursorrules already exist; if none do, creates AGENTS.md.")
|
|
48
49
|
.option("-y, --yes", "non-interactive: accept all defaults")
|
|
49
|
-
.option("--skip-agent-docs", "do not touch AGENTS.md/CLAUDE.md
|
|
50
|
+
.option("--skip-agent-docs", "do not touch AGENTS.md / CLAUDE.md / .cursorrules")
|
|
50
51
|
.action(async (opts) => {
|
|
51
52
|
await initCmd({ yes: opts.yes, skipAgentDocs: opts.skipAgentDocs });
|
|
52
53
|
});
|
|
@@ -196,6 +197,12 @@ async function main() {
|
|
|
196
197
|
}
|
|
197
198
|
main().catch((err) => {
|
|
198
199
|
const mode = detectMode();
|
|
200
|
+
// `--debug` (real global flag) prints the full stack trace for any error,
|
|
201
|
+
// structured or not, so the next_action hint that mentions it is honest.
|
|
202
|
+
const debug = process.argv.includes("--debug") || process.env.LAYERO_DEBUG === "1";
|
|
203
|
+
if (debug && err instanceof Error && err.stack) {
|
|
204
|
+
console.error(chalk.dim(err.stack));
|
|
205
|
+
}
|
|
199
206
|
if (err instanceof LayeroError) {
|
|
200
207
|
emit({
|
|
201
208
|
event: "error",
|
|
@@ -212,12 +219,17 @@ main().catch((err) => {
|
|
|
212
219
|
emit({
|
|
213
220
|
event: "error",
|
|
214
221
|
code: "internal",
|
|
215
|
-
next_action:
|
|
222
|
+
next_action: debug
|
|
223
|
+
? "report at https://github.com/LayeroInfra/core/issues"
|
|
224
|
+
: "re-run with --debug for a stack trace, or report at https://github.com/LayeroInfra/core/issues",
|
|
216
225
|
message,
|
|
217
226
|
});
|
|
218
227
|
}
|
|
219
228
|
else {
|
|
220
229
|
console.error(chalk.red(message));
|
|
230
|
+
if (!debug) {
|
|
231
|
+
console.error(chalk.dim(" (re-run with --debug for a stack trace)"));
|
|
232
|
+
}
|
|
221
233
|
}
|
|
222
234
|
}
|
|
223
235
|
process.exit(1);
|
package/dist/commands/deploy.js
CHANGED
|
@@ -8,6 +8,7 @@ import { loadProjectConfig, persistProjectLinking, projectConfigPath, } from "..
|
|
|
8
8
|
import { packCwd, packDirectory } from "../pack.js";
|
|
9
9
|
import { streamDeployLogs } from "../logs.js";
|
|
10
10
|
import { detectProject } from "../detect.js";
|
|
11
|
+
import { runDeviceLogin } from "../auth.js";
|
|
11
12
|
import { LayeroError, detectMode, emit } from "../agent.js";
|
|
12
13
|
const VALID_TYPES = new Set([
|
|
13
14
|
"vite",
|
|
@@ -44,6 +45,33 @@ function dashboardOrigin(apiUrl) {
|
|
|
44
45
|
function projectUrl(apiUrl, projectId) {
|
|
45
46
|
return `${dashboardOrigin(apiUrl)}/projects/${projectId}`;
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* After a deploy reaches `ready`, ask the backend where it's actually
|
|
50
|
+
* reachable. The builder marks status=ready *before* calling /activate
|
|
51
|
+
* (which runs auto-promote + schedules CDN warmup), so a probe fired the
|
|
52
|
+
* instant we see `ready` can race ahead of the apex pointer being set.
|
|
53
|
+
*
|
|
54
|
+
* Poll the probe briefly (bounded) and return as soon as the env is
|
|
55
|
+
* reachable (`available` — the preview host is up) or we learn the CDN
|
|
56
|
+
* edge is already warm. Best-effort: any error returns null and the caller
|
|
57
|
+
* falls back to apex/dashboard URLs.
|
|
58
|
+
*/
|
|
59
|
+
async function resolveReachability(api, environmentId) {
|
|
60
|
+
const deadline = Date.now() + 15_000;
|
|
61
|
+
let last = null;
|
|
62
|
+
while (Date.now() < deadline) {
|
|
63
|
+
try {
|
|
64
|
+
last = await api.probeEnvironment(environmentId);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return last; // permission/network — caller falls back
|
|
68
|
+
}
|
|
69
|
+
if (last.available || last.cdn_ready)
|
|
70
|
+
return last;
|
|
71
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
72
|
+
}
|
|
73
|
+
return last;
|
|
74
|
+
}
|
|
47
75
|
async function prompt(question, fallback) {
|
|
48
76
|
const rl = readline.createInterface({
|
|
49
77
|
input: process.stdin,
|
|
@@ -87,7 +115,15 @@ async function resolveOrCreateProject(api, cwd, opts, existing) {
|
|
|
87
115
|
}
|
|
88
116
|
return { project: match, createdNow: false };
|
|
89
117
|
}
|
|
90
|
-
if
|
|
118
|
+
// Treat the local config as a real link only if it actually carries a
|
|
119
|
+
// project_id. `layero init` scaffolds .layero/project.json with the
|
|
120
|
+
// detected framework/build/output but NO project_id (the project isn't
|
|
121
|
+
// created until the first deploy). Without this guard, deploy would treat
|
|
122
|
+
// that scaffold as an existing link and call GET /projects/undefined → 422
|
|
123
|
+
// (B1 — the documented init→deploy path was broken). When there's no id,
|
|
124
|
+
// fall through to the create path below, which preserves the scaffold's
|
|
125
|
+
// setup fields via persistProjectLinking().
|
|
126
|
+
if (existing && existing.project_id) {
|
|
91
127
|
try {
|
|
92
128
|
const project = await api.getProject(existing.project_id);
|
|
93
129
|
return { project, createdNow: false };
|
|
@@ -242,9 +278,13 @@ export async function deployCmd(opts) {
|
|
|
242
278
|
if (opts.type && !VALID_TYPES.has(opts.type.toLowerCase())) {
|
|
243
279
|
throw new LayeroError("invalid_type", `unknown --type "${opts.type}"`, `valid types: ${[...VALID_TYPES].join(", ")}`);
|
|
244
280
|
}
|
|
245
|
-
|
|
281
|
+
let cliCfg = await loadConfig();
|
|
246
282
|
if (!cliCfg.token) {
|
|
247
|
-
|
|
283
|
+
// Not authenticated yet. Kick off the browser device-login flow inline
|
|
284
|
+
// and poll, exactly as the docs describe (B5/I4) — no separate `layero
|
|
285
|
+
// login` step required. In JSON/agent mode this emits `auth_required`
|
|
286
|
+
// so the caller can render the link and keep waiting.
|
|
287
|
+
cliCfg = await runDeviceLogin(cliCfg);
|
|
248
288
|
}
|
|
249
289
|
const api = new ApiClient(cliCfg);
|
|
250
290
|
const cwd = process.cwd();
|
|
@@ -375,14 +415,39 @@ export async function deployCmd(opts) {
|
|
|
375
415
|
` retry with: layero promote ${deploy.id.slice(0, 8)}`));
|
|
376
416
|
}
|
|
377
417
|
}
|
|
418
|
+
// Where is this build actually reachable? Ask the backend rather than
|
|
419
|
+
// guessing — it knows the live public URL (apex once it's the production
|
|
420
|
+
// pointer), the off-CDN preview URL that's reachable immediately, and
|
|
421
|
+
// how far along CDN propagation is. Note: a plain `layero deploy` of a
|
|
422
|
+
// CLI project auto-promotes to the apex on activate (no `--prod` /
|
|
423
|
+
// `promote` needed), so the apex IS the destination for the common case.
|
|
424
|
+
const dashboardUrl = projectUrl(cliCfg.apiUrl, project.id);
|
|
378
425
|
const apexUrl = `https://${project.apex_hostname}`;
|
|
379
|
-
const
|
|
380
|
-
|
|
426
|
+
const probe = await resolveReachability(api, deploy.environment_id);
|
|
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);
|
|
381
441
|
emit({
|
|
382
442
|
event: "ready",
|
|
383
443
|
url: liveUrl,
|
|
384
444
|
deploy_id: deploy.id,
|
|
385
|
-
preview_url:
|
|
445
|
+
preview_url: probe?.preview_url ?? undefined,
|
|
446
|
+
dashboard_url: dashboardUrl,
|
|
447
|
+
edge_ready: probe ? probe.cdn_ready : undefined,
|
|
448
|
+
edge_eta_seconds: probe && !probe.cdn_ready && probe.cdn_eta_seconds != null
|
|
449
|
+
? probe.cdn_eta_seconds
|
|
450
|
+
: undefined,
|
|
386
451
|
});
|
|
387
452
|
}
|
|
388
453
|
finally {
|
package/dist/commands/deploys.js
CHANGED
|
@@ -3,6 +3,7 @@ import chalk from "chalk";
|
|
|
3
3
|
import { ApiClient, ApiError } from "../api.js";
|
|
4
4
|
import { loadConfig } from "../config.js";
|
|
5
5
|
import { loadProjectConfig } from "../project-config.js";
|
|
6
|
+
import { detectMode } from "../agent.js";
|
|
6
7
|
async function resolveProjectId(api, cwd, override) {
|
|
7
8
|
if (override) {
|
|
8
9
|
// Accept slug or id; resolve via list to keep consistent error UX.
|
|
@@ -59,6 +60,25 @@ export async function deploysListCmd(opts) {
|
|
|
59
60
|
const deploys = await api.listProjectDeploys(projectId, opts.branch);
|
|
60
61
|
const limit = opts.limit ?? 20;
|
|
61
62
|
const rows = deploys.slice(0, limit);
|
|
63
|
+
// JSON mode (B6): emit one structured object per deploy on stdout instead
|
|
64
|
+
// of the coloured human one-liner. One JSON object per line keeps it
|
|
65
|
+
// consistent with the rest of the CLI's JSON-lines protocol.
|
|
66
|
+
if (detectMode().json) {
|
|
67
|
+
for (const d of rows) {
|
|
68
|
+
process.stdout.write(JSON.stringify({
|
|
69
|
+
event: "deploy",
|
|
70
|
+
id: d.id,
|
|
71
|
+
environment_id: d.environment_id,
|
|
72
|
+
status: d.status,
|
|
73
|
+
commit_sha: d.commit_sha,
|
|
74
|
+
commit_message: (d.commit_message ?? "").split("\n")[0] ?? "",
|
|
75
|
+
source_type: d.source_type ?? "github",
|
|
76
|
+
created_at: d.created_at ?? null,
|
|
77
|
+
finished_at: d.finished_at ?? null,
|
|
78
|
+
}) + "\n");
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
62
82
|
if (rows.length === 0) {
|
|
63
83
|
console.log(chalk.dim("no deploys yet."));
|
|
64
84
|
return;
|
package/dist/commands/init.js
CHANGED
|
@@ -20,7 +20,8 @@ is required — Layero packs and uploads the local directory directly.
|
|
|
20
20
|
|
|
21
21
|
### First-time auth (one-click device flow)
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
If you're not logged in yet, \`deploy\` (or \`login\`) starts the browser
|
|
24
|
+
device-flow automatically and emits a JSON line:
|
|
24
25
|
|
|
25
26
|
\`\`\`json
|
|
26
27
|
{"event":"auth_required","url":"https://app.layero.ru/cli?code=ABCD-1234","user_code":"ABCD-1234"}
|
|
@@ -43,7 +44,7 @@ stdout), the CLI auto-switches to JSON-lines. Key events to watch:
|
|
|
43
44
|
| \`detected\` | framework auto-detection result |
|
|
44
45
|
| \`project_created\` / \`project_linked\` | project bound for this directory |
|
|
45
46
|
| \`build_log\` | forward only if it contains errors |
|
|
46
|
-
| \`ready\` | \`url\`
|
|
47
|
+
| \`ready\` | \`url\` = live public site (show to user, stop). \`preview_url\` = reachable immediately while the apex CDN edge warms; \`edge_ready\`/\`edge_eta_seconds\` say whether the apex is serving yet. \`dashboard_url\` = management page. |
|
|
47
48
|
| \`error\` | follow \`next_action\` field verbatim |
|
|
48
49
|
|
|
49
50
|
Common error codes and remediation:
|
|
@@ -56,11 +57,32 @@ Common error codes and remediation:
|
|
|
56
57
|
|
|
57
58
|
### Re-deploys and production
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
\`https://<org>-<project
|
|
60
|
+
A plain \`npx layero deploy\` of a CLI project **publishes to the apex**
|
|
61
|
+
\`https://<org>-<project>.layero.ru\` — direct uploads auto-promote, so you do
|
|
62
|
+
**not** need \`--prod\` or a separate \`promote\` step. Safe to run repeatedly;
|
|
63
|
+
each run replaces what the apex serves.
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
Every deploy also gets a per-deploy preview at
|
|
66
|
+
\`https://<org>-<project>-<sha>.preview.layero.ru\` (read it from \`ready.preview_url\`).
|
|
67
|
+
The preview is reachable **immediately**; the apex can take a few minutes to
|
|
68
|
+
serve on the **first** deploy of a project while the CDN edge issues its cert
|
|
69
|
+
and propagates (\`ready.edge_ready=false\` with an \`edge_eta_seconds\` estimate).
|
|
70
|
+
Hand the user \`ready.url\` (the apex); if \`edge_ready\` is false, also offer
|
|
71
|
+
\`preview_url\` so they can see it right away.
|
|
72
|
+
|
|
73
|
+
Use \`--branch <name>\` to deploy to an isolated preview environment that does
|
|
74
|
+
**not** touch the apex. (\`--prod\` exists for git-connected projects; for
|
|
75
|
+
direct CLI uploads it's redundant.)
|
|
76
|
+
|
|
77
|
+
### Already built? Skip the server build
|
|
78
|
+
|
|
79
|
+
If the site is already built locally (e.g. a Next.js static export in \`out/\`,
|
|
80
|
+
or a \`dist/\`), ship the artifact directly and skip the server-side
|
|
81
|
+
\`npm install\` + build:
|
|
82
|
+
|
|
83
|
+
\`\`\`bash
|
|
84
|
+
npx layero@latest deploy --prebuilt out
|
|
85
|
+
\`\`\`
|
|
64
86
|
|
|
65
87
|
Full reference: https://docs.layero.ru/cli/agents
|
|
66
88
|
${AGENT_BLOCK_MARKER_END}
|
package/dist/commands/login.js
CHANGED
|
@@ -1,55 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import { loadConfig, saveConfig } from "../config.js";
|
|
4
|
-
import { ApiClient } from "../api.js";
|
|
5
|
-
import { LayeroError, detectMode, emit } from "../agent.js";
|
|
6
|
-
const MAX_WAIT_MS = 15 * 60 * 1000;
|
|
1
|
+
import { loadConfig } from "../config.js";
|
|
2
|
+
import { runDeviceLogin } from "../auth.js";
|
|
7
3
|
export async function loginCmd(_opts) {
|
|
8
4
|
const cfg = await loadConfig();
|
|
9
|
-
|
|
10
|
-
const api = new ApiClient(cfg);
|
|
11
|
-
const device = await api.startDeviceAuth();
|
|
12
|
-
const { device_code, user_code, verification_url, poll_interval } = device;
|
|
13
|
-
const pollMs = (poll_interval ?? 2) * 1000;
|
|
14
|
-
if (mode.json) {
|
|
15
|
-
emit({ event: "auth_required", url: verification_url, user_code });
|
|
16
|
-
}
|
|
17
|
-
else {
|
|
18
|
-
console.log(chalk.cyan("\nOpen this URL to sign in:"));
|
|
19
|
-
console.log(chalk.bold(` ${verification_url}`));
|
|
20
|
-
console.log(chalk.dim(`\n Confirmation code: `) + chalk.white.bold(user_code));
|
|
21
|
-
console.log(chalk.dim(` (expires in ${device.expires_in}s)\n`));
|
|
22
|
-
try {
|
|
23
|
-
await open(verification_url);
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
// non-fatal — user can paste the URL manually
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
const deadline = Date.now() + MAX_WAIT_MS;
|
|
30
|
-
while (Date.now() < deadline) {
|
|
31
|
-
await new Promise((r) => setTimeout(r, pollMs));
|
|
32
|
-
const poll = await api.pollDeviceAuth(device_code);
|
|
33
|
-
if (poll.status === "approved" && poll.token) {
|
|
34
|
-
cfg.token = poll.token;
|
|
35
|
-
const probe = new ApiClient(cfg);
|
|
36
|
-
const me = await probe.me();
|
|
37
|
-
cfg.user = { id: me.id, username: me.username, email: me.email };
|
|
38
|
-
await saveConfig(cfg);
|
|
39
|
-
if (mode.json) {
|
|
40
|
-
emit({ event: "authorized", user: me.username ?? me.email ?? me.id });
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
43
|
-
console.log(chalk.green(`Logged in as ${me.username ?? me.email ?? me.id}`));
|
|
44
|
-
if (!me.username) {
|
|
45
|
-
console.log(chalk.yellow("No username set — open https://app.layero.ru/onboarding to pick one."));
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
if (poll.status === "expired") {
|
|
51
|
-
throw new LayeroError("auth_expired", "Login timed out — code expired. Run `layero login` again.", "run_login");
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
throw new LayeroError("auth_timeout", "Login timed out. Run `layero login` again.", "run_login");
|
|
5
|
+
await runDeviceLogin(cfg);
|
|
55
6
|
}
|
package/dist/commands/promote.js
CHANGED
|
@@ -3,6 +3,7 @@ import chalk from "chalk";
|
|
|
3
3
|
import { ApiClient, ApiError } from "../api.js";
|
|
4
4
|
import { loadConfig } from "../config.js";
|
|
5
5
|
import { loadProjectConfig } from "../project-config.js";
|
|
6
|
+
import { detectMode, emit } from "../agent.js";
|
|
6
7
|
async function resolveProjectId(api, cwd, override) {
|
|
7
8
|
if (override) {
|
|
8
9
|
const all = await api.listProjects();
|
|
@@ -45,6 +46,7 @@ async function confirm(question) {
|
|
|
45
46
|
* returns 400 with a readable message if the flag is off; we surface it.
|
|
46
47
|
*/
|
|
47
48
|
export async function promoteCmd(deployArg, opts) {
|
|
49
|
+
const mode = detectMode();
|
|
48
50
|
const cliCfg = await loadConfig();
|
|
49
51
|
if (!cliCfg.token) {
|
|
50
52
|
throw new Error("not logged in. run `layero login` first.");
|
|
@@ -77,14 +79,21 @@ export async function promoteCmd(deployArg, opts) {
|
|
|
77
79
|
throw new Error(`cannot promote deploy ${shortSha(target.commit_sha)}: status is "${target.status}", not ready.`);
|
|
78
80
|
}
|
|
79
81
|
const apex = project.apex_hostname;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
console.log(
|
|
82
|
+
// Human-only plan. In JSON mode we skip the chatter and emit a single
|
|
83
|
+
// structured `promoted` event below.
|
|
84
|
+
if (!mode.json) {
|
|
85
|
+
console.log(chalk.cyan("promote plan:"));
|
|
86
|
+
console.log(` project: ${project.slug}`);
|
|
87
|
+
console.log(` apex: https://${apex}`);
|
|
88
|
+
console.log(` deploy: ${shortSha(target.commit_sha)} ${chalk.dim((target.commit_message ?? "").split("\n")[0] ?? "")}`);
|
|
89
|
+
if (project.production_deploy_id) {
|
|
90
|
+
console.log(chalk.dim(` current: ${project.production_deploy_id.slice(0, 8)} (will be replaceable via re-promote)`));
|
|
91
|
+
}
|
|
86
92
|
}
|
|
87
|
-
|
|
93
|
+
// Confirm only when there's a human at a TTY. In --json / agent / CI /
|
|
94
|
+
// non-interactive mode, never block on a prompt (B6) — proceed, matching
|
|
95
|
+
// `deploy --prod`'s non-interactive behaviour. `--yes` forces it anywhere.
|
|
96
|
+
if (!opts.yes && mode.interactive) {
|
|
88
97
|
const ok = await confirm("publish this build to production?");
|
|
89
98
|
if (!ok) {
|
|
90
99
|
console.log(chalk.yellow("aborted."));
|
|
@@ -94,8 +103,17 @@ export async function promoteCmd(deployArg, opts) {
|
|
|
94
103
|
try {
|
|
95
104
|
const updated = await api.promoteDeploy(projectId, target.id);
|
|
96
105
|
const newPin = updated.production_deploy_id ?? target.id;
|
|
97
|
-
|
|
98
|
-
|
|
106
|
+
if (mode.json) {
|
|
107
|
+
emit({
|
|
108
|
+
event: "promoted",
|
|
109
|
+
url: `https://${updated.apex_hostname}`,
|
|
110
|
+
deploy_id: target.id,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
console.log(chalk.green(`published. https://${updated.apex_hostname} now serves deploy ${newPin.slice(0, 8)}.`));
|
|
115
|
+
console.log(chalk.dim(" edge cache flushes within a few seconds; visitors see the new build immediately."));
|
|
116
|
+
}
|
|
99
117
|
}
|
|
100
118
|
catch (err) {
|
|
101
119
|
if (err instanceof ApiError) {
|
package/dist/detect.js
CHANGED
|
@@ -1,499 +1,178 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
|
|
3
|
+
import * as dc from "layero-detection";
|
|
4
|
+
async function readJson(p) {
|
|
4
5
|
try {
|
|
5
|
-
|
|
6
|
-
return JSON.parse(raw);
|
|
6
|
+
return JSON.parse(await fs.readFile(p, "utf-8"));
|
|
7
7
|
}
|
|
8
|
-
catch
|
|
9
|
-
|
|
10
|
-
return null;
|
|
11
|
-
throw err;
|
|
8
|
+
catch {
|
|
9
|
+
return null;
|
|
12
10
|
}
|
|
13
11
|
}
|
|
14
|
-
function
|
|
15
|
-
|
|
12
|
+
async function readText(p) {
|
|
13
|
+
try {
|
|
14
|
+
return await fs.readFile(p, "utf-8");
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
16
19
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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")))
|
|
21
33
|
return true;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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;
|
|
25
39
|
}
|
|
26
40
|
}
|
|
27
41
|
return false;
|
|
28
42
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return null;
|
|
35
|
-
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();
|
|
36
48
|
try {
|
|
37
|
-
|
|
49
|
+
for (const e of await fs.readdir(cwd, { withFileTypes: true })) {
|
|
50
|
+
(e.isDirectory() ? dirs : files).add(e.name);
|
|
51
|
+
}
|
|
38
52
|
}
|
|
39
53
|
catch {
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
for (const lib of ["streamlit", "gradio", "flask"]) {
|
|
43
|
-
if (reqs.includes(lib))
|
|
44
|
-
return lib;
|
|
54
|
+
/* empty / unreadable cwd */
|
|
45
55
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// what counts as a static-export Next.js project. The class-of-bug we
|
|
51
|
-
// hit on 2026-05-22 (builder detector v72 fix) and 2026-05-26 (CLI side)
|
|
52
|
-
// was exactly this kind of dual detector drift.
|
|
53
|
-
const NEXT_STATIC_EXPORT_RE = /output\s*:\s*['"]export['"]/m;
|
|
54
|
-
// Strip JS line and block comments before regex matching. Without this,
|
|
55
|
-
// a comment like `// see output: 'export'` falsely matches as
|
|
56
|
-
// static-export — observed 2026-05-26 on the SSR smoke canary fixture.
|
|
57
|
-
// Naive (does not understand strings that contain comment-like tokens),
|
|
58
|
-
// but real next.config files don't have that shape.
|
|
59
|
-
const JS_COMMENT_RE = /\/\/[^\n]*|\/\*[\s\S]*?\*\//gm;
|
|
60
|
-
const stripJsComments = (text) => text.replace(JS_COMMENT_RE, "");
|
|
61
|
-
async function readNextConfigText(cwd) {
|
|
62
|
-
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
|
+
]) {
|
|
63
60
|
try {
|
|
64
|
-
|
|
61
|
+
await fs.access(path.join(cwd, nested));
|
|
62
|
+
files.add(nested);
|
|
65
63
|
}
|
|
66
|
-
catch
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
});
|
|
73
93
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (text === null)
|
|
80
|
-
return null;
|
|
81
|
-
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]));
|
|
82
99
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
+
}
|
|
89
121
|
}
|
|
90
|
-
|
|
91
|
-
|
|
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 для статического хостинга.";
|
|
92
132
|
}
|
|
93
|
-
|
|
94
|
-
// Build command preference:
|
|
95
|
-
// 1. If `scripts.build` exists in package.json, use `npm run build` —
|
|
96
|
-
// it respects whatever the user set up (custom flags, monorepo
|
|
97
|
-
// filters, etc.). `npm run` works regardless of package manager
|
|
98
|
-
// installed on the build VM, because the builder runs `npm` itself.
|
|
99
|
-
// 2. Otherwise, use the framework's CLI directly via `npx`.
|
|
100
|
-
function buildCmd(pkg, fallback) {
|
|
101
|
-
if (pkg?.scripts?.build)
|
|
102
|
-
return "npm run build";
|
|
103
|
-
return fallback;
|
|
133
|
+
return undefined;
|
|
104
134
|
}
|
|
105
135
|
/**
|
|
106
|
-
* Detect framework / build command / output directory from the project
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* Order matters: more specific signals first (Next, Nuxt, SvelteKit,
|
|
112
|
-
* 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).
|
|
113
141
|
*/
|
|
114
142
|
export async function detectProject(cwd) {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
// err on the side of static (legacy `next export` workflow).
|
|
123
|
-
const exportFlag = await nextConfigStaticExport(cwd);
|
|
124
|
-
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");
|
|
125
150
|
return {
|
|
126
151
|
framework_hint: "nextjs",
|
|
127
|
-
build_cmd: buildCmd
|
|
128
|
-
output_dir:
|
|
152
|
+
build_cmd: unit.buildCmd ?? "npx next build",
|
|
153
|
+
output_dir: ".next",
|
|
129
154
|
confident: true,
|
|
130
|
-
|
|
155
|
+
runtime_kind: "ssr_next",
|
|
131
156
|
};
|
|
132
157
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const buildScript = pkg.scripts?.build;
|
|
136
|
-
const cmd = generateScript
|
|
137
|
-
? "npm run generate"
|
|
138
|
-
: buildScript
|
|
139
|
-
? "npm run build"
|
|
140
|
-
: "npx nuxt generate";
|
|
141
|
-
// Nuxt defaults to SSR. Layero hosts only static for Nuxt today,
|
|
142
|
-
// so a project without an explicit static signal (`nuxt generate`
|
|
143
|
-
// script, or `ssr: false` / `nitro.preset='static'` in config)
|
|
144
|
-
// will build but the resulting `.output/server/` is useless to us.
|
|
145
|
-
// Mirrors builder's `frameworks/nuxt.py` SSR hint.
|
|
146
|
-
const nuxtStatic = !!generateScript || (await nuxtConfigDeclaresStatic(cwd));
|
|
147
|
-
return {
|
|
148
|
-
framework_hint: "nuxt",
|
|
149
|
-
build_cmd: cmd,
|
|
150
|
-
output_dir: ".output/public",
|
|
151
|
-
confident: true,
|
|
152
|
-
...(nuxtStatic ? {} : {
|
|
153
|
-
ssr_warning: "Nuxt без `nuxt generate` или `ssr: false` собирается как SSR-сервер; " +
|
|
154
|
-
"Layero хостит только статику Nuxt. Добавьте `\"generate\": \"nuxt generate\"` " +
|
|
155
|
-
"в scripts или поставьте `ssr: false` в nuxt.config.",
|
|
156
|
-
}),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
// Remix / React Router v7 before SvelteKit/Vite — RR7 ships Vite
|
|
160
|
-
// internally, so the Vite-dep check would otherwise win. Mirrors the
|
|
161
|
-
// builder's RemixFramework + backend `remix` table entry (output
|
|
162
|
-
// build/client). Was missing from the CLI entirely — same dual-detector
|
|
163
|
-
// gap class as Angular: a Remix repo deployed via the CLI fell through
|
|
164
|
-
// to the static fallback and shipped raw sources.
|
|
165
|
-
if (hasDep(pkg, "@remix-run/dev") ||
|
|
166
|
-
hasDep(pkg, "@remix-run/react") ||
|
|
167
|
-
hasDep(pkg, "@remix-run/node") ||
|
|
168
|
-
hasDep(pkg, "@react-router/dev") ||
|
|
169
|
-
hasDep(pkg, "@react-router/node") ||
|
|
170
|
-
(await fileExists(cwd, "react-router.config.ts", "react-router.config.js"))) {
|
|
171
|
-
return {
|
|
172
|
-
framework_hint: "remix",
|
|
173
|
-
build_cmd: buildCmd(pkg, "npx react-router build"),
|
|
174
|
-
output_dir: "build/client",
|
|
175
|
-
confident: true,
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
if (hasDep(pkg, "@sveltejs/kit") || (await fileExists(cwd, "svelte.config.js"))) {
|
|
179
|
-
// SvelteKit needs an adapter. adapter-static = SPA path; anything
|
|
180
|
-
// else (adapter-node, adapter-auto, …) is server-side. Mirrors
|
|
181
|
-
// builder's `frameworks/svelte.py:validate_static`.
|
|
182
|
-
const hasStaticAdapter = hasDep(pkg, "@sveltejs/adapter-static");
|
|
183
|
-
const serverAdapter = !hasStaticAdapter
|
|
184
|
-
? Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) })
|
|
185
|
-
.find((d) => d.startsWith("@sveltejs/adapter-") && d !== "@sveltejs/adapter-static")
|
|
186
|
-
: undefined;
|
|
187
|
-
let ssrWarning;
|
|
188
|
-
if (serverAdapter) {
|
|
189
|
-
ssrWarning =
|
|
190
|
-
`SvelteKit использует ${serverAdapter} (серверный адаптер). ` +
|
|
191
|
-
"Layero хостит только статику — поставьте `@sveltejs/adapter-static`.";
|
|
192
|
-
}
|
|
193
|
-
else if (!hasStaticAdapter) {
|
|
194
|
-
ssrWarning =
|
|
195
|
-
"SvelteKit без явного адаптера — поставьте `@sveltejs/adapter-static` " +
|
|
196
|
-
"для статического хостинга на Layero.";
|
|
197
|
-
}
|
|
198
|
-
return {
|
|
199
|
-
framework_hint: "sveltekit",
|
|
200
|
-
build_cmd: buildCmd(pkg, "npx vite build"),
|
|
201
|
-
output_dir: "build",
|
|
202
|
-
confident: true,
|
|
203
|
-
...(ssrWarning ? { ssr_warning: ssrWarning } : {}),
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
if (hasDep(pkg, "gatsby") || (await fileExists(cwd, "gatsby-config.js", "gatsby-config.ts"))) {
|
|
207
|
-
return {
|
|
208
|
-
framework_hint: "gatsby",
|
|
209
|
-
build_cmd: buildCmd(pkg, "npx gatsby build"),
|
|
210
|
-
output_dir: "public",
|
|
211
|
-
confident: true,
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
if (hasDep(pkg, "astro") || (await fileExists(cwd, "astro.config.mjs", "astro.config.ts", "astro.config.js"))) {
|
|
215
|
-
return {
|
|
216
|
-
framework_hint: "astro",
|
|
217
|
-
build_cmd: buildCmd(pkg, "npx astro build"),
|
|
218
|
-
output_dir: "dist",
|
|
219
|
-
confident: true,
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
if (hasDep(pkg, "@docusaurus/core") || (await fileExists(cwd, "docusaurus.config.js", "docusaurus.config.ts", "docusaurus.config.mjs"))) {
|
|
223
|
-
return {
|
|
224
|
-
framework_hint: "docusaurus",
|
|
225
|
-
build_cmd: buildCmd(pkg, "npx docusaurus build"),
|
|
226
|
-
output_dir: "build",
|
|
227
|
-
confident: true,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
// Storybook before Vite/CRA: Storybook 7+ uses Vite or webpack
|
|
231
|
-
// internally, so `vite` / `react-scripts` is in deps. Without
|
|
232
|
-
// listing it first the SPA Vite path would output_dir=dist, which
|
|
233
|
-
// doesn't exist after `build-storybook`.
|
|
234
|
-
const storybookDeps = [
|
|
235
|
-
"@storybook/cli",
|
|
236
|
-
"@storybook/react",
|
|
237
|
-
"@storybook/react-vite",
|
|
238
|
-
"@storybook/react-webpack5",
|
|
239
|
-
"@storybook/vue",
|
|
240
|
-
"@storybook/vue3",
|
|
241
|
-
"@storybook/vue3-vite",
|
|
242
|
-
"@storybook/svelte",
|
|
243
|
-
"@storybook/svelte-vite",
|
|
244
|
-
"@storybook/web-components",
|
|
245
|
-
"@storybook/web-components-vite",
|
|
246
|
-
"@storybook/preact",
|
|
247
|
-
"@storybook/angular",
|
|
248
|
-
"@storybook/nextjs",
|
|
249
|
-
"@storybook/html",
|
|
250
|
-
"@storybook/html-vite",
|
|
251
|
-
"storybook",
|
|
252
|
-
];
|
|
253
|
-
const hasStorybookDep = storybookDeps.some((d) => hasDep(pkg, d));
|
|
254
|
-
const hasStorybookScript = pkg.scripts?.["build-storybook"] !== undefined
|
|
255
|
-
|| Object.values(pkg.scripts ?? {}).some((s) => typeof s === "string" && s.includes("storybook build"));
|
|
256
|
-
const hasStorybookDir = await fileExists(cwd, ".storybook/main.js", ".storybook/main.ts", ".storybook/main.cjs", ".storybook/main.mjs");
|
|
257
|
-
if (hasStorybookDep || hasStorybookScript || hasStorybookDir) {
|
|
258
|
-
const cmd = pkg.scripts?.["build-storybook"]
|
|
259
|
-
? "npm run build-storybook"
|
|
260
|
-
: (pkg.scripts?.build && pkg.scripts.build.includes("storybook"))
|
|
261
|
-
? "npm run build"
|
|
262
|
-
: "npx storybook build";
|
|
263
|
-
return {
|
|
264
|
-
framework_hint: "storybook",
|
|
265
|
-
build_cmd: cmd,
|
|
266
|
-
output_dir: "storybook-static",
|
|
267
|
-
confident: true,
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
// VitePress before generic Vite: VitePress repos often pull `vite`
|
|
271
|
-
// transitively, and the SPA Vite path would set output_dir=dist —
|
|
272
|
-
// wrong for VitePress, which writes to `.vitepress/dist/` (or
|
|
273
|
-
// `docs/.vitepress/dist/` when the config lives under docs/).
|
|
274
|
-
if (hasDep(pkg, "vitepress") || (await hasVitepressConfig(cwd))) {
|
|
275
|
-
const docsLayout = await hasVitepressConfig(cwd, "docs/.vitepress");
|
|
276
|
-
const outputDir = docsLayout ? "docs/.vitepress/dist" : ".vitepress/dist";
|
|
277
|
-
const cmd = pkg.scripts?.["docs:build"]
|
|
278
|
-
? "npm run docs:build"
|
|
279
|
-
: buildCmd(pkg, "npx vitepress build");
|
|
280
|
-
return {
|
|
281
|
-
framework_hint: "vitepress",
|
|
282
|
-
build_cmd: cmd,
|
|
283
|
-
output_dir: outputDir,
|
|
284
|
-
confident: true,
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
if (hasDep(pkg, "vite") || (await fileExists(cwd, "vite.config.ts", "vite.config.js", "vite.config.mjs"))) {
|
|
288
|
-
return {
|
|
289
|
-
framework_hint: "vite",
|
|
290
|
-
build_cmd: buildCmd(pkg, "npx vite build"),
|
|
291
|
-
output_dir: "dist",
|
|
292
|
-
confident: true,
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
// Angular after Vite — mirrors builder ALL order. Angular ships its
|
|
296
|
-
// own CLI (`ng build`) and writes to `dist/{project}/` (Angular <17)
|
|
297
|
-
// or `dist/{project}/browser/` (17+ `application` builder), NOT bare
|
|
298
|
-
// `dist`. Until this branch existed the CLI fell through to the
|
|
299
|
-
// static fallback (`output_dir='.'`), so the first deploy of an
|
|
300
|
-
// Angular repo shipped the raw sources — the dist/<project> S3 404
|
|
301
|
-
// incident. `angularOutputDir` parses angular.json to pin the path
|
|
302
|
-
// (lockstep with `frameworks/angular.py:extract_output_dir`).
|
|
303
|
-
if (hasDep(pkg, "@angular/core") || (await fileExists(cwd, "angular.json"))) {
|
|
304
|
-
return {
|
|
305
|
-
framework_hint: "angular",
|
|
306
|
-
build_cmd: buildCmd(pkg, "npx ng build"),
|
|
307
|
-
output_dir: await angularOutputDir(cwd),
|
|
308
|
-
confident: true,
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
if (hasDep(pkg, "react-scripts")) {
|
|
312
|
-
return {
|
|
313
|
-
framework_hint: "cra",
|
|
314
|
-
build_cmd: buildCmd(pkg, "npx react-scripts build"),
|
|
315
|
-
output_dir: "build",
|
|
316
|
-
confident: true,
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
// Eleventy late in the package.json branch — `@11ty/eleventy` is
|
|
320
|
-
// unique enough not to collide with other frameworks, but Vite /
|
|
321
|
-
// CRA / etc. should win if both are present (a project that pulls
|
|
322
|
-
// both is probably using Vite as the runtime and 11ty just for one
|
|
323
|
-
// build step).
|
|
324
|
-
if (hasDep(pkg, "@11ty/eleventy") ||
|
|
325
|
-
hasDep(pkg, "eleventy") ||
|
|
326
|
-
(await fileExists(cwd, ".eleventy.js", "eleventy.config.js", "eleventy.config.mjs", "eleventy.config.cjs"))) {
|
|
327
|
-
return {
|
|
328
|
-
framework_hint: "eleventy",
|
|
329
|
-
build_cmd: buildCmd(pkg, "npx @11ty/eleventy"),
|
|
330
|
-
output_dir: "_site",
|
|
331
|
-
confident: true,
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
// package.json present but no framework matched → a custom Node build,
|
|
335
|
-
// NOT a static site. Mirrors the backend's detect(): `pkg is None →
|
|
336
|
-
// _STATIC`, otherwise `_GENERIC` (npm run build → dist). Falling through
|
|
337
|
-
// to the static fallback here shipped the unbuilt sources — exactly the
|
|
338
|
-
// `diplomtest` case (Express app + custom build script detected as static
|
|
339
|
-
// by the CLI but `generic` by the backend).
|
|
340
|
-
return {
|
|
341
|
-
framework_hint: "generic",
|
|
342
|
-
build_cmd: buildCmd(pkg, "npm run build"),
|
|
343
|
-
output_dir: "dist",
|
|
344
|
-
confident: false,
|
|
345
|
-
};
|
|
346
|
-
}
|
|
347
|
-
// Python runtimes (Streamlit / Gradio / Flask): an `app.py` entry plus a
|
|
348
|
-
// runtime lib in requirements.txt. Mirrors detect_runtime() / runtime_detect.py.
|
|
349
|
-
// No package.json, so without this they fall to the static fallback and ship
|
|
350
|
-
// `app.py` as a static file (never runs) — the `streamlit-hello` case.
|
|
351
|
-
const pyRuntime = await detectPythonRuntime(cwd);
|
|
352
|
-
if (pyRuntime) {
|
|
158
|
+
// node_web / python_web / streamlit / gradio → static placeholder; the
|
|
159
|
+
// container serves the app, the SPA pipeline is a no-op.
|
|
353
160
|
return {
|
|
354
161
|
framework_hint: "static",
|
|
355
162
|
build_cmd: "true",
|
|
356
163
|
output_dir: ".",
|
|
357
164
|
confident: true,
|
|
358
|
-
runtime_kind:
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
// Non-Node SSGs (Hugo today). Recognise repos without package.json
|
|
362
|
-
// before we fall back to "static" with a no-op build command — Hugo
|
|
363
|
-
// needs `hugo --gc --minify` and writes to `public/`, the static
|
|
364
|
-
// fallback would just upload the raw .md sources.
|
|
365
|
-
if ((await fileExists(cwd, "hugo.toml", "hugo.yaml", "hugo.json")) ||
|
|
366
|
-
((await fileExists(cwd, "config.toml", "config.yaml", "config.json")) &&
|
|
367
|
-
(await hasHugoConfigMarker(cwd)))) {
|
|
368
|
-
return {
|
|
369
|
-
framework_hint: "hugo",
|
|
370
|
-
build_cmd: "hugo --gc --minify",
|
|
371
|
-
output_dir: "public",
|
|
372
|
-
confident: true,
|
|
165
|
+
runtime_kind: plan.projectType,
|
|
373
166
|
};
|
|
374
167
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
// ship whatever is on disk verbatim.
|
|
379
|
-
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);
|
|
380
171
|
return {
|
|
381
|
-
framework_hint:
|
|
382
|
-
build_cmd
|
|
383
|
-
output_dir: ".",
|
|
384
|
-
confident:
|
|
172
|
+
framework_hint: framework,
|
|
173
|
+
build_cmd,
|
|
174
|
+
output_dir: plan.outputDir ?? ".",
|
|
175
|
+
confident: plan.confident,
|
|
176
|
+
...(warning ? { ssr_warning: warning } : {}),
|
|
385
177
|
};
|
|
386
178
|
}
|
|
387
|
-
const HUGO_CONFIG_TOKENS = [
|
|
388
|
-
"baseURL",
|
|
389
|
-
"baseurl",
|
|
390
|
-
"languageCode",
|
|
391
|
-
"languagecode",
|
|
392
|
-
"[params]",
|
|
393
|
-
"[markup]",
|
|
394
|
-
"[menu",
|
|
395
|
-
"[taxonomies]",
|
|
396
|
-
"hugoVersion",
|
|
397
|
-
"minVersion",
|
|
398
|
-
"theme",
|
|
399
|
-
];
|
|
400
|
-
// Match `ssr: false` or `nitro: { preset: 'static' }` in nuxt.config —
|
|
401
|
-
// either marker keeps the project on the SPA pipeline. Mirrors the same
|
|
402
|
-
// "string-search before AST" approach used for Next.js.
|
|
403
|
-
const NUXT_STATIC_SSR_RE = /\bssr\s*:\s*false\b/m;
|
|
404
|
-
const NUXT_STATIC_PRESET_RE = /preset\s*:\s*['"]static['"]/m;
|
|
405
|
-
async function nuxtConfigDeclaresStatic(cwd) {
|
|
406
|
-
for (const name of ["nuxt.config.mjs", "nuxt.config.ts", "nuxt.config.js"]) {
|
|
407
|
-
try {
|
|
408
|
-
const txt = stripJsComments(await fs.readFile(path.join(cwd, name), "utf-8"));
|
|
409
|
-
if (NUXT_STATIC_SSR_RE.test(txt) || NUXT_STATIC_PRESET_RE.test(txt))
|
|
410
|
-
return true;
|
|
411
|
-
return false; // config found, no static marker → SSR
|
|
412
|
-
}
|
|
413
|
-
catch (err) {
|
|
414
|
-
if (err?.code === "ENOENT")
|
|
415
|
-
continue;
|
|
416
|
-
return false;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
return false; // no config at all → defaults to SSR
|
|
420
|
-
}
|
|
421
|
-
async function hasVitepressConfig(cwd, prefix = ".vitepress") {
|
|
422
|
-
for (const name of ["config.ts", "config.js", "config.mts", "config.mjs"]) {
|
|
423
|
-
try {
|
|
424
|
-
await fs.access(path.join(cwd, prefix, name));
|
|
425
|
-
return true;
|
|
426
|
-
}
|
|
427
|
-
catch {
|
|
428
|
-
// try next
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
return false;
|
|
432
|
-
}
|
|
433
|
-
async function hasHugoConfigMarker(cwd) {
|
|
434
|
-
for (const fn of ["config.toml", "config.yaml", "config.json"]) {
|
|
435
|
-
try {
|
|
436
|
-
const head = await fs.readFile(path.join(cwd, fn), "utf-8");
|
|
437
|
-
if (HUGO_CONFIG_TOKENS.some((t) => head.includes(t)))
|
|
438
|
-
return true;
|
|
439
|
-
}
|
|
440
|
-
catch {
|
|
441
|
-
// try next
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
return false;
|
|
445
|
-
}
|
|
446
|
-
// Resolve Angular's real build output directory from angular.json.
|
|
447
|
-
// Lockstep with `core/builder/src/frameworks/angular.py:extract_output_dir`
|
|
448
|
-
// — same dual-detector-drift class as Next.js static-export, so the two
|
|
449
|
-
// implementations must agree (parity fixtures in
|
|
450
|
-
// core/tests/fixtures/framework-detect/angular-*).
|
|
451
|
-
//
|
|
452
|
-
// Resolution:
|
|
453
|
-
// 1. defaultProject if set & present, else first project in `projects`
|
|
454
|
-
// 2. project.architect.build.options.outputPath, else the CLI default
|
|
455
|
-
// `dist/{projectName}`
|
|
456
|
-
// 3. strip leading `./`
|
|
457
|
-
// 4. append `/browser` for the Angular 17+ `application` builder
|
|
458
|
-
// (builder id ends with `:application`) — it writes the served
|
|
459
|
-
// assets one level deeper. We also probe disk in case the repo was
|
|
460
|
-
// built locally before `layero deploy`.
|
|
461
|
-
// Returns the framework default `dist` on any parse error — matches
|
|
462
|
-
// AngularFramework.default_output_dir, and the builder re-derives the
|
|
463
|
-
// exact path at upload time anyway.
|
|
464
|
-
async function angularOutputDir(cwd) {
|
|
465
|
-
const FALLBACK = "dist";
|
|
466
|
-
let cfg;
|
|
467
|
-
try {
|
|
468
|
-
cfg = JSON.parse(await fs.readFile(path.join(cwd, "angular.json"), "utf-8"));
|
|
469
|
-
}
|
|
470
|
-
catch {
|
|
471
|
-
return FALLBACK;
|
|
472
|
-
}
|
|
473
|
-
const projects = cfg?.projects;
|
|
474
|
-
if (!projects || typeof projects !== "object")
|
|
475
|
-
return FALLBACK;
|
|
476
|
-
const firstName = Object.keys(projects)[0];
|
|
477
|
-
if (!firstName)
|
|
478
|
-
return FALLBACK;
|
|
479
|
-
const defaultName = cfg.defaultProject;
|
|
480
|
-
const projectName = defaultName && projects[defaultName] ? defaultName : firstName;
|
|
481
|
-
const buildCfg = projects[projectName]?.architect?.build;
|
|
482
|
-
if (!buildCfg || typeof buildCfg !== "object")
|
|
483
|
-
return FALLBACK;
|
|
484
|
-
const opts = (buildCfg.options ?? {});
|
|
485
|
-
const rawOut = typeof opts === "object" && opts ? opts.outputPath : undefined;
|
|
486
|
-
let out = typeof rawOut === "string" && rawOut.trim() ? rawOut.trim() : `dist/${projectName}`;
|
|
487
|
-
if (out.startsWith("./"))
|
|
488
|
-
out = out.slice(2);
|
|
489
|
-
const builderId = typeof buildCfg.builder === "string" ? buildCfg.builder : "";
|
|
490
|
-
const isApplicationBuilder = builderId.endsWith(":application");
|
|
491
|
-
let browserExists = false;
|
|
492
|
-
try {
|
|
493
|
-
browserExists = (await fs.stat(path.join(cwd, out, "browser"))).isDirectory();
|
|
494
|
-
}
|
|
495
|
-
catch {
|
|
496
|
-
// not built locally — rely on the builder-id signal below
|
|
497
|
-
}
|
|
498
|
-
return browserExists || isApplicationBuilder ? `${out}/browser` : out;
|
|
499
|
-
}
|
package/dist/logs.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
+
import { detectMode, emit } from "./agent.js";
|
|
2
3
|
const POLL_INTERVAL_MS = 1500;
|
|
3
4
|
function colorForStream(stream) {
|
|
4
5
|
switch (stream) {
|
|
@@ -11,6 +12,11 @@ function colorForStream(stream) {
|
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
14
|
export async function streamDeployLogs(api, deployId) {
|
|
15
|
+
// In JSON mode stdout must stay a clean JSON-lines stream: build logs go
|
|
16
|
+
// out as structured `build_log` / `stage` events (N2), never as raw text
|
|
17
|
+
// interleaved with the lifecycle events. In human mode we keep the
|
|
18
|
+
// coloured stdout + stage banners on stderr.
|
|
19
|
+
const json = detectMode().json;
|
|
14
20
|
let afterId = 0;
|
|
15
21
|
let lastStage = null;
|
|
16
22
|
// Loop until terminal — the server's per-stage timeouts bound duration.
|
|
@@ -19,12 +25,22 @@ export async function streamDeployLogs(api, deployId) {
|
|
|
19
25
|
const poll = await api.pollLogs(deployId, afterId);
|
|
20
26
|
if (poll.current_stage && poll.current_stage !== lastStage) {
|
|
21
27
|
lastStage = poll.current_stage;
|
|
22
|
-
|
|
28
|
+
if (json) {
|
|
29
|
+
emit({ event: "stage", name: poll.current_stage });
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
process.stderr.write(chalk.bold.blue(`▶ stage: ${poll.current_stage}\n`));
|
|
33
|
+
}
|
|
23
34
|
}
|
|
24
35
|
for (const line of poll.lines) {
|
|
25
36
|
afterId = Math.max(afterId, line.id);
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
if (json) {
|
|
38
|
+
emit({ event: "build_log", line: line.line, stream: line.stream });
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const paint = colorForStream(line.stream);
|
|
42
|
+
process.stdout.write(paint(line.line) + "\n");
|
|
43
|
+
}
|
|
28
44
|
}
|
|
29
45
|
if (poll.terminal) {
|
|
30
46
|
return poll;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "layero",
|
|
3
|
-
"version": "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
|
},
|