layero 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -7
- package/dist/agent.js +158 -0
- package/dist/api.js +4 -0
- package/dist/bin/layero.js +54 -10
- package/dist/commands/deploy.js +124 -122
- package/dist/commands/init.js +152 -0
- package/dist/commands/orgs.js +24 -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/api.js
CHANGED
|
@@ -44,6 +44,9 @@ export class ApiClient {
|
|
|
44
44
|
listProjects() {
|
|
45
45
|
return this.request("GET", "/projects");
|
|
46
46
|
}
|
|
47
|
+
listOrganizations() {
|
|
48
|
+
return this.request("GET", "/organizations");
|
|
49
|
+
}
|
|
47
50
|
getProject(idOrSlug) {
|
|
48
51
|
return this.request("GET", `/projects/${idOrSlug}`);
|
|
49
52
|
}
|
|
@@ -53,6 +56,7 @@ export class ApiClient {
|
|
|
53
56
|
slug: input.slug,
|
|
54
57
|
source_type: "cli",
|
|
55
58
|
framework_hint: input.framework_hint,
|
|
59
|
+
organization_slug: input.organization_slug,
|
|
56
60
|
});
|
|
57
61
|
}
|
|
58
62
|
initUpload(projectId) {
|
package/dist/bin/layero.js
CHANGED
|
@@ -12,6 +12,9 @@ import { tokenSetCmd } from "../commands/token.js";
|
|
|
12
12
|
import { deployCmd } from "../commands/deploy.js";
|
|
13
13
|
import { deploysListCmd, rollbackCmd } from "../commands/deploys.js";
|
|
14
14
|
import { loginCmd } from "../commands/login.js";
|
|
15
|
+
import { orgsListCmd } from "../commands/orgs.js";
|
|
16
|
+
import { initCmd } from "../commands/init.js";
|
|
17
|
+
import { LayeroError, detectMode, emit } from "../agent.js";
|
|
15
18
|
// Read version from the shipped package.json (two levels up from dist/bin/).
|
|
16
19
|
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
17
20
|
const VERSION = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
|
|
@@ -19,12 +22,13 @@ async function main() {
|
|
|
19
22
|
const program = new Command();
|
|
20
23
|
program
|
|
21
24
|
.name("layero")
|
|
22
|
-
.description("Layero CLI — publish a local
|
|
23
|
-
.version(VERSION)
|
|
25
|
+
.description("Layero CLI — publish a local directory with one command. No git required.")
|
|
26
|
+
.version(VERSION)
|
|
27
|
+
.option("--json", "emit machine-readable JSON-lines on stdout (for agents and CI)");
|
|
24
28
|
program
|
|
25
29
|
.command("login")
|
|
26
30
|
.description("Authenticate via browser (GitHub / Google / Yandex).")
|
|
27
|
-
.option("-p, --provider <provider>", "OAuth provider
|
|
31
|
+
.option("-p, --provider <provider>", "OAuth provider hint (github | google | yandex)", "github")
|
|
28
32
|
.option("--port <port>", "fixed loopback port (default: random)", (v) => Number(v))
|
|
29
33
|
.addHelpText("after", "\nExamples:\n $ layero login\n $ layero login --provider google")
|
|
30
34
|
.action(async (opts) => {
|
|
@@ -38,6 +42,14 @@ async function main() {
|
|
|
38
42
|
.command("whoami")
|
|
39
43
|
.description("Show the currently logged-in account.")
|
|
40
44
|
.action(whoamiCmd);
|
|
45
|
+
program
|
|
46
|
+
.command("init")
|
|
47
|
+
.description("Scaffold .layero/project.json from auto-detected framework, and write a Layero deployment block into AGENTS.md / CLAUDE.md / .cursorrules so future chat sessions know how to deploy.")
|
|
48
|
+
.option("-y, --yes", "non-interactive: accept all defaults")
|
|
49
|
+
.option("--skip-agent-docs", "do not touch AGENTS.md/CLAUDE.md/.cursorrules")
|
|
50
|
+
.action(async (opts) => {
|
|
51
|
+
await initCmd({ yes: opts.yes, skipAgentDocs: opts.skipAgentDocs });
|
|
52
|
+
});
|
|
41
53
|
const projects = program
|
|
42
54
|
.command("projects")
|
|
43
55
|
.description("Inspect projects on your account.");
|
|
@@ -45,6 +57,13 @@ async function main() {
|
|
|
45
57
|
.command("list")
|
|
46
58
|
.description("List your projects.")
|
|
47
59
|
.action(projectsListCmd);
|
|
60
|
+
const orgs = program
|
|
61
|
+
.command("orgs")
|
|
62
|
+
.description("Layero organizations on your account (personal + teams).");
|
|
63
|
+
orgs
|
|
64
|
+
.command("list")
|
|
65
|
+
.description("Show every Layero organization you belong to.")
|
|
66
|
+
.action(orgsListCmd);
|
|
48
67
|
const deploys = program
|
|
49
68
|
.command("deploys")
|
|
50
69
|
.description("List and inspect deploys for the linked project.");
|
|
@@ -86,27 +105,52 @@ async function main() {
|
|
|
86
105
|
.action(tokenSetCmd);
|
|
87
106
|
program
|
|
88
107
|
.command("deploy")
|
|
89
|
-
.description("Pack the current directory and deploy it.")
|
|
90
|
-
.option("-t, --type <preset>", "framework
|
|
108
|
+
.description("Pack the current directory and deploy it. Framework, build command and output directory are auto-detected.")
|
|
109
|
+
.option("-t, --type <preset>", "framework override (vite | next | astro | cra | sveltekit | nuxt | gatsby | docusaurus | static)")
|
|
91
110
|
.option("--name <name>", "project name (only used on first deploy)")
|
|
92
111
|
.option("--project <id_or_slug>", "deploy into an existing project, ignoring local config")
|
|
93
112
|
.option("-y, --yes", "non-interactive: accept defaults and skip --prod confirmation")
|
|
94
|
-
.option("--config", "
|
|
113
|
+
.option("--config", "(legacy alias of the default behaviour — auto-detect + .layero/project.json values)")
|
|
95
114
|
.option("--prod", "deploy to production (replaces apex_hostname's active deploy). Without this flag, deploys go to the project's CLI preview pseudo-branch.")
|
|
96
115
|
.option("--branch <name>", "deploy to a specific branch's environment. Wins over --prod.")
|
|
116
|
+
.option("--org <slug>", "Layero organization slug for first-time project creation. Defaults to personal; required when you're a member of multiple orgs and want a non-personal home.")
|
|
97
117
|
.addHelpText("after", "\nExamples:\n" +
|
|
98
|
-
" $ layero deploy # preview
|
|
118
|
+
" $ layero deploy # preview deploy (CLI pseudo-branch), auto-detect framework\n" +
|
|
99
119
|
" $ layero deploy --prod # production deploy (interactive confirm)\n" +
|
|
100
120
|
" $ layero deploy --prod --yes # production deploy, no prompt (CI)\n" +
|
|
101
121
|
" $ layero deploy --branch=staging # preview on a specific branch\n" +
|
|
102
|
-
" $ layero deploy --
|
|
103
|
-
" $ layero deploy --
|
|
122
|
+
" $ layero deploy --type vite # force a framework preset\n" +
|
|
123
|
+
" $ layero deploy --json # machine-readable output for agents")
|
|
104
124
|
.action(async (opts) => {
|
|
105
125
|
await deployCmd(opts);
|
|
106
126
|
});
|
|
107
127
|
await program.parseAsync(process.argv);
|
|
108
128
|
}
|
|
109
129
|
main().catch((err) => {
|
|
110
|
-
|
|
130
|
+
const mode = detectMode();
|
|
131
|
+
if (err instanceof LayeroError) {
|
|
132
|
+
emit({
|
|
133
|
+
event: "error",
|
|
134
|
+
code: err.code,
|
|
135
|
+
next_action: err.next_action,
|
|
136
|
+
message: err.message,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
// Anything else (network error, unexpected exception) — still emit
|
|
141
|
+
// a structured error so agents have a consistent format to parse.
|
|
142
|
+
const message = err?.message ?? String(err);
|
|
143
|
+
if (mode.json) {
|
|
144
|
+
emit({
|
|
145
|
+
event: "error",
|
|
146
|
+
code: "internal",
|
|
147
|
+
next_action: "re-run with --debug for a stack trace, or report at https://github.com/layero/layero/issues",
|
|
148
|
+
message,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
console.error(chalk.red(message));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
111
155
|
process.exit(1);
|
|
112
156
|
});
|
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,64 +89,113 @@ async function resolveOrCreateProject(api, cwd, opts, existing) {
|
|
|
88
89
|
}
|
|
89
90
|
catch (err) {
|
|
90
91
|
if (err instanceof ApiError && err.status === 404) {
|
|
91
|
-
throw new
|
|
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
|
-
|
|
99
|
+
throw new LayeroError("username_missing", "no username set on your account", "open https://app.layero.ru/onboarding to pick one, then re-run");
|
|
100
|
+
}
|
|
101
|
+
let organizationSlug = opts.org;
|
|
102
|
+
if (organizationSlug) {
|
|
103
|
+
const orgs = await api.listOrganizations();
|
|
104
|
+
if (!orgs.some((o) => o.slug === organizationSlug)) {
|
|
105
|
+
throw new LayeroError("org_membership_missing", `you're not a member of organization "${organizationSlug}"`, `available: ${orgs.map((o) => o.slug).join(", ") || "(none)"}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
const orgs = await api.listOrganizations();
|
|
110
|
+
if (orgs.length === 0) {
|
|
111
|
+
throw new LayeroError("no_organization", "no organization found on your account", "finish onboarding at https://app.layero.ru/onboarding");
|
|
112
|
+
}
|
|
113
|
+
else if (orgs.length === 1) {
|
|
114
|
+
organizationSlug = orgs[0].slug;
|
|
115
|
+
}
|
|
116
|
+
else if (opts.yes || opts.config || !mode.interactive) {
|
|
117
|
+
// Non-interactive: prefer personal, fall back to first.
|
|
118
|
+
organizationSlug =
|
|
119
|
+
orgs.find((o) => o.kind === "personal")?.slug ?? orgs[0].slug;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
console.log(chalk.cyan("which organization?"));
|
|
123
|
+
orgs.forEach((o, i) => {
|
|
124
|
+
const tag = o.kind === "personal" ? "personal" : "team";
|
|
125
|
+
console.log(` ${i + 1}. ${o.slug} (${tag}, ${o.my_role})`);
|
|
126
|
+
});
|
|
127
|
+
const choiceRaw = await prompt("choose number", "1");
|
|
128
|
+
const idx = Number(choiceRaw) - 1;
|
|
129
|
+
if (Number.isNaN(idx) || idx < 0 || idx >= orgs.length) {
|
|
130
|
+
throw new LayeroError("invalid_choice", `invalid org choice "${choiceRaw}"`, "enter a number from the list");
|
|
131
|
+
}
|
|
132
|
+
organizationSlug = orgs[idx].slug;
|
|
133
|
+
}
|
|
102
134
|
}
|
|
103
135
|
const fallbackName = path.basename(cwd);
|
|
104
|
-
const name = opts.name ??
|
|
136
|
+
const name = opts.name ??
|
|
137
|
+
(opts.yes || opts.config || !mode.interactive
|
|
138
|
+
? fallbackName
|
|
139
|
+
: await prompt("project name", fallbackName));
|
|
105
140
|
const project = await api.createCliProject({
|
|
106
141
|
name,
|
|
107
142
|
framework_hint: opts.type,
|
|
143
|
+
organization_slug: organizationSlug,
|
|
108
144
|
});
|
|
109
145
|
return { project, createdNow: true };
|
|
110
146
|
}
|
|
111
147
|
async function packAndUpload(api, cwd, project) {
|
|
112
|
-
console.log(chalk.cyan("packing source..."));
|
|
113
148
|
const pack = await packCwd(cwd, project.slug);
|
|
114
|
-
|
|
115
|
-
|
|
149
|
+
emit({
|
|
150
|
+
event: "packing",
|
|
151
|
+
files: pack.fileCount,
|
|
152
|
+
bytes: pack.size,
|
|
153
|
+
sha256: pack.sha256,
|
|
154
|
+
});
|
|
116
155
|
const init = await api.initUpload(project.id);
|
|
117
|
-
|
|
156
|
+
emit({ event: "uploading" });
|
|
118
157
|
await uploadArchive(init, pack.archivePath);
|
|
158
|
+
emit({ event: "uploaded", archive_key: init.source_archive_key });
|
|
119
159
|
return {
|
|
120
160
|
archive_key: init.source_archive_key,
|
|
121
161
|
commit_sha: pack.sha256,
|
|
122
162
|
archivePath: pack.archivePath,
|
|
123
163
|
};
|
|
124
164
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
165
|
+
// Resolve the framework / build / output config to send to completeSetup.
|
|
166
|
+
// Precedence: explicit CLI args > .layero/project.json > auto-detect.
|
|
167
|
+
async function resolveSetupConfig(cwd, opts, existing) {
|
|
168
|
+
const detected = await detectProject(cwd);
|
|
169
|
+
emit({
|
|
170
|
+
event: "detected",
|
|
171
|
+
framework: detected.framework_hint,
|
|
172
|
+
build_cmd: detected.build_cmd,
|
|
173
|
+
output_dir: detected.output_dir,
|
|
174
|
+
confident: detected.confident,
|
|
175
|
+
});
|
|
176
|
+
const framework_hint = opts.type ?? existing?.framework_hint ?? detected.framework_hint;
|
|
177
|
+
const build_cmd = existing?.build_cmd ?? detected.build_cmd;
|
|
178
|
+
const output_dir = existing?.output_dir ?? detected.output_dir;
|
|
179
|
+
let source;
|
|
180
|
+
if (existing?.build_cmd || existing?.output_dir || existing?.framework_hint) {
|
|
181
|
+
source =
|
|
182
|
+
existing?.build_cmd && existing?.output_dir && existing?.framework_hint
|
|
183
|
+
? "config"
|
|
184
|
+
: "hybrid";
|
|
130
185
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
missing.push("framework_hint");
|
|
134
|
-
if (!cfg.build_cmd)
|
|
135
|
-
missing.push("build_cmd");
|
|
136
|
-
if (!cfg.output_dir)
|
|
137
|
-
missing.push("output_dir");
|
|
138
|
-
if (missing.length > 0) {
|
|
139
|
-
throw new Error(`.layero/project.json is missing required fields for --config: ${missing.join(", ")}`);
|
|
186
|
+
else {
|
|
187
|
+
source = "detected";
|
|
140
188
|
}
|
|
189
|
+
return { framework_hint, build_cmd, output_dir, source };
|
|
141
190
|
}
|
|
142
191
|
export async function deployCmd(opts) {
|
|
192
|
+
const mode = detectMode();
|
|
143
193
|
if (opts.type && !VALID_TYPES.has(opts.type.toLowerCase())) {
|
|
144
|
-
throw new
|
|
194
|
+
throw new LayeroError("invalid_type", `unknown --type "${opts.type}"`, `valid types: ${[...VALID_TYPES].join(", ")}`);
|
|
145
195
|
}
|
|
146
196
|
const cliCfg = await loadConfig();
|
|
147
197
|
if (!cliCfg.token) {
|
|
148
|
-
throw new
|
|
198
|
+
throw new LayeroError("not_logged_in", "not authenticated", "run: layero login");
|
|
149
199
|
}
|
|
150
200
|
const api = new ApiClient(cliCfg);
|
|
151
201
|
const cwd = process.cwd();
|
|
@@ -153,127 +203,79 @@ export async function deployCmd(opts) {
|
|
|
153
203
|
const { project: created, createdNow } = await resolveOrCreateProject(api, cwd, opts, existing);
|
|
154
204
|
let project = created;
|
|
155
205
|
if (project.cli_deploys_enabled === false) {
|
|
156
|
-
throw new
|
|
157
|
-
"Enable them in project settings or use the dashboard's Deploy button.");
|
|
206
|
+
throw new LayeroError("cli_deploys_disabled", `CLI deploys are disabled on project "${project.slug}"`, "enable them in project settings, or remove --project to use a different project");
|
|
158
207
|
}
|
|
159
|
-
// Prompt before overwriting production
|
|
160
|
-
|
|
161
|
-
// on apex_hostname. --yes (CI) or --branch=<default_branch> bypass.
|
|
162
|
-
if (opts.prod && !opts.yes && !opts.branch) {
|
|
208
|
+
// Prompt before overwriting production — only in interactive mode.
|
|
209
|
+
if (opts.prod && !opts.yes && !opts.branch && mode.interactive) {
|
|
163
210
|
const ok = await confirm(`deploy to production (https://${project.apex_hostname})?`);
|
|
164
211
|
if (!ok) {
|
|
165
212
|
console.log(chalk.yellow("aborted."));
|
|
166
213
|
return;
|
|
167
214
|
}
|
|
168
215
|
}
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
// raw JSON, overlays just project_id/slug/organization_slug/apex_hostname,
|
|
173
|
-
// and writes it back.
|
|
216
|
+
// Resolve setup config (auto-detect + overrides). Done eagerly so the
|
|
217
|
+
// user sees what we detected before any network I/O.
|
|
218
|
+
const setup = await resolveSetupConfig(cwd, opts, existing);
|
|
174
219
|
const persistedCfg = await persistProjectLinking(cwd, {
|
|
175
220
|
project_id: project.id,
|
|
176
221
|
slug: project.slug,
|
|
177
222
|
organization_slug: project.organization.slug,
|
|
178
223
|
apex_hostname: project.apex_hostname,
|
|
179
|
-
},
|
|
224
|
+
}, setup.framework_hint);
|
|
180
225
|
if (createdNow) {
|
|
181
|
-
|
|
226
|
+
emit({
|
|
227
|
+
event: "project_created",
|
|
228
|
+
project_id: project.id,
|
|
229
|
+
slug: project.slug,
|
|
230
|
+
organization: project.organization.slug,
|
|
231
|
+
});
|
|
182
232
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
console.log(chalk.cyan("triggering deploy..."));
|
|
203
|
-
const targeting = deployTargeting(opts);
|
|
204
|
-
const deploy = await api.triggerDeploy(project.id, {
|
|
205
|
-
source_archive_key: upload.archive_key,
|
|
206
|
-
commit_sha: upload.commit_sha,
|
|
207
|
-
commit_message: "CLI deploy",
|
|
208
|
-
framework_hint: persistedCfg.framework_hint ?? undefined,
|
|
209
|
-
target: targeting.target,
|
|
210
|
-
branch: targeting.branch,
|
|
211
|
-
});
|
|
212
|
-
console.log(chalk.dim(` deploy_id=${deploy.id}`));
|
|
213
|
-
const final = await streamDeployLogs(api, deploy.id);
|
|
214
|
-
if (final.status !== "ready") {
|
|
215
|
-
console.error(chalk.red(`deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`));
|
|
216
|
-
process.exitCode = 1;
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
console.log(chalk.green(`deploy ready → ${projectUrl(cliCfg.apiUrl, project.id)}`));
|
|
220
|
-
if (opts.prod && !opts.branch) {
|
|
221
|
-
console.log(chalk.dim(` production: https://${project.apex_hostname} (CDN may take ~30-60s to propagate)`));
|
|
222
|
-
}
|
|
223
|
-
else {
|
|
224
|
-
console.log(chalk.dim(` preview deploy — open the dashboard for the exact URL`));
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
finally {
|
|
228
|
-
if (upload) {
|
|
229
|
-
await fs.unlink(upload.archivePath).catch(() => undefined);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
return;
|
|
233
|
+
else {
|
|
234
|
+
emit({
|
|
235
|
+
event: "project_linked",
|
|
236
|
+
project_id: project.id,
|
|
237
|
+
slug: project.slug,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
// Apply setup if the project is still in pending_setup. After first
|
|
241
|
+
// run, subsequent deploys reuse whatever was set then — the user can
|
|
242
|
+
// edit .layero/project.json or use the dashboard to change it.
|
|
243
|
+
if (project.status === "pending_setup") {
|
|
244
|
+
project = await api.completeSetup(project.id, {
|
|
245
|
+
framework_hint: setup.framework_hint,
|
|
246
|
+
build_cmd: setup.build_cmd,
|
|
247
|
+
output_dir: setup.output_dir,
|
|
248
|
+
analytics_enabled: persistedCfg.analytics_enabled ?? false,
|
|
249
|
+
env_vars: persistedCfg.env_vars ?? {},
|
|
250
|
+
});
|
|
251
|
+
emit({ event: "setup_applied" });
|
|
233
252
|
}
|
|
234
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
235
|
-
// Path B: no flag — upload the source, stash it on the project row,
|
|
236
|
-
// print the setup URL. The user finishes the flow
|
|
237
|
-
// in the browser, exactly like a GitHub import.
|
|
238
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
239
253
|
let upload = null;
|
|
240
254
|
try {
|
|
241
255
|
upload = await packAndUpload(api, cwd, project);
|
|
242
|
-
await api.finalizeUpload(project.id, {
|
|
243
|
-
source_archive_key: upload.archive_key,
|
|
244
|
-
commit_sha: upload.commit_sha,
|
|
245
|
-
});
|
|
246
|
-
if (project.status === "pending_setup") {
|
|
247
|
-
const url = setupUrl(cliCfg.apiUrl, project.id);
|
|
248
|
-
console.log("");
|
|
249
|
-
console.log(chalk.green("source uploaded — finish setup in the browser:"));
|
|
250
|
-
console.log(` ${chalk.bold(url)}`);
|
|
251
|
-
console.log("");
|
|
252
|
-
console.log(chalk.dim(" pick framework, build command, output dir, env vars and click Deploy."));
|
|
253
|
-
console.log(chalk.dim(" to skip the browser next time, fill .layero/project.json and run `layero deploy --config`."));
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
// Already-active project: behave like the old `layero deploy` — fire
|
|
257
|
-
// a build straight from the freshly-uploaded archive.
|
|
258
|
-
console.log(chalk.cyan("triggering deploy..."));
|
|
259
256
|
const targeting = deployTargeting(opts);
|
|
260
257
|
const deploy = await api.triggerDeploy(project.id, {
|
|
261
258
|
source_archive_key: upload.archive_key,
|
|
262
259
|
commit_sha: upload.commit_sha,
|
|
263
260
|
commit_message: "CLI deploy",
|
|
264
|
-
framework_hint:
|
|
261
|
+
framework_hint: setup.framework_hint,
|
|
265
262
|
target: targeting.target,
|
|
266
263
|
branch: targeting.branch,
|
|
267
264
|
});
|
|
268
|
-
|
|
265
|
+
emit({ event: "deploy_started", deploy_id: deploy.id });
|
|
269
266
|
const final = await streamDeployLogs(api, deploy.id);
|
|
270
267
|
if (final.status !== "ready") {
|
|
271
|
-
|
|
272
|
-
process.exitCode = 1;
|
|
273
|
-
return;
|
|
268
|
+
throw new LayeroError(`deploy_${final.status}`, `deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`, `inspect logs at ${projectUrl(cliCfg.apiUrl, project.id)}`);
|
|
274
269
|
}
|
|
275
|
-
|
|
276
|
-
|
|
270
|
+
const liveUrl = opts.prod && !opts.branch
|
|
271
|
+
? `https://${project.apex_hostname}`
|
|
272
|
+
: projectUrl(cliCfg.apiUrl, project.id);
|
|
273
|
+
emit({
|
|
274
|
+
event: "ready",
|
|
275
|
+
url: liveUrl,
|
|
276
|
+
deploy_id: deploy.id,
|
|
277
|
+
preview_url: opts.prod && !opts.branch ? undefined : projectUrl(cliCfg.apiUrl, project.id),
|
|
278
|
+
});
|
|
277
279
|
}
|
|
278
280
|
finally {
|
|
279
281
|
if (upload) {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { detectProject } from "../detect.js";
|
|
5
|
+
import { emit, detectMode } from "../agent.js";
|
|
6
|
+
const AGENT_BLOCK_MARKER_START = "<!-- layero:start -->";
|
|
7
|
+
const AGENT_BLOCK_MARKER_END = "<!-- layero:end -->";
|
|
8
|
+
function agentDocBlock(framework) {
|
|
9
|
+
return `${AGENT_BLOCK_MARKER_START}
|
|
10
|
+
## Deploying with Layero
|
|
11
|
+
|
|
12
|
+
This project ships to https://layero.ru. To deploy any local changes:
|
|
13
|
+
|
|
14
|
+
\`\`\`bash
|
|
15
|
+
npx layero deploy
|
|
16
|
+
\`\`\`
|
|
17
|
+
|
|
18
|
+
First-time setup:
|
|
19
|
+
- The CLI prints an authorization URL on first run. Show it to the user as a clickable link; they sign in once (GitHub / Google / Yandex), and the token is cached locally.
|
|
20
|
+
- Detected framework: **${framework}**.
|
|
21
|
+
- No git repository or GitHub connection is required — Layero ships the local directory directly.
|
|
22
|
+
|
|
23
|
+
Re-deploy after edits: run \`npx layero deploy\` again. Each run produces a new preview URL.
|
|
24
|
+
|
|
25
|
+
Add \`--prod\` to publish to the project's apex hostname instead of a preview URL.
|
|
26
|
+
${AGENT_BLOCK_MARKER_END}
|
|
27
|
+
`;
|
|
28
|
+
}
|
|
29
|
+
async function upsertAgentDoc(cwd, filename, content) {
|
|
30
|
+
const filePath = path.join(cwd, filename);
|
|
31
|
+
let existing = null;
|
|
32
|
+
try {
|
|
33
|
+
existing = await fs.readFile(filePath, "utf-8");
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (err?.code !== "ENOENT")
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
if (existing === null) {
|
|
40
|
+
await fs.writeFile(filePath, content, "utf-8");
|
|
41
|
+
return "created";
|
|
42
|
+
}
|
|
43
|
+
// Already has our block — replace it in place so we can iterate
|
|
44
|
+
// safely (re-running `layero init` updates the block, doesn't append).
|
|
45
|
+
if (existing.includes(AGENT_BLOCK_MARKER_START)) {
|
|
46
|
+
const start = existing.indexOf(AGENT_BLOCK_MARKER_START);
|
|
47
|
+
const endMarker = existing.indexOf(AGENT_BLOCK_MARKER_END);
|
|
48
|
+
if (endMarker === -1) {
|
|
49
|
+
// Malformed — fall through to append.
|
|
50
|
+
const merged = existing.trimEnd() + "\n\n" + content;
|
|
51
|
+
await fs.writeFile(filePath, merged, "utf-8");
|
|
52
|
+
return "updated";
|
|
53
|
+
}
|
|
54
|
+
const newBlock = content.trimEnd() + "\n";
|
|
55
|
+
const merged = existing.slice(0, start) +
|
|
56
|
+
newBlock +
|
|
57
|
+
existing.slice(endMarker + AGENT_BLOCK_MARKER_END.length).replace(/^\n+/, "\n");
|
|
58
|
+
if (merged === existing)
|
|
59
|
+
return "unchanged";
|
|
60
|
+
await fs.writeFile(filePath, merged, "utf-8");
|
|
61
|
+
return "updated";
|
|
62
|
+
}
|
|
63
|
+
// Append our block to the existing file with a separating blank line.
|
|
64
|
+
const merged = existing.trimEnd() + "\n\n" + content;
|
|
65
|
+
await fs.writeFile(filePath, merged, "utf-8");
|
|
66
|
+
return "updated";
|
|
67
|
+
}
|
|
68
|
+
async function ensureProjectJson(cwd, framework, build_cmd, output_dir) {
|
|
69
|
+
const dir = path.join(cwd, ".layero");
|
|
70
|
+
const file = path.join(dir, "project.json");
|
|
71
|
+
try {
|
|
72
|
+
await fs.access(file);
|
|
73
|
+
return "unchanged";
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// missing → create
|
|
77
|
+
}
|
|
78
|
+
await fs.mkdir(dir, { recursive: true });
|
|
79
|
+
const scaffold = {
|
|
80
|
+
framework_hint: framework,
|
|
81
|
+
build_cmd,
|
|
82
|
+
output_dir,
|
|
83
|
+
analytics_enabled: false,
|
|
84
|
+
env_vars: {},
|
|
85
|
+
};
|
|
86
|
+
await fs.writeFile(file, JSON.stringify(scaffold, null, 2) + "\n", "utf-8");
|
|
87
|
+
return "created";
|
|
88
|
+
}
|
|
89
|
+
async function ensureGitignore(cwd) {
|
|
90
|
+
const gi = path.join(cwd, ".gitignore");
|
|
91
|
+
let content = "";
|
|
92
|
+
try {
|
|
93
|
+
content = await fs.readFile(gi, "utf-8");
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return; // no .gitignore — leave alone, this is meant to be additive
|
|
97
|
+
}
|
|
98
|
+
// We only want to ignore the auth-state-bearing files. `project.json`
|
|
99
|
+
// itself is safe to commit (project_id is not a secret).
|
|
100
|
+
if (!content.includes(".layero/")) {
|
|
101
|
+
const append = (content.endsWith("\n") ? "" : "\n") + "\n# Layero local state\n.layero/cache/\n";
|
|
102
|
+
await fs.writeFile(gi, content + append, "utf-8");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function initCmd(opts) {
|
|
106
|
+
const cwd = process.cwd();
|
|
107
|
+
const mode = detectMode();
|
|
108
|
+
const detected = await detectProject(cwd);
|
|
109
|
+
emit({
|
|
110
|
+
event: "detected",
|
|
111
|
+
framework: detected.framework_hint,
|
|
112
|
+
build_cmd: detected.build_cmd,
|
|
113
|
+
output_dir: detected.output_dir,
|
|
114
|
+
confident: detected.confident,
|
|
115
|
+
});
|
|
116
|
+
const block = agentDocBlock(detected.framework_hint);
|
|
117
|
+
if (!opts.skipAgentDocs) {
|
|
118
|
+
// Touch every agent-doc convention we know about. If one already
|
|
119
|
+
// exists we update it in-place; otherwise we create only the most
|
|
120
|
+
// common one (AGENTS.md, the cross-vendor convention) so we don't
|
|
121
|
+
// litter the repo with .cursorrules / CLAUDE.md the user doesn't use.
|
|
122
|
+
const candidates = ["AGENTS.md", "CLAUDE.md", ".cursorrules"];
|
|
123
|
+
const existing = [];
|
|
124
|
+
for (const f of candidates) {
|
|
125
|
+
try {
|
|
126
|
+
await fs.access(path.join(cwd, f));
|
|
127
|
+
existing.push(f);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// not present
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const targets = existing.length > 0 ? existing : ["AGENTS.md"];
|
|
134
|
+
for (const f of targets) {
|
|
135
|
+
const result = await upsertAgentDoc(cwd, f, block);
|
|
136
|
+
if (mode.interactive) {
|
|
137
|
+
console.log(chalk.green(` ${result === "created" ? "✓ created" : result === "updated" ? "✓ updated" : "= unchanged"} ${f}`));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const pjResult = await ensureProjectJson(cwd, detected.framework_hint, detected.build_cmd, detected.output_dir);
|
|
142
|
+
if (mode.interactive) {
|
|
143
|
+
console.log(chalk.green(` ${pjResult === "created" ? "✓ created" : "= unchanged"} .layero/project.json`));
|
|
144
|
+
}
|
|
145
|
+
await ensureGitignore(cwd);
|
|
146
|
+
if (mode.interactive) {
|
|
147
|
+
console.log("");
|
|
148
|
+
console.log(chalk.bold("Next:"));
|
|
149
|
+
console.log(" $ npx layero login # one-time, in your browser");
|
|
150
|
+
console.log(" $ npx layero deploy # ship the current directory");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { ApiClient } from "../api.js";
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
4
|
+
/** `layero orgs list` — show every Layero organization the caller belongs to.
|
|
5
|
+
*
|
|
6
|
+
* Useful before `layero deploy --org=<slug>` so the user can see which
|
|
7
|
+
* slugs to pass without leaving the terminal.
|
|
8
|
+
*/
|
|
9
|
+
export async function orgsListCmd() {
|
|
10
|
+
const cfg = await loadConfig();
|
|
11
|
+
if (!cfg.token)
|
|
12
|
+
throw new Error("not logged in. run `layero login` first.");
|
|
13
|
+
const api = new ApiClient(cfg);
|
|
14
|
+
const orgs = await api.listOrganizations();
|
|
15
|
+
if (orgs.length === 0) {
|
|
16
|
+
console.log(chalk.dim("no organizations on this account."));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
for (const o of orgs) {
|
|
20
|
+
const kindBadge = o.kind === "personal" ? chalk.dim("personal") : chalk.cyan("team");
|
|
21
|
+
const roleBadge = chalk.dim(`(${o.my_role})`);
|
|
22
|
+
console.log(` ${chalk.bold(o.slug.padEnd(20))} ${kindBadge} ${roleBadge}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
async function readPkg(cwd) {
|
|
4
|
+
try {
|
|
5
|
+
const raw = await fs.readFile(path.join(cwd, "package.json"), "utf-8");
|
|
6
|
+
return JSON.parse(raw);
|
|
7
|
+
}
|
|
8
|
+
catch (err) {
|
|
9
|
+
if (err?.code === "ENOENT")
|
|
10
|
+
return null;
|
|
11
|
+
throw err;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function hasDep(pkg, name) {
|
|
15
|
+
return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]);
|
|
16
|
+
}
|
|
17
|
+
async function fileExists(cwd, ...candidates) {
|
|
18
|
+
for (const c of candidates) {
|
|
19
|
+
try {
|
|
20
|
+
await fs.access(path.join(cwd, c));
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// try next
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
async function hasAnyHtml(cwd) {
|
|
30
|
+
// Shallow check — only top-level. Recursive walk is too expensive here
|
|
31
|
+
// and the static fallback is already permissive enough.
|
|
32
|
+
try {
|
|
33
|
+
const entries = await fs.readdir(cwd);
|
|
34
|
+
return entries.some((e) => e.endsWith(".html") || e.endsWith(".htm"));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Build command preference:
|
|
41
|
+
// 1. If `scripts.build` exists in package.json, use `npm run build` —
|
|
42
|
+
// it respects whatever the user set up (custom flags, monorepo
|
|
43
|
+
// filters, etc.). `npm run` works regardless of package manager
|
|
44
|
+
// installed on the build VM, because the builder runs `npm` itself.
|
|
45
|
+
// 2. Otherwise, use the framework's CLI directly via `npx`.
|
|
46
|
+
function buildCmd(pkg, fallback) {
|
|
47
|
+
if (pkg?.scripts?.build)
|
|
48
|
+
return "npm run build";
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Detect framework / build command / output directory from the project
|
|
53
|
+
* shape. Mirrors the builder's detector ([core/builder/src/frameworks])
|
|
54
|
+
* so the values we ship to `completeSetup` match what the builder will
|
|
55
|
+
* actually do at build time.
|
|
56
|
+
*
|
|
57
|
+
* Order matters: more specific signals first (Next, Nuxt, SvelteKit,
|
|
58
|
+
* Gatsby, Astro, Docusaurus) before catch-alls (Vite, CRA, static).
|
|
59
|
+
*/
|
|
60
|
+
export async function detectProject(cwd) {
|
|
61
|
+
const pkg = await readPkg(cwd);
|
|
62
|
+
if (pkg) {
|
|
63
|
+
if (hasDep(pkg, "next") || (await fileExists(cwd, "next.config.js", "next.config.mjs", "next.config.ts"))) {
|
|
64
|
+
return {
|
|
65
|
+
framework_hint: "nextjs",
|
|
66
|
+
build_cmd: buildCmd(pkg, "npx next build"),
|
|
67
|
+
output_dir: "out",
|
|
68
|
+
confident: true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (hasDep(pkg, "nuxt") || hasDep(pkg, "nuxt3") || (await fileExists(cwd, "nuxt.config.ts", "nuxt.config.js", "nuxt.config.mjs"))) {
|
|
72
|
+
const generateScript = pkg.scripts?.generate;
|
|
73
|
+
const buildScript = pkg.scripts?.build;
|
|
74
|
+
const cmd = generateScript
|
|
75
|
+
? "npm run generate"
|
|
76
|
+
: buildScript
|
|
77
|
+
? "npm run build"
|
|
78
|
+
: "npx nuxt generate";
|
|
79
|
+
return {
|
|
80
|
+
framework_hint: "nuxt",
|
|
81
|
+
build_cmd: cmd,
|
|
82
|
+
output_dir: ".output/public",
|
|
83
|
+
confident: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (hasDep(pkg, "@sveltejs/kit") || (await fileExists(cwd, "svelte.config.js"))) {
|
|
87
|
+
return {
|
|
88
|
+
framework_hint: "sveltekit",
|
|
89
|
+
build_cmd: buildCmd(pkg, "npx vite build"),
|
|
90
|
+
output_dir: "build",
|
|
91
|
+
confident: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (hasDep(pkg, "gatsby") || (await fileExists(cwd, "gatsby-config.js", "gatsby-config.ts"))) {
|
|
95
|
+
return {
|
|
96
|
+
framework_hint: "gatsby",
|
|
97
|
+
build_cmd: buildCmd(pkg, "npx gatsby build"),
|
|
98
|
+
output_dir: "public",
|
|
99
|
+
confident: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (hasDep(pkg, "astro") || (await fileExists(cwd, "astro.config.mjs", "astro.config.ts", "astro.config.js"))) {
|
|
103
|
+
return {
|
|
104
|
+
framework_hint: "astro",
|
|
105
|
+
build_cmd: buildCmd(pkg, "npx astro build"),
|
|
106
|
+
output_dir: "dist",
|
|
107
|
+
confident: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (hasDep(pkg, "@docusaurus/core") || (await fileExists(cwd, "docusaurus.config.js", "docusaurus.config.ts", "docusaurus.config.mjs"))) {
|
|
111
|
+
return {
|
|
112
|
+
framework_hint: "docusaurus",
|
|
113
|
+
build_cmd: buildCmd(pkg, "npx docusaurus build"),
|
|
114
|
+
output_dir: "build",
|
|
115
|
+
confident: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (hasDep(pkg, "vite") || (await fileExists(cwd, "vite.config.ts", "vite.config.js", "vite.config.mjs"))) {
|
|
119
|
+
return {
|
|
120
|
+
framework_hint: "vite",
|
|
121
|
+
build_cmd: buildCmd(pkg, "npx vite build"),
|
|
122
|
+
output_dir: "dist",
|
|
123
|
+
confident: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (hasDep(pkg, "react-scripts")) {
|
|
127
|
+
return {
|
|
128
|
+
framework_hint: "cra",
|
|
129
|
+
build_cmd: buildCmd(pkg, "npx react-scripts build"),
|
|
130
|
+
output_dir: "build",
|
|
131
|
+
confident: true,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// No (or unrecognised) package.json. If there's HTML on disk we treat
|
|
136
|
+
// the current directory as a static site. Otherwise we still fall back
|
|
137
|
+
// to static — it's the most permissive option and the builder will
|
|
138
|
+
// ship whatever is on disk verbatim.
|
|
139
|
+
const _staticHtml = await hasAnyHtml(cwd);
|
|
140
|
+
return {
|
|
141
|
+
framework_hint: "static",
|
|
142
|
+
build_cmd: "true",
|
|
143
|
+
output_dir: ".",
|
|
144
|
+
confident: _staticHtml,
|
|
145
|
+
};
|
|
146
|
+
}
|