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 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
- process.stdout.write(`✓ Live at ${event.url}\n`);
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
+ }
@@ -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 so future chat sessions know how to deploy.")
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/.cursorrules")
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: "re-run with --debug for a stack trace, or report at https://github.com/layero/layero/issues",
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);
@@ -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 (existing) {
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
- const cliCfg = await loadConfig();
281
+ let cliCfg = await loadConfig();
246
282
  if (!cliCfg.token) {
247
- throw new LayeroError("not_logged_in", "not authenticated", "run: layero login");
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 previewUrl = projectUrl(cliCfg.apiUrl, project.id);
380
- const liveUrl = promoted || (opts.prod && !opts.branch) ? apexUrl : previewUrl;
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: promoted || (opts.prod && !opts.branch) ? undefined : previewUrl,
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 {
@@ -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;
@@ -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
- The first run emits a JSON line:
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\` field is the live site show to user, stop |
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
- Each \`npx layero deploy\` produces a new preview URL like
60
- \`https://<org>-<project>-cli.layero.ru\` — safe to run repeatedly.
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
- Add \`--prod\` only when the user explicitly asks; without it, deploys go to
63
- an isolated preview branch and never replace the apex domain.
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}
@@ -1,55 +1,6 @@
1
- import chalk from "chalk";
2
- import open from "open";
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
- const mode = detectMode();
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
  }
@@ -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
- console.log(chalk.cyan("promote plan:"));
81
- console.log(` project: ${project.slug}`);
82
- console.log(` apex: https://${apex}`);
83
- console.log(` deploy: ${shortSha(target.commit_sha)} ${chalk.dim((target.commit_message ?? "").split("\n")[0] ?? "")}`);
84
- if (project.production_deploy_id) {
85
- console.log(chalk.dim(` current: ${project.production_deploy_id.slice(0, 8)} (will be replaceable via re-promote)`));
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
- if (!opts.yes) {
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
- console.log(chalk.green(`published. https://${updated.apex_hostname} now serves deploy ${newPin.slice(0, 8)}.`));
98
- console.log(chalk.dim(" edge cache flushes within a few seconds; visitors see the new build immediately."));
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
- async function readPkg(cwd) {
3
+ import * as dc from "layero-detection";
4
+ async function readJson(p) {
4
5
  try {
5
- const raw = await fs.readFile(path.join(cwd, "package.json"), "utf-8");
6
- return JSON.parse(raw);
6
+ return JSON.parse(await fs.readFile(p, "utf-8"));
7
7
  }
8
- catch (err) {
9
- if (err?.code === "ENOENT")
10
- return null;
11
- throw err;
8
+ catch {
9
+ return null;
12
10
  }
13
11
  }
14
- function hasDep(pkg, name) {
15
- 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
+ }
16
19
  }
17
- async function fileExists(cwd, ...candidates) {
18
- for (const c of candidates) {
19
- try {
20
- 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")))
21
33
  return true;
22
- }
23
- catch {
24
- // 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;
25
39
  }
26
40
  }
27
41
  return false;
28
42
  }
29
- // Python runtime: `app.py` entry + a runtime lib in requirements.txt.
30
- // Lockstep with detect_runtime() (backend) / runtime_detect.py (builder):
31
- // app.py + requirements.txt mentioning streamlit | gradio | flask.
32
- async function detectPythonRuntime(cwd) {
33
- if (!(await fileExists(cwd, "app.py")))
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
- 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
+ }
38
52
  }
39
53
  catch {
40
- return null;
41
- }
42
- for (const lib of ["streamlit", "gradio", "flask"]) {
43
- if (reqs.includes(lib))
44
- return lib;
54
+ /* empty / unreadable cwd */
45
55
  }
46
- return null;
47
- }
48
- // Mirrors `frameworks/nextjs.py:_NEXT_STATIC_EXPORT_RE` and
49
- // `next_config_static_export()` so CLI and builder can't disagree on
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
- return await fs.readFile(path.join(cwd, name), "utf-8");
61
+ await fs.access(path.join(cwd, nested));
62
+ files.add(nested);
65
63
  }
66
- catch (err) {
67
- if (err?.code === "ENOENT")
68
- continue;
69
- return null;
70
- }
71
- }
72
- 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
+ });
73
93
  }
74
- // True -> config has `output: 'export'` (static-export SPA).
75
- // False -> config exists but no export marker (SSR Next.js).
76
- // null -> no next.config file at all; caller treats as static-default.
77
- async function nextConfigStaticExport(cwd) {
78
- const text = await readNextConfigText(cwd);
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
- async function hasAnyHtml(cwd) {
84
- // Shallow check only top-level. Recursive walk is too expensive here
85
- // and the static fallback is already permissive enough.
86
- try {
87
- const entries = await fs.readdir(cwd);
88
- 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
+ }
89
121
  }
90
- catch {
91
- 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 для статического хостинга.";
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
- * shape. Mirrors the builder's detector ([core/builder/src/frameworks])
108
- * so the values we ship to `completeSetup` match what the builder will
109
- * actually do at build time.
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 pkg = await readPkg(cwd);
116
- if (pkg) {
117
- if (hasDep(pkg, "next") || (await fileExists(cwd, "next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"))) {
118
- // A Next.js repo is SSR unless next.config explicitly declares
119
- // `output: 'export'`. We only flag ssr_next when the config file
120
- // exists AND lacks the export marker same rule the builder uses
121
- // in `runtime_detect.py:46`. Without a next.config file at all we
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(pkg, "npx next build"),
128
- output_dir: isSsr ? ".next" : "out",
152
+ build_cmd: unit.buildCmd ?? "npx next build",
153
+ output_dir: ".next",
129
154
  confident: true,
130
- ...(isSsr ? { runtime_kind: "ssr_next" } : {}),
155
+ runtime_kind: "ssr_next",
131
156
  };
132
157
  }
133
- if (hasDep(pkg, "nuxt") || hasDep(pkg, "nuxt3") || (await fileExists(cwd, "nuxt.config.ts", "nuxt.config.js", "nuxt.config.mjs"))) {
134
- const generateScript = pkg.scripts?.generate;
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: pyRuntime,
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
- // No (or unrecognised) package.json. If there's HTML on disk we treat
376
- // the current directory as a static site. Otherwise we still fall back
377
- // to static it's the most permissive option and the builder will
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: "static",
382
- build_cmd: "true",
383
- output_dir: ".",
384
- confident: _staticHtml,
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
- process.stderr.write(chalk.bold.blue(`▶ stage: ${poll.current_stage}\n`));
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
- const paint = colorForStream(line.stream);
27
- process.stdout.write(paint(line.line) + "\n");
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.7.5",
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
  },