layero 0.11.19 → 0.11.20

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,7 +276,7 @@ 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
- // ── исходящие вызовы (G-12): секреты, хосты, описания ──────────────────
279
+ // ── секреты (HTTP из SQL и вход) и журнал запросов ─────────────────────
280
280
  listDataSecrets(org, dbId) {
281
281
  return this.request("GET", `/organizations/${org}/databases/${dbId}/api/secrets`);
282
282
  }
@@ -287,28 +287,6 @@ export class ApiClient {
287
287
  deleteDataSecret(org, dbId, name) {
288
288
  return this.request("DELETE", `/organizations/${org}/databases/${dbId}/api/secrets/${encodeURIComponent(name)}`);
289
289
  }
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
290
  listDataMethods(org, dbId) {
313
291
  return this.request("GET", `/organizations/${org}/databases/${dbId}/api/methods`);
314
292
  }
@@ -20,7 +20,7 @@ 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 { registerDataCallsCommands } from "./data-calls.js";
23
+ import { registerDataSecretsCommands } from "./data-secrets.js";
24
24
  const LEVELS = ["closed", "visitor", "user", "server"];
25
25
  const LEVEL_WORD = {
26
26
  closed: "закрыто",
@@ -641,5 +641,5 @@ export function registerDataApiCommands(data, program) {
641
641
  .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
642
  .option("-y, --yes", "do not ask for confirmation for --repair")).action(async (opts) => dataEnableCmd(json(opts)));
643
643
  registerDataProbeCommand(data, withDb, json);
644
- registerDataCallsCommands(data, withDb, json);
644
+ registerDataSecretsCommands(data, withDb, json);
645
645
  }
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layero",
3
- "version": "0.11.19",
3
+ "version": "0.11.20",
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",
@@ -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
- }