layero 0.6.2 → 0.7.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/dist/agent.js +7 -0
- package/dist/api.js +13 -0
- package/dist/bin/layero.js +18 -0
- package/dist/commands/deploy.js +49 -5
- package/dist/commands/promote.js +106 -0
- package/dist/detect.js +38 -2
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -124,6 +124,13 @@ function renderHuman(event) {
|
|
|
124
124
|
case "setup_applied":
|
|
125
125
|
process.stdout.write(`✓ Setup applied\n`);
|
|
126
126
|
break;
|
|
127
|
+
case "runtime_type_applied":
|
|
128
|
+
process.stdout.write(`✓ Project type set to ${event.project_type}\n`);
|
|
129
|
+
break;
|
|
130
|
+
case "runtime_type_apply_failed":
|
|
131
|
+
process.stdout.write(`! Failed to set project_type automatically: ${event.error}\n`);
|
|
132
|
+
process.stdout.write(` Build may fail at detect; accept the suggestion in the dashboard if so.\n`);
|
|
133
|
+
break;
|
|
127
134
|
case "deploy_started":
|
|
128
135
|
process.stdout.write(`→ Building...\n`);
|
|
129
136
|
break;
|
package/dist/api.js
CHANGED
|
@@ -68,6 +68,9 @@ export class ApiClient {
|
|
|
68
68
|
completeSetup(projectId, input) {
|
|
69
69
|
return this.request("POST", `/projects/${projectId}/setup`, input);
|
|
70
70
|
}
|
|
71
|
+
setRuntimeType(projectId, projectType) {
|
|
72
|
+
return this.request("POST", `/projects/${projectId}/runtime-type`, { project_type: projectType });
|
|
73
|
+
}
|
|
71
74
|
updateProject(projectId, input) {
|
|
72
75
|
return this.request("PATCH", `/projects/${projectId}`, input);
|
|
73
76
|
}
|
|
@@ -84,6 +87,16 @@ export class ApiClient {
|
|
|
84
87
|
rollbackProject(projectId, input) {
|
|
85
88
|
return this.request("POST", `/projects/${projectId}/rollback`, input);
|
|
86
89
|
}
|
|
90
|
+
// V071 production-pointer: pin apex to a specific deploy. `source` is
|
|
91
|
+
// recorded in promote_events and lets us split CLI vs UI adoption later.
|
|
92
|
+
promoteDeploy(projectId, deployId) {
|
|
93
|
+
return this.request("POST", `/projects/${projectId}/promote`, { deploy_id: deployId, source: "cli" });
|
|
94
|
+
}
|
|
95
|
+
// Clear projects.production_deploy_id — apex resumes following latest
|
|
96
|
+
// ready deploy of default_branch (or sole-env, see V071 host_resolver).
|
|
97
|
+
unpinProduction(projectId) {
|
|
98
|
+
return this.request("POST", `/projects/${projectId}/unpin`);
|
|
99
|
+
}
|
|
87
100
|
listDeployHooks(projectId) {
|
|
88
101
|
return this.request("GET", `/projects/${projectId}/deploy-hooks`);
|
|
89
102
|
}
|
package/dist/bin/layero.js
CHANGED
|
@@ -11,6 +11,7 @@ import { linkCmd } from "../commands/link.js";
|
|
|
11
11
|
import { tokenSetCmd } from "../commands/token.js";
|
|
12
12
|
import { deployCmd } from "../commands/deploy.js";
|
|
13
13
|
import { deploysListCmd, rollbackCmd } from "../commands/deploys.js";
|
|
14
|
+
import { promoteCmd } from "../commands/promote.js";
|
|
14
15
|
import { hooksCreateCmd, hooksDeleteCmd, hooksListCmd } from "../commands/hooks.js";
|
|
15
16
|
import { loginCmd } from "../commands/login.js";
|
|
16
17
|
import { orgsListCmd } from "../commands/orgs.js";
|
|
@@ -124,6 +125,21 @@ async function main() {
|
|
|
124
125
|
process.exitCode = 1;
|
|
125
126
|
}
|
|
126
127
|
});
|
|
128
|
+
program
|
|
129
|
+
.command("promote [deploy]")
|
|
130
|
+
.description("Pin the project apex to a specific deploy (V071 production-pointer). " +
|
|
131
|
+
"Without [deploy] picks the latest ready build on --branch (defaults to the 'cli' pseudo-branch).")
|
|
132
|
+
.option("--project <id_or_slug>", "target project (default: linked .layero/project.json)")
|
|
133
|
+
.option("--branch <name>", "branch to pick latest ready deploy from (default: cli)")
|
|
134
|
+
.option("-y, --yes", "skip the confirmation prompt (CI)")
|
|
135
|
+
.addHelpText("after", "\nExamples:\n" +
|
|
136
|
+
" $ layero promote # pin apex to latest ready deploy on `cli` branch\n" +
|
|
137
|
+
" $ layero promote --branch=main # pin apex to latest ready deploy on main\n" +
|
|
138
|
+
" $ layero promote a3f9c2b # pin apex to a specific commit sha\n" +
|
|
139
|
+
" $ layero promote --yes # CI-friendly, no prompt")
|
|
140
|
+
.action(async (deploy, opts) => {
|
|
141
|
+
await promoteCmd(deploy, opts);
|
|
142
|
+
});
|
|
127
143
|
program
|
|
128
144
|
.command("rollback")
|
|
129
145
|
.description("Re-activate the previous successful deploy on the project's default branch.")
|
|
@@ -162,12 +178,14 @@ async function main() {
|
|
|
162
178
|
.option("--prebuilt [dir]", "ship an already-built artifact directory (default auto-pick: dist/build/public/out/_site/...)")
|
|
163
179
|
.option("--root <dir>", "monorepo: subdirectory inside the repo that the builder treats as the app root (saved on the project; future GitHub-push and hook triggers use the same value)")
|
|
164
180
|
.option("--prod", "deploy to production (replaces apex_hostname's active deploy). Without this flag, deploys go to the project's CLI preview pseudo-branch.")
|
|
181
|
+
.option("--promote", "pin the project apex to this deploy after a successful build (V071). Works for any branch — e.g. `--promote` without --prod publishes a CLI preview straight to production.")
|
|
165
182
|
.option("--branch <name>", "deploy to a specific branch's environment. Wins over --prod.")
|
|
166
183
|
.option("--org <slug>", "Layero organization slug for first-time project creation. Defaults to personal; required when you're a member of multiple orgs and want a non-personal home.")
|
|
167
184
|
.addHelpText("after", "\nExamples:\n" +
|
|
168
185
|
" $ layero deploy # preview deploy (CLI pseudo-branch), auto-detect framework\n" +
|
|
169
186
|
" $ layero deploy --prod # production deploy (interactive confirm)\n" +
|
|
170
187
|
" $ layero deploy --prod --yes # production deploy, no prompt (CI)\n" +
|
|
188
|
+
" $ layero deploy --promote # preview deploy + pin apex (one-shot publish)\n" +
|
|
171
189
|
" $ layero deploy --branch=staging # preview on a specific branch\n" +
|
|
172
190
|
" $ layero deploy --type vite # force a framework preset\n" +
|
|
173
191
|
" $ layero deploy --json # machine-readable output for agents")
|
package/dist/commands/deploy.js
CHANGED
|
@@ -213,6 +213,7 @@ async function resolveSetupConfig(cwd, opts, existing) {
|
|
|
213
213
|
build_cmd: detected.build_cmd,
|
|
214
214
|
output_dir: detected.output_dir,
|
|
215
215
|
confident: detected.confident,
|
|
216
|
+
...(detected.runtime_kind ? { runtime_kind: detected.runtime_kind } : {}),
|
|
216
217
|
});
|
|
217
218
|
const framework_hint = opts.type ?? existing?.framework_hint ?? detected.framework_hint;
|
|
218
219
|
const build_cmd = existing?.build_cmd ?? detected.build_cmd;
|
|
@@ -227,7 +228,13 @@ async function resolveSetupConfig(cwd, opts, existing) {
|
|
|
227
228
|
else {
|
|
228
229
|
source = "detected";
|
|
229
230
|
}
|
|
230
|
-
return {
|
|
231
|
+
return {
|
|
232
|
+
framework_hint,
|
|
233
|
+
build_cmd,
|
|
234
|
+
output_dir,
|
|
235
|
+
source,
|
|
236
|
+
...(detected.runtime_kind ? { runtime_kind: detected.runtime_kind } : {}),
|
|
237
|
+
};
|
|
231
238
|
}
|
|
232
239
|
export async function deployCmd(opts) {
|
|
233
240
|
const mode = detectMode();
|
|
@@ -306,6 +313,26 @@ export async function deployCmd(opts) {
|
|
|
306
313
|
root_directory: opts.root ?? null,
|
|
307
314
|
});
|
|
308
315
|
emit({ event: "setup_applied" });
|
|
316
|
+
// Newly-created projects default to project_type='spa'. If detect
|
|
317
|
+
// says the repo is SSR (Next.js without `output: 'export'`), flip
|
|
318
|
+
// the type now — otherwise the first build crashes at the detect
|
|
319
|
+
// stage with "looks like ssr_next but configured as spa", forcing
|
|
320
|
+
// the user to open the dashboard and accept the suggestion. Only
|
|
321
|
+
// applied on first setup; an explicitly-configured spa project
|
|
322
|
+
// that drops a next.config later keeps user's choice.
|
|
323
|
+
if (setup.runtime_kind) {
|
|
324
|
+
try {
|
|
325
|
+
project = await api.setRuntimeType(project.id, setup.runtime_kind);
|
|
326
|
+
emit({ event: "runtime_type_applied", project_type: setup.runtime_kind });
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
// Non-fatal: the build will fall back to the dashboard suggestion
|
|
330
|
+
// flow exactly as it did before this CLI fix. Tell the user what
|
|
331
|
+
// happened so they don't burn a deploy on a surprise crash.
|
|
332
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
333
|
+
emit({ event: "runtime_type_apply_failed", error: msg });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
309
336
|
}
|
|
310
337
|
else if (opts.root !== undefined) {
|
|
311
338
|
// Active project: --root patches the existing row so the *next*
|
|
@@ -330,14 +357,31 @@ export async function deployCmd(opts) {
|
|
|
330
357
|
if (final.status !== "ready") {
|
|
331
358
|
throw new LayeroError(`deploy_${final.status}`, `deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`, `inspect logs at ${projectUrl(cliCfg.apiUrl, project.id)}`);
|
|
332
359
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
360
|
+
// --promote: pin apex_hostname to this deploy after a successful
|
|
361
|
+
// build. Independent of --prod; --promote lets the typical "CLI
|
|
362
|
+
// preview branch" deploy publish straight to production in one
|
|
363
|
+
// command. Best-effort: a failure here doesn't fail the deploy
|
|
364
|
+
// (the artifact is good; promote can be retried via `layero promote`).
|
|
365
|
+
let promoted = false;
|
|
366
|
+
if (opts.promote) {
|
|
367
|
+
try {
|
|
368
|
+
await api.promoteDeploy(project.id, deploy.id);
|
|
369
|
+
promoted = true;
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
373
|
+
console.warn(chalk.yellow(`warning: deploy succeeded but promote failed — ${msg}.\n` +
|
|
374
|
+
` retry with: layero promote ${deploy.id.slice(0, 8)}`));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const apexUrl = `https://${project.apex_hostname}`;
|
|
378
|
+
const previewUrl = projectUrl(cliCfg.apiUrl, project.id);
|
|
379
|
+
const liveUrl = promoted || (opts.prod && !opts.branch) ? apexUrl : previewUrl;
|
|
336
380
|
emit({
|
|
337
381
|
event: "ready",
|
|
338
382
|
url: liveUrl,
|
|
339
383
|
deploy_id: deploy.id,
|
|
340
|
-
preview_url: opts.prod && !opts.branch ? undefined :
|
|
384
|
+
preview_url: promoted || (opts.prod && !opts.branch) ? undefined : previewUrl,
|
|
341
385
|
});
|
|
342
386
|
}
|
|
343
387
|
finally {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import readline from "node:readline/promises";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { ApiClient, ApiError } from "../api.js";
|
|
4
|
+
import { loadConfig } from "../config.js";
|
|
5
|
+
import { loadProjectConfig } from "../project-config.js";
|
|
6
|
+
async function resolveProjectId(api, cwd, override) {
|
|
7
|
+
if (override) {
|
|
8
|
+
const all = await api.listProjects();
|
|
9
|
+
const match = all.find((p) => p.id === override) ?? all.find((p) => p.slug === override);
|
|
10
|
+
if (!match)
|
|
11
|
+
throw new Error(`no project with id/slug "${override}"`);
|
|
12
|
+
return match.id;
|
|
13
|
+
}
|
|
14
|
+
const cfg = await loadProjectConfig(cwd);
|
|
15
|
+
if (!cfg) {
|
|
16
|
+
throw new Error("no .layero/project.json found. cd into a linked project or pass --project <slug>.");
|
|
17
|
+
}
|
|
18
|
+
return cfg.project_id;
|
|
19
|
+
}
|
|
20
|
+
function shortSha(sha) {
|
|
21
|
+
return (sha ?? "").slice(0, 7);
|
|
22
|
+
}
|
|
23
|
+
async function confirm(question) {
|
|
24
|
+
const rl = readline.createInterface({
|
|
25
|
+
input: process.stdin,
|
|
26
|
+
output: process.stdout,
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
const answer = (await rl.question(`${question} [y/N]: `)).trim().toLowerCase();
|
|
30
|
+
return answer === "y" || answer === "yes";
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
rl.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `layero promote [deploy]` — pin apex to a specific deploy.
|
|
38
|
+
*
|
|
39
|
+
* Without an argument: picks the latest ready deploy on --branch (or the
|
|
40
|
+
* "cli" pseudo-branch when not specified). With an argument: takes either
|
|
41
|
+
* a deploy id or a commit-sha prefix and promotes that one — same shape
|
|
42
|
+
* as `layero rollback --deploy`.
|
|
43
|
+
*
|
|
44
|
+
* Requires production_pointer_enabled=true on the project. The server
|
|
45
|
+
* returns 400 with a readable message if the flag is off; we surface it.
|
|
46
|
+
*/
|
|
47
|
+
export async function promoteCmd(deployArg, opts) {
|
|
48
|
+
const cliCfg = await loadConfig();
|
|
49
|
+
if (!cliCfg.token) {
|
|
50
|
+
throw new Error("not logged in. run `layero login` first.");
|
|
51
|
+
}
|
|
52
|
+
const api = new ApiClient(cliCfg);
|
|
53
|
+
const projectId = await resolveProjectId(api, process.cwd(), opts.project);
|
|
54
|
+
const project = await api.getProject(projectId);
|
|
55
|
+
let target;
|
|
56
|
+
if (deployArg) {
|
|
57
|
+
// Search across all branches — the user may know the sha but not which
|
|
58
|
+
// branch it came from. Behaviour mirrors `rollback --deploy`.
|
|
59
|
+
const all = await api.listProjectDeploys(projectId, opts.branch);
|
|
60
|
+
const m = all.find((d) => d.id === deployArg || d.commit_sha.startsWith(deployArg));
|
|
61
|
+
if (!m)
|
|
62
|
+
throw new Error(`no deploy matching "${deployArg}"`);
|
|
63
|
+
target = m;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
// No deploy arg → promote latest ready on the branch (defaults to "cli"
|
|
67
|
+
// pseudo-branch, which is where `layero deploy` without --branch lands).
|
|
68
|
+
const branch = opts.branch ?? "cli";
|
|
69
|
+
const all = await api.listProjectDeploys(projectId, branch);
|
|
70
|
+
const m = all.find((d) => d.status === "ready");
|
|
71
|
+
if (!m) {
|
|
72
|
+
throw new Error(`no ready deploy on branch "${branch}".`);
|
|
73
|
+
}
|
|
74
|
+
target = m;
|
|
75
|
+
}
|
|
76
|
+
if (target.status !== "ready") {
|
|
77
|
+
throw new Error(`cannot promote deploy ${shortSha(target.commit_sha)}: status is "${target.status}", not ready.`);
|
|
78
|
+
}
|
|
79
|
+
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)`));
|
|
86
|
+
}
|
|
87
|
+
if (!opts.yes) {
|
|
88
|
+
const ok = await confirm("publish this build to production?");
|
|
89
|
+
if (!ok) {
|
|
90
|
+
console.log(chalk.yellow("aborted."));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const updated = await api.promoteDeploy(projectId, target.id);
|
|
96
|
+
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."));
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
if (err instanceof ApiError) {
|
|
102
|
+
throw new Error(`promote failed (${err.status}): ${err.body.slice(0, 200)}`);
|
|
103
|
+
}
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/detect.js
CHANGED
|
@@ -26,6 +26,34 @@ async function fileExists(cwd, ...candidates) {
|
|
|
26
26
|
}
|
|
27
27
|
return false;
|
|
28
28
|
}
|
|
29
|
+
// Mirrors `frameworks/nextjs.py:_NEXT_STATIC_EXPORT_RE` and
|
|
30
|
+
// `next_config_static_export()` so CLI and builder can't disagree on
|
|
31
|
+
// what counts as a static-export Next.js project. The class-of-bug we
|
|
32
|
+
// hit on 2026-05-22 (builder detector v72 fix) and 2026-05-26 (CLI side)
|
|
33
|
+
// was exactly this kind of dual detector drift.
|
|
34
|
+
const NEXT_STATIC_EXPORT_RE = /output\s*:\s*['"]export['"]/m;
|
|
35
|
+
async function readNextConfigText(cwd) {
|
|
36
|
+
for (const name of ["next.config.mjs", "next.config.ts", "next.config.js", "next.config.cjs"]) {
|
|
37
|
+
try {
|
|
38
|
+
return await fs.readFile(path.join(cwd, name), "utf-8");
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (err?.code === "ENOENT")
|
|
42
|
+
continue;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
// True -> config has `output: 'export'` (static-export SPA).
|
|
49
|
+
// False -> config exists but no export marker (SSR Next.js).
|
|
50
|
+
// null -> no next.config file at all; caller treats as static-default.
|
|
51
|
+
async function nextConfigStaticExport(cwd) {
|
|
52
|
+
const text = await readNextConfigText(cwd);
|
|
53
|
+
if (text === null)
|
|
54
|
+
return null;
|
|
55
|
+
return NEXT_STATIC_EXPORT_RE.test(text);
|
|
56
|
+
}
|
|
29
57
|
async function hasAnyHtml(cwd) {
|
|
30
58
|
// Shallow check — only top-level. Recursive walk is too expensive here
|
|
31
59
|
// and the static fallback is already permissive enough.
|
|
@@ -60,12 +88,20 @@ function buildCmd(pkg, fallback) {
|
|
|
60
88
|
export async function detectProject(cwd) {
|
|
61
89
|
const pkg = await readPkg(cwd);
|
|
62
90
|
if (pkg) {
|
|
63
|
-
if (hasDep(pkg, "next") || (await fileExists(cwd, "next.config.js", "next.config.mjs", "next.config.ts"))) {
|
|
91
|
+
if (hasDep(pkg, "next") || (await fileExists(cwd, "next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"))) {
|
|
92
|
+
// A Next.js repo is SSR unless next.config explicitly declares
|
|
93
|
+
// `output: 'export'`. We only flag ssr_next when the config file
|
|
94
|
+
// exists AND lacks the export marker — same rule the builder uses
|
|
95
|
+
// in `runtime_detect.py:46`. Without a next.config file at all we
|
|
96
|
+
// err on the side of static (legacy `next export` workflow).
|
|
97
|
+
const exportFlag = await nextConfigStaticExport(cwd);
|
|
98
|
+
const isSsr = exportFlag === false;
|
|
64
99
|
return {
|
|
65
100
|
framework_hint: "nextjs",
|
|
66
101
|
build_cmd: buildCmd(pkg, "npx next build"),
|
|
67
|
-
output_dir: "out",
|
|
102
|
+
output_dir: isSsr ? ".next" : "out",
|
|
68
103
|
confident: true,
|
|
104
|
+
...(isSsr ? { runtime_kind: "ssr_next" } : {}),
|
|
69
105
|
};
|
|
70
106
|
}
|
|
71
107
|
if (hasDep(pkg, "nuxt") || hasDep(pkg, "nuxt3") || (await fileExists(cwd, "nuxt.config.ts", "nuxt.config.js", "nuxt.config.mjs"))) {
|