layero 0.8.22 → 0.8.24
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/agent.js +8 -0
- package/dist/auth.js +20 -2
- package/dist/bin/layero.js +22 -1
- package/dist/commands/deploy.js +178 -43
- package/dist/commands/init.js +2 -2
- package/dist/username.js +117 -0
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -112,6 +112,9 @@ function renderHuman(event) {
|
|
|
112
112
|
case "authorized":
|
|
113
113
|
process.stdout.write(`✓ Authorized as ${event.user}\n`);
|
|
114
114
|
break;
|
|
115
|
+
case "username_set":
|
|
116
|
+
process.stdout.write(`✓ Имя аккаунта: ${event.username} (организация ${event.organization})\n`);
|
|
117
|
+
break;
|
|
115
118
|
case "project_created":
|
|
116
119
|
process.stdout.write(`✓ Created project ${event.slug} (org: ${event.organization})\n`);
|
|
117
120
|
break;
|
|
@@ -145,6 +148,11 @@ function renderHuman(event) {
|
|
|
145
148
|
process.stdout.write(`! Failed to set project_type automatically: ${event.error}\n`);
|
|
146
149
|
process.stdout.write(` Build may fail at detect; accept the suggestion in the dashboard if so.\n`);
|
|
147
150
|
break;
|
|
151
|
+
case "repeated_failure_guard":
|
|
152
|
+
// В человеческом режиме подробности печатает сама команда: там они с
|
|
153
|
+
// цветом, в stderr и с вопросом. Дублировать их здесь значило бы
|
|
154
|
+
// показать одно и то же дважды.
|
|
155
|
+
break;
|
|
148
156
|
case "deploy_started":
|
|
149
157
|
process.stdout.write(`→ Building...\n`);
|
|
150
158
|
break;
|
package/dist/auth.js
CHANGED
|
@@ -9,6 +9,7 @@ import open from "open";
|
|
|
9
9
|
import { saveConfig } from "./config.js";
|
|
10
10
|
import { ApiClient } from "./api.js";
|
|
11
11
|
import { LayeroError, detectMode, emit } from "./agent.js";
|
|
12
|
+
import { ensureUsername } from "./username.js";
|
|
12
13
|
const MAX_WAIT_MS = 15 * 60 * 1000;
|
|
13
14
|
/**
|
|
14
15
|
* Run the browser device-auth flow against `cfg`, persist the resulting
|
|
@@ -55,8 +56,25 @@ export async function runDeviceLogin(cfg) {
|
|
|
55
56
|
}
|
|
56
57
|
else {
|
|
57
58
|
console.log(chalk.green(`Logged in as ${me.username ?? me.email ?? me.id}`));
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
}
|
|
60
|
+
// Имя аккаунта спрашиваем здесь же, а не отправляем в браузер: без него
|
|
61
|
+
// первый же деплой упрётся в 412, а CLI ровно затем и нужен, чтобы в
|
|
62
|
+
// дашборд не ходить. В агентском режиме ensureUsername не спрашивает —
|
|
63
|
+
// отдаёт `username_required` с командой, которую агент выполнит сам.
|
|
64
|
+
if (!me.username) {
|
|
65
|
+
try {
|
|
66
|
+
const picked = await ensureUsername(probe, me);
|
|
67
|
+
cfg.user = { ...cfg.user, username: picked };
|
|
68
|
+
await saveConfig(cfg);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
// Вход УЖЕ состоялся и токен сохранён — валить команду из-за
|
|
72
|
+
// невыбранного имени нельзя. В агентском режиме ensureUsername
|
|
73
|
+
// бросает `username_required`: показываем подсказку на stderr
|
|
74
|
+
// (stdout занят JSON-строками) и выходим с успехом. Отказом это
|
|
75
|
+
// станет на первом деплое, где действительно мешает.
|
|
76
|
+
process.stderr.write(`\n ${err instanceof Error ? err.message : String(err)}\n` +
|
|
77
|
+
" Задайте имя аккаунта: layero username <имя>\n\n");
|
|
60
78
|
}
|
|
61
79
|
}
|
|
62
80
|
return cfg;
|
package/dist/bin/layero.js
CHANGED
|
@@ -19,7 +19,9 @@ import { envListCmd, envSetCmd, envUnsetCmd } from "../commands/env.js";
|
|
|
19
19
|
import { analyticsConnectCmd, analyticsDisconnectCmd, analyticsStatsCmd, analyticsStatusCmd, } from "../commands/analytics.js";
|
|
20
20
|
import { domainsAddCmd, domainsListCmd, domainsPrimaryCmd, domainsRemoveCmd, domainsVerifyCmd, } from "../commands/domains.js";
|
|
21
21
|
import { LayeroError, detectMode, emit } from "../agent.js";
|
|
22
|
-
import { ApiError } from "../api.js";
|
|
22
|
+
import { ApiClient, ApiError } from "../api.js";
|
|
23
|
+
import { loadConfig } from "../config.js";
|
|
24
|
+
import { usernameSetCmd } from "../username.js";
|
|
23
25
|
import { notifyIfOutdated } from "../update-notifier.js";
|
|
24
26
|
import { CLI_VERSION } from "../version.js";
|
|
25
27
|
// Одно чтение package.json на процесс — версия нужна и здесь (`--version`,
|
|
@@ -48,6 +50,20 @@ async function main() {
|
|
|
48
50
|
.command("whoami")
|
|
49
51
|
.description("Show the currently logged-in account.")
|
|
50
52
|
.action(whoamiCmd);
|
|
53
|
+
program
|
|
54
|
+
.command("username <value>")
|
|
55
|
+
.description("Задать имя аккаунта — оно же адрес личной организации. " +
|
|
56
|
+
"Без него платформе некуда положить проект. " +
|
|
57
|
+
"В интерактивном терминале `login` и `deploy` спросят его сами; " +
|
|
58
|
+
"эта команда нужна агентам и CI, где спрашивать некого.")
|
|
59
|
+
.addHelpText("after", "\nExamples:\n" +
|
|
60
|
+
" $ layero username alice\n" +
|
|
61
|
+
" $ layero username my-team-bot\n\n" +
|
|
62
|
+
"Строчные латинские буквы, цифры и дефис; 2–32 символа.")
|
|
63
|
+
.action(async (value) => {
|
|
64
|
+
const cfg = await loadConfig();
|
|
65
|
+
await usernameSetCmd(value, new ApiClient(cfg));
|
|
66
|
+
});
|
|
51
67
|
program
|
|
52
68
|
.command("init")
|
|
53
69
|
.description("Scaffold .layero/project.json from the auto-detected framework, and write a Layero deployment block into your agent-instructions file so future chat sessions know how to deploy. Updates whichever of AGENTS.md / CLAUDE.md / .cursorrules already exist; if none do, creates AGENTS.md.")
|
|
@@ -276,6 +292,11 @@ async function main() {
|
|
|
276
292
|
"environments come from pushes to a connected repository, not from " +
|
|
277
293
|
"this flag. Kept for backwards compatibility.")
|
|
278
294
|
.option("--org <slug>", "Layero organization slug for first-time project creation. Defaults to personal; required when you're a member of multiple orgs and want a non-personal home.")
|
|
295
|
+
// Осознанное подтверждение выкатки, когда предыдущие сборки подряд падают
|
|
296
|
+
// с ОДНОЙ И ТОЙ ЖЕ ошибкой. Намеренно НЕ покрывается `--yes`: смысл стопа
|
|
297
|
+
// в том, чтобы прервать автоматический цикл, а `--yes` в скриптах уже
|
|
298
|
+
// стоит по умолчанию и снял бы стоп, ничего не остановив.
|
|
299
|
+
.option("--confirm-repeated-failure", "proceed even though recent deploys keep failing with the SAME error (the platform stops repeat deploys until you confirm you know what changed)")
|
|
279
300
|
.addHelpText("after", "\nExamples:\n" +
|
|
280
301
|
" $ layero deploy # preview deploy (CLI pseudo-branch), auto-detect framework\n" +
|
|
281
302
|
" $ layero deploy --prod # production deploy (interactive confirm)\n" +
|
package/dist/commands/deploy.js
CHANGED
|
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import readline from "node:readline/promises";
|
|
4
4
|
import chalk from "chalk";
|
|
5
|
-
import { ApiClient, ApiError, uploadArchive } from "../api.js";
|
|
5
|
+
import { ApiClient, ApiError, uploadArchive, } from "../api.js";
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
7
|
import { loadProjectConfig, persistProjectLinking, } from "../project-config.js";
|
|
8
8
|
import { packCwd, packDirectory } from "../pack.js";
|
|
@@ -10,6 +10,7 @@ import { streamDeployLogs } from "../logs.js";
|
|
|
10
10
|
import { detectProject } from "../detect.js";
|
|
11
11
|
import { runDeviceLogin } from "../auth.js";
|
|
12
12
|
import { LayeroError, detectMode, emit, isCiEnv } from "../agent.js";
|
|
13
|
+
import { ensureUsername } from "../username.js";
|
|
13
14
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
14
15
|
const VALID_TYPES = new Set([
|
|
15
16
|
"vite",
|
|
@@ -92,7 +93,9 @@ async function confirm(question) {
|
|
|
92
93
|
output: process.stdout,
|
|
93
94
|
});
|
|
94
95
|
try {
|
|
95
|
-
const answer = (await rl.question(`${question} [y/N]: `))
|
|
96
|
+
const answer = (await rl.question(`${question} [y/N]: `))
|
|
97
|
+
.trim()
|
|
98
|
+
.toLowerCase();
|
|
96
99
|
return answer === "y" || answer === "yes";
|
|
97
100
|
}
|
|
98
101
|
finally {
|
|
@@ -195,6 +198,119 @@ async function resolveSetupConfig(cwd, opts, existing) {
|
|
|
195
198
|
...(detected.runtime_kind ? { runtime_kind: detected.runtime_kind } : {}),
|
|
196
199
|
};
|
|
197
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Русская форма числительного. Шесть строк вместо зависимости: в CLI своего
|
|
203
|
+
* плюрализатора нет, а «2 сборок» в стоп-сообщении читается как небрежность
|
|
204
|
+
* ровно там, где нужно доверие. Порог настраивается платформой, поэтому
|
|
205
|
+
* подставить одну форму нельзя — при пороге 2 и при пороге 5 они разные.
|
|
206
|
+
*/
|
|
207
|
+
function pluralRu(n, one, few, many) {
|
|
208
|
+
const a = Math.abs(n) % 100;
|
|
209
|
+
if (a >= 11 && a <= 14)
|
|
210
|
+
return many;
|
|
211
|
+
const b = a % 10;
|
|
212
|
+
if (b === 1)
|
|
213
|
+
return one;
|
|
214
|
+
if (b >= 2 && b <= 4)
|
|
215
|
+
return few;
|
|
216
|
+
return many;
|
|
217
|
+
}
|
|
218
|
+
function parseRepeatedFailure(err) {
|
|
219
|
+
if (!(err instanceof ApiError) || err.status !== 409)
|
|
220
|
+
return null;
|
|
221
|
+
try {
|
|
222
|
+
const detail = JSON.parse(err.body)?.detail;
|
|
223
|
+
return detail?.code === "repeated_failure" ? detail : null;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Запуск сборки со стопом на повторяющейся ошибке.
|
|
231
|
+
*
|
|
232
|
+
* СМЫСЛ СТОПА — ОСТАНОВИТЬ ТОГО, КТО ГОНИТ ВЫКАТКУ ВСЛЕПУЮ. Агент, десятый раз
|
|
233
|
+
* собирающий одно и то же, ошибку обычно НЕ читает: она приходит в конце
|
|
234
|
+
* длинного лога сборки, а он смотрит на код возврата и запускает снова. Поэтому
|
|
235
|
+
* здесь текст ошибки печатается ПЕРВЫМ и отдельно от всего остального, и лишь
|
|
236
|
+
* потом объясняется, что делать.
|
|
237
|
+
*
|
|
238
|
+
* В интерактивном терминале спрашиваем прямо; без TTY (агент, CI) — падаем с
|
|
239
|
+
* отдельным кодом ошибки. Автоматически продолжать нельзя: это ровно тот цикл,
|
|
240
|
+
* который правило и разрывает.
|
|
241
|
+
*/
|
|
242
|
+
async function startWithRepeatedFailureGuard(api, sessionId, commitSha, opts) {
|
|
243
|
+
try {
|
|
244
|
+
return await api.startDeploySession(sessionId, {
|
|
245
|
+
commit_sha: commitSha,
|
|
246
|
+
confirm_repeated_failure: opts.confirmRepeatedFailure === true,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
const detail = parseRepeatedFailure(err);
|
|
251
|
+
if (!detail)
|
|
252
|
+
throw err;
|
|
253
|
+
// Машиночитаемое событие — для агентов в режиме --json.
|
|
254
|
+
// `scope` отвечает на вопрос, который агент задаст первым: «я собирал в
|
|
255
|
+
// этом проекте три раза, откуда десять?». Серия могла набраться суммой по
|
|
256
|
+
// нескольким его проектам — перенос в новый проект правило не обходит.
|
|
257
|
+
const scope = detail.scope === "owner" ? "owner" : "project";
|
|
258
|
+
emit({
|
|
259
|
+
event: "repeated_failure_guard",
|
|
260
|
+
streak: detail.streak,
|
|
261
|
+
threshold: detail.threshold,
|
|
262
|
+
scope,
|
|
263
|
+
failure_stage: detail.failure_stage ?? undefined,
|
|
264
|
+
error: detail.error ?? undefined,
|
|
265
|
+
});
|
|
266
|
+
const errorText = (detail.error || "").trim();
|
|
267
|
+
console.error("");
|
|
268
|
+
console.error(chalk.red.bold(`Стоп: ${detail.streak} ${pluralRu(detail.streak, "сборка", "сборки", "сборок")} ` +
|
|
269
|
+
(scope === "owner"
|
|
270
|
+
? "в ваших проектах упали с одной и той же ошибкой."
|
|
271
|
+
: "подряд упали с одной и той же ошибкой.")));
|
|
272
|
+
if (errorText) {
|
|
273
|
+
console.error("");
|
|
274
|
+
console.error(chalk.red(errorText));
|
|
275
|
+
}
|
|
276
|
+
if (detail.failure_stage) {
|
|
277
|
+
console.error(chalk.dim(`Стадия: ${detail.failure_stage}`));
|
|
278
|
+
}
|
|
279
|
+
console.error("");
|
|
280
|
+
if (scope === "owner") {
|
|
281
|
+
console.error("Ошибка повторяется в разных проектах — значит, дело не в конкретном");
|
|
282
|
+
console.error("проекте, а в коде приложения или в настройках сборки.");
|
|
283
|
+
console.error("");
|
|
284
|
+
}
|
|
285
|
+
console.error("Повторная выкатка без изменений даст тот же результат. Исправьте причину —");
|
|
286
|
+
console.error("или подтвердите, что изменили что-то, влияющее на неё.");
|
|
287
|
+
console.error("");
|
|
288
|
+
const mode = detectMode();
|
|
289
|
+
if (mode.interactive) {
|
|
290
|
+
const rl = readline.createInterface({
|
|
291
|
+
input: process.stdin,
|
|
292
|
+
output: process.stdout,
|
|
293
|
+
});
|
|
294
|
+
try {
|
|
295
|
+
const answer = (await rl.question("Всё равно выкатить? [y/N]: ")).trim().toLowerCase();
|
|
296
|
+
if (answer !== "y" && answer !== "yes") {
|
|
297
|
+
throw new LayeroError("repeated_failure_declined", "deploy cancelled: the same error keeps failing the build", "fix the error above, then run `layero deploy` again");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
rl.close();
|
|
302
|
+
}
|
|
303
|
+
return await api.startDeploySession(sessionId, {
|
|
304
|
+
commit_sha: commitSha,
|
|
305
|
+
confirm_repeated_failure: true,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
throw new LayeroError("repeated_failure", `deploy stopped: the last ${detail.streak} deploys failed with the SAME error` +
|
|
309
|
+
(errorText ? ` — ${errorText}` : ""), "read the error above and fix its cause. Re-running the same deploy will " +
|
|
310
|
+
"fail the same way. If you changed something that affects it, re-run with " +
|
|
311
|
+
"`--confirm-repeated-failure`.");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
198
314
|
export async function deployCmd(opts) {
|
|
199
315
|
const mode = detectMode();
|
|
200
316
|
if (opts.type && !VALID_TYPES.has(opts.type.toLowerCase())) {
|
|
@@ -257,43 +373,46 @@ export async function deployCmd(opts) {
|
|
|
257
373
|
const targeting = deployTargeting(opts);
|
|
258
374
|
// --- Одно обращение вместо пяти: резолв или создание проекта, настройка,
|
|
259
375
|
// тип рантайма, root и выдача адреса для загрузки архива.
|
|
376
|
+
// Тело вынесено из вызова, чтобы повтор после выбора имени аккаунта уходил
|
|
377
|
+
// РОВНО с тем же payload — собирать его второй раз значит однажды разойтись.
|
|
378
|
+
const sessionPayload = {
|
|
379
|
+
// `--project` ПЕРЕОПРЕДЕЛЯЕТ запись в .layero/project.json: пользователь
|
|
380
|
+
// явно сказал, куда деплоить, и залинкованный проект тут не при чём.
|
|
381
|
+
// Передать оба поля нельзя — сервер выберет project_id и молча уедет
|
|
382
|
+
// не туда, куда просили.
|
|
383
|
+
//
|
|
384
|
+
// Флаг принимает И id, И слаг (так было до переноса оркестрации), а
|
|
385
|
+
// на сервере это разные поля: `project_id` ищется по идентификатору,
|
|
386
|
+
// `name` — по слагу. Отличаем по форме значения, иначе UUID уходит в
|
|
387
|
+
// поиск по слагу и не находится — живой прогон это и показал.
|
|
388
|
+
//
|
|
389
|
+
// Опечатка в слаге при этом обязана дать 404, а не завести лишний
|
|
390
|
+
// проект с похожим именем: `create_if_missing: false`.
|
|
391
|
+
...(opts.project
|
|
392
|
+
? UUID_RE.test(opts.project)
|
|
393
|
+
? { project_id: opts.project }
|
|
394
|
+
: { name: opts.project, create_if_missing: false }
|
|
395
|
+
: existing?.project_id
|
|
396
|
+
? { project_id: existing.project_id }
|
|
397
|
+
: { name }),
|
|
398
|
+
organization_slug: organizationSlug,
|
|
399
|
+
target: targeting.target,
|
|
400
|
+
branch: targeting.branch,
|
|
401
|
+
promote: Boolean(opts.promote),
|
|
402
|
+
prebuilt: prebuiltDir !== null,
|
|
403
|
+
framework_hint: setup.framework_hint,
|
|
404
|
+
build_cmd: setup.build_cmd,
|
|
405
|
+
output_dir: setup.output_dir,
|
|
406
|
+
runtime_kind: setup.runtime_kind,
|
|
407
|
+
root_directory: opts.root ?? null,
|
|
408
|
+
env_vars: existing?.env_vars ?? {},
|
|
409
|
+
commit_message: prebuiltDir
|
|
410
|
+
? `CLI prebuilt deploy (${prebuiltDir})`
|
|
411
|
+
: "CLI deploy",
|
|
412
|
+
};
|
|
260
413
|
let session;
|
|
261
414
|
try {
|
|
262
|
-
session = await api.createDeploySession(
|
|
263
|
-
// `--project` ПЕРЕОПРЕДЕЛЯЕТ запись в .layero/project.json: пользователь
|
|
264
|
-
// явно сказал, куда деплоить, и залинкованный проект тут не при чём.
|
|
265
|
-
// Передать оба поля нельзя — сервер выберет project_id и молча уедет
|
|
266
|
-
// не туда, куда просили.
|
|
267
|
-
//
|
|
268
|
-
// Флаг принимает И id, И слаг (так было до переноса оркестрации), а
|
|
269
|
-
// на сервере это разные поля: `project_id` ищется по идентификатору,
|
|
270
|
-
// `name` — по слагу. Отличаем по форме значения, иначе UUID уходит в
|
|
271
|
-
// поиск по слагу и не находится — живой прогон это и показал.
|
|
272
|
-
//
|
|
273
|
-
// Опечатка в слаге при этом обязана дать 404, а не завести лишний
|
|
274
|
-
// проект с похожим именем: `create_if_missing: false`.
|
|
275
|
-
...(opts.project
|
|
276
|
-
? UUID_RE.test(opts.project)
|
|
277
|
-
? { project_id: opts.project }
|
|
278
|
-
: { name: opts.project, create_if_missing: false }
|
|
279
|
-
: existing?.project_id
|
|
280
|
-
? { project_id: existing.project_id }
|
|
281
|
-
: { name }),
|
|
282
|
-
organization_slug: organizationSlug,
|
|
283
|
-
target: targeting.target,
|
|
284
|
-
branch: targeting.branch,
|
|
285
|
-
promote: Boolean(opts.promote),
|
|
286
|
-
prebuilt: prebuiltDir !== null,
|
|
287
|
-
framework_hint: setup.framework_hint,
|
|
288
|
-
build_cmd: setup.build_cmd,
|
|
289
|
-
output_dir: setup.output_dir,
|
|
290
|
-
runtime_kind: setup.runtime_kind,
|
|
291
|
-
root_directory: opts.root ?? null,
|
|
292
|
-
env_vars: existing?.env_vars ?? {},
|
|
293
|
-
commit_message: prebuiltDir
|
|
294
|
-
? `CLI prebuilt deploy (${prebuiltDir})`
|
|
295
|
-
: "CLI deploy",
|
|
296
|
-
});
|
|
415
|
+
session = await api.createDeploySession(sessionPayload);
|
|
297
416
|
}
|
|
298
417
|
catch (err) {
|
|
299
418
|
if (err instanceof ApiError && err.status === 404 && opts.project) {
|
|
@@ -305,7 +424,17 @@ export async function deployCmd(opts) {
|
|
|
305
424
|
if (err instanceof ApiError && err.status === 403) {
|
|
306
425
|
throw new LayeroError("forbidden", `access denied: ${err.body.slice(0, 200)}`, "check that your token has the required scope and that you're a member of the organization");
|
|
307
426
|
}
|
|
308
|
-
|
|
427
|
+
// 412 = у аккаунта нет имени, и платформе некуда положить проект (имя
|
|
428
|
+
// становится слагом личной организации). В интерактивном терминале
|
|
429
|
+
// спрашиваем прямо здесь и повторяем — отправлять в браузер за одним
|
|
430
|
+
// словом значит обрывать ровно тот сценарий, ради которого ставят CLI.
|
|
431
|
+
if (err instanceof ApiError && err.status === 412) {
|
|
432
|
+
await ensureUsername(api);
|
|
433
|
+
session = await api.createDeploySession(sessionPayload);
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
throw err;
|
|
437
|
+
}
|
|
309
438
|
}
|
|
310
439
|
const project = session.project;
|
|
311
440
|
if (project.cli_deploys_enabled === false) {
|
|
@@ -371,14 +500,20 @@ export async function deployCmd(opts) {
|
|
|
371
500
|
expires_in: session.expires_in,
|
|
372
501
|
}, pack.archivePath);
|
|
373
502
|
emit({ event: "uploaded", archive_key: session.source_archive_key });
|
|
374
|
-
const started = await api
|
|
375
|
-
commit_sha: pack.sha256,
|
|
376
|
-
});
|
|
503
|
+
const started = await startWithRepeatedFailureGuard(api, session.session_id, pack.sha256, opts);
|
|
377
504
|
if (!started.deploy_id) {
|
|
378
505
|
throw new LayeroError("deploy_not_started", `deploy session ended as "${started.status}"${started.error ? `: ${started.error}` : ""}`, "re-run `layero deploy`; if it repeats, check the project in the dashboard");
|
|
379
506
|
}
|
|
380
507
|
emit({ event: "deploy_started", deploy_id: started.deploy_id });
|
|
381
508
|
const final = await streamDeployLogs(api, started.deploy_id);
|
|
509
|
+
// Отмена — не отказ. С выделением 'cancelled' в отдельный статус
|
|
510
|
+
// (08.08.2026) прежняя строка напечатала бы «deploy failed (cancelled)»:
|
|
511
|
+
// деплой одновременно и упал, и отменён. Чаще всего причина — вытеснение
|
|
512
|
+
// более новым пушем, и совет «посмотрите логи» тут не по адресу: смотреть
|
|
513
|
+
// надо на деплой-преемник, а не на этот.
|
|
514
|
+
if (final.status === "cancelled") {
|
|
515
|
+
throw new LayeroError("deploy_cancelled", `deploy cancelled${final.error_message ? `: ${final.error_message}` : ""}`, "a newer deploy superseded this one — check the latest deploy in the dashboard");
|
|
516
|
+
}
|
|
382
517
|
if (final.status !== "ready") {
|
|
383
518
|
throw new LayeroError(`deploy_${final.status}`, `deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`, `inspect logs at ${projectUrl(cliCfg.apiUrl, project.id)}`);
|
|
384
519
|
}
|
|
@@ -404,8 +539,8 @@ export async function deployCmd(opts) {
|
|
|
404
539
|
? await resolveReachability(api, deployRow.environment_id)
|
|
405
540
|
: null;
|
|
406
541
|
const liveUrl = promoted || !opts.branch
|
|
407
|
-
? probe?.canonical_url ?? apexUrl
|
|
408
|
-
: probe?.preview_url ?? probe?.canonical_url ?? apexUrl;
|
|
542
|
+
? (probe?.canonical_url ?? apexUrl)
|
|
543
|
+
: (probe?.preview_url ?? probe?.canonical_url ?? apexUrl);
|
|
409
544
|
emit({
|
|
410
545
|
event: "ready",
|
|
411
546
|
url: liveUrl,
|
package/dist/commands/init.js
CHANGED
|
@@ -20,8 +20,8 @@ is required — Layero packs and uploads the local directory directly.
|
|
|
20
20
|
|
|
21
21
|
### First-time auth (one-click device flow)
|
|
22
22
|
|
|
23
|
-
If you're not logged in yet, \`deploy\` (or
|
|
24
|
-
device-flow automatically and emits a JSON line:
|
|
23
|
+
If you're not logged in yet, \`npx layero@latest deploy\` (or \`… login\`)
|
|
24
|
+
starts the browser device-flow automatically and emits a JSON line:
|
|
25
25
|
|
|
26
26
|
\`\`\`json
|
|
27
27
|
{"event":"auth_required","url":"https://app.layero.ru/cli?code=ABCD-1234","user_code":"ABCD-1234"}
|
package/dist/username.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Имя аккаунта (оно же слаг личной организации) — из терминала.
|
|
3
|
+
*
|
|
4
|
+
* WHY (02.08.2026): без имени бэкенд отвечает 412 на создание проекта, а CLI
|
|
5
|
+
* умел только отправить в браузер: «откройте /onboarding и выберите имя». Для
|
|
6
|
+
* инструмента, который весь смысл имеет в том, чтобы не ходить в дашборд, это
|
|
7
|
+
* тупик — и в нём на момент правки сидело 14 активных аккаунтов. Ручка
|
|
8
|
+
* `POST /auth/me/username` была всё это время, `setUsername` лежал в api.ts и
|
|
9
|
+
* не вызывался ниоткуда.
|
|
10
|
+
*
|
|
11
|
+
* Имя НЕ подбирается молча: оно становится слагом личной организации и попадёт
|
|
12
|
+
* в адреса, то есть это выбор человека, а не догадка программы. Мы только
|
|
13
|
+
* предлагаем вариант по почте или логину GitHub — принять или заменить.
|
|
14
|
+
*/
|
|
15
|
+
import readline from "node:readline/promises";
|
|
16
|
+
import chalk from "chalk";
|
|
17
|
+
import { LayeroError, detectMode, emit } from "./agent.js";
|
|
18
|
+
/** Правила совпадают с CHECK-ограничением в БД (V044). */
|
|
19
|
+
const MIN_LEN = 2;
|
|
20
|
+
const MAX_LEN = 32;
|
|
21
|
+
/**
|
|
22
|
+
* Привести произвольную строку к допустимому виду: строчные, только
|
|
23
|
+
* `[a-z0-9-]`, без ведущих/замыкающих и сдвоенных дефисов.
|
|
24
|
+
*
|
|
25
|
+
* Это ПОДСКАЗКА для приглашения ввода, а не валидация — последнее слово всегда
|
|
26
|
+
* за сервером (`checkUsername`), иначе правила разъедутся молча.
|
|
27
|
+
*/
|
|
28
|
+
export function suggestUsername(me) {
|
|
29
|
+
const raw = me.github_login || (me.email ?? "").split("@")[0] || "";
|
|
30
|
+
const slug = raw
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
33
|
+
.replace(/-{2,}/g, "-")
|
|
34
|
+
.replace(/^-+|-+$/g, "")
|
|
35
|
+
.slice(0, MAX_LEN)
|
|
36
|
+
.replace(/-+$/g, "");
|
|
37
|
+
return slug.length >= MIN_LEN ? slug : "";
|
|
38
|
+
}
|
|
39
|
+
async function ask(question, fallback) {
|
|
40
|
+
const rl = readline.createInterface({
|
|
41
|
+
input: process.stdin,
|
|
42
|
+
output: process.stdout,
|
|
43
|
+
});
|
|
44
|
+
try {
|
|
45
|
+
const hint = fallback ? ` [${fallback}]` : "";
|
|
46
|
+
const answer = (await rl.question(`${question}${hint}: `)).trim();
|
|
47
|
+
return answer || fallback;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
rl.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Человеческий текст на код отказа от `/auth/me/username/check`. */
|
|
54
|
+
function reasonText(reason, value) {
|
|
55
|
+
switch (reason) {
|
|
56
|
+
case "taken":
|
|
57
|
+
return `Имя «${value}» уже занято.`;
|
|
58
|
+
case "reserved":
|
|
59
|
+
return `Имя «${value}» зарезервировано платформой.`;
|
|
60
|
+
case "too_short":
|
|
61
|
+
return `Слишком коротко — нужно от ${MIN_LEN} символов.`;
|
|
62
|
+
case "too_long":
|
|
63
|
+
return `Слишком длинно — не больше ${MAX_LEN} символов.`;
|
|
64
|
+
default:
|
|
65
|
+
return `Имя «${value}» не подходит: только строчные латинские буквы, цифры и дефис.`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Вернуть имя аккаунта, спросив его, если не задано.
|
|
70
|
+
*
|
|
71
|
+
* В агентском/неинтерактивном режиме НЕ спрашиваем — там некому отвечать, и
|
|
72
|
+
* приглашение ввода просто повисло бы навсегда. Вместо этого понятный отказ с
|
|
73
|
+
* кодом и командой, которую агент может выполнить сам.
|
|
74
|
+
*/
|
|
75
|
+
export async function ensureUsername(api, me) {
|
|
76
|
+
const current = me ?? (await api.me());
|
|
77
|
+
if (current.username)
|
|
78
|
+
return current.username;
|
|
79
|
+
const mode = detectMode();
|
|
80
|
+
if (!mode.interactive) {
|
|
81
|
+
throw new LayeroError("username_required", "У аккаунта не выбрано имя — без него платформе некуда положить проект.", "задайте его: `layero username <имя>` (строчные латинские буквы, цифры и дефис)");
|
|
82
|
+
}
|
|
83
|
+
console.log(chalk.cyan("\nУ аккаунта ещё нет имени.") +
|
|
84
|
+
chalk.dim("\n Оно станет адресом вашей личной организации, поэтому выбираете вы.\n" +
|
|
85
|
+
" Строчные латинские буквы, цифры и дефис.\n"));
|
|
86
|
+
let fallback = suggestUsername(current);
|
|
87
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
88
|
+
const value = await ask("Имя аккаунта", fallback);
|
|
89
|
+
if (!value)
|
|
90
|
+
continue;
|
|
91
|
+
const check = await api.checkUsername(value);
|
|
92
|
+
if (!check.available) {
|
|
93
|
+
console.log(chalk.yellow(` ${reasonText(check.reason, check.normalized || value)}`));
|
|
94
|
+
// Занятое имя не предлагаем повторно — иначе Enter молча упрётся в то же.
|
|
95
|
+
fallback = "";
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const saved = await api.setUsername(check.normalized);
|
|
99
|
+
console.log(chalk.green(`✓ Имя аккаунта: ${saved.username}`) +
|
|
100
|
+
chalk.dim(` (организация ${saved.organization_slug})`));
|
|
101
|
+
return saved.username;
|
|
102
|
+
}
|
|
103
|
+
throw new LayeroError("username_required", "Имя аккаунта так и не выбрано.", "задайте его: `layero username <имя>`");
|
|
104
|
+
}
|
|
105
|
+
/** `layero username <value>` — неинтерактивный путь для агентов и CI. */
|
|
106
|
+
export async function usernameSetCmd(value, api) {
|
|
107
|
+
const check = await api.checkUsername(value);
|
|
108
|
+
if (!check.available) {
|
|
109
|
+
throw new LayeroError("username_rejected", reasonText(check.reason, check.normalized || value), "выберите другое имя: строчные латинские буквы, цифры и дефис");
|
|
110
|
+
}
|
|
111
|
+
const saved = await api.setUsername(check.normalized);
|
|
112
|
+
emit({
|
|
113
|
+
event: "username_set",
|
|
114
|
+
username: saved.username,
|
|
115
|
+
organization: saved.organization_slug,
|
|
116
|
+
});
|
|
117
|
+
}
|