layero 0.7.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,29 @@ 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: prefer the backend's canonical_url; fall back to the
428
+ // apex for the no-branch case (CLI auto-promote), else the dashboard.
429
+ const liveUrl = probe?.canonical_url ??
430
+ (promoted || !opts.branch ? apexUrl : dashboardUrl);
381
431
  emit({
382
432
  event: "ready",
383
433
  url: liveUrl,
384
434
  deploy_id: deploy.id,
385
- preview_url: promoted || (opts.prod && !opts.branch) ? undefined : previewUrl,
435
+ preview_url: probe?.preview_url ?? undefined,
436
+ dashboard_url: dashboardUrl,
437
+ edge_ready: probe ? probe.cdn_ready : undefined,
438
+ edge_eta_seconds: probe && !probe.cdn_ready && probe.cdn_eta_seconds != null
439
+ ? probe.cdn_eta_seconds
440
+ : undefined,
386
441
  });
387
442
  }
388
443
  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,5 +1,22 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
+ // package.json deps marking a Node web backend → node_web. Lockstep with
4
+ // builder/src/runtime_detect.py NODE_WEB_SIGNALS / NODE_FRONTEND_DEPS.
5
+ const NODE_WEB_SIGNALS = [
6
+ "express", "fastify", "koa", "@nestjs/core", "@hapi/hapi", "hapi",
7
+ "hono", "@adonisjs/core", "restify", "polka", "@feathersjs/feathers",
8
+ "sails", "h3", "json-server",
9
+ ];
10
+ const NODE_FRONTEND_DEPS = [
11
+ "next", "nuxt", "vite", "react-scripts", "@angular/core", "@sveltejs/kit",
12
+ "gatsby", "astro", "@docusaurus/core", "@11ty/eleventy", "vitepress",
13
+ ];
14
+ // A Node backend: a server framework AND no frontend/SSG framework (which
15
+ // would build to static and belong to the SPA pipeline instead).
16
+ function detectNodeRuntime(pkg) {
17
+ return (NODE_WEB_SIGNALS.some((d) => hasDep(pkg, d)) &&
18
+ !NODE_FRONTEND_DEPS.some((d) => hasDep(pkg, d)));
19
+ }
3
20
  async function readPkg(cwd) {
4
21
  try {
5
22
  const raw = await fs.readFile(path.join(cwd, "package.json"), "utf-8");
@@ -26,11 +43,23 @@ async function fileExists(cwd, ...candidates) {
26
43
  }
27
44
  return false;
28
45
  }
29
- // Python runtime: `app.py` entry + a runtime lib in requirements.txt.
46
+ // Python runtime: `app.py`/`main.py` entry + a runtime lib in requirements.txt.
30
47
  // Lockstep with detect_runtime() (backend) / runtime_detect.py (builder):
31
- // app.py + requirements.txt mentioning streamlit | gradio | flask.
48
+ // Streamlit/Gradio keep their self-contained runtimes; Flask/FastAPI and any
49
+ // WSGI|ASGI server route to the generic python_web runtime.
50
+ // Lockstep with builder/src/runtime_detect.py PY_WEB_SIGNALS + framework_detector.py.
51
+ const PY_WEB_SIGNALS = [
52
+ "fastapi", "starlette", "litestar", "starlite", "quart", "sanic",
53
+ "blacksheep", "hypercorn", "daphne", "uvicorn",
54
+ "flask", "falcon", "bottle", "pyramid", "cherrypy", "werkzeug", "gunicorn",
55
+ "aiohttp", "tornado", "django",
56
+ ];
32
57
  async function detectPythonRuntime(cwd) {
33
- if (!(await fileExists(cwd, "app.py")))
58
+ const appPy = await fileExists(cwd, "app.py");
59
+ const mainPy = await fileExists(cwd, "main.py");
60
+ // Django's entrypoint is manage.py (no app.py/main.py at the root).
61
+ const managePy = await fileExists(cwd, "manage.py");
62
+ if (!appPy && !mainPy && !managePy)
34
63
  return null;
35
64
  let reqs;
36
65
  try {
@@ -39,10 +68,12 @@ async function detectPythonRuntime(cwd) {
39
68
  catch {
40
69
  return null;
41
70
  }
42
- for (const lib of ["streamlit", "gradio", "flask"]) {
43
- if (reqs.includes(lib))
44
- return lib;
45
- }
71
+ if (reqs.includes("streamlit") && appPy)
72
+ return "streamlit";
73
+ if (reqs.includes("gradio") && appPy)
74
+ return "gradio";
75
+ if (PY_WEB_SIGNALS.some((s) => reqs.includes(s)))
76
+ return "python_web";
46
77
  return null;
47
78
  }
48
79
  // Mirrors `frameworks/nextjs.py:_NEXT_STATIC_EXPORT_RE` and
@@ -130,6 +161,18 @@ export async function detectProject(cwd) {
130
161
  ...(isSsr ? { runtime_kind: "ssr_next" } : {}),
131
162
  };
132
163
  }
164
+ // Node web backend (Express/Fastify/Koa/NestJS/Hapi/Hono/…) → node_web.
165
+ // Build (TypeScript compile) runs inside the container image, so the SPA
166
+ // pipeline is a no-op here.
167
+ if (detectNodeRuntime(pkg)) {
168
+ return {
169
+ framework_hint: "static",
170
+ build_cmd: "true",
171
+ output_dir: ".",
172
+ confident: true,
173
+ runtime_kind: "node_web",
174
+ };
175
+ }
133
176
  if (hasDep(pkg, "nuxt") || hasDep(pkg, "nuxt3") || (await fileExists(cwd, "nuxt.config.ts", "nuxt.config.js", "nuxt.config.mjs"))) {
134
177
  const generateScript = pkg.scripts?.generate;
135
178
  const buildScript = pkg.scripts?.build;
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.0",
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",