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.
@@ -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, detectMode } from "../agent.js";
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
- ## Deploying with Layero
14
+ ## Layero
11
15
 
12
- This project deploys to Layero (https://layero.ru). To ship local changes:
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
- \`\`\`bash
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
- There is no \`not_logged_in\`, \`deploy_error\` or \`deploy_timed_out\` do not
61
- branch on codes that are not listed here.
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
- ### Re-deploys and production
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 --prebuilt out
37
+ npx layero@latest deploy --json
91
38
  \`\`\`
92
39
 
93
- Full reference: https://docs.layero.ru/en/cli/agents
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
- if (mode.interactive) {
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
- if (mode.interactive) {
215
- console.log("");
216
- console.log(chalk.bold("Next:"));
217
- console.log(" $ npx layero login # one-time, in your browser");
218
- console.log(" $ npx layero deploy # ship the current directory");
219
- }
186
+ emit({
187
+ event: "init_done",
188
+ framework: detected.framework_hint,
189
+ agent_docs: agentDocs,
190
+ project_json: pjResult,
191
+ });
220
192
  }
@@ -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
- console.error(chalk.yellow("not logged in. run `layero login` first."));
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
- // The backend accepts UUIDs only on /projects/{id}; for slug we fall back
14
- // to listing this is fine because users typically have <100 projects.
15
- let proj;
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
- console.log(chalk.green(`linked ${proj.slug} (${proj.id}) → ./.layero/project.json`));
36
- if (proj.status === "pending_setup") {
37
- console.log(chalk.yellow(" note: project is in pending_setup. finish the setup wizard in the dashboard, " +
38
- "or run `layero deploy` to upload source and get the wizard URL."));
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
  }
@@ -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
- console.log(chalk.green(`logged out (removed ${configPath()})`));
5
+ emit({ event: "logged_out", config_path: configPath() });
6
6
  }
@@ -1,24 +1,20 @@
1
- import chalk from "chalk";
2
1
  import { ApiClient } from "../api.js";
3
2
  import { loadConfig } from "../config.js";
4
- /** `layero orgs list` show every Layero organization the caller belongs to.
3
+ import { LayeroError, emit } from "../agent.js";
4
+ /** `layero orgs list` — организации аккаунта: личная и команды.
5
5
  *
6
- * Useful before `layero deploy --org=<slug>` so the user can see which
7
- * slugs to pass without leaving the terminal.
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 Error("not logged in. run `layero login` first.");
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
- 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
- }
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 chalk from "chalk";
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
- export async function projectsListCmd() {
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
- console.error(chalk.yellow("not logged in. run `layero login` first."));
8
- process.exitCode = 1;
9
- return;
9
+ throw new LayeroError("auth_required", "вход не выполнен", "выполните `layero login` или задайте LAYERO_TOKEN");
10
10
  }
11
- const api = new ApiClient(cfg);
11
+ return new ApiClient(cfg);
12
+ }
13
+ export async function projectsListCmd() {
14
+ const api = await makeClient();
12
15
  const list = await api.listProjects();
13
- if (list.length === 0) {
14
- console.log("no projects yet — `layero deploy` to create one.");
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
- for (const p of list) {
18
- const tag = p.source_type === "cli" ? chalk.magenta("[cli]") : chalk.blue("[gh] ");
19
- console.log(`${tag} ${chalk.bold(p.slug)} ${chalk.dim(p.id)} https://${p.apex_hostname}`);
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
+ }
@@ -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
- console.error(chalk.yellow("not logged in. run `layero login` first."));
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
- console.log(`id: ${me.id}`);
14
- console.log(`username: ${me.username ?? chalk.yellow("(not set)")}`);
15
- console.log(`email: ${me.email ?? "(none)"}`);
16
- if (me.github_login) {
17
- console.log(`github: ${me.github_login}`);
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
  }