layero 0.8.24 → 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
@@ -89,8 +89,15 @@ export class ApiClient {
89
89
  completeSetup(projectId, input) {
90
90
  return this.request("POST", `/projects/${projectId}/setup`, input);
91
91
  }
92
- setRuntimeType(projectId, projectType) {
93
- return this.request("POST", `/projects/${projectId}/runtime-type`, { project_type: projectType });
92
+ /**
93
+ * Сменить тип проекта.
94
+ *
95
+ * `force` — записать вопреки возражению платформы (409). Владелец имеет на
96
+ * это право: детект ошибается, и репозиторий, который эвристика считает «не
97
+ * бэкендом», прекрасно работает одним сервисом.
98
+ */
99
+ setRuntimeType(projectId, projectType, force = false) {
100
+ return this.request("POST", `/projects/${projectId}/runtime-type`, { project_type: projectType, force });
94
101
  }
95
102
  updateProject(projectId, input) {
96
103
  return this.request("PATCH", `/projects/${projectId}`, input);
@@ -126,6 +133,61 @@ export class ApiClient {
126
133
  listEnvVars(projectId) {
127
134
  return this.request("GET", `/projects/${projectId}/env`);
128
135
  }
136
+ /**
137
+ * Адрес и ПУБЛИЧНЫЙ ключ Data API — те же, что платформа кладёт в сборку.
138
+ *
139
+ * Единственное место, где значение приезжает открытым, и это не исключение
140
+ * из правила выше, а другая природа: публичный ключ уезжает в бандл и виден
141
+ * любому посетителю сайта. Секретного здесь не бывает.
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
+ }
188
+ dataEnv(projectId) {
189
+ return this.request("GET", `/projects/${projectId}/data-env`);
190
+ }
129
191
  /**
130
192
  * Значение `null` = «оставить как есть». Благодаря этому добавить одну
131
193
  * переменную можно, не читая остальные, — то есть не имея доступа к
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";
@@ -15,6 +15,8 @@ import { orgsListCmd } from "../commands/orgs.js";
15
15
  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
+ import { dataEnvCmd } from "../commands/data.js";
19
+ import { dbConnectCmd, dbCreateCmd, dbListCmd, dbSqlCmd } from "../commands/db.js";
18
20
  import { envListCmd, envSetCmd, envUnsetCmd } from "../commands/env.js";
19
21
  import { analyticsConnectCmd, analyticsDisconnectCmd, analyticsStatsCmd, analyticsStatusCmd, } from "../commands/analytics.js";
20
22
  import { domainsAddCmd, domainsListCmd, domainsPrimaryCmd, domainsRemoveCmd, domainsVerifyCmd, } from "../commands/domains.js";
@@ -37,8 +39,14 @@ async function main() {
37
39
  .option("--debug", "print a full stack trace when a command errors");
38
40
  program
39
41
  .command("login")
40
- .description("Authenticate via browser (email code or Yandex ID). Opens a one-time URL — no localhost server required.")
41
- .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")
42
50
  .action(async (opts) => {
43
51
  await loginCmd(opts);
44
52
  });
@@ -193,6 +201,39 @@ async function main() {
193
201
  .command("unset <keys...>")
194
202
  .description("Удалить переменные.")
195
203
  .option("-y, --yes", "не спрашивать подтверждение")).action(async (keys, opts) => envUnsetCmd(keys, { ...opts, json: program.opts().json }));
204
+ const data = program
205
+ .command("data")
206
+ .description("Data API: адрес и публичный ключ базы для фронтенда.");
207
+ data
208
+ .command("env")
209
+ .description("Показать VITE_/NEXT_PUBLIC_ переменные Data API; --write кладёт их в .env.local.")
210
+ .option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)")
211
+ .option("-w, --write", "записать в файл, а не печатать")
212
+ .option("--file <path>", "имя файла (по умолчанию .env.local)")
213
+ .addHelpText("after", "\nПримеры:\n" +
214
+ " $ layero data env # посмотреть\n" +
215
+ " $ layero data env --write # положить в .env.local\n" +
216
+ "\nОтдаётся только ПУБЛИЧНЫЙ ключ — тот, что и так уезжает в бандл.\n" +
217
+ "Секретный ключ платформа не хранит и не отдаёт: он для сервера.")
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 }));
196
237
  const analytics = program
197
238
  .command("analytics")
198
239
  .description("Яндекс.Метрика: подключение и статистика сайта.");
@@ -270,15 +311,33 @@ async function main() {
270
311
  const token = program
271
312
  .command("token")
272
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 }));
273
332
  token
274
333
  .command("set <jwt>")
275
- .description("Persist a JWT obtained out-of-band (e.g. from the web UI). " +
276
- "Use this until `layero login` is fully wired up.")
334
+ .description("Сохранить токен, полученный иначе (например, `layero token create` на другой машине).")
277
335
  .action(tokenSetCmd);
278
336
  program
279
337
  .command("deploy")
280
338
  .description("Pack the current directory and deploy it. Framework, build command and output directory are auto-detected.")
281
- .option("-t, --type <preset>", "framework override (vite | vitepress | next | astro | cra | sveltekit | nuxt | gatsby | docusaurus | storybook | eleventy | hugo | static)")
339
+ .option("-t, --type <preset>", "type override — static preset (vite | vitepress | next | astro | cra | sveltekit | nuxt | gatsby | docusaurus | storybook | eleventy | hugo | static) " +
340
+ "or runtime kind for apps the platform RUNS (node_web | python_web | flask | streamlit | gradio | ssr_next; aliases: express, fastapi, django, node, python)")
282
341
  .option("--name <name>", "project name (only used on first deploy)")
283
342
  .option("--project <id_or_slug>", "deploy into an existing project, ignoring local config")
284
343
  .option("-y, --yes", "non-interactive: accept defaults and skip --prod confirmation")
@@ -304,6 +363,7 @@ async function main() {
304
363
  " $ layero deploy --promote # preview deploy + pin apex (one-shot publish)\n" +
305
364
  " $ layero deploy --branch=staging # preview on a specific branch\n" +
306
365
  " $ layero deploy --type vite # force a framework preset\n" +
366
+ " $ layero deploy --type express # Node backend: platform RUNS it, not serves files\n" +
307
367
  " $ layero deploy --json # machine-readable output for agents")
308
368
  .action(async (opts) => {
309
369
  await deployCmd(opts);
@@ -360,6 +420,49 @@ function asAccountStateError(err) {
360
420
  ? `Тариф не позволяет больше ${what}${counts}.`
361
421
  : "Действие недоступно на текущем тарифе.", "смените тариф на app.layero.ru/billing или освободите место, удалив ненужное");
362
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
+ }
363
466
  return null;
364
467
  }
365
468
  main().catch((err) => {
@@ -0,0 +1,97 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import chalk from "chalk";
4
+ import { ApiClient } from "../api.js";
5
+ import { loadConfig } from "../config.js";
6
+ import { loadProjectConfig } from "../project-config.js";
7
+ import { LayeroError, detectMode, emit } from "../agent.js";
8
+ const DEFAULT_FILE = ".env.local";
9
+ async function projectRef(api, opts) {
10
+ const linked = await loadProjectConfig(process.cwd());
11
+ const ref = opts.project ?? linked?.project_id;
12
+ if (!ref) {
13
+ throw new LayeroError("project_unknown", "не понятно, у какого проекта брать переменные Data API", "запусти из каталога проекта или передай --project <id|slug>");
14
+ }
15
+ const p = await api.getProject(ref);
16
+ return { id: p.id, slug: p.slug };
17
+ }
18
+ /**
19
+ * Дописывает переменные в файл, не трогая чужие строки.
20
+ *
21
+ * Перезапись файла целиком была бы проще и однажды стёрла бы чужую строку,
22
+ * которую человек добавил руками. Свои ключи узнаём по имени и обновляем на
23
+ * месте — остальное остаётся как было.
24
+ */
25
+ function mergeEnvFile(previous, vars) {
26
+ const lines = previous ? previous.split("\n") : [];
27
+ const remaining = new Map(Object.entries(vars));
28
+ const out = lines.map((line) => {
29
+ const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
30
+ const name = match?.[1];
31
+ if (!name)
32
+ return line;
33
+ const value = remaining.get(name);
34
+ if (value === undefined)
35
+ return line;
36
+ remaining.delete(name);
37
+ return `${name}=${value}`;
38
+ });
39
+ if (remaining.size) {
40
+ if (out.length && (out[out.length - 1] ?? "").trim() !== "")
41
+ out.push("");
42
+ out.push("# Layero Data API — адрес и публичный ключ (layero data env)");
43
+ for (const [key, value] of remaining)
44
+ out.push(`${key}=${value}`);
45
+ out.push("");
46
+ }
47
+ return out.join("\n");
48
+ }
49
+ /** Прикрыт ли файл от коммита. Проверяем буквально то имя, которое пишем. */
50
+ function ignored(cwd, file) {
51
+ const gitignore = join(cwd, ".gitignore");
52
+ if (!existsSync(gitignore))
53
+ return false;
54
+ const patterns = readFileSync(gitignore, "utf8")
55
+ .split("\n")
56
+ .map((l) => l.trim())
57
+ .filter((l) => l && !l.startsWith("#"));
58
+ return patterns.some((p) => p === file || p === `/${file}` || p === "*.local" || p === ".env*" || p === ".env.*");
59
+ }
60
+ export async function dataEnvCmd(opts) {
61
+ const mode = detectMode();
62
+ const api = new ApiClient(await loadConfig());
63
+ const project = await projectRef(api, opts);
64
+ const vars = await api.dataEnv(project.id);
65
+ if (!Object.keys(vars).length) {
66
+ if (mode.json) {
67
+ emit({ event: "data_env", project: project.slug, vars: {} });
68
+ return;
69
+ }
70
+ console.log(chalk.dim(`у проекта "${project.slug}" нет базы с включённым Data API\n` +
71
+ "включить: панель → база → «API» → «Включить API»"));
72
+ return;
73
+ }
74
+ if (mode.json) {
75
+ emit({ event: "data_env", project: project.slug, vars });
76
+ return;
77
+ }
78
+ const file = opts.file ?? DEFAULT_FILE;
79
+ if (!opts.write) {
80
+ // По умолчанию печатаем, а не пишем: команда, которая молча правит файлы в
81
+ // каталоге, — неприятный сюрприз, особенно у агента.
82
+ for (const [key, value] of Object.entries(vars))
83
+ console.log(`${key}=${value}`);
84
+ console.log(chalk.dim(`\n записать в ${file}: layero data env --write`));
85
+ return;
86
+ }
87
+ const path = join(process.cwd(), file);
88
+ const previous = existsSync(path) ? readFileSync(path, "utf8") : "";
89
+ writeFileSync(path, mergeEnvFile(previous, vars), "utf8");
90
+ console.log(`${chalk.green("✓")} ${file}: ${Object.keys(vars).length} переменных`);
91
+ if (!ignored(process.cwd(), file)) {
92
+ console.log(chalk.yellow(`\n⚠ ${file} не закрыт .gitignore.`) +
93
+ chalk.dim(`\n Публичный ключ не секрет — он и так уезжает в бандл. Но файл с таким` +
94
+ `\n именем однажды примет настоящий секрет, и тогда будет поздно.` +
95
+ `\n Добавьте строку: echo "${file}" >> .gitignore`));
96
+ }
97
+ }
@@ -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
+ }
@@ -31,6 +31,45 @@ const VALID_TYPES = new Set([
31
31
  "vitepress",
32
32
  "storybook",
33
33
  ]);
34
+ /**
35
+ * Runtime kinds — приложения, которые платформа ЗАПУСКАЕТ, а не раздаёт
36
+ * файлами.
37
+ *
38
+ * 🚨 Их здесь не было вовсе, и это стоило переноса. `--type` принимал только
39
+ * статические пресеты, а Express-репозиторий детект уверенно опознавал как
40
+ * `vite` — из-за devDependency `vite`, которую тянет `vitest`. Первый деплой
41
+ * умирал на «собранный сайт не содержит index.html», и единственным выходом
42
+ * оставался curl в недокументированную ручку `/projects/<id>/runtime-type`.
43
+ * Найдено переносом настоящего приложения 16.08.2026.
44
+ *
45
+ * Слева — то, что человек напишет, справа — то, что понимает платформа.
46
+ * Синонимы не украшение: `--type express` пишут чаще, чем `--type node_web`,
47
+ * и отказ на нём отправляет читать исходники, а не деплоить.
48
+ */
49
+ const RUNTIME_TYPES = {
50
+ node_web: "node_web",
51
+ node: "node_web",
52
+ express: "node_web",
53
+ fastify: "node_web",
54
+ nest: "node_web",
55
+ nestjs: "node_web",
56
+ koa: "node_web",
57
+ python_web: "python_web",
58
+ python: "python_web",
59
+ fastapi: "python_web",
60
+ django: "python_web",
61
+ flask: "flask",
62
+ streamlit: "streamlit",
63
+ gradio: "gradio",
64
+ ssr_next: "ssr_next",
65
+ "next-ssr": "ssr_next",
66
+ };
67
+ /** Каноничный runtime-kind по тому, что написал человек, либо `null`. */
68
+ export function runtimeTypeOf(raw) {
69
+ if (!raw)
70
+ return null;
71
+ return RUNTIME_TYPES[raw.trim().toLowerCase()] ?? null;
72
+ }
34
73
  function dashboardOrigin(apiUrl) {
35
74
  const override = process.env.LAYERO_DASHBOARD_URL;
36
75
  if (override)
@@ -177,7 +216,14 @@ async function resolveSetupConfig(cwd, opts, existing) {
177
216
  ...(detected.runtime_kind ? { runtime_kind: detected.runtime_kind } : {}),
178
217
  ...(detected.ssr_warning ? { ssr_warning: detected.ssr_warning } : {}),
179
218
  });
180
- const framework_hint = opts.type ?? existing?.framework_hint ?? detected.framework_hint;
219
+ // ⚠️ Runtime-тип в `framework_hint` не уходит: это разные вопросы. Хинт
220
+ // отвечает «чем собирать» (vite, next, …), а runtime-kind — «запускать или
221
+ // раздавать файлами». Положи мы сюда `node_web`, сборщик получил бы имя
222
+ // фреймворка, которого не существует.
223
+ const asRuntime = runtimeTypeOf(opts.type);
224
+ const framework_hint = (asRuntime ? undefined : opts.type) ??
225
+ existing?.framework_hint ??
226
+ detected.framework_hint;
181
227
  const build_cmd = existing?.build_cmd ?? detected.build_cmd;
182
228
  const output_dir = existing?.output_dir ?? detected.output_dir;
183
229
  let source;
@@ -195,7 +241,13 @@ async function resolveSetupConfig(cwd, opts, existing) {
195
241
  build_cmd,
196
242
  output_dir,
197
243
  source,
198
- ...(detected.runtime_kind ? { runtime_kind: detected.runtime_kind } : {}),
244
+ // Явный `--type` сильнее детекта: человек уже видел, как детект ошибся,
245
+ // иначе бы не писал флаг.
246
+ ...(asRuntime
247
+ ? { runtime_kind: asRuntime }
248
+ : detected.runtime_kind
249
+ ? { runtime_kind: detected.runtime_kind }
250
+ : {}),
199
251
  };
200
252
  }
201
253
  /**
@@ -313,8 +365,15 @@ async function startWithRepeatedFailureGuard(api, sessionId, commitSha, opts) {
313
365
  }
314
366
  export async function deployCmd(opts) {
315
367
  const mode = detectMode();
316
- if (opts.type && !VALID_TYPES.has(opts.type.toLowerCase())) {
317
- throw new LayeroError("invalid_type", `unknown --type "${opts.type}"`, `valid types: ${[...VALID_TYPES].join(", ")}`);
368
+ if (opts.type &&
369
+ !VALID_TYPES.has(opts.type.toLowerCase()) &&
370
+ !runtimeTypeOf(opts.type)) {
371
+ throw new LayeroError("invalid_type", `unknown --type "${opts.type}"`,
372
+ // Две группы, а не один список: у них разная судьба. Статический пресет
373
+ // говорит, ЧЕМ собирать; runtime-тип — что приложение надо ЗАПУСКАТЬ.
374
+ `static presets: ${[...VALID_TYPES].join(", ")}\n` +
375
+ `runtime kinds: ${[...new Set(Object.values(RUNTIME_TYPES))].join(", ")} ` +
376
+ `(aliases: ${Object.keys(RUNTIME_TYPES).join(", ")})`);
318
377
  }
319
378
  let cliCfg = await loadConfig();
320
379
  if (!cliCfg.token) {
@@ -453,6 +512,34 @@ export async function deployCmd(opts) {
453
512
  slug: project.slug,
454
513
  });
455
514
  emit({ event: "setup_applied" });
515
+ // 🚨 На ПЕРВОЙ настройке хватает `runtime_kind` в сессии, а у уже
516
+ // существующего проекта побеждает его `project_type` — и `--type node_web`
517
+ // молча не делал ничего. Ровно здесь человек и упирался: тип приходилось
518
+ // менять curl'ом в `/projects/<id>/runtime-type`.
519
+ //
520
+ // Момент выбран не случайно: сессия создана (значит, id проекта известен),
521
+ // но архив ещё не упакован и сборка не запущена — флип успевает повлиять на
522
+ // ту же выкатку.
523
+ const wantRuntime = runtimeTypeOf(opts.type);
524
+ if (wantRuntime && project.project_type !== wantRuntime) {
525
+ try {
526
+ await api.setRuntimeType(project.id, wantRuntime);
527
+ }
528
+ catch (err) {
529
+ // 409 — платформа возражает: репозиторий не похож на этот тип. Возражение
530
+ // обязано быть заметным, но не запирающим: человек написал флаг явно, и
531
+ // спорить с ним мы перестали намеренно (та же логика, что у панели).
532
+ if (err instanceof ApiError && err.status === 409) {
533
+ process.stderr.write(chalk.yellow(`! platform disagrees with --type ${wantRuntime}: ${err.body.slice(0, 200)}\n` +
534
+ ` applying anyway because you asked explicitly\n`));
535
+ await api.setRuntimeType(project.id, wantRuntime, true);
536
+ }
537
+ else {
538
+ throw err;
539
+ }
540
+ }
541
+ emit({ event: "runtime_type_applied", project_type: wantRuntime });
542
+ }
456
543
  await persistProjectLinking(cwd, {
457
544
  project_id: project.id,
458
545
  slug: project.slug,
@@ -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.24",
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",