layero 0.4.0 → 0.5.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/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
+ }
@@ -13,6 +13,8 @@ import { deployCmd } from "../commands/deploy.js";
13
13
  import { deploysListCmd, rollbackCmd } from "../commands/deploys.js";
14
14
  import { loginCmd } from "../commands/login.js";
15
15
  import { orgsListCmd } from "../commands/orgs.js";
16
+ import { initCmd } from "../commands/init.js";
17
+ import { LayeroError, detectMode, emit } from "../agent.js";
16
18
  // Read version from the shipped package.json (two levels up from dist/bin/).
17
19
  const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
18
20
  const VERSION = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
@@ -20,12 +22,13 @@ async function main() {
20
22
  const program = new Command();
21
23
  program
22
24
  .name("layero")
23
- .description("Layero CLI — publish a local site with one command.")
24
- .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)");
25
28
  program
26
29
  .command("login")
27
30
  .description("Authenticate via browser (GitHub / Google / Yandex).")
28
- .option("-p, --provider <provider>", "OAuth provider to use (github | google | yandex)", "github")
31
+ .option("-p, --provider <provider>", "OAuth provider hint (github | google | yandex)", "github")
29
32
  .option("--port <port>", "fixed loopback port (default: random)", (v) => Number(v))
30
33
  .addHelpText("after", "\nExamples:\n $ layero login\n $ layero login --provider google")
31
34
  .action(async (opts) => {
@@ -39,6 +42,14 @@ async function main() {
39
42
  .command("whoami")
40
43
  .description("Show the currently logged-in account.")
41
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
+ });
42
53
  const projects = program
43
54
  .command("projects")
44
55
  .description("Inspect projects on your account.");
@@ -94,28 +105,52 @@ async function main() {
94
105
  .action(tokenSetCmd);
95
106
  program
96
107
  .command("deploy")
97
- .description("Pack the current directory and deploy it.")
98
- .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)")
99
110
  .option("--name <name>", "project name (only used on first deploy)")
100
111
  .option("--project <id_or_slug>", "deploy into an existing project, ignoring local config")
101
112
  .option("-y, --yes", "non-interactive: accept defaults and skip --prod confirmation")
102
- .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)")
103
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.")
104
115
  .option("--branch <name>", "deploy to a specific branch's environment. Wins over --prod.")
105
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.")
106
117
  .addHelpText("after", "\nExamples:\n" +
107
- " $ layero deploy # preview on CLI pseudo-branch (never replaces prod)\n" +
118
+ " $ layero deploy # preview deploy (CLI pseudo-branch), auto-detect framework\n" +
108
119
  " $ layero deploy --prod # production deploy (interactive confirm)\n" +
109
120
  " $ layero deploy --prod --yes # production deploy, no prompt (CI)\n" +
110
121
  " $ layero deploy --branch=staging # preview on a specific branch\n" +
111
- " $ layero deploy --config # uses .layero/project.json end-to-end (CI-friendly)\n" +
112
- " $ layero deploy --type vite")
122
+ " $ layero deploy --type vite # force a framework preset\n" +
123
+ " $ layero deploy --json # machine-readable output for agents")
113
124
  .action(async (opts) => {
114
125
  await deployCmd(opts);
115
126
  });
116
127
  await program.parseAsync(process.argv);
117
128
  }
118
129
  main().catch((err) => {
119
- 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
+ }
120
155
  process.exit(1);
121
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,40 +89,32 @@ 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");
102
100
  }
103
- // Resolve target Layero organization. Three paths:
104
- // * --org=slug explicit → verify membership, use it
105
- // * single org → silent default (the personal account)
106
- // * multiple orgs → prompt unless --yes
107
101
  let organizationSlug = opts.org;
