layero 0.5.4 → 0.6.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
@@ -59,8 +59,17 @@ Run `layero <cmd> --help` for full options.
59
59
 
60
60
  ## `layero deploy` flags
61
61
 
62
- - `--type <preset>` — framework override: `vite`, `next`, `astro`, `cra`,
63
- `sveltekit`, `nuxt`, `gatsby`, `docusaurus`, `static`. **Optional** — auto-detected from `package.json` and config files when omitted.
62
+ - `--type <preset>` — framework override: `vite`, `vitepress`, `next`,
63
+ `astro`, `cra`, `sveltekit`, `nuxt`, `gatsby`, `docusaurus`, `eleventy`
64
+ (alias `11ty`), `hugo`, `static`. **Optional** — auto-detected from
65
+ `package.json` and config files when omitted.
66
+ - `--prebuilt [dir]` — ship an already-built artifact instead of building
67
+ remotely. Without an argument, picks the first existing of
68
+ `dist/`, `build/`, `public/`, `out/`, `_site/`, `.output/public/`,
69
+ `docs/.vitepress/dist/`, `.vitepress/dist/`. With `--prebuilt ./my-out`
70
+ uses that explicit path. Use this for CI flows that build in the
71
+ pipeline, Webflow / Framer exports, or whenever you don't want the
72
+ platform to run install/build for you.
64
73
  - `--name <name>` — project name (only on first deploy).
65
74
  - `--project <id_or_slug>` — deploy into an existing project, ignoring
66
75
  `./.layero/project.json` (useful for CI).
@@ -82,10 +91,33 @@ Run `layero <cmd> --help` for full options.
82
91
  | `gatsby` dep | gatsby | `npm run build` | `public` |
83
92
  | `astro` dep / `astro.config.*` | astro | `npm run build` | `dist` |
84
93
  | `@docusaurus/core` dep / `docusaurus.config.*` | docusaurus | `npm run build` | `build` |
94
+ | `vitepress` dep / `.vitepress/config.*` / `docs/.vitepress/config.*` | vitepress | `npm run docs:build` (or `npx vitepress build`) | `.vitepress/dist` or `docs/.vitepress/dist` |
85
95
  | `vite` dep / `vite.config.*` | vite | `npm run build` | `dist` |
86
96
  | `react-scripts` dep | cra | `npm run build` | `build` |
97
+ | `@11ty/eleventy` dep / `.eleventy.js` / `eleventy.config.*` | eleventy | `npm run build` (or `npx @11ty/eleventy`) | `_site` |
98
+ | `hugo.{toml,yaml,json}` or `config.*` with Hugo markers (`baseURL`, `[markup]`, …) | hugo | `hugo --gc --minify` (no install needed) | `public` |
87
99
  | any `.html` at root, no `package.json` | static | `true` (no-op) | `.` |
88
100
 
101
+ ## Bring-your-own-build (`--prebuilt`)
102
+
103
+ If you already build your site yourself — in CI, via a desktop tool like
104
+ Webflow/Framer, or because you want a guaranteed deterministic artifact —
105
+ skip the platform's install/build entirely:
106
+
107
+ ```bash
108
+ # Auto-pick the output directory:
109
+ layero deploy --prebuilt
110
+
111
+ # Or point at a specific one:
112
+ layero deploy --prebuilt ./dist
113
+ layero deploy --prebuilt ./build/static
114
+ ```
115
+
116
+ What changes: only the files inside the directory you point at are
117
+ uploaded (no source-tree filters like `.gitignore` apply). The platform
118
+ ships them verbatim — no detect, no install, no build. Smaller archive,
119
+ faster deploys, no surprises from the platform's package-manager defaults.
120
+
89
121
  Override anything by editing `.layero/project.json` after the first `layero init`.
90
122
 
91
123
  ## Agent / JSON mode
