layero 0.4.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 +88 -7
- package/dist/agent.js +158 -0
- package/dist/bin/layero.js +45 -10
- package/dist/commands/deploy.js +94 -132
- package/dist/commands/init.js +152 -0
- package/dist/detect.js +146 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
# layero
|
|
2
2
|
|
|
3
|
-
CLI for [Layero](https://layero.ru) — publish a local
|
|
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 #
|
|
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` |
|
|
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
|
|
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/bin/layero.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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", "
|
|
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
|
|
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 --
|
|
112
|
-
" $ layero deploy --
|
|
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
|
-
|
|
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
|
});
|
package/dist/commands/deploy.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
200
|
-
|
|
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
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
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
|
-
},
|
|
224
|
+
}, setup.framework_hint);
|
|
220
225
|
if (createdNow) {
|
|
221
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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:
|
|
261
|
+
framework_hint: setup.framework_hint,
|
|
305
262
|
target: targeting.target,
|
|
306
263
|
branch: targeting.branch,
|
|
307
264
|
});
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
+
}
|