108
102
  if (organizationSlug) {
109
103
  const orgs = await api.listOrganizations();
110
104
  if (!orgs.some((o) => o.slug === organizationSlug)) {
111
- throw new Error(`you're not a member of organization "${organizationSlug}". ` +
112
- `available: ${orgs.map((o) => o.slug).join(", ") || "(none)"}`);
105
+ throw new LayeroError("org_membership_missing", `you're not a member of organization "${organizationSlug}"`, `available: ${orgs.map((o) => o.slug).join(", ") || "(none)"}`);
113
106
  }
114
107
  }
115
108
  else {
116
109
  const orgs = await api.listOrganizations();
117
110
  if (orgs.length === 0) {
118
- throw new Error("no organization found on your account. finish onboarding at https://app.layero.ru/onboarding");
111
+ throw new LayeroError("no_organization", "no organization found on your account", "finish onboarding at https://app.layero.ru/onboarding");
119
112
  }
120
113
  else if (orgs.length === 1) {
121
114
  organizationSlug = orgs[0].slug;
122
115
  }
123
- else if (opts.yes || opts.config) {
124
- // CI without --org: prefer personal, fall back to first.
116
+ else if (opts.yes || opts.config || !mode.interactive) {
117
+ // Non-interactive: prefer personal, fall back to first.
125
118
  organizationSlug =
126
119
  orgs.find((o) => o.kind === "personal")?.slug ?? orgs[0].slug;
127
120
  }
@@ -134,13 +127,16 @@ async function resolveOrCreateProject(api, cwd, opts, existing) {
134
127
  const choiceRaw = await prompt("choose number", "1");
135
128
  const idx = Number(choiceRaw) - 1;
136
129
  if (Number.isNaN(idx) || idx < 0 || idx >= orgs.length) {
137
- throw new Error(`invalid choice "${choiceRaw}"`);
130
+ throw new LayeroError("invalid_choice", `invalid org choice "${choiceRaw}"`, "enter a number from the list");
138
131
  }
139
132
  organizationSlug = orgs[idx].slug;
140
133
  }
141
134
  }
142
135
  const fallbackName = path.basename(cwd);
143
- 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));
144
140
  const project = await api.createCliProject({
145
141
  name,
146
142
  framework_hint: opts.type,
@@ -149,43 +145,57 @@ async function resolveOrCreateProject(api, cwd, opts, existing) {
149
145
  return { project, createdNow: true };
150
146
  }
151
147
  async function packAndUpload(api, cwd, project) {
152
- console.log(chalk.cyan("packing source..."));
153
148
  const pack = await packCwd(cwd, project.slug);
154
- console.log(chalk.dim(` ${pack.fileCount} files, ${(pack.size / (1024 * 1024)).toFixed(2)} MB, sha256=${pack.sha256.slice(0, 12)}`));
155
- 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
+ });
156
155
  const init = await api.initUpload(project.id);
157
- console.log(chalk.cyan("uploading archive..."));
156
+ emit({ event: "uploading" });
158
157
  await uploadArchive(init, pack.archivePath);
158
+ emit({ event: "uploaded", archive_key: init.source_archive_key });
159
159
  return {
160
160
  archive_key: init.source_archive_key,
161
161
  commit_sha: pack.sha256,
162
162
  archivePath: pack.archivePath,
163
163
  };
164
164
  }
