layero 0.8.25 → 0.9.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/dist/api.js CHANGED
@@ -140,6 +140,51 @@ export class ApiClient {
140
140
  * из правила выше, а другая природа: публичный ключ уезжает в бандл и виден
141
141
  * любому посетителю сайта. Секретного здесь не бывает.
142
142
  */
143
+ // ── Базы организации (DX-03) ──────────────────────────────────────────
144
+ //
145
+ // 🚨 Заведено потому, что базу нельзя было создать ничем, кроме панели: у
146
+ // CLI была одна команда `data env` — показать ключ УЖЕ существующей базы.
147
+ // Адрес ручки и форму тела приходилось читать в исходниках платформы.
148
+ listDatabases(org) {
149
+ return this.request("GET", `/organizations/${org}/databases`);
150
+ }
151
+ createDatabase(org, input) {
152
+ return this.request("POST", `/organizations/${org}/databases`, {
153
+ name: input.name,
154
+ quota_gb: input.quota_gb ?? null,
155
+ extensions: input.extensions ?? [],
156
+ });
157
+ }
158
+ connectDatabaseToProject(org, dbId, projectId) {
159
+ return this.request("POST", `/organizations/${org}/databases/${dbId}/projects`, {
160
+ project_id: projectId,
161
+ });
162
+ }
163
+ queryDatabase(org, dbId, sql) {
164
+ return this.request("POST", `/organizations/${org}/databases/${dbId}/query`, {
165
+ sql,
166
+ read_only: false,
167
+ });
168
+ }
169
+ // ── Долгоживущие токены для CI (DX-02) ────────────────────────────────
170
+ //
171
+ // 🚨 Заведено потому, что неинтерактивного пути входа у CLI не было вовсе.
172
+ // `layero login` требует человека с браузером, а единственной подсказкой
173
+ // была команда `token set <jwt>` с подписью «пока login не доделан» — то
174
+ // есть «раздобудьте токен где-нибудь ещё». В CI на этом месте вставали
175
+ // насмерть. Ручка на сервере существовала с AGENT-02, у CLI её не было.
176
+ createApiToken(input) {
177
+ return this.request("POST", "/auth/tokens", {
178
+ name: input.name,
179
+ scopes: input.scopes ?? null,
180
+ });
181
+ }
182
+ listApiTokens() {
183
+ return this.request("GET", "/auth/tokens");
184
+ }
185
+ revokeApiToken(id) {
186
+ return this.request("DELETE", `/auth/tokens/${id}`);
187
+ }
143
188
  dataEnv(projectId) {
144
189
  return this.request("GET", `/projects/${projectId}/data-env`);
145
190
  }
package/dist/auth.js CHANGED
@@ -20,7 +20,7 @@ const MAX_WAIT_MS = 15 * 60 * 1000;
20
20
  * Throws LayeroError("auth_expired" | "auth_timeout") if the user doesn't
21
21
  * approve in time.
22
22
  */
23
- export async function runDeviceLogin(cfg) {
23
+ export async function runDeviceLogin(cfg, opts = {}) {
24
24
  const mode = detectMode();
25
25
  const api = new ApiClient(cfg);
26
26
  const device = await api.startDeviceAuth();
@@ -34,11 +34,13 @@ export async function runDeviceLogin(cfg) {
34
34
  console.log(chalk.bold(` ${verification_url}`));
35
35
  console.log(chalk.dim(`\n Confirmation code: `) + chalk.white.bold(user_code));
36
36
  console.log(chalk.dim(` (expires in ${device.expires_in}s)\n`));
37
- try {
38
- await open(verification_url);
39
- }
40
- catch {
41
- // non-fatal — user can paste the URL manually
37
+ if (!opts.noBrowser) {
38
+ try {
39
+ await open(verification_url);
40
+ }
41
+ catch {
42
+ // non-fatal — user can paste the URL manually
43
+ }
42
44
  }
43
45
  }
44
46
  const deadline = Date.now() + MAX_WAIT_MS;
@@ -5,7 +5,7 @@ import { whoamiCmd } from "../commands/whoami.js";
5
5
  import { logoutCmd } from "../commands/logout.js";
6
6
  import { projectsListCmd } from "../commands/projects.js";
7
7
  import { linkCmd } from "../commands/link.js";
8
- import { tokenSetCmd } from "../commands/token.js";
8
+ import { tokenCreateCmd, tokenListCmd, tokenRevokeCmd, tokenSetCmd } from "../commands/token.js";
9
9
  import { deployCmd } from "../commands/deploy.js";
10
10
  import { deploysListCmd, rollbackCmd } from "../commands/deploys.js";
11
11
  import { promoteCmd } from "../commands/promote.js";
@@ -16,6 +16,7 @@ import { initCmd } from "../commands/init.js";
16
16
  import { diagnoseCmd, logsCmd } from "../commands/diagnose.js";
17
17
  import { perfCheckCmd, perfShowCmd } from "../commands/perf.js";
18
18
  import { dataEnvCmd } from "../commands/data.js";
19
+ import { dbConnectCmd, dbCreateCmd, dbListCmd, dbSqlCmd } from "../commands/db.js";
19
20
  import { envListCmd, envSetCmd, envUnsetCmd } from "../commands/env.js";
20
21
  import { analyticsConnectCmd, analyticsDisconnectCmd, analyticsStatsCmd, analyticsStatusCmd, } from "../commands/analytics.js";
21
22
  import { domainsAddCmd, domainsListCmd, domainsPrimaryCmd, domainsRemoveCmd, domainsVerifyCmd, } from "../commands/domains.js";
@@ -38,8 +39,14 @@ async function main() {
38
39
  .option("--debug", "print a full stack trace when a command errors");
39
40
  program
40
41
  .command("login")
41
- .description("Authenticate via browser (email code or Yandex ID). Opens a one-time URL — no localhost server required.")
42
- .addHelpText("after", "\nExamples:\n $ layero login\n $ npx layero login")
42
+ .description("Вход через браузер (код из почты или Yandex ID): печатает одноразовый адрес и код.")
43
+ .option("--no-browser", "не открывать браузер только напечатать адрес и код")
44
+ .addHelpText("after", "\nПримеры:\n" +
45
+ " $ layero login\n" +
46
+ " $ layero login --no-browser # SSH, контейнер, среда агента\n" +
47
+ "\nДля CI и агентов вход человеком не годится — нужен долгоживущий токен:\n" +
48
+ " $ layero token create ci\n" +
49
+ " $ LAYERO_TOKEN=<токен> npx layero@latest deploy")
43
50
  .action(async (opts) => {
44
51
  await loginCmd(opts);
45
52
  });
@@ -209,6 +216,24 @@ async function main() {
209
216
  "\nОтдаётся только ПУБЛИЧНЫЙ ключ — тот, что и так уезжает в бандл.\n" +
210
217
  "Секретный ключ платформа не хранит и не отдаёт: он для сервера.")
211
218
  .action(async (opts) => dataEnvCmd({ ...opts, json: program.opts().json }));
219
+ const db = program
220
+ .command("db")
221
+ .description("Базы организации: завести, посмотреть, подключить к проекту, выполнить SQL.");
222
+ const withOrg = (c) => c.option("--org <slug>", "организация (по умолчанию единственная)");
223
+ withOrg(db.command("list").description("Базы организации."))
224
+ .action(async (opts) => dbListCmd({ ...opts, json: program.opts().json }));
225
+ withOrg(db
226
+ .command("create <name>")
227
+ .description("Завести базу. Строка подключения печатается ОДИН раз.")
228
+ .option("--gb <number>", "объём платной базы в гигабайтах", (v) => parseInt(v, 10))).action(async (name, opts) => dbCreateCmd(name, { ...opts, json: program.opts().json }));
229
+ withOrg(db
230
+ .command("connect <database>")
231
+ .description("Подключить проект к базе: строка подключения приедет в его переменные.")
232
+ .option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)")).action(async (database, opts) => dbConnectCmd(database, { ...opts, json: program.opts().json }));
233
+ withOrg(db
234
+ .command("sql <database>")
235
+ .description("Выполнить SQL в базе. Скрипт из нескольких операторов — одной транзакцией.")
236
+ .requiredOption("-c, --command <sql>", "запрос или скрипт")).action(async (database, opts) => dbSqlCmd(database, { ...opts, json: program.opts().json }));
212
237
  const analytics = program
213
238
  .command("analytics")
214
239
  .description("Яндекс.Метрика: подключение и статистика сайта.");
@@ -286,10 +311,27 @@ async function main() {
286
311
  const token = program
287
312
  .command("token")
288
313
  .description("Manage the auth token directly (advanced).");
314
+ token
315
+ .command("create <name>")
316
+ .description("Выпустить долгоживущий токен для CI и агентов. " +
317
+ "Показывается ОДИН раз. По умолчанию read+deploy, без необратимого.")
318
+ .option("--scope <list>", "через запятую: read, deploy, admin")
319
+ .addHelpText("after", "\nПримеры:\n" +
320
+ " $ layero token create ci # read+deploy\n" +
321
+ " $ layero token create ci --scope read # только чтение\n" +
322
+ "\nВ CI: LAYERO_TOKEN=<токен> npx layero@latest deploy")
323
+ .action(async (name, opts) => tokenCreateCmd(name, { ...opts, json: program.opts().json }));
324
+ token
325
+ .command("list")
326
+ .description("Выпущенные токены: имя, подсказка, права, последнее использование.")
327
+ .action(async () => tokenListCmd({ json: program.opts().json }));
328
+ token
329
+ .command("revoke <id>")
330
+ .description("Отозвать токен. Действует немедленно.")
331
+ .action(async (id) => tokenRevokeCmd(id, { json: program.opts().json }));
289
332
  token
290
333
  .command("set <jwt>")
291
- .description("Persist a JWT obtained out-of-band (e.g. from the web UI). " +
292
- "Use this until `layero login` is fully wired up.")
334
+ .description("Сохранить токен, полученный иначе (например, `layero token create` на другой машине).")
293
335
  .action(tokenSetCmd);
294
336
  program
295
337
  .command("deploy")
@@ -378,6 +420,49 @@ function asAccountStateError(err) {
378
420
  ? `Тариф не позволяет больше ${what}${counts}.`
379
421
  : "Действие недоступно на текущем тарифе.", "смените тариф на app.layero.ru/billing или освободите место, удалив ненужное");
380
422
  }
423
+ // 🚨 Понятный отказ сервера не имеет права превращаться в нашу поломку.
424
+ // 409 с `reason: reserved` и текстом «имя зарезервировано платформой»
425
+ // печатался как `code: internal` с советом «re-run with --debug for a stack
426
+ // trace» — человек шёл искать стек вместо того, чтобы сменить имя.
427
+ //
428
+ // Разбираем ЛЮБОЙ 4xx с телом, а не отдельные коды: заплатка на один код
429
+ // означала бы, что следующий понятный отказ снова станет «внутренней
430
+ // ошибкой». 5xx сюда не попадает намеренно — это как раз наша авария.
431
+ if (err.status >= 400 && err.status < 500) {
432
+ const detail = detailOf(err.body);
433
+ if (detail) {
434
+ return new LayeroError(detail.code ?? `http_${err.status}`, detail.message, detail.next_action ?? "исправьте запрос и повторите");
435
+ }
436
+ }
437
+ return null;
438
+ }
439
+ /**
440
+ * Достаёт человеческую часть отказа из тела ответа.
441
+ *
442
+ * FastAPI кладёт её в `detail` — либо строкой, либо объектом с `code`,
443
+ * `reason` и `message`. Обе формы живые, и обе должны доезжать до человека.
444
+ */
445
+ function detailOf(body) {
446
+ let parsed;
447
+ try {
448
+ parsed = JSON.parse(body);
449
+ }
450
+ catch {
451
+ return null;
452
+ }
453
+ const detail = parsed?.detail ?? parsed;
454
+ if (typeof detail === "string" && detail.trim())
455
+ return { message: detail };
456
+ if (detail && typeof detail === "object") {
457
+ const message = detail.message ?? detail.detail ?? detail.reason;
458
+ if (typeof message === "string" && message.trim()) {
459
+ return {
460
+ code: typeof detail.code === "string" ? detail.code : undefined,
461
+ message,
462
+ next_action: typeof detail.next_action === "string" ? detail.next_action : undefined,
463
+ };
464
+ }
465
+ }
381
466
  return null;
382
467
  }
383
468
  main().catch((err) => {
@@ -0,0 +1,131 @@
1
+ // `layero db` — базы организации из терминала (DX-03).
2
+ //
3
+ // 🚨 Зачем команда вообще. Продукт обещает «весь бэкенд — это база», но завести
4
+ // базу можно было только в панели или сырой ручкой `POST /organizations/<org>/
5
+ // databases`: у CLI существовала одна команда `data env`, показывающая ключ УЖЕ
6
+ // существующей базы. Сборка приложения из терминала упиралась в переход в
7
+ // браузер, а агент в CI — в чтение исходников платформы ради адреса ручки.
8
+ //
9
+ // ⚠️ Организация выбирается явно (`--org`) или единственная доступная. Угадывать
10
+ // «первую попавшуюся» из нескольких нельзя: базы стоят денег и живут в разных
11
+ // командах.
12
+ import chalk from "chalk";
13
+ import { ApiClient } from "../api.js";
14
+ import { loadConfig } from "../config.js";
15
+ import { loadProjectConfig } from "../project-config.js";
16
+ import { LayeroError, detectMode, emit } from "../agent.js";
17
+ async function orgOf(api, opts) {
18
+ if (opts.org)
19
+ return opts.org;
20
+ const orgs = await api.listOrganizations();
21
+ if (orgs.length === 1)
22
+ return orgs[0].slug;
23
+ const own = orgs.filter((o) => o.kind === "personal");
24
+ if (own.length === 1)
25
+ return own[0].slug;
26
+ throw new LayeroError("org_unknown", `у вас несколько организаций: ${orgs.map((o) => o.slug).join(", ")}`, "укажите нужную: --org <slug>");
27
+ }
28
+ /** База по имени, слагу или id — как её назвал человек. */
29
+ async function pick(api, org, ref) {
30
+ const list = await api.listDatabases(org);
31
+ const needle = ref.trim().toLowerCase();
32
+ const found = list.find((d) => d.id === ref ||
33
+ (d.name_slug ?? "").toLowerCase() === needle ||
34
+ d.name.toLowerCase() === needle);
35
+ if (!found) {
36
+ throw new LayeroError("database_unknown", `в организации «${org}» нет базы «${ref}»`, list.length
37
+ ? `есть: ${list.map((d) => d.name_slug ?? d.name).join(", ")}`
38
+ : "заведите базу: layero db create <имя>");
39
+ }
40
+ return found;
41
+ }
42
+ function gb(bytes) {
43
+ if (!bytes)
44
+ return "—";
45
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} ГБ`;
46
+ }
47
+ export async function dbListCmd(opts) {
48
+ const mode = detectMode();
49
+ const api = new ApiClient(await loadConfig());
50
+ const org = await orgOf(api, opts);
51
+ const list = await api.listDatabases(org);
52
+ if (mode.json || opts.json) {
53
+ emit({ event: "databases", org, databases: list });
54
+ return;
55
+ }
56
+ if (!list.length) {
57
+ console.log(chalk.dim(`в организации «${org}» баз нет — layero db create <имя>`));
58
+ return;
59
+ }
60
+ for (const d of list) {
61
+ const api_on = d.api_enabled ? chalk.green("API") : chalk.dim("без API");
62
+ console.log(`${chalk.bold(d.name)} ${chalk.dim(d.name_slug ?? "")} ${d.status} ` +
63
+ `${api_on} проектов: ${d.projects_count} ${gb(d.size_bytes)} из ${gb(d.quota_bytes)}`);
64
+ }
65
+ }
66
+ export async function dbCreateCmd(name, opts) {
67
+ const mode = detectMode();
68
+ const api = new ApiClient(await loadConfig());
69
+ const org = await orgOf(api, opts);
70
+ const created = await api.createDatabase(org, { name, quota_gb: opts.gb });
71
+ // Пароль отдаётся ОДИН РАЗ — второй раз его не покажет никто, только
72
+ // ротация. Поэтому он и в JSON-режиме, и в терминале.
73
+ if (mode.json || opts.json) {
74
+ emit({ event: "database_created", org, name, ...created });
75
+ return;
76
+ }
77
+ console.log(`${chalk.green("✓")} база «${name}» заведена в организации ${org}`);
78
+ console.log(`\n ${created.connection_string}`);
79
+ console.log(chalk.dim("\n Пароль показывается один раз — сохраните строку подключения.\n" +
80
+ " Ключи Data API для фронтенда: layero data env"));
81
+ }
82
+ export async function dbConnectCmd(ref, opts) {
83
+ const mode = detectMode();
84
+ const api = new ApiClient(await loadConfig());
85
+ const org = await orgOf(api, opts);
86
+ const linked = await loadProjectConfig(process.cwd());
87
+ const projectRef = opts.project ?? linked?.project_id;
88
+ if (!projectRef) {
89
+ throw new LayeroError("project_unknown", "не понятно, какой проект подключать к базе", "запустите из каталога проекта или передайте --project <id|slug>");
90
+ }
91
+ const project = await api.getProject(projectRef);
92
+ const db = await pick(api, org, ref);
93
+ await api.connectDatabaseToProject(org, db.id, project.id);
94
+ if (mode.json || opts.json) {
95
+ emit({ event: "database_connected", org, database: db.name, project: project.slug });
96
+ return;
97
+ }
98
+ console.log(`${chalk.green("✓")} проект «${project.slug}» подключён к базе «${db.name}»\n` +
99
+ chalk.dim(" строка подключения приедет в переменные окружения проекта"));
100
+ }
101
+ export async function dbSqlCmd(ref, opts) {
102
+ const mode = detectMode();
103
+ const sql = (opts.command ?? "").trim();
104
+ if (!sql) {
105
+ throw new LayeroError("sql_missing", "нечего выполнять", 'передайте запрос: -c "SELECT 1"');
106
+ }
107
+ const api = new ApiClient(await loadConfig());
108
+ const org = await orgOf(api, opts);
109
+ const db = await pick(api, org, ref);
110
+ const result = await api.queryDatabase(org, db.id, sql);
111
+ if (mode.json || opts.json) {
112
+ emit({ event: "query_result", database: db.name, ...result });
113
+ return;
114
+ }
115
+ // Скрипт из нескольких операторов отвечает по каждому — показываем все,
116
+ // иначе миграция из десяти команд выглядит как «ничего не выполнилось».
117
+ if (result.statements?.length) {
118
+ for (const [i, one] of result.statements.entries()) {
119
+ console.log(chalk.dim(`${i + 1}. ${one.sql.split("\n")[0]}`) + ` ${one.status ?? ""}`);
120
+ }
121
+ }
122
+ if (result.columns.length) {
123
+ console.log(chalk.bold(result.columns.join("\t")));
124
+ for (const row of result.rows)
125
+ console.log(row.map((c) => String(c ?? "")).join("\t"));
126
+ console.log(chalk.dim(`\n${result.row_count} строк${result.truncated ? " (усечено)" : ""}`));
127
+ }
128
+ else if (!result.statements?.length) {
129
+ console.log(result.status ?? "выполнено");
130
+ }
131
+ }
@@ -1,6 +1,17 @@
1
1
  import { loadConfig } from "../config.js";
2
2
  import { runDeviceLogin } from "../auth.js";
3
- export async function loginCmd(_opts) {
3
+ /**
4
+ * `layero login` — device flow: печатает адрес и код, ждёт подтверждения.
5
+ *
6
+ * ⚠️ `--no-browser` нужен там, где браузера на этой машине нет вовсе — SSH,
7
+ * контейнер, удалённая среда агента. Без флага CLI пытается открыть адрес
8
+ * сам, и на такой машине это выглядит как зависание.
9
+ *
10
+ * Для CI и агентов вход человеком не годится в принципе: там нужен
11
+ * долгоживущий токен — `layero token create <имя>` и `LAYERO_TOKEN` в
12
+ * окружении.
13
+ */
14
+ export async function loginCmd(opts) {
4
15
  const cfg = await loadConfig();
5
- await runDeviceLogin(cfg);
16
+ await runDeviceLogin(cfg, { noBrowser: Boolean(opts.noBrowser) });
6
17
  }
@@ -1,6 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { loadConfig, saveConfig } from "../config.js";
3
3
  import { ApiClient } from "../api.js";
4
+ import { detectMode, emit } from "../agent.js";
4
5
  export async function tokenSetCmd(jwt) {
5
6
  const cfg = await loadConfig();
6
7
  cfg.token = jwt.trim();
@@ -22,3 +23,61 @@ export async function tokenSetCmd(jwt) {
22
23
  "before `layero deploy`."));
23
24
  }
24
25
  }
26
+ /**
27
+ * `layero token create <имя>` — долгоживущий токен для CI и агентов.
28
+ *
29
+ * 🚨 Это и есть неинтерактивный путь входа, которого не хватало. `layero login`
30
+ * требует человека с браузером; в CI работать было нечем, а единственной
31
+ * подсказкой была команда `token set <jwt>` — «раздобудьте токен где-нибудь
32
+ * ещё». Ручка на сервере существовала, у CLI её не было.
33
+ *
34
+ * ⚠️ По умолчанию токен умеет читать и деплоить, но не умеет необратимого:
35
+ * удалить проект, сменить адрес, передать владение, выписать себе новый
36
+ * токен. `--scope admin` запрашивается явно.
37
+ */
38
+ export async function tokenCreateCmd(name, opts) {
39
+ const mode = detectMode();
40
+ const api = new ApiClient(await loadConfig());
41
+ const scopes = (opts.scope ?? "")
42
+ .split(",")
43
+ .map((s) => s.trim())
44
+ .filter(Boolean);
45
+ const created = await api.createApiToken({ name, scopes: scopes.length ? scopes : undefined });
46
+ if (mode.json || opts.json) {
47
+ emit({ event: "token_created", id: created.id, name: created.name,
48
+ token: created.token, scopes: created.scopes });
49
+ return;
50
+ }
51
+ // Сырой токен показывается ЕДИНСТВЕННЫЙ раз: в базе лежит только хеш.
52
+ console.log(`${chalk.green("✓")} токен «${created.name}» (${created.scopes.join(", ")})\n`);
53
+ console.log(created.token);
54
+ console.log(chalk.dim("\n Показывается один раз. В CI: LAYERO_TOKEN=<токен> npx layero@latest deploy"));
55
+ }
56
+ export async function tokenListCmd(opts) {
57
+ const mode = detectMode();
58
+ const api = new ApiClient(await loadConfig());
59
+ const list = await api.listApiTokens();
60
+ if (mode.json || opts.json) {
61
+ emit({ event: "tokens", tokens: list });
62
+ return;
63
+ }
64
+ if (!list.length) {
65
+ console.log(chalk.dim("токенов нет — layero token create <имя>"));
66
+ return;
67
+ }
68
+ for (const t of list) {
69
+ const used = t.last_used_at ? `использован ${t.last_used_at.slice(0, 10)}` : "не использован";
70
+ console.log(`${chalk.bold(t.name)} ${t.hint} [${t.scopes.join(", ")}] ${used}`);
71
+ console.log(chalk.dim(` id: ${t.id}`));
72
+ }
73
+ }
74
+ export async function tokenRevokeCmd(id, opts) {
75
+ const mode = detectMode();
76
+ const api = new ApiClient(await loadConfig());
77
+ await api.revokeApiToken(id);
78
+ if (mode.json || opts.json) {
79
+ emit({ event: "token_revoked", id });
80
+ return;
81
+ }
82
+ console.log(`${chalk.green("✓")} токен отозван`);
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layero",
3
- "version": "0.8.25",
3
+ "version": "0.9.0",
4
4
  "description": "Layero CLI — publish a local site with one command. No git, no GitHub, agent-friendly (Cursor, Claude Code).",
5
5
  "license": "MIT",
6
6
  "type": "module",