layero 0.6.1 → 0.7.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/README.md CHANGED
@@ -71,6 +71,12 @@ Run `layero <cmd> --help` for full options.
71
71
  uses that explicit path. Use this for CI flows that build in the
72
72
  pipeline, Webflow / Framer exports, or whenever you don't want the
73
73
  platform to run install/build for you.
74
+ - `--root <dir>` — monorepo: tell the builder the app lives in a
75
+ subdirectory of the repo (e.g. `--root apps/web`). Saved on the
76
+ project; future GitHub-push and hook triggers use the same value.
77
+ CLI auto-detect honours it: framework signals are looked up inside
78
+ `<cwd>/<root>` so a `package.json` workspace at the repo root
79
+ doesn't shadow the real app's stack.
74
80
  - `--name <name>` — project name (only on first deploy).
75
81
  - `--project <id_or_slug>` — deploy into an existing project, ignoring
76
82
  `./.layero/project.json` (useful for CI).
@@ -92,6 +98,7 @@ Run `layero <cmd> --help` for full options.
92
98
  | `gatsby` dep | gatsby | `npm run build` | `public` |
93
99
  | `astro` dep / `astro.config.*` | astro | `npm run build` | `dist` |
94
100
  | `@docusaurus/core` dep / `docusaurus.config.*` | docusaurus | `npm run build` | `build` |
101
+ | `@storybook/*` dep / `scripts.build-storybook` / `.storybook/main.*` | storybook | `npm run build-storybook` (or `npx storybook build`) | `storybook-static` |
95
102
  | `vitepress` dep / `.vitepress/config.*` / `docs/.vitepress/config.*` | vitepress | `npm run docs:build` (or `npx vitepress build`) | `.vitepress/dist` or `docs/.vitepress/dist` |
96
103
  | `vite` dep / `vite.config.*` | vite | `npm run build` | `dist` |
97
104
  | `react-scripts` dep | cra | `npm run build` | `build` |
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
+ updateProject(projectId, input) {
72
+ return this.request("PATCH", `/projects/${projectId}`, input);
73
+ }
71
74
  triggerDeploy(projectId, input) {
72
75
  return this.request("POST", `/projects/${projectId}/deploy`, input);
73
76
  }