package/dist/agent.js CHANGED
@@ -109,6 +109,9 @@ function renderHuman(event) {
109
109
  case "detected":
110
110
  process.stdout.write(`→ Detected ${event.framework} (build: ${event.build_cmd}, output: ${event.output_dir})\n`);
111
111
  break;
112
+ case "prebuilt":
113
+ process.stdout.write(`→ Prebuilt mode: shipping ${event.dir}\n`);
114
+ break;
112
115
  case "packing":
113
116
  process.stdout.write(`→ Packed ${event.files} files (${(event.bytes / (1024 * 1024)).toFixed(2)} MB)\n`);
114
117
  break;
@@ -104,11 +104,12 @@ async function main() {
104
104
  program
105
105
  .command("deploy")
106
106
  .description("Pack the current directory and deploy it. Framework, build command and output directory are auto-detected.")
107
- .option("-t, --type <preset>", "framework override (vite | next | astro | cra | sveltekit | nuxt | gatsby | docusaurus | static)")
107
+ .option("-t, --type <preset>", "framework override (vite | vitepress | next | astro | cra | sveltekit | nuxt | gatsby | docusaurus | eleventy | hugo | static)")
108
108
  .option("--name <name>", "project name (only used on first deploy)")
109
109
  .option("--project <id_or_slug>", "deploy into an existing project, ignoring local config")
110
110
  .option("-y, --yes", "non-interactive: accept defaults and skip --prod confirmation")
111
111
  .option("--config", "(legacy alias of the default behaviour — auto-detect + .layero/project.json values)")
112
+ .option("--prebuilt [dir]", "ship an already-built artifact directory (default auto-pick: dist/build/public/out/_site/...)")
112
113
  .option("--prod", "deploy to production (replaces apex_hostname's active deploy). Without this flag, deploys go to the project's CLI preview pseudo-branch.")
113
114
  .option("--branch <name>", "deploy to a specific branch's environment. Wins over --prod.")
114
115
  .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.")
@@ -5,7 +5,7 @@ import chalk from "chalk";
5
5
  import { ApiClient, ApiError, uploadArchive } from "../api.js";
6
6
  import { loadConfig } from "../config.js";
7
7
  import { loadProjectConfig, persistProjectLinking, projectConfigPath, } from "../project-config.js";
8
- import { packCwd } from "../pack.js";
8
+ import { packCwd, packDirectory } from "../pack.js";
9
9
  import { streamDeployLogs } from "../logs.js";
10
10
  import { detectProject } from "../detect.js";
11
11
  import { LayeroError, detectMode, emit } from "../agent.js";
@@ -22,6 +22,10 @@ const VALID_TYPES = new Set([
22
22
  "static",
23
23
  "generic",
24
24
  "docusaurus",
25
+ "hugo",
26
+ "eleventy",
27
+ "11ty",
28
+ "vitepress",
25
29
  ]);
26
30
  function dashboardOrigin(apiUrl) {
27
31
  const override = process.env.LAYERO_DASHBOARD_URL;
@@ -144,13 +148,16 @@ async function resolveOrCreateProject(api, cwd, opts, existing) {
144
148
  });
145
149
  return { project, createdNow: true };
146
150
  }
147
- async function packAndUpload(api, cwd, project) {
148
- const pack = await packCwd(cwd, project.slug);
151
+ async function packAndUpload(api, cwd, project, prebuiltDir) {
152
+ const pack = prebuiltDir
153
+ ? await packDirectory(path.resolve(cwd, prebuiltDir), project.slug)
154
+ : await packCwd(cwd, project.slug);
149
155
  emit({
150
156
  event: "packing",
151
157
  files: pack.fileCount,
152
158
  bytes: pack.size,
153
159
  sha256: pack.sha256,
160
+ ...(prebuiltDir ? { prebuilt_dir: prebuiltDir } : {}),
154
161
  });
155
162
  const init = await api.initUpload(project.id);
156
163
  emit({ event: "uploading" });
@@ -162,6 +169,35 @@ async function packAndUpload(api, cwd, project) {
162
169
  archivePath: pack.archivePath,
163
170
  };
164
171
  }
172
+ const PREBUILT_AUTO_DIRS = [
173
+ "dist",
174
+ "build",
175
+ "public",
176
+ "out",
177
+ "_site",
178
+ ".output/public",
179
+ "docs/.vitepress/dist",
180
+ ".vitepress/dist",
181
+ ];
182
+ async function resolvePrebuiltDir(cwd, raw) {
183
+ if (raw === undefined || raw === false)
184
+ return null;
185
+ if (typeof raw === "string" && raw.length > 0) {
186
+ return raw;
187
+ }
188
+ // No explicit dir — pick the first existing common output directory.
189
+ for (const candidate of PREBUILT_AUTO_DIRS) {
190
+ try {
191
+ const stat = await fs.stat(path.join(cwd, candidate));
192
+ if (stat.isDirectory())
193
+ return candidate;
194
+ }
195
+ catch {
196
+ // try next
197
+ }
198
+ }
199
+ throw new LayeroError("prebuilt_no_dir", "could not auto-detect a built artifact directory", `pass it explicitly: --prebuilt ./dist (tried: ${PREBUILT_AUTO_DIRS.join(", ")})`);
200
+ }
165
201
  // Resolve the framework / build / output config to send to completeSetup.
166
202
  // Precedence: explicit CLI args > .layero/project.json > auto-detect.
167
203
  async function resolveSetupConfig(cwd, opts, existing) {
@@ -213,9 +249,24 @@ export async function deployCmd(opts) {
213
249
  return;
214
250
  }
215
251
  }
252
+ // Prebuilt deploys: caller has already built the artifact and points at
253
+ // its directory. Setup-config detection is skipped (we report a synthetic
254
+ // static setup so completeSetup gets *some* values and the dashboard
255
+ // shows the project is configured).
256
+ const prebuiltDir = await resolvePrebuiltDir(cwd, opts.prebuilt);
216
257
  // Resolve setup config (auto-detect + overrides). Done eagerly so the
217
258
  // user sees what we detected before any network I/O.
218
- const setup = await resolveSetupConfig(cwd, opts, existing);
259
+ const setup = prebuiltDir
260
+ ? {
261
+ framework_hint: "static",
262
+ build_cmd: "true",
263
+ output_dir: ".",
264
+ source: "prebuilt",
265
+ }
266
+ : await resolveSetupConfig(cwd, opts, existing);
267
+ if (prebuiltDir) {
268
+ emit({ event: "prebuilt", dir: prebuiltDir });
269
+ }
219
270
  const persistedCfg = await persistProjectLinking(cwd, {
220
271
  project_id: project.id,
221
272
  slug: project.slug,
@@ -252,15 +303,16 @@ export async function deployCmd(opts) {
252
303
  }
253
304
  let upload = null;
254
305
  try {
255
- upload = await packAndUpload(api, cwd, project);
306
+ upload = await packAndUpload(api, cwd, project, prebuiltDir);
256
307
  const targeting = deployTargeting(opts);
257
308
  const deploy = await api.triggerDeploy(project.id, {
258
309
  source_archive_key: upload.archive_key,
259
310
  commit_sha: upload.commit_sha,
260
- commit_message: "CLI deploy",
311
+ commit_message: prebuiltDir ? `CLI prebuilt deploy (${prebuiltDir})` : "CLI deploy",
261
312
  framework_hint: setup.framework_hint,
262
313
  target: targeting.target,
263
314
  branch: targeting.branch,
315
+ prebuilt: prebuiltDir !== null,
264
316
  });
265
317
  emit({ event: "deploy_started", deploy_id: deploy.id });
266
318
  const final = await streamDeployLogs(api, deploy.id);
package/dist/detect.js CHANGED
@@ -115,6 +115,23 @@ export async function detectProject(cwd) {
115
115
  confident: true,
116
116
  };
117
117
  }
118
+ // VitePress before generic Vite: VitePress repos often pull `vite`
119
+ // transitively, and the SPA Vite path would set output_dir=dist —
120
+ // wrong for VitePress, which writes to `.vitepress/dist/` (or
121
+ // `docs/.vitepress/dist/` when the config lives under docs/).
122
+ if (hasDep(pkg, "vitepress") || (await hasVitepressConfig(cwd))) {
123
+ const docsLayout = await hasVitepressConfig(cwd, "docs/.vitepress");
124
+ const outputDir = docsLayout ? "docs/.vitepress/dist" : ".vitepress/dist";
125
+ const cmd = pkg.scripts?.["docs:build"]
126
+ ? "npm run docs:build"
127
+ : buildCmd(pkg, "npx vitepress build");
128
+ return {
129
+ framework_hint: "vitepress",
130
+ build_cmd: cmd,
131
+ output_dir: outputDir,
132
+ confident: true,
133
+ };
134
+ }
118
135
  if (hasDep(pkg, "vite") || (await fileExists(cwd, "vite.config.ts", "vite.config.js", "vite.config.mjs"))) {
119
136
  return {
120
137
  framework_hint: "vite",
@@ -131,6 +148,35 @@ export async function detectProject(cwd) {
131
148
  confident: true,
132
149
  };
133
150
  }
151
+ // Eleventy late in the package.json branch — `@11ty/eleventy` is
152
+ // unique enough not to collide with other frameworks, but Vite /
153
+ // CRA / etc. should win if both are present (a project that pulls
154
+ // both is probably using Vite as the runtime and 11ty just for one
155
+ // build step).
156
+ if (hasDep(pkg, "@11ty/eleventy") ||
157
+ hasDep(pkg, "eleventy") ||
158
+ (await fileExists(cwd, ".eleventy.js", "eleventy.config.js", "eleventy.config.mjs", "eleventy.config.cjs"))) {
159
+ return {
160
+ framework_hint: "eleventy",
161
+ build_cmd: buildCmd(pkg, "npx @11ty/eleventy"),
162
+ output_dir: "_site",
163
+ confident: true,
164
+ };
165
+ }
166
+ }
167
+ // Non-Node SSGs (Hugo today). Recognise repos without package.json
168
+ // before we fall back to "static" with a no-op build command — Hugo
169
+ // needs `hugo --gc --minify` and writes to `public/`, the static
170
+ // fallback would just upload the raw .md sources.
171
+ if ((await fileExists(cwd, "hugo.toml", "hugo.yaml", "hugo.json")) ||
172
+ ((await fileExists(cwd, "config.toml", "config.yaml", "config.json")) &&
173
+ (await hasHugoConfigMarker(cwd)))) {
174
+ return {
175
+ framework_hint: "hugo",
176
+ build_cmd: "hugo --gc --minify",
177
+ output_dir: "public",
178
+ confident: true,
179
+ };
134
180
  }
135
181
  // No (or unrecognised) package.json. If there's HTML on disk we treat
136
182
  // the current directory as a static site. Otherwise we still fall back
@@ -144,3 +190,41 @@ export async function detectProject(cwd) {
144
190
  confident: _staticHtml,
145
191
  };
146
192
  }
193
+ const HUGO_CONFIG_TOKENS = [
194
+ "baseURL",
195
+ "baseurl",
196
+ "languageCode",
197
+ "languagecode",
198
+ "[params]",
199
+ "[markup]",
200
+ "[menu",
201
+ "[taxonomies]",
202
+ "hugoVersion",
203
+ "minVersion",
204
+ "theme",
205
+ ];
206
+ async function hasVitepressConfig(cwd, prefix = ".vitepress") {
207
+ for (const name of ["config.ts", "config.js", "config.mts", "config.mjs"]) {
208
+ try {
209
+ await fs.access(path.join(cwd, prefix, name));
210
+ return true;
211
+ }
212
+ catch {
213
+ // try next
214
+ }
215
+ }
216
+ return false;
217
+ }
218
+ async function hasHugoConfigMarker(cwd) {
219
+ for (const fn of ["config.toml", "config.yaml", "config.json"]) {
220
+ try {
221
+ const head = await fs.readFile(path.join(cwd, fn), "utf-8");
222
+ if (HUGO_CONFIG_TOKENS.some((t) => head.includes(t)))
223
+ return true;
224
+ }
225
+ catch {
226
+ // try next
227
+ }
228
+ }
229
+ return false;
230
+ }
package/dist/pack.js CHANGED
@@ -80,6 +80,58 @@ async function sha256OfFile(p) {
80
80
  }
81
81
  return hash.digest("hex");
82
82
  }
83
+ /** Pack only the contents of an already-built artifact directory.
84
+ *
85
+ * Used by `layero deploy --prebuilt`. We deliberately bypass DEFAULT_IGNORE
86
+ * (no `dist`/`build`/`.next` filtering — those names might appear *inside*
87
+ * a built artifact and they're now meaningful files, not source-tree
88
+ * leftovers). We still apply a minimal safety filter so accidental clutter
89
+ * doesn't blow up the archive: `.git/`, `node_modules/`, `.DS_Store`,
90
+ * `.env*`. The caller resolves the directory; the archive's internal layout
91
+ * is content-only (no source-tree prefix).
92
+ */
93
+ export async function packDirectory(targetDir, projectName) {
94
+ const stat = await fs.stat(targetDir);
95
+ if (!stat.isDirectory()) {
96
+ throw new Error(`prebuilt path is not a directory: ${targetDir}`);
97
+ }
98
+ const minimalIgnore = ignore();
99
+ minimalIgnore.add([
100
+ "node_modules",
101
+ ".git",
102
+ ".svn",
103
+ ".hg",
104
+ ".env",
105
+ ".env.*",
106
+ "*.log",
107
+ ".DS_Store",
108
+ "Thumbs.db",
109
+ ".layero",
110
+ ]);
111
+ const files = await walk(targetDir, minimalIgnore);
112
+ if (files.length === 0) {
113
+ throw new Error(`no files to upload in ${targetDir} — check that the build produced output`);
114
+ }
115
+ const stamp = Date.now();
116
+ const safeName = projectName.replace(/[^a-zA-Z0-9._-]/g, "_");
117
+ const archivePath = path.join(tmpdir(), `layero-${safeName}-prebuilt-${stamp}.tgz`);
118
+ const rootName = `layero-${safeName}-prebuilt-${stamp}`;
119
+ await tarCreate({
120
+ file: archivePath,
121
+ gzip: { level: 6 },
122
+ cwd: targetDir,
123
+ portable: true,
124
+ prefix: rootName,
125
+ }, files);
126
+ const fst = await fs.stat(archivePath);
127
+ if (fst.size > MAX_BYTES) {
128
+ await fs.unlink(archivePath).catch(() => undefined);
129
+ throw new Error(`archive is ${(fst.size / (1024 * 1024)).toFixed(1)}MB — over the 200MB limit. ` +
130
+ "Trim the build output or skip large files (`.layeroignore`).");
131
+ }
132
+ const sha256 = await sha256OfFile(archivePath);
133
+ return { archivePath, sha256, size: fst.size, fileCount: files.length };
134
+ }
83
135
  export async function packCwd(cwd, projectName) {
84
136
  const ig = await buildIgnore(cwd);
85
137
  const files = await walk(cwd, ig);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layero",
3
- "version": "0.5.4",
3
+ "version": "0.6.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",