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 +64 -2
- package/dist/auth.js +8 -6
- package/dist/bin/layero.js +109 -6
- package/dist/commands/data.js +97 -0
- package/dist/commands/db.js +131 -0
- package/dist/commands/deploy.js +91 -4
- package/dist/commands/login.js +13 -2
- package/dist/commands/token.js +59 -0
- package/package.json +1 -1
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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;
|
package/dist/bin/layero.js
CHANGED
|
@@ -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("
|
|
41
|
-
.
|
|
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("
|
|
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>", "
|
|
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
|
+
}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 &&
|
|
317
|
-
|
|
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,
|
package/dist/commands/login.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { loadConfig } from "../config.js";
|
|
2
2
|
import { runDeviceLogin } from "../auth.js";
|
|
3
|
-
|
|
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
|
}
|
package/dist/commands/token.js
CHANGED
|
@@ -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
|
+
}
|