layero 0.11.20 → 0.11.22

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
@@ -276,6 +276,18 @@ export class ApiClient {
276
276
  removeDataOrigin(org, dbId, origin) {
277
277
  return this.request("DELETE", `/organizations/${org}/databases/${dbId}/api/origins?origin=${encodeURIComponent(origin)}`);
278
278
  }
279
+ /** Удаление базы — та же ручка, что у кнопки панели: снос сразу, без окна (R2-03). */
280
+ deleteDatabase(org, dbId) {
281
+ return this.request("DELETE", `/organizations/${org}/databases/${dbId}`);
282
+ }
283
+ /** Короткий ключ хранилища — тот же, что берёт раздел «Файлы» панели (R2-04). */
284
+ openStorageSession(org, dbId) {
285
+ return this.request("POST", `/organizations/${org}/databases/${dbId}/api/storage/session`);
286
+ }
287
+ /** Настройки входа: незаданное не трогается (R2-08 — имя отправителя писем). */
288
+ setDataAuthSettings(org, dbId, input) {
289
+ return this.request("PUT", `/organizations/${org}/databases/${dbId}/api/auth/settings`, input);
290
+ }
279
291
  // ── секреты (HTTP из SQL и вход) и журнал запросов ─────────────────────
280
292
  listDataSecrets(org, dbId) {
281
293
  return this.request("GET", `/organizations/${org}/databases/${dbId}/api/secrets`);
@@ -22,7 +22,7 @@ import { diagnoseCmd, logsCmd } from "../commands/diagnose.js";
22
22
  import { perfCheckCmd, perfShowCmd } from "../commands/perf.js";
23
23
  import { dataEnvCmd } from "../commands/data.js";
24
24
  import { registerDataApiCommands } from "../commands/data-api.js";
25
- import { dbConnectCmd, dbCreateCmd, dbDisconnectCmd, dbListCmd, dbSqlCmd, } from "../commands/db.js";
25
+ import { dbConnectCmd, dbCreateCmd, dbDisconnectCmd, dbListCmd, dbSqlCmd, dbDeleteCmd, } from "../commands/db.js";
26
26
  import { envListCmd, envSetCmd, envUnsetCmd } from "../commands/env.js";
27
27
  import { analyticsConnectCmd, analyticsDisconnectCmd, analyticsStatsCmd, analyticsStatusCmd, } from "../commands/analytics.js";
28
28
  import { domainsAddCmd, domainsListCmd, domainsPrimaryCmd, domainsRemoveCmd, domainsVerifyCmd, } from "../commands/domains.js";
@@ -118,7 +118,7 @@ async function main() {
118
118
  .action(async (opts) => projectsCreateCmd({ ...opts, json: program.opts().json }));
119
119
  projects
120
120
  .command("delete <id_or_slug>")
121
- .description("Delete a project. IRREVERSIBLE; requires a token with the admin scope (`layero token create <name> --scope admin`).")
121
+ .description("Delete a project. IRREVERSIBLE. Works with your `layero login` session; an API token needs the admin scope (`layero token create <name> --scope admin`).")
122
122
  .option("-y, --yes", "do not ask for confirmation (required outside a terminal)")
123
123
  .action(async (ref, opts) => projectsDeleteCmd(ref, { ...opts, json: program.opts().json }));
124
124
  const sources = program
@@ -323,13 +323,19 @@ async function main() {
323
323
  // (C6), и так её пишет всякий, кто помнит psql.
324
324
  .command("sql <database> [sql]")
325
325
  .description("Run SQL in a database. A script of several statements runs as one transaction.")
326
- .option("-c, --command <sql>", "query or script")).action(async (database, sql, opts) => dbSqlCmd(database, {
326
+ .option("-c, --command <sql>", "query or script")
327
+ .option("-f, --file <path>", "read the script from a file; - reads stdin (also read when stdin is piped)")
328
+ .addHelpText("after", "\nExamples:\n $ layero db sql shop \"select 1\"\n $ layero db sql shop --file migrations/0001.sql\n $ cat migrations/*.sql | layero db sql shop")).action(async (database, sql, opts) => dbSqlCmd(database, {
327
329
  ...opts,
328
330
  // Явный `-c` выигрывает: он написан намеренно, а позиционный аргумент
329
331
  // мог прилететь из истории команд.
330
332
  command: opts.command ?? sql,
331
333
  json: program.opts().json,
332
334
  }));
335
+ withOrg(db
336
+ .command("delete <database>")
337
+ .description("Delete a database with all its data and backups. IRREVERSIBLE. The name and the Data API address become free again.")
338
+ .option("-y, --yes", "do not ask for confirmation (required outside a terminal)")).action(async (database, opts) => dbDeleteCmd(database, { ...opts, json: program.opts().json }));
333
339
  const analytics = program
334
340
  .command("analytics")
335
341
  .description("Yandex Metrica: connect it and see the site's statistics.");
@@ -380,7 +386,7 @@ async function main() {
380
386
  domainOpts(domains.command("primary <domain>").description("Make the domain primary: the platform address becomes a 301 redirect to it.")).action(async (domain, opts) => domainsPrimaryCmd(domain, { ...opts, json: program.opts().json }));
381
387
  domainOpts(domains
382
388
  .command("remove <domain>")
383
- .description("Remove the domain from the project. Irreversible and breaks live traffic; requires a token with the admin scope.")
389
+ .description("Remove the domain from the project. Irreversible and breaks live traffic. Works with your `layero login` session; an API token needs the admin scope.")
384
390
  .option("-y, --yes", "do not ask for confirmation")).action(async (domain, opts) => domainsRemoveCmd(domain, { ...opts, json: program.opts().json }));
385
391
  program
386
392
  .command("diagnose")
@@ -445,6 +451,7 @@ async function main() {
445
451
  "`static` serves the files as they are and never runs a build; `generic` runs your own build command " +
446
452
  "(layero.json buildCommand or the package.json build script) and serves the folder with index.html")
447
453
  .option("--name <name>", "project name (only used on first deploy)")
454
+ .option("--db <database>", "connect the organization's database before the build, so the first build already gets the Data API URL and key")
448
455
  .option("--project <id_or_slug>", "deploy into an existing project, ignoring local config")
449
456
  .option("-y, --yes", "non-interactive: accept defaults and skip the --prod confirmation")
450
457
  .option("--config", "(legacy alias of the default behaviour — auto-detect + .layero/project.json values)")
@@ -21,6 +21,7 @@ import { LayeroError, detectMode, emit } from "../agent.js";
21
21
  import { orgOf, pick } from "./db.js";
22
22
  import { registerDataProbeCommand } from "./data-probe.js";
23
23
  import { registerDataSecretsCommands } from "./data-secrets.js";
24
+ import { registerDataFilesCommands } from "./data-files.js";
24
25
  const LEVELS = ["closed", "visitor", "user", "server"];
25
26
  const LEVEL_WORD = {
26
27
  closed: "закрыто",
@@ -642,4 +643,5 @@ export function registerDataApiCommands(data, program) {
642
643
  .option("-y, --yes", "do not ask for confirmation for --repair")).action(async (opts) => dataEnableCmd(json(opts)));
643
644
  registerDataProbeCommand(data, withDb, json);
644
645
  registerDataSecretsCommands(data, withDb, json);
646
+ registerDataFilesCommands(data, withDb, json);
645
647
  }
@@ -0,0 +1,132 @@
1
+ // `layero data buckets` и `layero data auth sender` — то, что раньше
2
+ // делалось только в панели (прогон 2 «Замечено», R2-04 и R2-08, 27.10.2026).
3
+ //
4
+ // Бакет создаётся тем же коротким ключом хранилища, что у раздела «Файлы»
5
+ // панели: выпускать секретный ключ ради одной настройки не нужно.
6
+ import chalk from "chalk";
7
+ import { LayeroError, detectMode, emit } from "../agent.js";
8
+ import { target } from "./data-api.js";
9
+ function asJson(opts) {
10
+ return detectMode().json || Boolean(opts.json);
11
+ }
12
+ /** «5MB», «512KB», «1.5 GB», «1048576» → байты. */
13
+ export function parseSize(raw) {
14
+ const m = /^\s*(\d+(?:[.,]\d+)?)\s*(b|kb|mb|gb)?\s*$/i.exec(raw);
15
+ if (!m) {
16
+ throw new LayeroError("bad_format", `не понял размер «${raw}»`, "пишите так: 5MB, 512KB или число байт");
17
+ }
18
+ const n = parseFloat((m[1] ?? "0").replace(",", "."));
19
+ const unit = (m[2] ?? "b").toLowerCase();
20
+ const mult = unit === "gb" ? 1024 ** 3 : unit === "mb" ? 1024 ** 2 : unit === "kb" ? 1024 : 1;
21
+ return Math.round(n * mult);
22
+ }
23
+ async function gateway(url, key, path, init = {}) {
24
+ const res = await fetch(`${url}/storage/v1${path}`, {
25
+ ...init,
26
+ headers: { apikey: key, "content-type": "application/json", ...(init.headers ?? {}) },
27
+ });
28
+ const text = await res.text();
29
+ let body = null;
30
+ try {
31
+ body = text ? JSON.parse(text) : null;
32
+ }
33
+ catch {
34
+ body = null;
35
+ }
36
+ if (!res.ok) {
37
+ const message = body?.message || body?.error || `${res.status}`;
38
+ throw new LayeroError("storage_refused", `хранилище отказало: ${message}`, body?.hint ?? refusalAdvice(res.status, body));
39
+ }
40
+ return body;
41
+ }
42
+ /** Совет по отказу — по его причине. Раньше на любой отказ советовали
43
+ * включить файлы, и на «бакет уже есть» это был совет мимо (R2-04, 27.10). */
44
+ function refusalAdvice(status, body) {
45
+ const text = `${body?.error ?? ""} ${body?.message ?? ""} ${body?.statusCode ?? ""}`.toLowerCase();
46
+ if (status === 409 || text.includes("already exists") || text.includes("duplicate")) {
47
+ return "бакет с таким именем уже есть — посмотреть: layero data buckets list, или выберите другое имя";
48
+ }
49
+ if (status === 401 || status === 403) {
50
+ return "ключ хранилища не подошёл — повторите команду; не помогло — проверьте, что файлы включены: layero data enable --storage";
51
+ }
52
+ if (status === 400 && text.includes("name")) {
53
+ return "имя бакета — латиница, цифры, дефис и подчёркивание";
54
+ }
55
+ return "проверьте, что файлы включены: layero data enable --storage";
56
+ }
57
+ export async function dataBucketsListCmd(opts) {
58
+ const { api, org, db, ref } = await target(opts);
59
+ const s = await api.openStorageSession(org, db.id);
60
+ const buckets = await gateway(s.url, s.key, "/bucket");
61
+ if (asJson(opts)) {
62
+ emit({ event: "data_buckets", org, database: ref, buckets });
63
+ return;
64
+ }
65
+ if (!buckets.length) {
66
+ console.log(chalk.dim(`бакетов нет — создать: layero data buckets create uploads --db ${ref}`));
67
+ return;
68
+ }
69
+ for (const b of buckets) {
70
+ const limit = b.file_size_limit ? `, до ${b.file_size_limit} байт` : "";
71
+ const types = b.allowed_mime_types?.length ? `, ${b.allowed_mime_types.join(" ")}` : "";
72
+ console.log(` ${b.id} ${b.public ? "публичный" : "закрытый"}${limit}${types}`);
73
+ }
74
+ }
75
+ export async function dataBucketsCreateCmd(name, opts) {
76
+ if (opts.public && opts.private) {
77
+ throw new LayeroError("bad_format", "бакет либо публичный, либо закрытый", "оставьте один из флагов --public и --private");
78
+ }
79
+ const body = {
80
+ id: name,
81
+ name,
82
+ public: Boolean(opts.public),
83
+ file_size_limit: opts.max ? parseSize(opts.max) : null,
84
+ allowed_mime_types: opts.types
85
+ ? opts.types.split(",").map((t) => t.trim()).filter(Boolean)
86
+ : null,
87
+ };
88
+ const { api, org, db, ref } = await target(opts);
89
+ const s = await api.openStorageSession(org, db.id);
90
+ await gateway(s.url, s.key, "/bucket", { method: "POST", body: JSON.stringify(body) });
91
+ if (asJson(opts)) {
92
+ emit({ event: "data_bucket_created", org, database: ref, bucket: name, public: body.public,
93
+ file_size_limit: body.file_size_limit, allowed_mime_types: body.allowed_mime_types });
94
+ return;
95
+ }
96
+ console.log(`${chalk.green("✓")} бакет ${name} создан — ${body.public ? "публичный" : "закрытый"}`);
97
+ if (!body.public) {
98
+ console.log(chalk.dim(" кто может читать и загружать — решают политики: раздел «Файлы» панели или SQL на storage.objects"));
99
+ }
100
+ }
101
+ export async function dataAuthSenderCmd(name, opts) {
102
+ const value = name.trim();
103
+ if (!value)
104
+ throw new LayeroError("bad_format", "пустое имя отправителя", 'пример: layero data auth sender "Замечено"');
105
+ const emails = { sender_name: value };
106
+ if (opts.replyTo)
107
+ emails.reply_to = opts.replyTo;
108
+ const { api, org, db, ref } = await target(opts);
109
+ await api.setDataAuthSettings(org, db.id, { emails });
110
+ if (asJson(opts)) {
111
+ emit({ event: "data_auth_sender_set", org, database: ref, sender_name: value, reply_to: opts.replyTo ?? null });
112
+ return;
113
+ }
114
+ console.log(`${chalk.green("✓")} письма входа уходят от имени «${value}»`);
115
+ }
116
+ export function registerDataFilesCommands(data, withDb, json) {
117
+ const buckets = data.command("buckets").description("Storage buckets: list and create without the dashboard or a secret key.");
118
+ withDb(buckets.command("list").description("The database's buckets.")).action(async (opts) => dataBucketsListCmd(json(opts)));
119
+ withDb(buckets
120
+ .command("create <name>")
121
+ .description("Create a bucket. Private by default: who reads and uploads is decided by policies.")
122
+ .option("--public", "anyone with the link can download files")
123
+ .option("--private", "private (the default)")
124
+ .option("--max <size>", "file size limit: 5MB, 512KB or bytes")
125
+ .option("--types <list>", "allowed MIME types, comma-separated: image/jpeg,image/png")
126
+ .addHelpText("after", "\nExample:\n $ layero data buckets create screenshots --private --max 5MB --types image/jpeg,image/png")).action(async (name, opts) => dataBucketsCreateCmd(name, json(opts)));
127
+ const auth = data.command("auth").description("Sign-in settings that used to live only in the dashboard.");
128
+ withDb(auth
129
+ .command("sender <name>")
130
+ .description("The sender name of sign-in emails (codes, links, password reset).")
131
+ .option("--reply-to <email>", "address for replies")).action(async (name, opts) => dataAuthSenderCmd(name, json(opts)));
132
+ }
@@ -9,6 +9,8 @@
9
9
  // ⚠️ Организация выбирается явно (`--org`) или единственная доступная. Угадывать
10
10
  // «первую попавшуюся» из нескольких нельзя: базы стоят денег и живут в разных
11
11
  // командах.
12
+ import { readFile } from "node:fs/promises";
13
+ import readline from "node:readline/promises";
12
14
  import chalk from "chalk";
13
15
  import { ApiClient } from "../api.js";
14
16
  import { dashboardOrigin } from "../urls.js";
@@ -285,11 +287,37 @@ export async function dbDisconnectCmd(ref, opts) {
285
287
  chalk.dim(" переменная уйдёт из окружения следующим деплоем, роль проекта удалена\n") +
286
288
  chalk.dim(" домены проекта больше не разрешены для Data API этой базы"));
287
289
  }
290
+ async function readStdin() {
291
+ const chunks = [];
292
+ for await (const chunk of process.stdin)
293
+ chunks.push(chunk);
294
+ return Buffer.concat(chunks).toString("utf8");
295
+ }
296
+ /** Текст запроса: `-c`/аргумент, `--file <путь>` или stdin (R2-05, 27.10).
297
+ * Миграции передавали как `-c "$(cat файл)"` — неудобно и упиралось в предел. */
298
+ async function sqlText(opts) {
299
+ if (opts.file && opts.command) {
300
+ throw new LayeroError("bad_format", "запрос задан дважды", "оставьте либо --file, либо текст запроса");
301
+ }
302
+ if (opts.file === "-")
303
+ return readStdin();
304
+ if (opts.file) {
305
+ try {
306
+ return await readFile(opts.file, "utf8");
307
+ }
308
+ catch {
309
+ throw new LayeroError("bad_format", `не прочитать файл ${opts.file}`, "проверьте путь");
310
+ }
311
+ }
312
+ if (opts.command === undefined && !process.stdin.isTTY)
313
+ return readStdin();
314
+ return opts.command ?? "";
315
+ }
288
316
  export async function dbSqlCmd(ref, opts) {
289
317
  const mode = detectMode();
290
- const sql = (opts.command ?? "").trim();
318
+ const sql = (await sqlText(opts)).trim();
291
319
  if (!sql) {
292
- throw new LayeroError("sql_missing", "нечего выполнять", 'передайте запрос следом за именем базы: layero db sql моя-база "select 1"');
320
+ throw new LayeroError("sql_missing", "нечего выполнять", 'передайте запрос следом за именем базы: layero db sql моя-база "select 1", или файл: --file migrations/0001.sql');
293
321
  }
294
322
  const api = new ApiClient(await loadConfig());
295
323
  const org = await orgOf(api, opts);
@@ -316,3 +344,34 @@ export async function dbSqlCmd(ref, opts) {
316
344
  console.log(result.status ?? "выполнено");
317
345
  }
318
346
  }
347
+ /** `layero db delete <база> --yes` — как у проектов (R2-03, 27.10). Снос сразу:
348
+ * окна хранения нет, имя и адрес API освобождаются. */
349
+ export async function dbDeleteCmd(ref, opts) {
350
+ const mode = detectMode();
351
+ const api = new ApiClient(await loadConfig());
352
+ const org = await orgOf(api, opts);
353
+ const db = await pick(api, org, ref);
354
+ if (!opts.yes) {
355
+ if (!mode.interactive || mode.json || opts.json) {
356
+ throw new LayeroError("confirmation_required", `удаление базы «${db.name}» не подтверждено`, `повторите с --yes: layero db delete ${ref} --yes`);
357
+ }
358
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
359
+ try {
360
+ const typed = (await rl.question(`База «${db.name}» и все её данные будут удалены безвозвратно, копии тоже.\n` +
361
+ `Наберите имя базы, чтобы подтвердить: `)).trim();
362
+ if (typed !== db.name) {
363
+ console.log(chalk.yellow("имя не совпало — отменено."));
364
+ return;
365
+ }
366
+ }
367
+ finally {
368
+ rl.close();
369
+ }
370
+ }
371
+ await api.deleteDatabase(org, db.id);
372
+ if (mode.json || opts.json) {
373
+ emit({ event: "database_deleted", org, database: db.name });
374
+ return;
375
+ }
376
+ console.log(`${chalk.green("✓")} база «${db.name}» удалена; имя и адрес API снова свободны`);
377
+ }
@@ -13,6 +13,7 @@ import { detectProject, detectedEvent } from "../detect.js";
13
13
  import { runDeviceLogin } from "../auth.js";
14
14
  import { LayeroError, detectMode, emit, isCiEnv } from "../agent.js";
15
15
  import { ensureUsername } from "../username.js";
16
+ import { pick } from "./db.js";
16
17
  import { assertSandboxAlive, claimFor, claimTokenFor, createClaimable, keepLegacyClaim } from "./claim.js";
17
18
  const VALID_TYPES = new Set([
18
19
  "vite",
@@ -820,6 +821,17 @@ export async function deployCmd(opts) {
820
821
  // ЗАПУСКАТЬ, а не раздавать файлами (симуляция 18.09.2026, `--type node_web`).
821
822
  emit({ event: "runtime_type_applied", project_type: setup.runtime_kind });
822
823
  }
824
+ // 🚨 БАЗА — ДО СБОРКИ (R2-06, 27.10). Проект появляется только с сессией, а
825
+ // ключи Data API платформа кладёт в сборку из связи проект↔база. Пока
826
+ // подключать можно было лишь отдельной командой после первого деплоя, каждый
827
+ // новый проект выкладывался дважды — сначала без ключей (лендинг ещё и падал),
828
+ // потом с ними. Здесь сессия уже есть, а сборка ещё не запущена.
829
+ if (opts.db) {
830
+ const org = project.organization.slug;
831
+ const db = await pick(api, org, opts.db);
832
+ await api.connectDatabaseToProject(org, db.id, project.id);
833
+ emit({ event: "database_connected", org, database: db.name, project: project.slug });
834
+ }
823
835
  // Только связка папки с проектом. Фреймворк сюда больше не пишется: поле
824
836
  // этого файла CLI читает как выбор человека (см. `resolveSetupConfig`).
825
837
  // Код забора старого CLI сначала уезжает в конфиг, потом файл
@@ -81,6 +81,7 @@ const REMOTE = new Set([
81
81
  "oauth_unavailable",
82
82
  "claimable_unavailable",
83
83
  "data_probe_gateway_failed",
84
+ "storage_refused",
84
85
  ]);
85
86
  export const EXIT_OK = 0;
86
87
  export const EXIT_OTHER = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layero",
3
- "version": "0.11.20",
3
+ "version": "0.11.22",
4
4
  "description": "Layero CLI — deploy a directory or a repo to hosting with build servers in Russia. JSON events and exit codes for AI agents and CI.",
5
5
  "license": "MIT",
6
6
  "type": "module",