layero 0.9.4 → 0.10.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/LICENSE +21 -0
- package/README.md +203 -167
- package/dist/agent.js +130 -2
- package/dist/api.js +130 -0
- package/dist/bin/layero.js +110 -36
- package/dist/commands/claim.js +135 -0
- package/dist/commands/data-api.js +578 -0
- package/dist/commands/data-probe.js +541 -0
- package/dist/commands/data.js +4 -1
- package/dist/commands/db.js +94 -4
- package/dist/commands/deploy.js +81 -23
- package/dist/commands/env.js +13 -2
- package/dist/commands/envs.js +44 -0
- package/dist/commands/hooks.js +29 -32
- package/dist/commands/init.js +63 -91
- package/dist/commands/link.js +12 -25
- package/dist/commands/logout.js +2 -2
- package/dist/commands/orgs.js +11 -15
- package/dist/commands/projects.js +179 -12
- package/dist/commands/sources.js +127 -0
- package/dist/commands/whoami.js +9 -10
- package/dist/config.js +1 -0
- package/dist/exit-codes.js +102 -0
- package/dist/project-config.js +2 -0
- package/dist/project-ref.js +20 -0
- package/dist/urls.js +21 -0
- package/package.json +10 -5
package/dist/commands/init.js
CHANGED
|
@@ -1,96 +1,73 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import chalk from "chalk";
|
|
4
3
|
import { detectProject } from "../detect.js";
|
|
5
|
-
import { emit
|
|
4
|
+
import { emit } from "../agent.js";
|
|
6
5
|
const AGENT_BLOCK_MARKER_START = "<!-- layero:start -->";
|
|
7
6
|
const AGENT_BLOCK_MARKER_END = "<!-- layero:end -->";
|
|
8
7
|
function agentDocBlock(framework) {
|
|
8
|
+
// Компактный индекс, а не ссылка на навык: по evals Vercel блок в
|
|
9
|
+
// AGENTS.md агент выполняет надёжнее (100 % против 79 % у навыка).
|
|
10
|
+
// Полная версия — навык `layero` в LayeroInfra/layero-agents; здесь —
|
|
11
|
+
// то, без чего первый деплой не случится. Коды ошибок сверяет
|
|
12
|
+
// `check-error-codes.py`: не перечислять здесь то, чего CLI не выдаёт.
|
|
9
13
|
return `${AGENT_BLOCK_MARKER_START}
|
|
10
|
-
##
|
|
14
|
+
## Layero
|
|
11
15
|
|
|
12
|
-
This project deploys to Layero (https://layero.ru)
|
|
16
|
+
This project deploys to Layero (https://layero.ru) — hosting with build servers
|
|
17
|
+
in Russia. Full agent skill: https://github.com/LayeroInfra/layero-agents
|
|
18
|
+
(\`npx skills add LayeroInfra/layero-agents\`, Claude Code:
|
|
19
|
+
\`claude plugin marketplace add LayeroInfra/layero-agents && claude plugin install layero@layero\`).
|
|
20
|
+
Docs for agents: https://docs.layero.ru/agents/
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
npx layero@latest deploy
|
|
16
|
-
\`\`\`
|
|
17
|
-
|
|
18
|
-
Detected framework: **${framework}**. No git repository or GitHub connection
|
|
19
|
-
is required — Layero packs and uploads the local directory directly.
|
|
20
|
-
|
|
21
|
-
### First-time auth (one-click device flow)
|
|
22
|
-
|
|
23
|
-
If you're not logged in yet, \`npx layero@latest deploy\` (or \`… login\`)
|
|
24
|
-
starts the browser device-flow automatically and emits a JSON line:
|
|
25
|
-
|
|
26
|
-
\`\`\`json
|
|
27
|
-
{"event":"auth_required","url":"https://app.layero.ru/cli?code=ABCD-1234","user_code":"ABCD-1234"}
|
|
28
|
-
\`\`\`
|
|
29
|
-
|
|
30
|
-
Render the \`url\` as a clickable link in chat. The user opens it, signs in
|
|
31
|
-
(by an emailed code or with Yandex ID — Layero creates the account on first sign-in),
|
|
32
|
-
clicks "Разрешить доступ", and the CLI's poll loop picks up the token within
|
|
33
|
-
2 seconds. No localhost server is involved — the browser can be on a
|
|
34
|
-
different machine than the CLI.
|
|
35
|
-
|
|
36
|
-
### JSON-lines events
|
|
37
|
-
|
|
38
|
-
When run inside an agent (\`CURSOR_AGENT\`, \`CLAUDECODE\`, or any non-TTY
|
|
39
|
-
stdout), the CLI auto-switches to JSON-lines. Key events to watch:
|
|
40
|
-
|
|
41
|
-
| event | meaning |
|
|
42
|
-
|---|---|
|
|
43
|
-
| \`auth_required\` | render \`url\` as a link, keep waiting |
|
|
44
|
-
| \`detected\` | framework auto-detection result |
|
|
45
|
-
| \`project_created\` / \`project_linked\` | project bound for this directory |
|
|
46
|
-
| \`build_log\` | forward only if it contains errors |
|
|
47
|
-
| \`ready\` | \`url\` = live public site (show to user, stop). \`dashboard_url\` = management page. |
|
|
48
|
-
| \`error\` | follow \`next_action\` field verbatim |
|
|
49
|
-
|
|
50
|
-
Common error codes and remediation:
|
|
51
|
-
|
|
52
|
-
- \`auth_required\` → run \`npx layero@latest login\`, or set \`LAYERO_TOKEN\`
|
|
53
|
-
- \`auth_expired\` / \`auth_timeout\` → user did not approve in time, re-run login
|
|
54
|
-
- \`project_unknown\` → run from the project directory, or pass \`--project\`
|
|
55
|
-
- \`invalid_type\` → drop \`--type\`, rely on auto-detect
|
|
56
|
-
- \`cli_deploys_disabled\` → user must enable CLI deploys in project settings
|
|
57
|
-
- \`deploy_failed\` → check the dashboard URL in the message
|
|
58
|
-
- \`internal\` → unexpected CLI error; re-run with \`--debug\`
|
|
22
|
+
### Three paths — pick by situation
|
|
59
23
|
|
|
60
|
-
|
|
61
|
-
branch
|
|
24
|
+
1. **Repository connected** (GitHub, GitVerse, GitLab, GitFlic, SourceCraft) —
|
|
25
|
+
push to a branch = preview, push to \`main\` = production. Connect one with
|
|
26
|
+
\`npx layero@latest projects create --repo <provider>:<owner/repo>\`.
|
|
27
|
+
2. **A directory with code** (this project, framework: **${framework}**) —
|
|
28
|
+
\`npx layero@latest deploy --json\`. The CLI packs the directory, the
|
|
29
|
+
platform builds it. No git repository is needed for this path.
|
|
30
|
+
3. **A site already on Layero** — \`npx layero@latest diagnose\`, \`logs\`,
|
|
31
|
+
\`rollback\`, \`domains\`, \`env\`, \`envs list\`, or the MCP server
|
|
32
|
+
\`https://mcp.layero.ru/mcp\`.
|
|
62
33
|
|
|
63
|
-
###
|
|
64
|
-
|
|
65
|
-
A plain \`npx layero deploy\` of a CLI project **publishes to the apex**
|
|
66
|
-
\`https://<project>.layero.app\` — direct uploads auto-promote, so you do
|
|
67
|
-
**not** need \`--prod\` or a separate \`promote\` step. Safe to run repeatedly;
|
|
68
|
-
each run replaces what the apex serves.
|
|
69
|
-
|
|
70
|
-
There is no separate per-deploy preview address: user sites live in the
|
|
71
|
-
\`layero.app\` zone, which has no preview sub-zone and no CDN in front, so the
|
|
72
|
-
apex is reachable the moment the deploy is ready.
|
|
73
|
-
|
|
74
|
-
Hand the user \`ready.url\` and stop — that address is live.
|
|
75
|
-
|
|
76
|
-
There is no way to publish without replacing the live site from the CLI:
|
|
77
|
-
\`--branch\` is accepted and **silently ignored** — archive uploads are always
|
|
78
|
-
filed under the reserved \`cli\` environment. If the user asks for a version
|
|
79
|
-
"just to look at" that leaves the live address alone, tell them it needs a
|
|
80
|
-
connected repository and a push to a branch. (\`--prod\` exists for
|
|
81
|
-
git-connected projects; for direct CLI uploads it's redundant.)
|
|
82
|
-
|
|
83
|
-
### Already built? Skip the server build
|
|
84
|
-
|
|
85
|
-
If the site is already built locally (e.g. a Next.js static export in \`out/\`,
|
|
86
|
-
or a \`dist/\`), ship the artifact directly and skip the server-side
|
|
87
|
-
\`npm install\` + build:
|
|
34
|
+
### Deploy from this directory
|
|
88
35
|
|
|
89
36
|
\`\`\`bash
|
|
90
|
-
npx layero@latest deploy --
|
|
37
|
+
npx layero@latest deploy --json
|
|
91
38
|
\`\`\`
|
|
92
39
|
|
|
93
|
-
|
|
40
|
+
Not logged in? The command starts the browser device flow itself and prints
|
|
41
|
+
\`{"event":"auth_required","url":"…","user_code":"…"}\` — show \`url\` as a
|
|
42
|
+
clickable link and keep waiting; the CLI polls every 2 s. No localhost
|
|
43
|
+
callback: the browser may be on another machine. In CI use
|
|
44
|
+
\`LAYERO_TOKEN=… npx layero@latest deploy --project <slug> --json --yes\`.
|
|
45
|
+
No account at all? \`npx layero@latest deploy --claim\` publishes to a
|
|
46
|
+
temporary project for 72 hours and prints a \`claim_url\` for a human to
|
|
47
|
+
take it over.
|
|
48
|
+
|
|
49
|
+
Key JSON events: \`detected\` (framework), \`project_created\` /
|
|
50
|
+
\`project_linked\`, \`build_log\` (forward only lines with errors),
|
|
51
|
+
\`claimable\` (\`claim_url\`, \`expires_at\`), \`ready\` — \`url\` is the live
|
|
52
|
+
site: show it as-is and stop; \`dashboard_url\` is the panel, not the site.
|
|
53
|
+
\`error\` — follow \`next_action\` verbatim.
|
|
54
|
+
|
|
55
|
+
Exit codes: 0 ok · 2 auth (\`auth_required\`, \`auth_expired\`, \`auth_timeout\`) ·
|
|
56
|
+
3 not found (\`project_unknown\`, \`project_not_found\`) · 4 invalid input
|
|
57
|
+
(\`invalid_type\`, \`prebuilt_no_dir\`, \`branch_unsupported\`) · 5 remote
|
|
58
|
+
(\`deploy_failed\`, \`internal\`). Codes \`not_logged_in\`, \`deploy_error\`,
|
|
59
|
+
\`deploy_timed_out\` do not exist — do not branch on them.
|
|
60
|
+
|
|
61
|
+
### Rules
|
|
62
|
+
|
|
63
|
+
- Re-running \`deploy\` is safe and reuses the project; no commit needed.
|
|
64
|
+
- A plain \`deploy\` of a CLI project **replaces the live site** at
|
|
65
|
+
\`ready.url\`: direct uploads auto-promote. \`--branch\` is refused
|
|
66
|
+
(\`branch_unsupported\`) — isolated previews come from pushing a branch of
|
|
67
|
+
a connected repository, nothing else.
|
|
68
|
+
- Already built locally? \`npx layero@latest deploy --prebuilt <dir>\`.
|
|
69
|
+
- Never \`git init\` just to deploy, never \`npm install -g layero\`, never
|
|
70
|
+
build the site address from a template — only \`ready.url\`.
|
|
94
71
|
${AGENT_BLOCK_MARKER_END}
|
|
95
72
|
`;
|
|
96
73
|
}
|
|
@@ -172,7 +149,6 @@ async function ensureGitignore(cwd) {
|
|
|
172
149
|
}
|
|
173
150
|
export async function initCmd(opts) {
|
|
174
151
|
const cwd = process.cwd();
|
|
175
|
-
const mode = detectMode();
|
|
176
152
|
const detected = await detectProject(cwd);
|
|
177
153
|
emit({
|
|
178
154
|
event: "detected",
|
|
@@ -182,6 +158,7 @@ export async function initCmd(opts) {
|
|
|
182
158
|
confident: detected.confident,
|
|
183
159
|
});
|
|
184
160
|
const block = agentDocBlock(detected.framework_hint);
|
|
161
|
+
const agentDocs = [];
|
|
185
162
|
if (!opts.skipAgentDocs) {
|
|
186
163
|
// Touch every agent-doc convention we know about. If one already
|
|
187
164
|
// exists we update it in-place; otherwise we create only the most
|
|
@@ -201,20 +178,15 @@ export async function initCmd(opts) {
|
|
|
201
178
|
const targets = existing.length > 0 ? existing : ["AGENTS.md"];
|
|
202
179
|
for (const f of targets) {
|
|
203
180
|
const result = await upsertAgentDoc(cwd, f, block);
|
|
204
|
-
|
|
205
|
-
console.log(chalk.green(` ${result === "created" ? "✓ created" : result === "updated" ? "✓ updated" : "= unchanged"} ${f}`));
|
|
206
|
-
}
|
|
181
|
+
agentDocs.push({ file: f, result });
|
|
207
182
|
}
|
|
208
183
|
}
|
|
209
184
|
const pjResult = await ensureProjectJson(cwd, detected.framework_hint, detected.build_cmd, detected.output_dir);
|
|
210
|
-
if (mode.interactive) {
|
|
211
|
-
console.log(chalk.green(` ${pjResult === "created" ? "✓ created" : "= unchanged"} .layero/project.json`));
|
|
212
|
-
}
|
|
213
185
|
await ensureGitignore(cwd);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
186
|
+
emit({
|
|
187
|
+
event: "init_done",
|
|
188
|
+
framework: detected.framework_hint,
|
|
189
|
+
agent_docs: agentDocs,
|
|
190
|
+
project_json: pjResult,
|
|
191
|
+
});
|
|
220
192
|
}
|
package/dist/commands/link.js
CHANGED
|
@@ -1,40 +1,27 @@
|
|
|
1
|
-
import chalk from "chalk";
|
|
2
1
|
import { ApiClient } from "../api.js";
|
|
3
2
|
import { loadConfig } from "../config.js";
|
|
4
3
|
import { persistProjectLinking } from "../project-config.js";
|
|
4
|
+
import { LayeroError, emit } from "../agent.js";
|
|
5
5
|
export async function linkCmd(idOrSlug) {
|
|
6
6
|
const cfg = await loadConfig();
|
|
7
7
|
if (!cfg.token) {
|
|
8
|
-
|
|
9
|
-
process.exitCode = 1;
|
|
10
|
-
return;
|
|
8
|
+
throw new LayeroError("auth_required", "вход не выполнен", "выполните `layero login` или задайте LAYERO_TOKEN");
|
|
11
9
|
}
|
|
12
10
|
const api = new ApiClient(cfg);
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
try {
|
|
17
|
-
proj = await api.getProject(idOrSlug);
|
|
18
|
-
}
|
|
19
|
-
catch {
|
|
20
|
-
const all = await api.listProjects();
|
|
21
|
-
const match = all.find((p) => p.slug === idOrSlug);
|
|
22
|
-
if (!match) {
|
|
23
|
-
console.error(chalk.red(`no project with id/slug "${idOrSlug}"`));
|
|
24
|
-
process.exitCode = 1;
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
proj = match;
|
|
28
|
-
}
|
|
11
|
+
// Слаг или id — различает клиент по форме значения (`resolveProject`):
|
|
12
|
+
// UUID, ушедший в поиск по слагу, не нашёлся бы.
|
|
13
|
+
const proj = await api.resolveProject(idOrSlug);
|
|
29
14
|
await persistProjectLinking(process.cwd(), {
|
|
30
15
|
project_id: proj.id,
|
|
31
16
|
slug: proj.slug,
|
|
32
17
|
organization_slug: proj.organization.slug,
|
|
33
18
|
apex_hostname: proj.apex_hostname,
|
|
34
19
|
}, proj.framework_hint ?? null);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
20
|
+
emit({
|
|
21
|
+
event: "project_linked",
|
|
22
|
+
project_id: proj.id,
|
|
23
|
+
slug: proj.slug,
|
|
24
|
+
url: `https://${proj.apex_hostname}`,
|
|
25
|
+
status: proj.status,
|
|
26
|
+
});
|
|
40
27
|
}
|
package/dist/commands/logout.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import chalk from "chalk";
|
|
2
1
|
import { clearConfig, configPath } from "../config.js";
|
|
2
|
+
import { emit } from "../agent.js";
|
|
3
3
|
export async function logoutCmd() {
|
|
4
4
|
await clearConfig();
|
|
5
|
-
|
|
5
|
+
emit({ event: "logged_out", config_path: configPath() });
|
|
6
6
|
}
|
package/dist/commands/orgs.js
CHANGED
|
@@ -1,24 +1,20 @@
|
|
|
1
|
-
import chalk from "chalk";
|
|
2
1
|
import { ApiClient } from "../api.js";
|
|
3
2
|
import { loadConfig } from "../config.js";
|
|
4
|
-
|
|
3
|
+
import { LayeroError, emit } from "../agent.js";
|
|
4
|
+
/** `layero orgs list` — организации аккаунта: личная и команды.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Нужна перед `layero deploy --org=<slug>`, чтобы увидеть слаги, не выходя
|
|
7
|
+
* из терминала.
|
|
8
8
|
*/
|
|
9
9
|
export async function orgsListCmd() {
|
|
10
10
|
const cfg = await loadConfig();
|
|
11
|
-
if (!cfg.token)
|
|
12
|
-
throw new
|
|
11
|
+
if (!cfg.token) {
|
|
12
|
+
throw new LayeroError("auth_required", "вход не выполнен", "выполните `layero login` или задайте LAYERO_TOKEN");
|
|
13
|
+
}
|
|
13
14
|
const api = new ApiClient(cfg);
|
|
14
15
|
const orgs = await api.listOrganizations();
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
}
|
|
16
|
+
emit({
|
|
17
|
+
event: "organizations",
|
|
18
|
+
organizations: orgs.map((o) => ({ id: o.id, slug: o.slug, kind: o.kind, role: o.my_role })),
|
|
19
|
+
});
|
|
24
20
|
}
|
|
@@ -1,21 +1,188 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { ApiClient } from "../api.js";
|
|
1
|
+
import readline from "node:readline/promises";
|
|
2
|
+
import { ApiClient, ApiError } from "../api.js";
|
|
3
3
|
import { loadConfig } from "../config.js";
|
|
4
|
-
|
|
4
|
+
import { LayeroError, detectMode, emit } from "../agent.js";
|
|
5
|
+
import { orgOf } from "./db.js";
|
|
6
|
+
async function makeClient() {
|
|
5
7
|
const cfg = await loadConfig();
|
|
6
8
|
if (!cfg.token) {
|
|
7
|
-
|
|
8
|
-
process.exitCode = 1;
|
|
9
|
-
return;
|
|
9
|
+
throw new LayeroError("auth_required", "вход не выполнен", "выполните `layero login` или задайте LAYERO_TOKEN");
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
return new ApiClient(cfg);
|
|
12
|
+
}
|
|
13
|
+
export async function projectsListCmd() {
|
|
14
|
+
const api = await makeClient();
|
|
12
15
|
const list = await api.listProjects();
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
emit({
|
|
17
|
+
event: "projects",
|
|
18
|
+
projects: list.map((p) => ({
|
|
19
|
+
id: p.id,
|
|
20
|
+
slug: p.slug,
|
|
21
|
+
name: p.name,
|
|
22
|
+
organization: p.organization.slug,
|
|
23
|
+
url: `https://${p.apex_hostname}`,
|
|
24
|
+
source_type: p.source_type,
|
|
25
|
+
repo: p.repo_full_name ?? null,
|
|
26
|
+
status: p.status,
|
|
27
|
+
})),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* `<provider>:<owner/repo>` → провайдер и путь. Путь у GitLab бывает
|
|
32
|
+
* вложенным (`group/sub/project`) — режем только по ПЕРВОМУ двоеточию.
|
|
33
|
+
*/
|
|
34
|
+
export function parseRepoRef(raw) {
|
|
35
|
+
const idx = raw.indexOf(":");
|
|
36
|
+
const provider = idx > 0 ? raw.slice(0, idx).trim().toLowerCase() : "";
|
|
37
|
+
const path = (idx > 0 ? raw.slice(idx + 1) : raw).trim().replace(/^\/+|\/+$/g, "");
|
|
38
|
+
if (!provider || !path.includes("/")) {
|
|
39
|
+
throw new LayeroError("repo_format", `не разобрать «${raw}»`, "формат: --repo <provider>:<owner/repo>, например --repo github:acme/site или --repo gitverse:acme/site; провайдеры — `layero sources list`");
|
|
40
|
+
}
|
|
41
|
+
return { provider, path };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `layero projects create --repo <provider>:<owner/repo>` — проект из
|
|
45
|
+
* репозитория без панели (этап 6 AX-аудита). Раньше путь (a) «есть
|
|
46
|
+
* репозиторий» начинался с кнопки «Импорт из репозитория», и агент, у
|
|
47
|
+
* которого панели нет, был вынужден выбирать путь (b) — заливать папку.
|
|
48
|
+
*
|
|
49
|
+
* GitHub — через ключ аккаунта установки App: сервер заводит проект и
|
|
50
|
+
* вебхук одним вызовом, иного режима у App нет. Остальные провайдеры — в два
|
|
51
|
+
* шага: проект, затем `connect-source`, потому что только он возвращает
|
|
52
|
+
* судьбу вебхука, а без вебхука push не собирается, и молчать об этом нельзя.
|
|
53
|
+
*/
|
|
54
|
+
export async function projectsCreateCmd(opts) {
|
|
55
|
+
if (!opts.repo) {
|
|
56
|
+
throw new LayeroError("repo_format", "не указан репозиторий", "`layero projects create --repo <provider>:<owner/repo>`; папку без репозитория выкладывает `layero deploy`");
|
|
57
|
+
}
|
|
58
|
+
const { provider, path } = parseRepoRef(opts.repo);
|
|
59
|
+
const api = await makeClient();
|
|
60
|
+
const org = await orgOf(api, opts);
|
|
61
|
+
const accounts = await api.listImportAccounts(org);
|
|
62
|
+
const candidates = accounts.filter((a) => a.provider === provider);
|
|
63
|
+
if (candidates.length === 0) {
|
|
64
|
+
const known = [...new Set(accounts.map((a) => a.provider))];
|
|
65
|
+
throw new LayeroError("account_not_found", `в организации «${org}» нет подключения к ${provider}`, known.length
|
|
66
|
+
? `подключены: ${known.join(", ")}; добавить — \`layero sources connect ${provider} --token-stdin\``
|
|
67
|
+
: `добавьте подключение: \`layero sources connect ${provider} --token-stdin\` (GitHub — установкой App в панели)`);
|
|
68
|
+
}
|
|
69
|
+
const account = candidates.find((a) => a.status === "active" && a.can_import !== false) ?? candidates[0];
|
|
70
|
+
if (account.status !== "active" || account.can_import === false) {
|
|
71
|
+
throw new LayeroError("account_not_found", `подключение ${provider} (${account.login}) в состоянии ${account.status}${account.status_note ? `: ${account.status_note}` : ""}`, account.configure_url ?? "переподключите провайдер: `layero sources connect …`");
|
|
72
|
+
}
|
|
73
|
+
// Репозиторий сверяем по списку аккаунта: опечатка в пути даёт понятный
|
|
74
|
+
// отказ здесь, а не 502 от провайдера после создания проекта.
|
|
75
|
+
const repos = await api.listImportRepos(org, account.key);
|
|
76
|
+
const repo = repos.find((r) => r.path.toLowerCase() === path.toLowerCase());
|
|
77
|
+
if (!repo) {
|
|
78
|
+
const sample = repos.slice(0, 8).map((r) => r.path).join(", ");
|
|
79
|
+
throw new LayeroError("repo_not_found", `репозиторий «${path}» не виден подключению ${provider} (${account.login})`, sample ? `доступны: ${sample}${repos.length > 8 ? ", …" : ""}` : "у подключения нет ни одного репозитория");
|
|
80
|
+
}
|
|
81
|
+
if (repo.imported_project_ids.length > 0) {
|
|
82
|
+
throw new LayeroError("repo_already_imported", `репозиторий «${repo.path}» уже привязан к проекту ${repo.imported_project_ids.join(", ")}`, "`layero link <id>` — привязать папку к нему; второй проект из того же репозитория заводится в панели");
|
|
83
|
+
}
|
|
84
|
+
const branch = opts.branch ?? repo.default_branch ?? "main";
|
|
85
|
+
const name = opts.name ?? repo.name;
|
|
86
|
+
let project;
|
|
87
|
+
if (account.key.startsWith("github:")) {
|
|
88
|
+
project = await api.createProjectFromAccount({
|
|
89
|
+
name,
|
|
90
|
+
organization_slug: org,
|
|
91
|
+
source_account_key: account.key,
|
|
92
|
+
repo_path: repo.path,
|
|
93
|
+
default_branch: branch,
|
|
94
|
+
});
|
|
95
|
+
emitCreated(project, repo.path, branch);
|
|
96
|
+
emit({ event: "source_connected", org, connection_id: account.key, provider, account: account.login });
|
|
97
|
+
// У GitHub App вебхук — часть установки: без него App не существует.
|
|
98
|
+
emit({ event: "webhook_installed", project: project.slug, url: "" });
|
|
15
99
|
return;
|
|
16
100
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
101
|
+
const connectionId = account.key.replace(/^connection:/, "");
|
|
102
|
+
project = await api.createCliProject({ name, organization_slug: org });
|
|
103
|
+
let connected;
|
|
104
|
+
try {
|
|
105
|
+
connected = await api.connectSource(project.id, {
|
|
106
|
+
connection_id: connectionId,
|
|
107
|
+
repo_path: repo.path,
|
|
108
|
+
branch,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// Проект без источника бесполезен и занимает адрес — убираем. Удаление
|
|
113
|
+
// требует scope admin; без него проект останется, и мы это скажем.
|
|
114
|
+
let cleaned = false;
|
|
115
|
+
try {
|
|
116
|
+
await api.deleteProject(project.id);
|
|
117
|
+
cleaned = true;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
/* нет прав или сеть — скажем словами ниже */
|
|
121
|
+
}
|
|
122
|
+
const reason = err instanceof ApiError ? err.body.slice(0, 300) : String(err);
|
|
123
|
+
throw new LayeroError("source_connect_failed", `репозиторий не привязался: ${reason}`, cleaned
|
|
124
|
+
? "проект удалён; проверьте токен подключения (`layero sources list`) и повторите"
|
|
125
|
+
: `проект ${project.slug} создан без репозитория — привяжите в панели или удалите: \`layero projects delete ${project.slug} --yes\``);
|
|
126
|
+
}
|
|
127
|
+
emitCreated(connected.project, repo.path, branch);
|
|
128
|
+
emit({ event: "source_connected", org, connection_id: connectionId, provider, account: account.login });
|
|
129
|
+
if (connected.webhook_registered) {
|
|
130
|
+
emit({ event: "webhook_installed", project: connected.project.slug, url: connected.webhook_url });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
emit({
|
|
134
|
+
event: "webhook_unavailable",
|
|
135
|
+
project: connected.project.slug,
|
|
136
|
+
url: connected.webhook_url,
|
|
137
|
+
hint: connected.webhook_hint ??
|
|
138
|
+
"провайдер не дал создать вебхук этим токеном — заведите его в настройках репозитория вручную",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function emitCreated(project, repo, branch) {
|
|
143
|
+
emit({
|
|
144
|
+
event: "project_created",
|
|
145
|
+
project_id: project.id,
|
|
146
|
+
slug: project.slug,
|
|
147
|
+
organization: project.organization.slug,
|
|
148
|
+
url: `https://${project.apex_hostname}`,
|
|
149
|
+
repo,
|
|
150
|
+
branch,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* `layero projects delete <slug> --yes` — необратимо. Маршрут требует у
|
|
155
|
+
* токена scope `admin`: токен по умолчанию (`read`+`deploy`) получит
|
|
156
|
+
* `forbidden`, и это правильно — агент с деплой-токеном не должен уметь
|
|
157
|
+
* снести проект.
|
|
158
|
+
*/
|
|
159
|
+
export async function projectsDeleteCmd(ref, opts) {
|
|
160
|
+
const api = await makeClient();
|
|
161
|
+
const project = await api.resolveProject(ref);
|
|
162
|
+
const mode = detectMode();
|
|
163
|
+
if (!opts.yes) {
|
|
164
|
+
if (!mode.interactive) {
|
|
165
|
+
throw new LayeroError("confirmation_required", `удаление проекта ${project.slug} (${project.apex_hostname}) необратимо, а подтвердить его здесь некому`, `покажите это человеку и повторите с --yes: \`layero projects delete ${project.slug} --yes\``);
|
|
166
|
+
}
|
|
167
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
168
|
+
try {
|
|
169
|
+
const answer = (await rl.question(`Удалить проект ${project.slug} и сайт https://${project.apex_hostname}? Необратимо. Введите слаг для подтверждения: `)).trim();
|
|
170
|
+
if (answer !== project.slug) {
|
|
171
|
+
throw new LayeroError("confirmation_required", "удаление отменено", "введите слаг проекта точно, как показан");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
rl.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
await api.deleteProject(project.id);
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
183
|
+
throw new LayeroError("forbidden", "удаление проекта требует токена со scope admin", "выпустите токен: `layero token create <имя> --scope admin` — или удалите проект в панели");
|
|
184
|
+
}
|
|
185
|
+
throw err;
|
|
20
186
|
}
|
|
187
|
+
emit({ event: "project_deleted", project_id: project.id, slug: project.slug });
|
|
21
188
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { ApiClient, ApiError } from "../api.js";
|
|
2
|
+
import { loadConfig } from "../config.js";
|
|
3
|
+
import { LayeroError, emit } from "../agent.js";
|
|
4
|
+
import { orgOf } from "./db.js";
|
|
5
|
+
async function makeClient() {
|
|
6
|
+
const cfg = await loadConfig();
|
|
7
|
+
if (!cfg.token) {
|
|
8
|
+
throw new LayeroError("auth_required", "вход не выполнен", "выполните `layero login` или задайте LAYERO_TOKEN");
|
|
9
|
+
}
|
|
10
|
+
return new ApiClient(cfg);
|
|
11
|
+
}
|
|
12
|
+
/** `layero sources list` — провайдеры, которые платформа умеет, и подключения организации. */
|
|
13
|
+
export async function sourcesListCmd(opts) {
|
|
14
|
+
const api = await makeClient();
|
|
15
|
+
const org = await orgOf(api, opts);
|
|
16
|
+
const [providers, connections] = await Promise.all([
|
|
17
|
+
api.listSourceProviders(org),
|
|
18
|
+
api.listSourceConnections(org),
|
|
19
|
+
]);
|
|
20
|
+
emit({
|
|
21
|
+
event: "sources",
|
|
22
|
+
org,
|
|
23
|
+
providers: providers.map((p) => ({
|
|
24
|
+
id: p.id,
|
|
25
|
+
title: p.title,
|
|
26
|
+
self_hosted: p.self_hosted,
|
|
27
|
+
webhook_supported: p.webhook_supported,
|
|
28
|
+
token_hint: p.token_hint ?? null,
|
|
29
|
+
})),
|
|
30
|
+
connections: connections.map((c) => ({
|
|
31
|
+
id: c.id,
|
|
32
|
+
provider: c.provider_id,
|
|
33
|
+
account: c.external_account,
|
|
34
|
+
status: c.status,
|
|
35
|
+
projects_count: c.projects_count,
|
|
36
|
+
token_expiry_state: c.token_expiry_state,
|
|
37
|
+
last_error: c.last_error,
|
|
38
|
+
})),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Читает stdin целиком — для `--token-stdin`. */
|
|
42
|
+
async function readStdin() {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
for await (const chunk of process.stdin) {
|
|
45
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
46
|
+
}
|
|
47
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* `layero sources connect <provider> --token-stdin` — подключить провайдера
|
|
51
|
+
* по PAT. Токен принимается флагом `--token` (для скриптов, где история не
|
|
52
|
+
* пишется) и через stdin (`--token-stdin`): в истории shell и в транскрипте
|
|
53
|
+
* агента `--token ghp_…` остаётся навсегда, а `echo $PAT | … --token-stdin`
|
|
54
|
+
* — нет. Сервер проверяет токен до записи и наружу его не возвращает.
|
|
55
|
+
*
|
|
56
|
+
* Список провайдеров — `/organizations/{slug}/source-providers`: сверяем до
|
|
57
|
+
* запроса, чтобы опечатка дала список, а не 400.
|
|
58
|
+
*/
|
|
59
|
+
export async function sourcesConnectCmd(provider, opts) {
|
|
60
|
+
const api = await makeClient();
|
|
61
|
+
const org = await orgOf(api, opts);
|
|
62
|
+
const providers = await api.listSourceProviders(org);
|
|
63
|
+
const spec = providers.find((p) => p.id === provider.trim().toLowerCase());
|
|
64
|
+
if (!spec) {
|
|
65
|
+
throw new LayeroError("provider_unknown", `провайдера «${provider}» нет`, `доступны: ${providers.map((p) => p.id).join(", ")}; GitHub подключается установкой App в панели`);
|
|
66
|
+
}
|
|
67
|
+
if (opts.baseUrl && !spec.self_hosted) {
|
|
68
|
+
throw new LayeroError("bad_format", `${spec.title} не поддерживает собственный инстанс`, "уберите --base-url");
|
|
69
|
+
}
|
|
70
|
+
let token = (opts.token ?? "").trim();
|
|
71
|
+
if (opts.tokenStdin) {
|
|
72
|
+
token = (await readStdin()).trim();
|
|
73
|
+
}
|
|
74
|
+
if (!token) {
|
|
75
|
+
throw new LayeroError("token_missing", "не передан токен провайдера", `${spec.token_hint ? spec.token_hint + ". " : ""}Передайте его через stdin: \`echo "$PAT" | layero sources connect ${spec.id} --token-stdin\``);
|
|
76
|
+
}
|
|
77
|
+
let created;
|
|
78
|
+
try {
|
|
79
|
+
created = await api.createSourceConnection(org, {
|
|
80
|
+
provider_id: spec.id,
|
|
81
|
+
token,
|
|
82
|
+
display_name: opts.name ?? null,
|
|
83
|
+
base_url: opts.baseUrl ?? null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (err instanceof ApiError && err.status === 502) {
|
|
88
|
+
throw new LayeroError("source_rejected", `${spec.title} не принял токен: ${err.body.slice(0, 300)}`, spec.token_hint ?? "проверьте токен и его права");
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
emit({
|
|
93
|
+
event: "source_connected",
|
|
94
|
+
org,
|
|
95
|
+
connection_id: created.id,
|
|
96
|
+
provider: created.provider_id,
|
|
97
|
+
account: created.external_account,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** `layero sources repos <connection_id>` — репозитории, видимые токену подключения. */
|
|
101
|
+
export async function sourcesReposCmd(connectionId, opts) {
|
|
102
|
+
const api = await makeClient();
|
|
103
|
+
const org = await orgOf(api, opts);
|
|
104
|
+
let repos;
|
|
105
|
+
try {
|
|
106
|
+
repos = await api.listSourceRepos(org, connectionId);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
110
|
+
throw new LayeroError("connection_not_found", `в организации «${org}» нет подключения ${connectionId}`, "`layero sources list`");
|
|
111
|
+
}
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
emit({
|
|
115
|
+
event: "source_repos",
|
|
116
|
+
org,
|
|
117
|
+
connection_id: connectionId,
|
|
118
|
+
repos: repos.map((r) => ({
|
|
119
|
+
path: r.path,
|
|
120
|
+
name: r.name,
|
|
121
|
+
default_branch: r.default_branch,
|
|
122
|
+
private: r.private,
|
|
123
|
+
can_admin: r.can_admin,
|
|
124
|
+
updated_at: r.updated_at,
|
|
125
|
+
})),
|
|
126
|
+
});
|
|
127
|
+
}
|
package/dist/commands/whoami.js
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
import chalk from "chalk";
|
|
2
1
|
import { ApiClient } from "../api.js";
|
|
3
2
|
import { loadConfig } from "../config.js";
|
|
3
|
+
import { LayeroError, emit } from "../agent.js";
|
|
4
4
|
export async function whoamiCmd() {
|
|
5
5
|
const cfg = await loadConfig();
|
|
6
6
|
if (!cfg.token) {
|
|
7
|
-
|
|
8
|
-
process.exitCode = 1;
|
|
9
|
-
return;
|
|
7
|
+
throw new LayeroError("auth_required", "вход не выполнен", "выполните `layero login` или задайте LAYERO_TOKEN");
|
|
10
8
|
}
|
|
11
9
|
const api = new ApiClient(cfg);
|
|
12
10
|
const me = await api.me();
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
emit({
|
|
12
|
+
event: "me",
|
|
13
|
+
id: me.id,
|
|
14
|
+
username: me.username ?? null,
|
|
15
|
+
email: me.email ?? null,
|
|
16
|
+
github_login: me.github_login ?? null,
|
|
17
|
+
});
|
|
19
18
|
}
|