layero 0.3.0 → 0.5.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
@@ -1,10 +1,28 @@
1
1
  # layero
2
2
 
3
- CLI for [Layero](https://layero.ru) — publish a local site with one command.
3
+ CLI for [Layero](https://layero.ru) — publish a local directory with one command. **No git or GitHub required.**
4
+
5
+ ## For AI agents (Cursor, Claude Code, Aider…)
6
+
7
+ If you're an agent reading this: the user asked you to deploy their site with Layero. Do exactly this — no git repo, no GitHub setup, no dashboard visits required:
8
+
9
+ ```bash
10
+ npx layero@latest init # scaffolds .layero/ + adds a block to AGENTS.md
11
+ npx layero login # one-time: prints a URL, user clicks once
12
+ npx layero deploy --json # ships current dir, streams JSON events to stdout
13
+ ```
14
+
15
+ `layero deploy` prints `{"event":"ready","url":"https://..."}` on success. Show the URL to the user. That's the whole job.
4
16
 
5
17
  ## Install
6
18
 
7
19
  ```bash
20
+ # Recommended (project-local):
21
+ npm install -D layero
22
+ # or one-shot:
23
+ npx layero@latest deploy
24
+
25
+ # System-wide (advanced; needs sudo on most setups):
8
26
  npm install -g layero
9
27
  ```
10
28
 
@@ -15,34 +33,93 @@ Requires Node.js ≥ 20.
15
33
  ```bash
16
34
  layero login # opens browser for OAuth (GitHub / Google / Yandex)
17
35
  cd my-site
18
- layero deploy # packs the current dir, uploads, builds and publishes
36
+ layero deploy # auto-detects framework, packs, uploads, builds, ships
19
37
  ```
20
38
 
21
39
  The first `layero deploy` in a directory creates a project and links it via
22
- `./.layero/project.json`. Subsequent runs reuse the same project.
40
+ `./.layero/project.json`. Subsequent runs reuse the same project — re-edit code,
41
+ re-run `layero deploy`, get a new preview URL each time.
23
42
 
24
43
  ## Commands
25
44
 
26
45
  | Command | Description |
27
46
  |---|---|
47
+ | `layero init` | Scaffold `.layero/project.json` + write a deploy block into `AGENTS.md` / `CLAUDE.md` / `.cursorrules` so future agent sessions know how to ship. |
28
48
  | `layero login` | Authenticate via browser. |
29
49
  | `layero logout` | Remove the saved auth token. |
30
50
  | `layero whoami` | Show current account. |
31
51
  | `layero projects list` | List projects on your account. |
32
52
  | `layero link <id_or_slug>` | Link cwd to an existing project. |
33
- | `layero deploy` | Pack cwd and deploy. |
53
+ | `layero deploy` | Auto-detect framework, pack cwd, build, ship. |
54
+ | `layero deploys list` | List recent deploys. |
55
+ | `layero rollback` | Re-activate the previous successful deploy. |
34
56
  | `layero token` | Manage the auth token directly. |
35
57
 
36
58
  Run `layero <cmd> --help` for full options.
37
59
 
38
60
  ## `layero deploy` flags
39
61
 
40
- - `--type <preset>` — framework preset: `vite`, `next`, `astro`, `cra`,
41
- `sveltekit`, `nuxt`, `gatsby`, `static`.
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.
42
64
  - `--name <name>` — project name (only on first deploy).
43
65
  - `--project <id_or_slug>` — deploy into an existing project, ignoring
44
66
  `./.layero/project.json` (useful for CI).
67
+ - `--prod` — deploy to the project's apex hostname (replaces production). Without it, deploys go to a preview pseudo-branch and never touch prod.
68
+ - `--branch <name>` — deploy to a specific branch's environment.
69
+ - `--org <slug>` — Layero organization for first-time project creation.
45
70
  - `--yes` / `-y` — non-interactive mode.
71
+ - `--json` — emit JSON-lines events on stdout (for agents and CI).
72
+
73
+ ## Framework auto-detection
74
+
75
+ `layero deploy` (and `layero init`) read your project on disk and pick sane defaults:
76
+
77
+ | Signal | Framework | `build_cmd` | `output_dir` |
78
+ |---|---|---|---|
79
+ | `next` dep / `next.config.*` | nextjs | `npm run build` (or `npx next build`) | `out` |
80
+ | `nuxt` dep / `nuxt.config.*` | nuxt | `npm run generate` if present, else `npm run build` | `.output/public` |
81
+ | `@sveltejs/kit` / `svelte.config.js` | sveltekit | `npm run build` | `build` |
82
+ | `gatsby` dep | gatsby | `npm run build` | `public` |
83
+ | `astro` dep / `astro.config.*` | astro | `npm run build` | `dist` |
84
+ | `@docusaurus/core` dep / `docusaurus.config.*` | docusaurus | `npm run build` | `build` |
85
+ | `vite` dep / `vite.config.*` | vite | `npm run build` | `dist` |
86
+ | `react-scripts` dep | cra | `npm run build` | `build` |
87
+ | any `.html` at root, no `package.json` | static | `true` (no-op) | `.` |
88
+
89
+ Override anything by editing `.layero/project.json` after the first `layero init`.
90
+
91
+ ## Agent / JSON mode
92
+
93
+ `layero` auto-switches to non-interactive + structured-output mode when any of these is true:
94
+
95
+ - `--json` flag passed
96
+ - `LAYERO_JSON=1` env var
97
+ - `CURSOR_AGENT`, `CLAUDECODE`, `LAYERO_AGENT` env vars set
98
+ - `CI=1` (non-interactive only; JSON-lines requires explicit opt-in)
99
+ - stdout is not a TTY
100
+
101
+ Event types emitted on stdout:
102
+
103
+ ```
104
+ {"event":"auth_required","url":"…","user_code":"…"}
105
+ {"event":"authorized","user":"…"}
106
+ {"event":"project_created","project_id":"…","slug":"…","organization":"…"}
107
+ {"event":"project_linked","project_id":"…","slug":"…"}
108
+ {"event":"detected","framework":"…","build_cmd":"…","output_dir":"…","confident":true}
109
+ {"event":"packing","files":N,"bytes":N,"sha256":"…"}
110
+ {"event":"uploading"}
111
+ {"event":"uploaded","archive_key":"…"}
112
+ {"event":"setup_applied"}
113
+ {"event":"deploy_started","deploy_id":"…"}
114
+ {"event":"build_log","line":"…","stream":"…"}
115
+ {"event":"stage","name":"…"}
116
+ {"event":"ready","url":"…","deploy_id":"…"}
117
+ {"event":"error","code":"…","next_action":"…","message":"…"}
118
+ ```
119
+
120
+ Errors carry a stable `code` (e.g. `not_logged_in`, `invalid_type`,
121
+ `project_not_found`, `cli_deploys_disabled`) and a `next_action` hint so
122
+ your agent can react without parsing prose.
46
123
 
47
124
  ## Ignore rules
48
125
 
@@ -53,9 +130,13 @@ always excluded: `node_modules`, `.git`, `dist`, `build`, `.next`, `.env*`,
53
130
  ## Config
54
131
 
55
132
  - Auth token: `~/.layero/config.json` (chmod 600).
56
- - Per-project link: `./.layero/project.json`.
133
+ - Per-project link: `./.layero/project.json` — `project_id`, `slug`,
134
+ `organization_slug`, `apex_hostname` are managed by the CLI;
135
+ `framework_hint`, `build_cmd`, `output_dir`, `analytics_enabled`,
136
+ `env_vars` are user-editable and override auto-detection.
57
137
 
58
138
  ## Links
59
139
 
60
140
  - Website: https://layero.ru
141
+ - Docs: https://docs.layero.ru
61
142
  - Issues: https://github.com/layero/layero/issues
package/dist/agent.js ADDED
@@ -0,0 +1,158 @@
1
+ // Agent / non-interactive mode detection + structured I/O.
2
+ //
3
+ // The CLI has historically assumed a human is watching. That breaks when
4
+ // Cursor / Claude Code / CI run `layero` as a tool: stdin/stdout aren't
5
+ // real TTYs, so any `readline.question` hangs forever and any ANSI escape
6
+ // in stderr leaks as `\x1b[36m` into the agent's transcript.
7
+ //
8
+ // Detection layers:
9
+ // 1. Explicit opt-in: LAYERO_JSON=1 or --json
10
+ // 2. Known agent envs: CURSOR_AGENT, CLAUDECODE, CLAUDE_CODE_*
11
+ // 3. CI envs: CI, GITHUB_ACTIONS, GITLAB_CI, ...
12
+ // 4. No-TTY fallback: !process.stdout.isTTY
13
+ //
14
+ // In agent/JSON mode:
15
+ // * never prompt — every default is taken
16
+ // * stdout becomes JSON-lines (`{"event":"...","..."}`)
17
+ // * stderr stays plain text but without colour codes
18
+ // * errors carry a machine-readable code + next_action
19
+ let cachedMode = null;
20
+ const AGENT_ENV_VARS = [
21
+ "LAYERO_JSON",
22
+ "LAYERO_AGENT",
23
+ "CURSOR_AGENT",
24
+ "CLAUDECODE",
25
+ "CLAUDE_CODE_SESSION",
26
+ "AIDER_CHAT",
27
+ "CONTINUE_CLI",
28
+ ];
29
+ const CI_ENV_VARS = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "CIRCLECI"];
30
+ export function detectMode(argv = process.argv) {
31
+ if (cachedMode)
32
+ return cachedMode;
33
+ const explicitJson = argv.includes("--json") || process.env.LAYERO_JSON === "1";
34
+ if (explicitJson) {
35
+ return (cachedMode = {
36
+ agent: true,
37
+ json: true,
38
+ interactive: false,
39
+ reason: "--json or LAYERO_JSON=1",
40
+ });
41
+ }
42
+ const agentEnv = AGENT_ENV_VARS.find((k) => process.env[k] && process.env[k] !== "0");
43
+ if (agentEnv) {
44
+ return (cachedMode = {
45
+ agent: true,
46
+ json: true,
47
+ interactive: false,
48
+ reason: `env ${agentEnv}=${process.env[agentEnv]}`,
49
+ });
50
+ }
51
+ const ciEnv = CI_ENV_VARS.find((k) => process.env[k] && process.env[k] !== "0");
52
+ if (ciEnv) {
53
+ return (cachedMode = {
54
+ agent: true,
55
+ // CI usually wants plain logs, not JSON-lines, unless the user opts in.
56
+ // The `--yes` semantics still apply though.
57
+ json: false,
58
+ interactive: false,
59
+ reason: `env ${ciEnv}=${process.env[ciEnv]}`,
60
+ });
61
+ }
62
+ if (!process.stdout.isTTY) {
63
+ return (cachedMode = {
64
+ agent: true,
65
+ json: false,
66
+ interactive: false,
67
+ reason: "stdout is not a TTY",
68
+ });
69
+ }
70
+ return (cachedMode = {
71
+ agent: false,
72
+ json: false,
73
+ interactive: true,
74
+ reason: "interactive TTY",
75
+ });
76
+ }
77
+ /** Force a specific mode. Used by tests; production code should rely on
78
+ * detectMode() reading the environment. */
79
+ export function setMode(mode) {
80
+ cachedMode = mode;
81
+ }
82
+ export function emit(event) {
83
+ const mode = detectMode();
84
+ const stamped = { ...event, ts: new Date().toISOString() };
85
+ if (mode.json) {
86
+ process.stdout.write(JSON.stringify(stamped) + "\n");
87
+ return;
88
+ }
89
+ renderHuman(event);
90
+ }
91
+ function renderHuman(event) {
92
+ // We deliberately use plain text + arrow/check glyphs that survive in
93
+ // every terminal we care about. Colours are layered on top via chalk
94
+ // in deploy.ts / login.ts when the mode is interactive.
95
+ switch (event.event) {
96
+ case "auth_required":
97
+ process.stdout.write(`→ Open ${event.url}\n`);
98
+ process.stdout.write(` (or enter the code ${event.user_code} after signing in)\n`);
99
+ break;
100
+ case "authorized":
101
+ process.stdout.write(`✓ Authorized as ${event.user}\n`);
102
+ break;
103
+ case "project_created":
104
+ process.stdout.write(`✓ Created project ${event.slug} (org: ${event.organization})\n`);
105
+ break;
106
+ case "project_linked":
107
+ process.stdout.write(`→ Project ${event.slug}\n`);
108
+ break;
109
+ case "detected":
110
+ process.stdout.write(`→ Detected ${event.framework} (build: ${event.build_cmd}, output: ${event.output_dir})\n`);
111
+ break;
112
+ case "packing":
113
+ process.stdout.write(`→ Packed ${event.files} files (${(event.bytes / (1024 * 1024)).toFixed(2)} MB)\n`);
114
+ break;
115
+ case "uploading":
116
+ process.stdout.write(`→ Uploading...\n`);
117
+ break;
118
+ case "uploaded":
119
+ // intentionally quiet in human mode — the next stage line takes over
120
+ break;
121
+ case "setup_applied":
122
+ process.stdout.write(`✓ Setup applied\n`);
123
+ break;
124
+ case "deploy_started":
125
+ process.stdout.write(`→ Building...\n`);
126
+ break;
127
+ case "stage":
128
+ process.stdout.write(` · ${event.name}\n`);
129
+ break;
130
+ case "build_log":
131
+ process.stdout.write(`${event.line}\n`);
132
+ break;
133
+ case "ready":
134
+ process.stdout.write(`✓ Live at ${event.url}\n`);
135
+ break;
136
+ case "error":
137
+ process.stderr.write(`Error: ${event.code}\n`);
138
+ process.stderr.write(` ${event.message}\n`);
139
+ if (event.next_action) {
140
+ process.stderr.write(` → ${event.next_action}\n`);
141
+ }
142
+ break;
143
+ }
144
+ }
145
+ // Structured error carrying a machine-readable code and a one-line
146
+ // remediation hint. The CLI's top-level catch turns this into an
147
+ // `event: "error"` line in JSON mode and a coloured stderr block in
148
+ // human mode, then exits non-zero.
149
+ export class LayeroError extends Error {
150
+ code;
151
+ next_action;
152
+ constructor(code, message, next_action = "") {
153
+ super(message);
154
+ this.code = code;
155
+ this.next_action = next_action;
156
+ this.name = "LayeroError";
157
+ }
158
+ }
package/dist/api.js CHANGED
@@ -44,6 +44,9 @@ export class ApiClient {
44
44
  listProjects() {
45
45
  return this.request("GET", "/projects");
46
46
  }
47
+ listOrganizations() {
48
+ return this.request("GET", "/organizations");
49
+ }
47
50
  getProject(idOrSlug) {
48
51
  return this.request("GET", `/projects/${idOrSlug}`);
49
52
  }
@@ -53,6 +56,7 @@ export class ApiClient {
53
56
  slug: input.slug,
54
57
  source_type: "cli",
55
58
  framework_hint: input.framework_hint,
59
+ organization_slug: input.organization_slug,
56
60
  });
57
61
  }