@@ -81,6 +84,16 @@ export class ApiClient {
81
84
  rollbackProject(projectId, input) {
82
85
  return this.request("POST", `/projects/${projectId}/rollback`, input);
83
86
  }
87
+ // V071 production-pointer: pin apex to a specific deploy. `source` is
88
+ // recorded in promote_events and lets us split CLI vs UI adoption later.
89
+ promoteDeploy(projectId, deployId) {
90
+ return this.request("POST", `/projects/${projectId}/promote`, { deploy_id: deployId, source: "cli" });
91
+ }
92
+ // Clear projects.production_deploy_id — apex resumes following latest
93
+ // ready deploy of default_branch (or sole-env, see V071 host_resolver).
94
+ unpinProduction(projectId) {
95
+ return this.request("POST", `/projects/${projectId}/unpin`);
96
+ }
84
97
  listDeployHooks(projectId) {
85
98
  return this.request("GET", `/projects/${projectId}/deploy-hooks`);
86
99
  }
@@ -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.")
@@ -154,19 +170,22 @@ async function main() {
154
170
  program
155
171
  .command("deploy")
156
172
  .description("Pack the current directory and deploy it. Framework, build command and output directory are auto-detected.")
157
- .option("-t, --type <preset>", "framework override (vite | vitepress | next | astro | cra | sveltekit | nuxt | gatsby | docusaurus | eleventy | hugo | static)")
173
+ .option("-t, --type <preset>", "framework override (vite | vitepress | next | astro | cra | sveltekit | nuxt | gatsby | docusaurus | storybook | eleventy | hugo | static)")
158
174
  .option("--name <name>", "project name (only used on first deploy)")
159
175
  .option("--project <id_or_slug>", "deploy into an existing project, ignoring local config")
160
176
  .option("-y, --yes", "non-interactive: accept defaults and skip --prod confirmation")
161
177
  .option("--config", "(legacy alias of the default behaviour — auto-detect + .layero/project.json values)")
162
178
  .option("--prebuilt [dir]", "ship an already-built artifact directory (default auto-pick: dist/build/public/out/_site/...)")
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)")
163
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.")
164
182
  .option("--branch <name>", "deploy to a specific branch's environment. Wins over --prod.")
165
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.")
166
184
  .addHelpText("after", "\nExamples:\n" +
167
185
  " $ layero deploy # preview deploy (CLI pseudo-branch), auto-detect framework\n" +
168
186
  " $ layero deploy --prod # production deploy (interactive confirm)\n" +
169
187
  " $ layero deploy --prod --yes # production deploy, no prompt (CI)\n" +
188
+ " $ layero deploy --promote # preview deploy + pin apex (one-shot publish)\n" +
170
189
  " $ layero deploy --branch=staging # preview on a specific branch\n" +
171
190
  " $ layero deploy --type vite # force a framework preset\n" +
172
191
  " $ layero deploy --json # machine-readable output for agents")
@@ -26,6 +26,7 @@ const VALID_TYPES = new Set([
26
26
  "eleventy",
27
27
  "11ty",
28
28
  "vitepress",
29
+ "storybook",
29
30
  ]);
30
31
  function dashboardOrigin(apiUrl) {
31
32
  const override = process.env.LAYERO_DASHBOARD_URL;
@@ -201,7 +202,11 @@ async function resolvePrebuiltDir(cwd, raw) {
201
202
  // Resolve the framework / build / output config to send to completeSetup.
202
203
  // Precedence: explicit CLI args > .layero/project.json > auto-detect.
203
204
  async function resolveSetupConfig(cwd, opts, existing) {
204
- const detected = await detectProject(cwd);
205
+ // Honour --root when auto-detecting: the framework signals live in the
206
+ // monorepo subdir, not the repo root. Without this the detector sees
207
+ // a bare workspace package.json and falls through to "static".
208
+ const detectCwd = opts.root ? path.join(cwd, opts.root) : cwd;
209
+ const detected = await detectProject(detectCwd);
205
210
  emit({
206
211
  event: "detected",
207
212
  framework: detected.framework_hint,
@@ -298,9 +303,15 @@ export async function deployCmd(opts) {
298
303
  output_dir: setup.output_dir,
299
304
  analytics_enabled: persistedCfg.analytics_enabled ?? false,
300
305
  env_vars: persistedCfg.env_vars ?? {},
306
+ root_directory: opts.root ?? null,
301
307
  });
302
308
  emit({ event: "setup_applied" });
303
309
  }
310
+ else if (opts.root !== undefined) {
311
+ // Active project: --root patches the existing row so the *next*
312
+ // GitHub-pushed or hook-triggered build uses the new subdir.
313
+ project = await api.updateProject(project.id, { root_directory: opts.root });
314
+ }
304
315
  let upload = null;
305
316
  try {
306
317
  upload = await packAndUpload(api, cwd, project, prebuiltDir);
@@ -319,14 +330,31 @@ export async function deployCmd(opts) {
319
330
  if (final.status !== "ready") {
320
331
  throw new LayeroError(`deploy_${final.status}`, `deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`, `inspect logs at ${projectUrl(cliCfg.apiUrl, project.id)}`);
321
332
  }
322
- const liveUrl = opts.prod && !opts.branch
323
- ? `https://${project.apex_hostname}`
324
- : projectUrl(cliCfg.apiUrl, project.id);
333
+ // --promote: pin apex_hostname to this deploy after a successful
334
+ // build. Independent of --prod; --promote lets the typical "CLI
335
+ // preview branch" deploy publish straight to production in one
336
+ // command. Best-effort: a failure here doesn't fail the deploy
337
+ // (the artifact is good; promote can be retried via `layero promote`).
338
+ let promoted = false;
339
+ if (opts.promote) {
340
+ try {
341
+ await api.promoteDeploy(project.id, deploy.id);
342
+ promoted = true;
343
+ }
344
+ catch (err) {
345
+ const msg = err instanceof Error ? err.message : String(err);
346
+ console.warn(chalk.yellow(`warning: deploy succeeded but promote failed — ${msg}.\n` +
347
+ ` retry with: layero promote ${deploy.id.slice(0, 8)}`));
348
+ }
349
+ }
350
+ const apexUrl = `https://${project.apex_hostname}`;
351
+ const previewUrl = projectUrl(cliCfg.apiUrl, project.id);
352
+ const liveUrl = promoted || (opts.prod && !opts.branch) ? apexUrl : previewUrl;
325
353
  emit({
326
354
  event: "ready",
327
355
  url: liveUrl,
328
356
  deploy_id: deploy.id,
329
- preview_url: opts.prod && !opts.branch ? undefined : projectUrl(cliCfg.apiUrl, project.id),
357
+ preview_url: promoted || (opts.prod && !opts.branch) ? undefined : previewUrl,
330
358
  });
331
359
  }
332
360
  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
@@ -115,6 +115,46 @@ export async function detectProject(cwd) {
115
115
  confident: true,
116
116
  };
117
117
  }
118
+ // Storybook before Vite/CRA: Storybook 7+ uses Vite or webpack
119
+ // internally, so `vite` / `react-scripts` is in deps. Without
120
+ // listing it first the SPA Vite path would output_dir=dist, which
121
+ // doesn't exist after `build-storybook`.
122
+ const storybookDeps = [
123
+ "@storybook/cli",
124
+ "@storybook/react",
125
+ "@storybook/react-vite",
126
+ "@storybook/react-webpack5",
127
+ "@storybook/vue",
128
+ "@storybook/vue3",
129
+ "@storybook/vue3-vite",
130
+ "@storybook/svelte",
131
+ "@storybook/svelte-vite",
132
+ "@storybook/web-components",
133
+ "@storybook/web-components-vite",
134
+ "@storybook/preact",
135
+ "@storybook/angular",
136
+ "@storybook/nextjs",
137
+ "@storybook/html",
138
+ "@storybook/html-vite",
139
+ "storybook",
140
+ ];
141
+ const hasStorybookDep = storybookDeps.some((d) => hasDep(pkg, d));
142
+ const hasStorybookScript = pkg.scripts?.["build-storybook"] !== undefined
143
+ || Object.values(pkg.scripts ?? {}).some((s) => typeof s === "string" && s.includes("storybook build"));
144
+ const hasStorybookDir = await fileExists(cwd, ".storybook/main.js", ".storybook/main.ts", ".storybook/main.cjs", ".storybook/main.mjs");
145
+ if (hasStorybookDep || hasStorybookScript || hasStorybookDir) {
146
+ const cmd = pkg.scripts?.["build-storybook"]
147
+ ? "npm run build-storybook"
148
+ : (pkg.scripts?.build && pkg.scripts.build.includes("storybook"))
149
+ ? "npm run build"
150
+ : "npx storybook build";
151
+ return {
152
+ framework_hint: "storybook",
153
+ build_cmd: cmd,
154
+ output_dir: "storybook-static",
155
+ confident: true,
156
+ };
157
+ }
118
158
  // VitePress before generic Vite: VitePress repos often pull `vite`
119
159
  // transitively, and the SPA Vite path would set output_dir=dist —
120
160
  // wrong for VitePress, which writes to `.vitepress/dist/` (or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layero",
3
- "version": "0.6.1",
3
+ "version": "0.7.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",