165
- function ensureConfigComplete(cfg) {
166
- if (!cfg) {
167
- throw new Error("no .layero/project.json found. run `layero deploy` once without --config " +
168
- "to create the project, fill in framework_hint/build_cmd/output_dir there, " +
169
- "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";
170
185
  }
171
- const missing = [];
172
- if (!cfg.framework_hint)
173
- missing.push("framework_hint");
174
- if (!cfg.build_cmd)
175
- missing.push("build_cmd");
176
- if (!cfg.output_dir)
177
- missing.push("output_dir");
178
- if (missing.length > 0) {
179
- throw new Error(`.layero/project.json is missing required fields for --config: ${missing.join(", ")}`);
186
+ else {
187
+ source = "detected";
180
188
  }
189
+ return { framework_hint, build_cmd, output_dir, source };
181
190
  }
182
191
  export async function deployCmd(opts) {
192
+ const mode = detectMode();
183
193
  if (opts.type && !VALID_TYPES.has(opts.type.toLowerCase())) {
184
- 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(", ")}`);
185
195
  }
186
196
  const cliCfg = await loadConfig();
187
197
  if (!cliCfg.token) {
188
- throw new Error("not logged in. run `layero login` first.");
198
+ throw new LayeroError("not_logged_in", "not authenticated", "run: layero login");
189
199
  }
190
200
  const api = new ApiClient(cliCfg);
191
201
  const cwd = process.cwd();
@@ -193,127 +203,79 @@ export async function deployCmd(opts) {
193
203
  const { project: created, createdNow } = await resolveOrCreateProject(api, cwd, opts, existing);
194
204
  let project = created;
195
205
  if (project.cli_deploys_enabled === false) {
196
- throw new Error(`CLI deploys are disabled on project "${project.slug}". ` +
197
- "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");
198
207
  }
199
- // Prompt before overwriting production. CLI deploys default to a per-project
200
- // "cli" pseudo-branch (preview-only); --prod is the explicit opt-in to land
201
- // on apex_hostname. --yes (CI) or --branch=<default_branch> bypass.
202
- 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) {
203
210
  const ok = await confirm(`deploy to production (https://${project.apex_hostname})?`);
204
211
  if (!ok) {
205
212
  console.log(chalk.yellow("aborted."));
206
213
  return;
207
214
  }
208
215
  }
209
- // Persist linking metadata only never touch hand-edited config fields
210
- // or unknown keys the user may have added (a --config file is the user's
211
- // source of truth, not ours). `persistProjectLinking` reads the file as
212
- // raw JSON, overlays just project_id/slug/organization_slug/apex_hostname,
213
- // 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);
214
219
  const persistedCfg = await persistProjectLinking(cwd, {
215
220
  project_id: project.id,
216
221
  slug: project.slug,
217
222
  organization_slug: project.organization.slug,
218
223
  apex_hostname: project.apex_hostname,
219
- }, opts.type ?? null);
224
+ }, setup.framework_hint);
220
225
  if (createdNow) {
221
- 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
+ });
222
232
  }
223
- // ─────────────────────────────────────────────────────────────────────
224
- // Path A: --config — fully scripted, runs the setup endpoint with
225
- // fields from .layero/project.json, then deploys.
226
- // ─────────────────────────────────────────────────────────────────────
227
- if (opts.config) {
228
- ensureConfigComplete(persistedCfg);
229
- if (project.status === "pending_setup") {
230
- console.log(chalk.cyan("applying config..."));
231
- project = await api.completeSetup(project.id, {
232
- framework_hint: persistedCfg.framework_hint,
233
- build_cmd: persistedCfg.build_cmd,
234
- output_dir: persistedCfg.output_dir,
235
- analytics_enabled: persistedCfg.analytics_enabled ?? false,
236
- env_vars: persistedCfg.env_vars ?? {},
237
- });
238
- }
239
- let upload = null;
240
- try {
241
- upload = await packAndUpload(api, cwd, project);
242
- console.log(chalk.cyan("triggering deploy..."));
243
- const targeting = deployTargeting(opts);
244
- const deploy = await api.triggerDeploy(project.id, {
245
- source_archive_key: upload.archive_key,
246
- commit_sha: upload.commit_sha,
247
- commit_message: "CLI deploy",
248
- framework_hint: persistedCfg.framework_hint ?? undefined,
249
- target: targeting.target,
250
- branch: targeting.branch,
251
- });
252
- console.log(chalk.dim(` deploy_id=${deploy.id}`));
253
- const final = await streamDeployLogs(api, deploy.id);
254
- if (final.status !== "ready") {
255
- console.error(chalk.red(`deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`));
256
- process.exitCode = 1;
257
- return;
258
- }
259
- console.log(chalk.green(`deploy ready → ${projectUrl(cliCfg.apiUrl, project.id)}`));
260
- if (opts.prod && !opts.branch) {
261
- console.log(chalk.dim(` production: https://${project.apex_hostname} (CDN may take ~30-60s to propagate)`));
262
- }
263
- else {
264
- console.log(chalk.dim(` preview deploy — open the dashboard for the exact URL`));
265
- }
266
- }
267
- finally {
268
- if (upload) {
269
- await fs.unlink(upload.archivePath).catch(() => undefined);
270
- }
271
- }
272
- 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" });
273
252
  }