58
62
  initUpload(projectId) {
@@ -12,6 +12,9 @@ import { tokenSetCmd } from "../commands/token.js";
12
12
  import { deployCmd } from "../commands/deploy.js";
13
13
  import { deploysListCmd, rollbackCmd } from "../commands/deploys.js";
14
14
  import { loginCmd } from "../commands/login.js";
15
+ import { orgsListCmd } from "../commands/orgs.js";
16
+ import { initCmd } from "../commands/init.js";
17
+ import { LayeroError, detectMode, emit } from "../agent.js";
15
18
  // Read version from the shipped package.json (two levels up from dist/bin/).
16
19
  const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
17
20
  const VERSION = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
@@ -19,12 +22,13 @@ async function main() {
19
22
  const program = new Command();
20
23
  program
21
24
  .name("layero")
22
- .description("Layero CLI — publish a local site with one command.")
23
- .version(VERSION);
25
+ .description("Layero CLI — publish a local directory with one command. No git required.")
26
+ .version(VERSION)
27
+ .option("--json", "emit machine-readable JSON-lines on stdout (for agents and CI)");
24
28
  program
25
29
  .command("login")
26
30
  .description("Authenticate via browser (GitHub / Google / Yandex).")
27
- .option("-p, --provider <provider>", "OAuth provider to use (github | google | yandex)", "github")
31
+ .option("-p, --provider <provider>", "OAuth provider hint (github | google | yandex)", "github")
28
32
  .option("--port <port>", "fixed loopback port (default: random)", (v) => Number(v))
29
33
  .addHelpText("after", "\nExamples:\n $ layero login\n $ layero login --provider google")
30
34
  .action(async (opts) => {
@@ -38,6 +42,14 @@ async function main() {
38
42
  .command("whoami")
39
43
  .description("Show the currently logged-in account.")
40
44
  .action(whoamiCmd);
45
+ program
46
+ .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
+ .option("-y, --yes", "non-interactive: accept all defaults")
49
+ .option("--skip-agent-docs", "do not touch AGENTS.md/CLAUDE.md/.cursorrules")
50
+ .action(async (opts) => {
51
+ await initCmd({ yes: opts.yes, skipAgentDocs: opts.skipAgentDocs });
52
+ });
41
53
  const projects = program
42
54
  .command("projects")
43
55
  .description("Inspect projects on your account.");
@@ -45,6 +57,13 @@ async function main() {
45
57
  .command("list")
46
58
  .description("List your projects.")
47
59
  .action(projectsListCmd);
60
+ const orgs = program
61
+ .command("orgs")
62
+ .description("Layero organizations on your account (personal + teams).");
63
+ orgs
64
+ .command("list")
65
+ .description("Show every Layero organization you belong to.")
66
+ .action(orgsListCmd);
48
67
  const deploys = program
49
68
  .command("deploys")
50
69
  .description("List and inspect deploys for the linked project.");
@@ -86,27 +105,52 @@ async function main() {
86
105
  .action(tokenSetCmd);
87
106
  program
88
107
  .command("deploy")
89
- .description("Pack the current directory and deploy it.")
90
- .option("-t, --type <preset>", "framework hint (vite | next | astro | cra | sveltekit | nuxt | gatsby | static)")
108
+ .description("Pack the current directory and deploy it. Framework, build command and output directory are auto-detected.")
109
+ .option("-t, --type <preset>", "framework override (vite | next | astro | cra | sveltekit | nuxt | gatsby | docusaurus | static)")
91
110
  .option("--name <name>", "project name (only used on first deploy)")
92
111
  .option("--project <id_or_slug>", "deploy into an existing project, ignoring local config")
93
112
  .option("-y, --yes", "non-interactive: accept defaults and skip --prod confirmation")
94
- .option("--config", "use framework/build settings + env vars from .layero/project.json (skips the browser setup wizard)")
113
+ .option("--config", "(legacy alias of the default behaviour auto-detect + .layero/project.json values)")
95
114
  .option("--prod", "deploy to production (replaces apex_hostname's active deploy). Without this flag, deploys go to the project's CLI preview pseudo-branch.")
96
115
  .option("--branch <name>", "deploy to a specific branch's environment. Wins over --prod.")
116
+ .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.")
97
117
  .addHelpText("after", "\nExamples:\n" +
98
- " $ layero deploy # preview on CLI pseudo-branch (never replaces prod)\n" +
118
+ " $ layero deploy # preview deploy (CLI pseudo-branch), auto-detect framework\n" +
99
119
  " $ layero deploy --prod # production deploy (interactive confirm)\n" +
100
120
  " $ layero deploy --prod --yes # production deploy, no prompt (CI)\n" +
101
121
  " $ layero deploy --branch=staging # preview on a specific branch\n" +
102
- " $ layero deploy --config # uses .layero/project.json end-to-end (CI-friendly)\n" +
103
- " $ layero deploy --type vite")
122
+ " $ layero deploy --type vite # force a framework preset\n" +
123
+ " $ layero deploy --json # machine-readable output for agents")
104
124
  .action(async (opts) => {
105
125
  await deployCmd(opts);
106
126
  });
107
127
  await program.parseAsync(process.argv);
108
128
  }
109
129
  main().catch((err) => {
110
- console.error(chalk.red(err.message ?? String(err)));
130
+ const mode = detectMode();
131
+ if (err instanceof LayeroError) {
132
+ emit({
133
+ event: "error",
134
+ code: err.code,
135
+ next_action: err.next_action,
136
+ message: err.message,
137
+ });
138
+ }
139
+ else {
140
+ // Anything else (network error, unexpected exception) — still emit
141
+ // a structured error so agents have a consistent format to parse.
142
+ const message = err?.message ?? String(err);
143
+ if (mode.json) {
144
+ emit({
145
+ event: "error",
146
+ code: "internal",
147
+ next_action: "re-run with --debug for a stack trace, or report at https://github.com/layero/layero/issues",
148
+ message,
149
+ });
150
+ }
151
+ else {
152
+ console.error(chalk.red(message));
153
+ }
154
+ }
111
155
  process.exit(1);
112
156
  });
@@ -7,6 +7,8 @@ import { loadConfig } from "../config.js";
7
7
  import { loadProjectConfig, persistProjectLinking, projectConfigPath, } from "../project-config.js";
8
8
  import { packCwd } from "../pack.js";
9
9
  import { streamDeployLogs } from "../logs.js";
10
+ import { detectProject } from "../detect.js";
11
+ import { LayeroError, detectMode, emit } from "../agent.js";
10
12
  const VALID_TYPES = new Set([
11
13
  "vite",
12
14
  "next",
@@ -19,6 +21,7 @@ const VALID_TYPES = new Set([
19
21
  "gatsby",
20
22
  "static",
21
23
  "generic",
24
+ "docusaurus",
22
25
  ]);
23
26
  function dashboardOrigin(apiUrl) {
24
27
  const override = process.env.LAYERO_DASHBOARD_URL;
@@ -33,9 +36,6 @@ function dashboardOrigin(apiUrl) {
33
36
  return "https://app.layero.ru";
34
37
  }
35
38
  }
36
- function setupUrl(apiUrl, projectId) {
37
- return `${dashboardOrigin(apiUrl)}/projects/${projectId}/setup`;
38
- }
39
39
  function projectUrl(apiUrl, projectId) {
40
40
  return `${dashboardOrigin(apiUrl)}/projects/${projectId}`;
41
41
  }
@@ -72,12 +72,13 @@ function deployTargeting(opts) {
72
72
  return { target: opts.prod ? "production" : "preview" };
73
73
  }
74
74
  async function resolveOrCreateProject(api, cwd, opts, existing) {
75
+ const mode = detectMode();
75
76
  if (opts.project) {
76
77
  const all = await api.listProjects();
77
78
  const match = all.find((p) => p.id === opts.project) ??
78
79
  all.find((p) => p.slug === opts.project);
79
80
  if (!match) {
80
- throw new Error(`no project with id/slug "${opts.project}"`);
81
+ throw new LayeroError("project_not_found", `no project with id/slug "${opts.project}"`, "run `layero projects list` to see available projects");
81
82
  }
82
83
  return { project: match, createdNow: false };
83
84
  }
@@ -88,64 +89,113 @@ async function resolveOrCreateProject(api, cwd, opts, existing) {
88
89
  }
89
90
  catch (err) {
90
91
  if (err instanceof ApiError && err.status === 404) {
91
- throw new Error(`linked project ${existing.project_id} no longer exists or isn't on your account.\n` +
92
- ` fix: delete ${projectConfigPath(cwd)} and re-run \`layero deploy\` (creates a new project),\n` +
93
- ` or run \`layero link <id_or_slug>\` to point at an existing one.`);
92
+ throw new LayeroError("project_unlinked", `linked project ${existing.project_id} no longer exists or isn't on your account`, `delete ${projectConfigPath(cwd)} and re-run, or run \`layero link <id_or_slug>\``);
94
93
  }
95
94
  throw err;
96
95
  }
97
96
  }
98
97
  const me = await api.me();
99
98
  if (!me.username) {
100
- throw new Error("no username set on your account. open https://app.layero.ru/onboarding " +
101
- "and pick one, then re-run.");
99
+ throw new LayeroError("username_missing", "no username set on your account", "open https://app.layero.ru/onboarding to pick one, then re-run");
100
+ }
101
+ let organizationSlug = opts.org;
102
+ if (organizationSlug) {
103
+ const orgs = await api.listOrganizations();
104
+ if (!orgs.some((o) => o.slug === organizationSlug)) {
105
+ throw new LayeroError("org_membership_missing", `you're not a member of organization "${organizationSlug}"`, `available: ${orgs.map((o) => o.slug).join(", ") || "(none)"}`);
106
+ }
107
+ }
108
+ else {
109
+ const orgs = await api.listOrganizations();
110
+ if (orgs.length === 0) {
111
+ throw new LayeroError("no_organization", "no organization found on your account", "finish onboarding at https://app.layero.ru/onboarding");
112
+ }
113
+ else if (orgs.length === 1) {
114
+ organizationSlug = orgs[0].slug;
115
+ }
116
+ else if (opts.yes || opts.config || !mode.interactive) {
117
+ // Non-interactive: prefer personal, fall back to first.
118
+ organizationSlug =
119
+ orgs.find((o) => o.kind === "personal")?.slug ?? orgs[0].slug;
120
+ }
121
+ else {
122
+ console.log(chalk.cyan("which organization?"));
123
+ orgs.forEach((o, i) => {
124
+ const tag = o.kind === "personal" ? "personal" : "team";
125
+ console.log(` ${i + 1}. ${o.slug} (${tag}, ${o.my_role})`);
126
+ });
127
+ const choiceRaw = await prompt("choose number", "1");
128
+ const idx = Number(choiceRaw) - 1;
129
+ if (Number.isNaN(idx) || idx < 0 || idx >= orgs.length) {
130
+ throw new LayeroError("invalid_choice", `invalid org choice "${choiceRaw}"`, "enter a number from the list");
131
+ }
132
+ organizationSlug = orgs[idx].slug;
133
+ }
102
134
  }
103
135
  const fallbackName = path.basename(cwd);
104
- const name = opts.name ?? (opts.yes || opts.config ? fallbackName : await prompt("project name", fallbackName));
136
+ const name = opts.name ??
137
+ (opts.yes || opts.config || !mode.interactive
138
+ ? fallbackName
139
+ : await prompt("project name", fallbackName));
105
140
  const project = await api.createCliProject({
106
141
  name,
107
142
  framework_hint: opts.type,
143
+ organization_slug: organizationSlug,
108
144
  });
109
145
  return { project, createdNow: true };
110
146
  }
111
147
  async function packAndUpload(api, cwd, project) {
112
- console.log(chalk.cyan("packing source..."));
113
148
  const pack = await packCwd(cwd, project.slug);
114
- console.log(chalk.dim(` ${pack.fileCount} files, ${(pack.size / (1024 * 1024)).toFixed(2)} MB, sha256=${pack.sha256.slice(0, 12)}`));
115
- console.log(chalk.cyan("requesting upload URL..."));
149
+ emit({
150
+ event: "packing",
151
+ files: pack.fileCount,
152
+ bytes: pack.size,
153
+ sha256: pack.sha256,
154
+ });
116
155
  const init = await api.initUpload(project.id);
117
- console.log(chalk.cyan("uploading archive..."));
156
+ emit({ event: "uploading" });
118
157
  await uploadArchive(init, pack.archivePath);
158
+ emit({ event: "uploaded", archive_key: init.source_archive_key });
119
159
  return {
120
160
  archive_key: init.source_archive_key,
121
161
  commit_sha: pack.sha256,
122
162
  archivePath: pack.archivePath,
123
163
  };
124
164
  }
125
- function ensureConfigComplete(cfg) {
126
- if (!cfg) {
127
- throw new Error("no .layero/project.json found. run `layero deploy` once without --config " +
128
- "to create the project, fill in framework_hint/build_cmd/output_dir there, " +
129
- "then re-run with --config.");
165
+ // Resolve the framework / build / output config to send to completeSetup.
166
+ // Precedence: explicit CLI args > .layero/project.json > auto-detect.
167
+ async function resolveSetupConfig(cwd, opts, existing) {
168
+ const detected = await detectProject(cwd);
169
+ emit({
170
+ event: "detected",
171
+ framework: detected.framework_hint,
172
+ build_cmd: detected.build_cmd,
173
+ output_dir: detected.output_dir,
174
+ confident: detected.confident,
175
+ });
176
+ const framework_hint = opts.type ?? existing?.framework_hint ?? detected.framework_hint;
177
+ const build_cmd = existing?.build_cmd ?? detected.build_cmd;
178
+ const output_dir = existing?.output_dir ?? detected.output_dir;
179
+ let source;
180
+ if (existing?.build_cmd || existing?.output_dir || existing?.framework_hint) {
181
+ source =
182
+ existing?.build_cmd && existing?.output_dir && existing?.framework_hint
183
+ ? "config"
184
+ : "hybrid";
130
185
  }
131
- const missing = [];
132
- if (!cfg.framework_hint)
133
- missing.push("framework_hint");
134
- if (!cfg.build_cmd)
135
- missing.push("build_cmd");
136
- if (!cfg.output_dir)
137
- missing.push("output_dir");
138
- if (missing.length > 0) {
139
- throw new Error(`.layero/project.json is missing required fields for --config: ${missing.join(", ")}`);
186
+ else {
187
+ source = "detected";
140
188
  }
189
+ return { framework_hint, build_cmd, output_dir, source };
141
190
  }
142
191
  export async function deployCmd(opts) {
192
+ const mode = detectMode();
143
193
  if (opts.type && !VALID_TYPES.has(opts.type.toLowerCase())) {
144
- throw new Error(`unknown --type "${opts.type}". valid: ${[...VALID_TYPES].join(", ")}`);
194
+ throw new LayeroError("invalid_type", `unknown --type "${opts.type}"`, `valid types: ${[...VALID_TYPES].join(", ")}`);
145
195
  }
146
196
  const cliCfg = await loadConfig();
147
197
  if (!cliCfg.token) {
148
- throw new Error("not logged in. run `layero login` first.");
198
+ throw new LayeroError("not_logged_in", "not authenticated", "run: layero login");
149
199
  }
150
200
  const api = new ApiClient(cliCfg);
151
201
  const cwd = process.cwd();
@@ -153,127 +203,79 @@ export async function deployCmd(opts) {
153
203
  const { project: created, createdNow } = await resolveOrCreateProject(api, cwd, opts, existing);
154
204
  let project = created;
155
205
  if (project.cli_deploys_enabled === false) {
156
- throw new Error(`CLI deploys are disabled on project "${project.slug}". ` +
157
- "Enable them in project settings or use the dashboard's Deploy button.");
206
+ throw new LayeroError("cli_deploys_disabled", `CLI deploys are disabled on project "${project.slug}"`, "enable them in project settings, or remove --project to use a different project");
158
207
  }
159
- // Prompt before overwriting production. CLI deploys default to a per-project
160
- // "cli" pseudo-branch (preview-only); --prod is the explicit opt-in to land
161
- // on apex_hostname. --yes (CI) or --branch=<default_branch> bypass.
162
- if (opts.prod && !opts.yes && !opts.branch) {
208
+ // Prompt before overwriting production only in interactive mode.
209
+ if (opts.prod && !opts.yes && !opts.branch && mode.interactive) {
163
210
  const ok = await confirm(`deploy to production (https://${project.apex_hostname})?`);
164
211
  if (!ok) {
165
212
  console.log(chalk.yellow("aborted."));
166
213
  return;
167
214
  }
168
215
  }
169
- // Persist linking metadata only never touch hand-edited config fields
170
- // or unknown keys the user may have added (a --config file is the user's
171
- // source of truth, not ours). `persistProjectLinking` reads the file as
172
- // raw JSON, overlays just project_id/slug/organization_slug/apex_hostname,
173
- // and writes it back.
216
+ // Resolve setup config (auto-detect + overrides). Done eagerly so the
217
+ // user sees what we detected before any network I/O.
218
+ const setup = await resolveSetupConfig(cwd, opts, existing);
174
219
  const persistedCfg = await persistProjectLinking(cwd, {
175
220
  project_id: project.id,
176
221
  slug: project.slug,
177
222
  organization_slug: project.organization.slug,
178
223
  apex_hostname: project.apex_hostname,
179
- }, opts.type ?? null);
224
+ }, setup.framework_hint);
180
225
  if (createdNow) {
181
- console.log(chalk.green(`created project ${project.slug}`));
226
+ emit({
227
+ event: "project_created",
228
+ project_id: project.id,
229
+ slug: project.slug,
230
+ organization: project.organization.slug,
231
+ });
182
232
  }
183
- // ─────────────────────────────────────────────────────────────────────
184
- // Path A: --config — fully scripted, runs the setup endpoint with
185
- // fields from .layero/project.json, then deploys.
186
- // ─────────────────────────────────────────────────────────────────────
187
- if (opts.config) {
188
- ensureConfigComplete(persistedCfg);
189
- if (project.status === "pending_setup") {
190
- console.log(chalk.cyan("applying config..."));
191
- project = await api.completeSetup(project.id, {
192
- framework_hint: persistedCfg.framework_hint,
193
- build_cmd: persistedCfg.build_cmd,
194
- output_dir: persistedCfg.output_dir,
195
- analytics_enabled: persistedCfg.analytics_enabled ?? false,
196
- env_vars: persistedCfg.env_vars ?? {},
197
- });
198
- }
199
- let upload = null;
200
- try {
201
- upload = await packAndUpload(api, cwd, project);
202
- console.log(chalk.cyan("triggering deploy..."));
203
- const targeting = deployTargeting(opts);
204
- const deploy = await api.triggerDeploy(project.id, {
205
- source_archive_key: upload.archive_key,
206
- commit_sha: upload.commit_sha,
207
- commit_message: "CLI deploy",
208
- framework_hint: persistedCfg.framework_hint ?? undefined,
209
- target: targeting.target,
210
- branch: targeting.branch,
211
- });
212
- console.log(chalk.dim(` deploy_id=${deploy.id}`));
213
- const final = await streamDeployLogs(api, deploy.id);
214
- if (final.status !== "ready") {
215
- console.error(chalk.red(`deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`));
216
- process.exitCode = 1;
217
- return;
218
- }
219
- console.log(chalk.green(`deploy ready → ${projectUrl(cliCfg.apiUrl, project.id)}`));
220
- if (opts.prod && !opts.branch) {
221
- console.log(chalk.dim(` production: https://${project.apex_hostname} (CDN may take ~30-60s to propagate)`));
222
- }
223
- else {
224
- console.log(chalk.dim(` preview deploy — open the dashboard for the exact URL`));
225
- }
226
- }
227
- finally {
228
- if (upload) {
229
- await fs.unlink(upload.archivePath).catch(() => undefined);
230
- }
231
- }
232
- return;
233
+ else {
234
+ emit({
235
+ event: "project_linked",
236
+ project_id: project.id,
237
+ slug: project.slug,
238
+ });
239
+ }
240
+ // Apply setup if the project is still in pending_setup. After first
241
+ // run, subsequent deploys reuse whatever was set then — the user can
242
+ // edit .layero/project.json or use the dashboard to change it.
243
+ if (project.status === "pending_setup") {
244
+ project = await api.completeSetup(project.id, {
245
+ framework_hint: setup.framework_hint,
246
+ build_cmd: setup.build_cmd,
247
+ output_dir: setup.output_dir,
248
+ analytics_enabled: persistedCfg.analytics_enabled ?? false,
249
+ env_vars: persistedCfg.env_vars ?? {},
250
+ });
251
+ emit({ event: "setup_applied" });
233
252
  }
234
- // ─────────────────────────────────────────────────────────────────────
235
- // Path B: no flag — upload the source, stash it on the project row,
236
- // print the setup URL. The user finishes the flow
237
- // in the browser, exactly like a GitHub import.
238
- // ─────────────────────────────────────────────────────────────────────
239
253
  let upload = null;
240
254
  try {
241
255
  upload = await packAndUpload(api, cwd, project);
242
- await api.finalizeUpload(project.id, {
243
- source_archive_key: upload.archive_key,
244
- commit_sha: upload.commit_sha,
245
- });
246
- if (project.status === "pending_setup") {
247
- const url = setupUrl(cliCfg.apiUrl, project.id);
248
- console.log("");
249
- console.log(chalk.green("source uploaded — finish setup in the browser:"));
250
- console.log(` ${chalk.bold(url)}`);
251
- console.log("");
252
- console.log(chalk.dim(" pick framework, build command, output dir, env vars and click Deploy."));
253
- console.log(chalk.dim(" to skip the browser next time, fill .layero/project.json and run `layero deploy --config`."));
254
- return;
255
- }
256
- // Already-active project: behave like the old `layero deploy` — fire
257
- // a build straight from the freshly-uploaded archive.
258
- console.log(chalk.cyan("triggering deploy..."));
259
256
  const targeting = deployTargeting(opts);
260
257
  const deploy = await api.triggerDeploy(project.id, {
261
258
  source_archive_key: upload.archive_key,
262
259
  commit_sha: upload.commit_sha,
263
260
  commit_message: "CLI deploy",
264
- framework_hint: opts.type,
261
+ framework_hint: setup.framework_hint,
265
262
  target: targeting.target,
266
263
  branch: targeting.branch,
267
264
  });
268
- console.log(chalk.dim(` deploy_id=${deploy.id}`));
265
+ emit({ event: "deploy_started", deploy_id: deploy.id });
269
266
  const final = await streamDeployLogs(api, deploy.id);
270
267
  if (final.status !== "ready") {
271
- console.error(chalk.red(`deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`));
272
- process.exitCode = 1;
273
- return;
268
+ throw new LayeroError(`deploy_${final.status}`, `deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`, `inspect logs at ${projectUrl(cliCfg.apiUrl, project.id)}`);
274
269
  }
275
- console.log(chalk.green(`deploy ready ${projectUrl(cliCfg.apiUrl, project.id)}`));
276
- console.log(chalk.dim(` site: https://${project.apex_hostname} (CDN may take ~30-60s to propagate)`));
270
+ const liveUrl = opts.prod && !opts.branch
271
+ ? `https://${project.apex_hostname}`
272
+ : projectUrl(cliCfg.apiUrl, project.id);
273
+ emit({
274
+ event: "ready",
275
+ url: liveUrl,
276
+ deploy_id: deploy.id,
277
+ preview_url: opts.prod && !opts.branch ? undefined : projectUrl(cliCfg.apiUrl, project.id),
278
+ });
277
279
  }
278
280
  finally {
279
281
  if (upload) {
@@ -0,0 +1,152 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import chalk from "chalk";
4
+ import { detectProject } from "../detect.js";
5
+ import { emit, detectMode } from "../agent.js";
6
+ const AGENT_BLOCK_MARKER_START = "<!-- layero:start -->";
7
+ const AGENT_BLOCK_MARKER_END = "<!-- layero:end -->";
8
+ function agentDocBlock(framework) {
9
+ return `${AGENT_BLOCK_MARKER_START}
10
+ ## Deploying with Layero
11
+
12
+ This project ships to https://layero.ru. To deploy any local changes:
13
+
14
+ \`\`\`bash
15
+ npx layero deploy
16
+ \`\`\`
17
+
18
+ First-time setup:
19
+ - The CLI prints an authorization URL on first run. Show it to the user as a clickable link; they sign in once (GitHub / Google / Yandex), and the token is cached locally.
20
+ - Detected framework: **${framework}**.
21
+ - No git repository or GitHub connection is required — Layero ships the local directory directly.
22
+
23
+ Re-deploy after edits: run \`npx layero deploy\` again. Each run produces a new preview URL.
24
+
25
+ Add \`--prod\` to publish to the project's apex hostname instead of a preview URL.
26
+ ${AGENT_BLOCK_MARKER_END}
27
+ `;
28
+ }
29
+ async function upsertAgentDoc(cwd, filename, content) {
30
+ const filePath = path.join(cwd, filename);
31
+ let existing = null;
32
+ try {
33
+ existing = await fs.readFile(filePath, "utf-8");
34
+ }
35
+ catch (err) {
36
+ if (err?.code !== "ENOENT")
37
+ throw err;
38
+ }
39
+ if (existing === null) {
40
+ await fs.writeFile(filePath, content, "utf-8");
41
+ return "created";
42
+ }
43
+ // Already has our block — replace it in place so we can iterate
44
+ // safely (re-running `layero init` updates the block, doesn't append).
45
+ if (existing.includes(AGENT_BLOCK_MARKER_START)) {
46
+ const start = existing.indexOf(AGENT_BLOCK_MARKER_START);
47
+ const endMarker = existing.indexOf(AGENT_BLOCK_MARKER_END);
48
+ if (endMarker === -1) {
49
+ // Malformed — fall through to append.
50
+ const merged = existing.trimEnd() + "\n\n" + content;
51
+ await fs.writeFile(filePath, merged, "utf-8");
52
+ return "updated";
53
+ }
54
+ const newBlock = content.trimEnd() + "\n";
55
+ const merged = existing.slice(0, start) +
56
+ newBlock +
57
+ existing.slice(endMarker + AGENT_BLOCK_MARKER_END.length).replace(/^\n+/, "\n");
58
+ if (merged === existing)
59
+ return "unchanged";
60
+ await fs.writeFile(filePath, merged, "utf-8");
61
+ return "updated";
62
+ }
63
+ // Append our block to the existing file with a separating blank line.
64
+ const merged = existing.trimEnd() + "\n\n" + content;
65
+ await fs.writeFile(filePath, merged, "utf-8");
66
+ return "updated";
67
+ }
68
+ async function ensureProjectJson(cwd, framework, build_cmd, output_dir) {
69
+ const dir = path.join(cwd, ".layero");
70
+ const file = path.join(dir, "project.json");
71
+ try {
72
+ await fs.access(file);
73
+ return "unchanged";
74
+ }
75
+ catch {
76
+ // missing → create
77
+ }
78
+ await fs.mkdir(dir, { recursive: true });
79
+ const scaffold = {
80
+ framework_hint: framework,
81
+ build_cmd,
82
+ output_dir,
83
+ analytics_enabled: false,
84
+ env_vars: {},
85
+ };
86
+ await fs.writeFile(file, JSON.stringify(scaffold, null, 2) + "\n", "utf-8");
87
+ return "created";
88
+ }
89
+ async function ensureGitignore(cwd) {
90
+ const gi = path.join(cwd, ".gitignore");
91
+ let content = "";
92
+ try {
93
+ content = await fs.readFile(gi, "utf-8");
94
+ }
95
+ catch {
96
+ return; // no .gitignore — leave alone, this is meant to be additive
97
+ }
98
+ // We only want to ignore the auth-state-bearing files. `project.json`
99
+ // itself is safe to commit (project_id is not a secret).
100
+ if (!content.includes(".layero/")) {
101
+ const append = (content.endsWith("\n") ? "" : "\n") + "\n# Layero local state\n.layero/cache/\n";
102
+ await fs.writeFile(gi, content + append, "utf-8");
103
+ }
104
+ }
105
+ export async function initCmd(opts) {
106
+ const cwd = process.cwd();
107
+ const mode = detectMode();
108
+ const detected = await detectProject(cwd);
109
+ emit({
110
+ event: "detected",
111
+ framework: detected.framework_hint,
112
+ build_cmd: detected.build_cmd,
113
+ output_dir: detected.output_dir,
114
+ confident: detected.confident,
115
+ });
116
+ const block = agentDocBlock(detected.framework_hint);
117
+ if (!opts.skipAgentDocs) {
118
+ // Touch every agent-doc convention we know about. If one already
119
+ // exists we update it in-place; otherwise we create only the most
120
+ // common one (AGENTS.md, the cross-vendor convention) so we don't
121
+ // litter the repo with .cursorrules / CLAUDE.md the user doesn't use.
122
+ const candidates = ["AGENTS.md", "CLAUDE.md", ".cursorrules"];
123
+ const existing = [];
124
+ for (const f of candidates) {
125
+ try {
126
+ await fs.access(path.join(cwd, f));
127
+ existing.push(f);
128
+ }
129
+ catch {
130
+ // not present
131
+ }
132
+ }
133
+ const targets = existing.length > 0 ? existing : ["AGENTS.md"];
134
+ for (const f of targets) {
135
+ const result = await upsertAgentDoc(cwd, f, block);
136
+ if (mode.interactive) {
137
+ console.log(chalk.green(` ${result === "created" ? "✓ created" : result === "updated" ? "✓ updated" : "= unchanged"} ${f}`));
138
+ }
139
+ }
140
+ }
141
+ const pjResult = await ensureProjectJson(cwd, detected.framework_hint, detected.build_cmd, detected.output_dir);
142
+ if (mode.interactive) {
143
+ console.log(chalk.green(` ${pjResult === "created" ? "✓ created" : "= unchanged"} .layero/project.json`));
144
+ }
145
+ await ensureGitignore(cwd);
146
+ if (mode.interactive) {
147
+ console.log("");
148
+ console.log(chalk.bold("Next:"));
149
+ console.log(" $ npx layero login # one-time, in your browser");
150
+ console.log(" $ npx layero deploy # ship the current directory");
151
+ }
152
+ }
@@ -0,0 +1,24 @@
1
+ import chalk from "chalk";
2
+ import { ApiClient } from "../api.js";
3
+ import { loadConfig } from "../config.js";
4
+ /** `layero orgs list` — show every Layero organization the caller belongs to.
5
+ *
6
+ * Useful before `layero deploy --org=<slug>` so the user can see which
7
+ * slugs to pass without leaving the terminal.
8
+ */
9
+ export async function orgsListCmd() {
10
+ const cfg = await loadConfig();
11
+ if (!cfg.token)
12
+ throw new Error("not logged in. run `layero login` first.");
13
+ const api = new ApiClient(cfg);
14
+ const orgs = await api.listOrganizations();
15
+ if (orgs.length === 0) {
16
+ console.log(chalk.dim("no organizations on this account."));
17
+ return;
18
+ }
19
+ for (const o of orgs) {
20
+ const kindBadge = o.kind === "personal" ? chalk.dim("personal") : chalk.cyan("team");
21
+ const roleBadge = chalk.dim(`(${o.my_role})`);
22
+ console.log(` ${chalk.bold(o.slug.padEnd(20))} ${kindBadge} ${roleBadge}`);
23
+ }
24
+ }
package/dist/detect.js ADDED
@@ -0,0 +1,146 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ async function readPkg(cwd) {
4
+ try {
5
+ const raw = await fs.readFile(path.join(cwd, "package.json"), "utf-8");
6
+ return JSON.parse(raw);
7
+ }
8
+ catch (err) {
9
+ if (err?.code === "ENOENT")
10
+ return null;
11
+ throw err;
12
+ }
13
+ }
14
+ function hasDep(pkg, name) {
15
+ return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]);
16
+ }
17
+ async function fileExists(cwd, ...candidates) {
18
+ for (const c of candidates) {
19
+ try {
20
+ await fs.access(path.join(cwd, c));
21
+ return true;
22
+ }
23
+ catch {
24
+ // try next
25
+ }
26
+ }
27
+ return false;
28
+ }
29
+ async function hasAnyHtml(cwd) {
30
+ // Shallow check — only top-level. Recursive walk is too expensive here
31
+ // and the static fallback is already permissive enough.
32
+ try {
33
+ const entries = await fs.readdir(cwd);
34
+ return entries.some((e) => e.endsWith(".html") || e.endsWith(".htm"));
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ // Build command preference:
41
+ // 1. If `scripts.build` exists in package.json, use `npm run build` —
42
+ // it respects whatever the user set up (custom flags, monorepo
43
+ // filters, etc.). `npm run` works regardless of package manager
44
+ // installed on the build VM, because the builder runs `npm` itself.
45
+ // 2. Otherwise, use the framework's CLI directly via `npx`.
46
+ function buildCmd(pkg, fallback) {
47
+ if (pkg?.scripts?.build)
48
+ return "npm run build";
49
+ return fallback;
50
+ }
51
+ /**
52
+ * Detect framework / build command / output directory from the project
53
+ * shape. Mirrors the builder's detector ([core/builder/src/frameworks])
54
+ * so the values we ship to `completeSetup` match what the builder will
55
+ * actually do at build time.
56
+ *
57
+ * Order matters: more specific signals first (Next, Nuxt, SvelteKit,
58
+ * Gatsby, Astro, Docusaurus) before catch-alls (Vite, CRA, static).
59
+ */
60
+ export async function detectProject(cwd) {
61
+ const pkg = await readPkg(cwd);
62
+ if (pkg) {
63
+ if (hasDep(pkg, "next") || (await fileExists(cwd, "next.config.js", "next.config.mjs", "next.config.ts"))) {
64
+ return {
65
+ framework_hint: "nextjs",
66
+ build_cmd: buildCmd(pkg, "npx next build"),
67
+ output_dir: "out",
68
+ confident: true,
69
+ };
70
+ }
71
+ if (hasDep(pkg, "nuxt") || hasDep(pkg, "nuxt3") || (await fileExists(cwd, "nuxt.config.ts", "nuxt.config.js", "nuxt.config.mjs"))) {
72
+ const generateScript = pkg.scripts?.generate;
73
+ const buildScript = pkg.scripts?.build;
74
+ const cmd = generateScript
75
+ ? "npm run generate"
76
+ : buildScript
77
+ ? "npm run build"
78
+ : "npx nuxt generate";
79
+ return {
80
+ framework_hint: "nuxt",
81
+ build_cmd: cmd,
82
+ output_dir: ".output/public",
83
+ confident: true,
84
+ };
85
+ }
86
+ if (hasDep(pkg, "@sveltejs/kit") || (await fileExists(cwd, "svelte.config.js"))) {
87
+ return {
88
+ framework_hint: "sveltekit",
89
+ build_cmd: buildCmd(pkg, "npx vite build"),
90
+ output_dir: "build",
91
+ confident: true,
92
+ };
93
+ }
94
+ if (hasDep(pkg, "gatsby") || (await fileExists(cwd, "gatsby-config.js", "gatsby-config.ts"))) {
95
+ return {
96
+ framework_hint: "gatsby",
97
+ build_cmd: buildCmd(pkg, "npx gatsby build"),
98
+ output_dir: "public",
99
+ confident: true,
100
+ };
101
+ }
102
+ if (hasDep(pkg, "astro") || (await fileExists(cwd, "astro.config.mjs", "astro.config.ts", "astro.config.js"))) {
103
+ return {
104
+ framework_hint: "astro",
105
+ build_cmd: buildCmd(pkg, "npx astro build"),
106
+ output_dir: "dist",
107
+ confident: true,
108
+ };
109
+ }
110
+ if (hasDep(pkg, "@docusaurus/core") || (await fileExists(cwd, "docusaurus.config.js", "docusaurus.config.ts", "docusaurus.config.mjs"))) {
111
+ return {
112
+ framework_hint: "docusaurus",
113
+ build_cmd: buildCmd(pkg, "npx docusaurus build"),
114
+ output_dir: "build",
115
+ confident: true,
116
+ };
117
+ }
118
+ if (hasDep(pkg, "vite") || (await fileExists(cwd, "vite.config.ts", "vite.config.js", "vite.config.mjs"))) {
119
+ return {
120
+ framework_hint: "vite",
121
+ build_cmd: buildCmd(pkg, "npx vite build"),
122
+ output_dir: "dist",
123
+ confident: true,
124
+ };
125
+ }
126
+ if (hasDep(pkg, "react-scripts")) {
127
+ return {
128
+ framework_hint: "cra",
129
+ build_cmd: buildCmd(pkg, "npx react-scripts build"),
130
+ output_dir: "build",
131
+ confident: true,
132
+ };
133
+ }
134
+ }
135
+ // No (or unrecognised) package.json. If there's HTML on disk we treat
136
+ // the current directory as a static site. Otherwise we still fall back
137
+ // to static — it's the most permissive option and the builder will
138
+ // ship whatever is on disk verbatim.
139
+ const _staticHtml = await hasAnyHtml(cwd);
140
+ return {
141
+ framework_hint: "static",
142
+ build_cmd: "true",
143
+ output_dir: ".",
144
+ confident: _staticHtml,
145
+ };
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layero",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Layero CLI — publish a local site with one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",