layero 0.11.19 → 0.11.21
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 +13 -23
- package/dist/bin/layero.js +11 -4
- package/dist/commands/data-api.js +4 -2
- package/dist/commands/data-files.js +117 -0
- package/dist/commands/data-secrets.js +116 -0
- package/dist/commands/db.js +61 -2
- package/dist/commands/deploy.js +12 -0
- package/dist/exit-codes.js +1 -0
- package/package.json +1 -1
- package/dist/commands/data-calls.js +0 -286
package/dist/api.js
CHANGED
|
@@ -276,7 +276,19 @@ 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
|
-
|
|
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
|
+
}
|
|
291
|
+
// ── секреты (HTTP из SQL и вход) и журнал запросов ─────────────────────
|
|
280
292
|
listDataSecrets(org, dbId) {
|
|
281
293
|
return this.request("GET", `/organizations/${org}/databases/${dbId}/api/secrets`);
|
|
282
294
|
}
|
|
@@ -287,28 +299,6 @@ export class ApiClient {
|
|
|
287
299
|
deleteDataSecret(org, dbId, name) {
|
|
288
300
|
return this.request("DELETE", `/organizations/${org}/databases/${dbId}/api/secrets/${encodeURIComponent(name)}`);
|
|
289
301
|
}
|
|
290
|
-
listDataHosts(org, dbId) {
|
|
291
|
-
return this.request("GET", `/organizations/${org}/databases/${dbId}/api/hosts`);
|
|
292
|
-
}
|
|
293
|
-
addDataHost(org, dbId, host) {
|
|
294
|
-
const body = { host };
|
|
295
|
-
return this.request("POST", `/organizations/${org}/databases/${dbId}/api/hosts`, body);
|
|
296
|
-
}
|
|
297
|
-
deleteDataHost(org, dbId, host) {
|
|
298
|
-
return this.request("DELETE", `/organizations/${org}/databases/${dbId}/api/hosts/${encodeURIComponent(host)}`);
|
|
299
|
-
}
|
|
300
|
-
listDataCalls(org, dbId) {
|
|
301
|
-
return this.request("GET", `/organizations/${org}/databases/${dbId}/api/calls`);
|
|
302
|
-
}
|
|
303
|
-
saveDataCall(org, dbId, call) {
|
|
304
|
-
return this.request("PUT", `/organizations/${org}/databases/${dbId}/api/calls`, call);
|
|
305
|
-
}
|
|
306
|
-
deleteDataCall(org, dbId, name) {
|
|
307
|
-
return this.request("DELETE", `/organizations/${org}/databases/${dbId}/api/calls/${encodeURIComponent(name)}`);
|
|
308
|
-
}
|
|
309
|
-
listDataCallLog(org, dbId, limit = 20) {
|
|
310
|
-
return this.request("GET", `/organizations/${org}/databases/${dbId}/api/call-log?limit=${limit}`);
|
|
311
|
-
}
|
|
312
302
|
listDataMethods(org, dbId) {
|
|
313
303
|
return this.request("GET", `/organizations/${org}/databases/${dbId}/api/methods`);
|
|
314
304
|
}
|
package/dist/bin/layero.js
CHANGED
|
@@ -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;
|
|
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")
|
|
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;
|
|
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)")
|
|
@@ -20,7 +20,8 @@ import { loadConfig } from "../config.js";
|
|
|
20
20
|
import { LayeroError, detectMode, emit } from "../agent.js";
|
|
21
21
|
import { orgOf, pick } from "./db.js";
|
|
22
22
|
import { registerDataProbeCommand } from "./data-probe.js";
|
|
23
|
-
import {
|
|
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: "закрыто",
|
|
@@ -641,5 +642,6 @@ export function registerDataApiCommands(data, program) {
|
|
|
641
642
|
.option("--repair", "re-apply the roles and the api schema for a database whose Data API is already enabled; table grants are kept, default privileges for new tables are removed")
|
|
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,117 @@
|
|
|
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 ?? "проверьте, что файлы включены: layero data enable --storage");
|
|
39
|
+
}
|
|
40
|
+
return body;
|
|
41
|
+
}
|
|
42
|
+
export async function dataBucketsListCmd(opts) {
|
|
43
|
+
const { api, org, db, ref } = await target(opts);
|
|
44
|
+
const s = await api.openStorageSession(org, db.id);
|
|
45
|
+
const buckets = await gateway(s.url, s.key, "/bucket");
|
|
46
|
+
if (asJson(opts)) {
|
|
47
|
+
emit({ event: "data_buckets", org, database: ref, buckets });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!buckets.length) {
|
|
51
|
+
console.log(chalk.dim(`бакетов нет — создать: layero data buckets create uploads --db ${ref}`));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
for (const b of buckets) {
|
|
55
|
+
const limit = b.file_size_limit ? `, до ${b.file_size_limit} байт` : "";
|
|
56
|
+
const types = b.allowed_mime_types?.length ? `, ${b.allowed_mime_types.join(" ")}` : "";
|
|
57
|
+
console.log(` ${b.id} ${b.public ? "публичный" : "закрытый"}${limit}${types}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function dataBucketsCreateCmd(name, opts) {
|
|
61
|
+
if (opts.public && opts.private) {
|
|
62
|
+
throw new LayeroError("bad_format", "бакет либо публичный, либо закрытый", "оставьте один из флагов --public и --private");
|
|
63
|
+
}
|
|
64
|
+
const body = {
|
|
65
|
+
id: name,
|
|
66
|
+
name,
|
|
67
|
+
public: Boolean(opts.public),
|
|
68
|
+
file_size_limit: opts.max ? parseSize(opts.max) : null,
|
|
69
|
+
allowed_mime_types: opts.types
|
|
70
|
+
? opts.types.split(",").map((t) => t.trim()).filter(Boolean)
|
|
71
|
+
: null,
|
|
72
|
+
};
|
|
73
|
+
const { api, org, db, ref } = await target(opts);
|
|
74
|
+
const s = await api.openStorageSession(org, db.id);
|
|
75
|
+
await gateway(s.url, s.key, "/bucket", { method: "POST", body: JSON.stringify(body) });
|
|
76
|
+
if (asJson(opts)) {
|
|
77
|
+
emit({ event: "data_bucket_created", org, database: ref, bucket: name, public: body.public,
|
|
78
|
+
file_size_limit: body.file_size_limit, allowed_mime_types: body.allowed_mime_types });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
console.log(`${chalk.green("✓")} бакет ${name} создан — ${body.public ? "публичный" : "закрытый"}`);
|
|
82
|
+
if (!body.public) {
|
|
83
|
+
console.log(chalk.dim(" кто может читать и загружать — решают политики: раздел «Файлы» панели или SQL на storage.objects"));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function dataAuthSenderCmd(name, opts) {
|
|
87
|
+
const value = name.trim();
|
|
88
|
+
if (!value)
|
|
89
|
+
throw new LayeroError("bad_format", "пустое имя отправителя", 'пример: layero data auth sender "Замечено"');
|
|
90
|
+
const emails = { sender_name: value };
|
|
91
|
+
if (opts.replyTo)
|
|
92
|
+
emails.reply_to = opts.replyTo;
|
|
93
|
+
const { api, org, db, ref } = await target(opts);
|
|
94
|
+
await api.setDataAuthSettings(org, db.id, { emails });
|
|
95
|
+
if (asJson(opts)) {
|
|
96
|
+
emit({ event: "data_auth_sender_set", org, database: ref, sender_name: value, reply_to: opts.replyTo ?? null });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
console.log(`${chalk.green("✓")} письма входа уходят от имени «${value}»`);
|
|
100
|
+
}
|
|
101
|
+
export function registerDataFilesCommands(data, withDb, json) {
|
|
102
|
+
const buckets = data.command("buckets").description("Storage buckets: list and create without the dashboard or a secret key.");
|
|
103
|
+
withDb(buckets.command("list").description("The database's buckets.")).action(async (opts) => dataBucketsListCmd(json(opts)));
|
|
104
|
+
withDb(buckets
|
|
105
|
+
.command("create <name>")
|
|
106
|
+
.description("Create a bucket. Private by default: who reads and uploads is decided by policies.")
|
|
107
|
+
.option("--public", "anyone with the link can download files")
|
|
108
|
+
.option("--private", "private (the default)")
|
|
109
|
+
.option("--max <size>", "file size limit: 5MB, 512KB or bytes")
|
|
110
|
+
.option("--types <list>", "allowed MIME types, comma-separated: image/jpeg,image/png")
|
|
111
|
+
.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)));
|
|
112
|
+
const auth = data.command("auth").description("Sign-in settings that used to live only in the dashboard.");
|
|
113
|
+
withDb(auth
|
|
114
|
+
.command("sender <name>")
|
|
115
|
+
.description("The sender name of sign-in emails (codes, links, password reset).")
|
|
116
|
+
.option("--reply-to <email>", "address for replies")).action(async (name, opts) => dataAuthSenderCmd(name, json(opts)));
|
|
117
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import readline from "node:readline/promises";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { LayeroError, detectMode, emit } from "../agent.js";
|
|
4
|
+
import { target } from "./data-api.js";
|
|
5
|
+
function asJson(opts) {
|
|
6
|
+
return detectMode().json || Boolean(opts.json);
|
|
7
|
+
}
|
|
8
|
+
async function readStdin() {
|
|
9
|
+
const chunks = [];
|
|
10
|
+
for await (const chunk of process.stdin)
|
|
11
|
+
chunks.push(chunk);
|
|
12
|
+
return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
13
|
+
}
|
|
14
|
+
/** Ввод без эха: символы не печатаются, как у `sudo`. */
|
|
15
|
+
async function askHidden(question) {
|
|
16
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
17
|
+
const out = rl;
|
|
18
|
+
let prompted = false;
|
|
19
|
+
out._writeToOutput = (s) => {
|
|
20
|
+
if (!prompted) {
|
|
21
|
+
process.stdout.write(s);
|
|
22
|
+
prompted = true;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
try {
|
|
26
|
+
return await rl.question(question);
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
rl.close();
|
|
30
|
+
process.stdout.write("\n");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function confirmed(question, opts) {
|
|
34
|
+
if (opts.yes)
|
|
35
|
+
return true;
|
|
36
|
+
const mode = detectMode();
|
|
37
|
+
if (!mode.interactive || asJson(opts))
|
|
38
|
+
return null;
|
|
39
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
40
|
+
try {
|
|
41
|
+
const answer = (await rl.question(`${question} [y/N]: `)).trim().toLowerCase();
|
|
42
|
+
return answer === "y" || answer === "yes";
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
rl.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function notConfirmed(what, command) {
|
|
49
|
+
return new LayeroError("confirmation_required", `${what} не подтверждено`, `повторите с --yes: ${command} --yes`);
|
|
50
|
+
}
|
|
51
|
+
// ── секреты ─────────────────────────────────────────────────────────────────
|
|
52
|
+
export async function dataSecretsListCmd(opts) {
|
|
53
|
+
const { api, org, db, ref } = await target(opts);
|
|
54
|
+
const names = (await api.listDataSecrets(org, db.id)).map((s) => s.name);
|
|
55
|
+
if (asJson(opts)) {
|
|
56
|
+
emit({ event: "data_secrets", org, database: ref, secrets: names });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
console.log(chalk.bold("Секреты") + chalk.dim(" — только имена, значения не показываются никогда"));
|
|
60
|
+
if (names.length)
|
|
61
|
+
for (const n of names)
|
|
62
|
+
console.log(` ${n}`);
|
|
63
|
+
else
|
|
64
|
+
console.log(chalk.dim(` нет — задать: layero data secrets set TELEGRAM_BOT_TOKEN --db ${ref}`));
|
|
65
|
+
}
|
|
66
|
+
export async function dataSecretsSetCmd(name, opts) {
|
|
67
|
+
let value;
|
|
68
|
+
if (opts.fromEnv) {
|
|
69
|
+
value = process.env[opts.fromEnv];
|
|
70
|
+
if (!value)
|
|
71
|
+
throw new LayeroError("bad_format", `переменная окружения ${opts.fromEnv} пуста или не задана`, "задайте её или передайте значение через stdin");
|
|
72
|
+
}
|
|
73
|
+
else if (!process.stdin.isTTY) {
|
|
74
|
+
value = await readStdin();
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
value = await askHidden(`значение ${name}: `);
|
|
78
|
+
}
|
|
79
|
+
if (!value) {
|
|
80
|
+
throw new LayeroError("bad_format", "пустое значение секрета", `передайте значение через stdin: printf %s "$TOKEN" | layero data secrets set ${name}`);
|
|
81
|
+
}
|
|
82
|
+
const { api, org, db, ref } = await target(opts);
|
|
83
|
+
await api.setDataSecret(org, db.id, name, value);
|
|
84
|
+
if (asJson(opts)) {
|
|
85
|
+
emit({ event: "data_secret_set", org, database: ref, name });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
console.log(`${chalk.green("✓")} секрет ${name} сохранён — в вызове: {{secrets.${name}}}`);
|
|
89
|
+
}
|
|
90
|
+
export async function dataSecretsRemoveCmd(name, opts) {
|
|
91
|
+
const { api, org, db, ref } = await target(opts);
|
|
92
|
+
const ok = await confirmed(`удалить секрет ${name}? Вызовы, которые его подставляют, начнут отказывать`, opts);
|
|
93
|
+
if (ok === null)
|
|
94
|
+
throw notConfirmed(`удаление секрета ${name}`, `layero data secrets remove ${name} --db ${ref}`);
|
|
95
|
+
if (!ok) {
|
|
96
|
+
console.log(chalk.yellow("отменено."));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
await api.deleteDataSecret(org, db.id, name);
|
|
100
|
+
if (asJson(opts)) {
|
|
101
|
+
emit({ event: "data_secret_removed", org, database: ref, name });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
console.log(`${chalk.green("✓")} секрет ${name} удалён`);
|
|
105
|
+
}
|
|
106
|
+
export function registerDataSecretsCommands(data, withDb, json) {
|
|
107
|
+
const secrets = data.command("secrets").description("Secrets substituted into net.http_* requests as {{secrets.NAME}}. Values are never shown.");
|
|
108
|
+
withDb(secrets.command("list").description("Secret names.")).action(async (opts) => dataSecretsListCmd(json(opts)));
|
|
109
|
+
withDb(secrets
|
|
110
|
+
.command("set <name>")
|
|
111
|
+
.description("Set a secret. The value is read from stdin, from --from-env, or typed without echo.")
|
|
112
|
+
.option("--from-env <var>", "take the value from this environment variable")
|
|
113
|
+
.addHelpText("after", '\nExample:\n $ printf %s "$BOT_TOKEN" | layero data secrets set TELEGRAM_BOT_TOKEN')).action(async (name, opts) => dataSecretsSetCmd(name, json(opts)));
|
|
114
|
+
withDb(secrets.command("remove <name>").description("Delete a secret.").option("-y, --yes", "do not ask for confirmation"))
|
|
115
|
+
.action(async (name, opts) => dataSecretsRemoveCmd(name, json(opts)));
|
|
116
|
+
}
|
package/dist/commands/db.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -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 сначала уезжает в конфиг, потом файл
|
package/dist/exit-codes.js
CHANGED
package/package.json
CHANGED
|
@@ -1,286 +0,0 @@
|
|
|
1
|
-
// `layero data hosts | secrets | calls` — исходящие вызовы базы из терминала
|
|
2
|
-
// (G-12, 26.10.2026).
|
|
3
|
-
//
|
|
4
|
-
// 🚨 Зачем. Механизм был только у сервера: документация звала в раздел панели,
|
|
5
|
-
// которого не было, а CLI и MCP о нём не знали. Строки `api.outbox` у клиента
|
|
6
|
-
// ложились в `failed: вызов не описан`, и обещанные уведомления в Telegram без
|
|
7
|
-
// своего сервера не запускались. Теперь всё описывается кодом:
|
|
8
|
-
//
|
|
9
|
-
// layero data hosts add api.telegram.org
|
|
10
|
-
// layero data secrets set TELEGRAM_BOT_TOKEN # значение — со stdin
|
|
11
|
-
// layero data calls set --file telegram_notify.json
|
|
12
|
-
//
|
|
13
|
-
// ⚠️ Значение секрета НЕ принимается аргументом: аргумент оседает в истории
|
|
14
|
-
// shell и в списке процессов. Только stdin, переменная окружения или ввод без
|
|
15
|
-
// эха в терминале.
|
|
16
|
-
import { readFile } from "node:fs/promises";
|
|
17
|
-
import readline from "node:readline/promises";
|
|
18
|
-
import chalk from "chalk";
|
|
19
|
-
import { LayeroError, detectMode, emit } from "../agent.js";
|
|
20
|
-
import { target } from "./data-api.js";
|
|
21
|
-
function asJson(opts) {
|
|
22
|
-
return detectMode().json || Boolean(opts.json);
|
|
23
|
-
}
|
|
24
|
-
async function readStdin() {
|
|
25
|
-
const chunks = [];
|
|
26
|
-
for await (const chunk of process.stdin)
|
|
27
|
-
chunks.push(chunk);
|
|
28
|
-
return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
29
|
-
}
|
|
30
|
-
/** Ввод без эха: символы не печатаются, как у `sudo`. */
|
|
31
|
-
async function askHidden(question) {
|
|
32
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
33
|
-
const out = rl;
|
|
34
|
-
let prompted = false;
|
|
35
|
-
out._writeToOutput = (s) => {
|
|
36
|
-
if (!prompted) {
|
|
37
|
-
process.stdout.write(s);
|
|
38
|
-
prompted = true;
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
try {
|
|
42
|
-
return await rl.question(question);
|
|
43
|
-
}
|
|
44
|
-
finally {
|
|
45
|
-
rl.close();
|
|
46
|
-
process.stdout.write("\n");
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
async function confirmed(question, opts) {
|
|
50
|
-
if (opts.yes)
|
|
51
|
-
return true;
|
|
52
|
-
const mode = detectMode();
|
|
53
|
-
if (!mode.interactive || asJson(opts))
|
|
54
|
-
return null;
|
|
55
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
56
|
-
try {
|
|
57
|
-
const answer = (await rl.question(`${question} [y/N]: `)).trim().toLowerCase();
|
|
58
|
-
return answer === "y" || answer === "yes";
|
|
59
|
-
}
|
|
60
|
-
finally {
|
|
61
|
-
rl.close();
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
function notConfirmed(what, command) {
|
|
65
|
-
return new LayeroError("confirmation_required", `${what} не подтверждено`, `повторите с --yes: ${command} --yes`);
|
|
66
|
-
}
|
|
67
|
-
// ── хосты ───────────────────────────────────────────────────────────────────
|
|
68
|
-
export async function dataHostsListCmd(opts) {
|
|
69
|
-
const { api, org, db, ref } = await target(opts);
|
|
70
|
-
const hosts = (await api.listDataHosts(org, db.id)).map((h) => h.host);
|
|
71
|
-
if (asJson(opts)) {
|
|
72
|
-
emit({ event: "data_hosts", org, database: ref, hosts });
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
console.log(chalk.bold("Разрешённые хосты") + chalk.dim(" — куда шлюзу можно ходить"));
|
|
76
|
-
if (hosts.length)
|
|
77
|
-
for (const h of hosts)
|
|
78
|
-
console.log(` ${h}`);
|
|
79
|
-
else
|
|
80
|
-
console.log(chalk.dim(` нет — добавить: layero data hosts add api.telegram.org --db ${ref}`));
|
|
81
|
-
}
|
|
82
|
-
export async function dataHostsAddCmd(host, opts) {
|
|
83
|
-
const { api, org, db, ref } = await target(opts);
|
|
84
|
-
await api.addDataHost(org, db.id, host);
|
|
85
|
-
if (asJson(opts)) {
|
|
86
|
-
emit({ event: "data_host_added", org, database: ref, host });
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
console.log(`${chalk.green("✓")} шлюз базы «${db.name}» может ходить на ${host}`);
|
|
90
|
-
}
|
|
91
|
-
export async function dataHostsRemoveCmd(host, opts) {
|
|
92
|
-
const { api, org, db, ref } = await target(opts);
|
|
93
|
-
const ok = await confirmed(`убрать ${host}? Вызовы на этот хост начнут отказывать`, opts);
|
|
94
|
-
if (ok === null)
|
|
95
|
-
throw notConfirmed(`удаление хоста ${host}`, `layero data hosts remove ${host} --db ${ref}`);
|
|
96
|
-
if (!ok) {
|
|
97
|
-
console.log(chalk.yellow("отменено."));
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
await api.deleteDataHost(org, db.id, host);
|
|
101
|
-
if (asJson(opts)) {
|
|
102
|
-
emit({ event: "data_host_removed", org, database: ref, host });
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
console.log(`${chalk.green("✓")} ${host} убран`);
|
|
106
|
-
}
|
|
107
|
-
// ── секреты ─────────────────────────────────────────────────────────────────
|
|
108
|
-
export async function dataSecretsListCmd(opts) {
|
|
109
|
-
const { api, org, db, ref } = await target(opts);
|
|
110
|
-
const names = (await api.listDataSecrets(org, db.id)).map((s) => s.name);
|
|
111
|
-
if (asJson(opts)) {
|
|
112
|
-
emit({ event: "data_secrets", org, database: ref, secrets: names });
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
console.log(chalk.bold("Секреты") + chalk.dim(" — только имена, значения не показываются никогда"));
|
|
116
|
-
if (names.length)
|
|
117
|
-
for (const n of names)
|
|
118
|
-
console.log(` ${n}`);
|
|
119
|
-
else
|
|
120
|
-
console.log(chalk.dim(` нет — задать: layero data secrets set TELEGRAM_BOT_TOKEN --db ${ref}`));
|
|
121
|
-
}
|
|
122
|
-
export async function dataSecretsSetCmd(name, opts) {
|
|
123
|
-
let value;
|
|
124
|
-
if (opts.fromEnv) {
|
|
125
|
-
value = process.env[opts.fromEnv];
|
|
126
|
-
if (!value)
|
|
127
|
-
throw new LayeroError("bad_format", `переменная окружения ${opts.fromEnv} пуста или не задана`, "задайте её или передайте значение через stdin");
|
|
128
|
-
}
|
|
129
|
-
else if (!process.stdin.isTTY) {
|
|
130
|
-
value = await readStdin();
|
|
131
|
-
}
|
|
132
|
-
else {
|
|
133
|
-
value = await askHidden(`значение ${name}: `);
|
|
134
|
-
}
|
|
135
|
-
if (!value) {
|
|
136
|
-
throw new LayeroError("bad_format", "пустое значение секрета", `передайте значение через stdin: printf %s "$TOKEN" | layero data secrets set ${name}`);
|
|
137
|
-
}
|
|
138
|
-
const { api, org, db, ref } = await target(opts);
|
|
139
|
-
await api.setDataSecret(org, db.id, name, value);
|
|
140
|
-
if (asJson(opts)) {
|
|
141
|
-
emit({ event: "data_secret_set", org, database: ref, name });
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
console.log(`${chalk.green("✓")} секрет ${name} сохранён — в вызове: {{secrets.${name}}}`);
|
|
145
|
-
}
|
|
146
|
-
export async function dataSecretsRemoveCmd(name, opts) {
|
|
147
|
-
const { api, org, db, ref } = await target(opts);
|
|
148
|
-
const ok = await confirmed(`удалить секрет ${name}? Вызовы, которые его подставляют, начнут отказывать`, opts);
|
|
149
|
-
if (ok === null)
|
|
150
|
-
throw notConfirmed(`удаление секрета ${name}`, `layero data secrets remove ${name} --db ${ref}`);
|
|
151
|
-
if (!ok) {
|
|
152
|
-
console.log(chalk.yellow("отменено."));
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
await api.deleteDataSecret(org, db.id, name);
|
|
156
|
-
if (asJson(opts)) {
|
|
157
|
-
emit({ event: "data_secret_removed", org, database: ref, name });
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
console.log(`${chalk.green("✓")} секрет ${name} удалён`);
|
|
161
|
-
}
|
|
162
|
-
// ── вызовы ──────────────────────────────────────────────────────────────────
|
|
163
|
-
/** Описание из файла → тело запроса. Те же поля, что на странице документации. */
|
|
164
|
-
export function parseCallSpec(text, source) {
|
|
165
|
-
let raw;
|
|
166
|
-
try {
|
|
167
|
-
raw = JSON.parse(text);
|
|
168
|
-
}
|
|
169
|
-
catch {
|
|
170
|
-
throw new LayeroError("bad_format", `${source}: не JSON`, "описание вызова — JSON-объект с полями name и url_template");
|
|
171
|
-
}
|
|
172
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
173
|
-
throw new LayeroError("bad_format", `${source}: ожидался JSON-объект`, "описание вызова — JSON-объект с полями name и url_template");
|
|
174
|
-
}
|
|
175
|
-
for (const field of ["name", "url_template"]) {
|
|
176
|
-
if (typeof raw[field] !== "string" || !raw[field]) {
|
|
177
|
-
throw new LayeroError("bad_format", `${source}: нет поля ${field}`, "описание вызова — JSON-объект с полями name и url_template");
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return {
|
|
181
|
-
name: raw.name,
|
|
182
|
-
method: typeof raw.method === "string" ? raw.method.toUpperCase() : "POST",
|
|
183
|
-
url_template: raw.url_template,
|
|
184
|
-
headers: raw.headers ?? null,
|
|
185
|
-
body_template: raw.body_template ?? null,
|
|
186
|
-
allow_anon: raw.allow_anon === true,
|
|
187
|
-
hourly_limit: Number.isInteger(raw.hourly_limit) ? raw.hourly_limit : 600,
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
export async function dataCallsListCmd(opts) {
|
|
191
|
-
const { api, org, db, ref } = await target(opts);
|
|
192
|
-
const calls = await api.listDataCalls(org, db.id);
|
|
193
|
-
if (asJson(opts)) {
|
|
194
|
-
emit({ event: "data_calls", org, database: ref, calls });
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
if (!calls.length) {
|
|
198
|
-
console.log(chalk.dim(`вызовов нет — описать: layero data calls set --file call.json --db ${ref}`));
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
for (const c of calls) {
|
|
202
|
-
console.log(`${chalk.bold(c.name)} ${c.method} ${c.url_template}${c.allow_anon ? chalk.yellow(" публичный ключ") : ""}`);
|
|
203
|
-
console.log(chalk.dim(` из базы: INSERT INTO api.outbox (call_name, args) VALUES ('${c.name}', …); с сайта: POST /call/${c.name}`));
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
export async function dataCallsSetCmd(opts) {
|
|
207
|
-
if (!opts.file) {
|
|
208
|
-
throw new LayeroError("bad_format", "не указан файл описания", "layero data calls set --file telegram_notify.json");
|
|
209
|
-
}
|
|
210
|
-
const text = opts.file === "-" ? await readStdin() : await readFile(opts.file, "utf8");
|
|
211
|
-
const call = parseCallSpec(text, opts.file === "-" ? "stdin" : opts.file);
|
|
212
|
-
const { api, org, db, ref } = await target(opts);
|
|
213
|
-
await api.saveDataCall(org, db.id, call);
|
|
214
|
-
if (asJson(opts)) {
|
|
215
|
-
emit({ event: "data_call_saved", org, database: ref, name: call.name });
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
console.log(`${chalk.green("✓")} вызов ${call.name} описан у базы «${db.name}»\n` +
|
|
219
|
-
chalk.dim(` из базы: INSERT INTO api.outbox (call_name, args) VALUES ('${call.name}', '{…}'::jsonb)\n`) +
|
|
220
|
-
chalk.dim(` журнал: layero data calls log --db ${ref}`));
|
|
221
|
-
}
|
|
222
|
-
export async function dataCallsRemoveCmd(name, opts) {
|
|
223
|
-
const { api, org, db, ref } = await target(opts);
|
|
224
|
-
const ok = await confirmed(`удалить вызов ${name}? Новые строки api.outbox с этим именем будут отклонены`, opts);
|
|
225
|
-
if (ok === null)
|
|
226
|
-
throw notConfirmed(`удаление вызова ${name}`, `layero data calls remove ${name} --db ${ref}`);
|
|
227
|
-
if (!ok) {
|
|
228
|
-
console.log(chalk.yellow("отменено."));
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
await api.deleteDataCall(org, db.id, name);
|
|
232
|
-
if (asJson(opts)) {
|
|
233
|
-
emit({ event: "data_call_removed", org, database: ref, name });
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
console.log(`${chalk.green("✓")} вызов ${name} удалён`);
|
|
237
|
-
}
|
|
238
|
-
export async function dataCallsLogCmd(opts) {
|
|
239
|
-
const { api, org, db, ref } = await target(opts);
|
|
240
|
-
const limit = Math.min(Math.max(Number(opts.limit ?? 20) || 20, 1), 200);
|
|
241
|
-
const rows = await api.listDataCallLog(org, db.id, limit);
|
|
242
|
-
if (asJson(opts)) {
|
|
243
|
-
emit({ event: "data_call_log", org, database: ref, calls: rows });
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
if (!rows.length) {
|
|
247
|
-
console.log(chalk.dim("вызовов ещё не было"));
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
for (const r of rows) {
|
|
251
|
-
const verdict = r.error ? chalk.red(r.error) : r.status_code && r.status_code < 400 ? chalk.green(String(r.status_code)) : chalk.yellow(String(r.status_code ?? "—"));
|
|
252
|
-
console.log(`${r.at} ${r.call_name} ${r.mode === "queue" ? "очередь" : "синхронно"} ${verdict}`);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
export function registerDataCallsCommands(data, withDb, json) {
|
|
256
|
-
const hosts = data.command("hosts").description("Hosts the gateway may call for the database's outbound calls.");
|
|
257
|
-
withDb(hosts.command("list").description("Allowed hosts.")).action(async (opts) => dataHostsListCmd(json(opts)));
|
|
258
|
-
withDb(hosts.command("add <host>").description("Allow a host, e.g. api.telegram.org."))
|
|
259
|
-
.action(async (host, opts) => dataHostsAddCmd(host, json(opts)));
|
|
260
|
-
withDb(hosts.command("remove <host>").description("Remove a host.").option("-y, --yes", "do not ask for confirmation"))
|
|
261
|
-
.action(async (host, opts) => dataHostsRemoveCmd(host, json(opts)));
|
|
262
|
-
const secrets = data.command("secrets").description("Secrets substituted into outbound calls as {{secrets.NAME}}. Values are never shown.");
|
|
263
|
-
withDb(secrets.command("list").description("Secret names.")).action(async (opts) => dataSecretsListCmd(json(opts)));
|
|
264
|
-
withDb(secrets
|
|
265
|
-
.command("set <name>")
|
|
266
|
-
.description("Set a secret. The value is read from stdin, from --from-env, or typed without echo.")
|
|
267
|
-
.option("--from-env <var>", "take the value from this environment variable")
|
|
268
|
-
.addHelpText("after", '\nExample:\n $ printf %s "$BOT_TOKEN" | layero data secrets set TELEGRAM_BOT_TOKEN')).action(async (name, opts) => dataSecretsSetCmd(name, json(opts)));
|
|
269
|
-
withDb(secrets.command("remove <name>").description("Delete a secret.").option("-y, --yes", "do not ask for confirmation"))
|
|
270
|
-
.action(async (name, opts) => dataSecretsRemoveCmd(name, json(opts)));
|
|
271
|
-
const calls = data.command("calls").description("Outbound calls: webhooks and Telegram from the database (api.outbox) or the site (POST /call/<name>).");
|
|
272
|
-
withDb(calls.command("list").description("Described calls.")).action(async (opts) => dataCallsListCmd(json(opts)));
|
|
273
|
-
withDb(calls
|
|
274
|
-
.command("set")
|
|
275
|
-
.description("Create or replace a call from a JSON description: name, method, url_template, headers, body_template, allow_anon.")
|
|
276
|
-
.option("--file <path>", "JSON file with the description, - for stdin")
|
|
277
|
-
.addHelpText("after", "\nThe host of url_template must be allowed first: layero data hosts add <host>.\n" +
|
|
278
|
-
"\nExample (telegram_notify.json):\n" +
|
|
279
|
-
' {"name": "telegram_notify", "method": "POST",\n' +
|
|
280
|
-
' "url_template": "https://api.telegram.org/bot{{secrets.TELEGRAM_BOT_TOKEN}}/sendMessage",\n' +
|
|
281
|
-
' "body_template": {"chat_id": "{{args.chat_id}}", "text": "{{args.text}}"}}')).action(async (opts) => dataCallsSetCmd(json(opts)));
|
|
282
|
-
withDb(calls.command("remove <name>").description("Delete a call.").option("-y, --yes", "do not ask for confirmation"))
|
|
283
|
-
.action(async (name, opts) => dataCallsRemoveCmd(name, json(opts)));
|
|
284
|
-
withDb(calls.command("log").description("Recent calls: status code and refusal reason.").option("--limit <n>", "how many, up to 200", "20"))
|
|
285
|
-
.action(async (opts) => dataCallsLogCmd(json(opts)));
|
|
286
|
-
}
|