274
- // ─────────────────────────────────────────────────────────────────────
275
- // Path B: no flag — upload the source, stash it on the project row,
276
- // print the setup URL. The user finishes the flow
277
- // in the browser, exactly like a GitHub import.
278
- // ─────────────────────────────────────────────────────────────────────
279
253
  let upload = null;
280
254
  try {
281
255
  upload = await packAndUpload(api, cwd, project);
282
- await api.finalizeUpload(project.id, {
283
- source_archive_key: upload.archive_key,
284
- commit_sha: upload.commit_sha,
285
- });
286
- if (project.status === "pending_setup") {
287
- const url = setupUrl(cliCfg.apiUrl, project.id);
288
- console.log("");
289
- console.log(chalk.green("source uploaded — finish setup in the browser:"));
290
- console.log(` ${chalk.bold(url)}`);
291
- console.log("");
292
- console.log(chalk.dim(" pick framework, build command, output dir, env vars and click Deploy."));
293
- console.log(chalk.dim(" to skip the browser next time, fill .layero/project.json and run `layero deploy --config`."));
294
- return;
295
- }
296
- // Already-active project: behave like the old `layero deploy` — fire
297
- // a build straight from the freshly-uploaded archive.
298
- console.log(chalk.cyan("triggering deploy..."));
299
256
  const targeting = deployTargeting(opts);
300
257
  const deploy = await api.triggerDeploy(project.id, {
301
258
  source_archive_key: upload.archive_key,
302
259
  commit_sha: upload.commit_sha,
303
260
  commit_message: "CLI deploy",
304
- framework_hint: opts.type,
261
+ framework_hint: setup.framework_hint,
305
262
  target: targeting.target,
306
263
  branch: targeting.branch,
307
264
  });
308
- console.log(chalk.dim(` deploy_id=${deploy.id}`));
265
+ emit({ event: "deploy_started", deploy_id: deploy.id });
309
266
  const final = await streamDeployLogs(api, deploy.id);
310
267
  if (final.status !== "ready") {
311
- console.error(chalk.red(`deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`));
312
- process.exitCode = 1;
313
- 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)}`);
314
269
  }
315
- console.log(chalk.green(`deploy ready ${projectUrl(cliCfg.apiUrl, project.id)}`));
316
- 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
+ });
317
279
  }
318
280
  finally {
319
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
+ }
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,9 +1,32 @@
1
1
  {
2
2
  "name": "layero",
3
- "version": "0.4.0",
4
- "description": "Layero CLI — publish a local site with one command.",
3
+ "version": "0.5.1",
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",
7
+ "homepage": "https://layero.ru",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/LayeroInfra/core.git",
11
+ "directory": "cli"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/LayeroInfra/core/issues"
15
+ },
16
+ "keywords": [
17
+ "layero",
18
+ "deploy",
19
+ "deployment",
20
+ "hosting",
21
+ "cdn",
22
+ "cli",
23
+ "static-site",
24
+ "spa",
25
+ "ssr",
26
+ "ai-agents",
27
+ "cursor",
28
+ "claude-code"
29
+ ],
7
30
  "engines": {
8
31
  "node": ">=20"
9
32
  },