layero 0.8.23 → 0.8.25
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 +5 -0
- package/dist/api.js +19 -2
- package/dist/bin/layero.js +24 -1
- package/dist/commands/data.js +97 -0
- package/dist/commands/deploy.js +213 -7
- package/dist/commands/init.js +2 -2
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -148,6 +148,11 @@ function renderHuman(event) {
|
|
|
148
148
|
process.stdout.write(`! Failed to set project_type automatically: ${event.error}\n`);
|
|
149
149
|
process.stdout.write(` Build may fail at detect; accept the suggestion in the dashboard if so.\n`);
|
|
150
150
|
break;
|
|
151
|
+
case "repeated_failure_guard":
|
|
152
|
+
// В человеческом режиме подробности печатает сама команда: там они с
|
|
153
|
+
// цветом, в stderr и с вопросом. Дублировать их здесь значило бы
|
|
154
|
+
// показать одно и то же дважды.
|
|
155
|
+
break;
|
|
151
156
|
case "deploy_started":
|
|
152
157
|
process.stdout.write(`→ Building...\n`);
|
|
153
158
|
break;
|
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,16 @@ 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
|
+
dataEnv(projectId) {
|
|
144
|
+
return this.request("GET", `/projects/${projectId}/data-env`);
|
|
145
|
+
}
|
|
129
146
|
/**
|
|
130
147
|
* Значение `null` = «оставить как есть». Благодаря этому добавить одну
|
|
131
148
|
* переменную можно, не читая остальные, — то есть не имея доступа к
|
package/dist/bin/layero.js
CHANGED
|
@@ -15,6 +15,7 @@ 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";
|
|
18
19
|
import { envListCmd, envSetCmd, envUnsetCmd } from "../commands/env.js";
|
|
19
20
|
import { analyticsConnectCmd, analyticsDisconnectCmd, analyticsStatsCmd, analyticsStatusCmd, } from "../commands/analytics.js";
|
|
20
21
|
import { domainsAddCmd, domainsListCmd, domainsPrimaryCmd, domainsRemoveCmd, domainsVerifyCmd, } from "../commands/domains.js";
|
|
@@ -193,6 +194,21 @@ async function main() {
|
|
|
193
194
|
.command("unset <keys...>")
|
|
194
195
|
.description("Удалить переменные.")
|
|
195
196
|
.option("-y, --yes", "не спрашивать подтверждение")).action(async (keys, opts) => envUnsetCmd(keys, { ...opts, json: program.opts().json }));
|
|
197
|
+
const data = program
|
|
198
|
+
.command("data")
|
|
199
|
+
.description("Data API: адрес и публичный ключ базы для фронтенда.");
|
|
200
|
+
data
|
|
201
|
+
.command("env")
|
|
202
|
+
.description("Показать VITE_/NEXT_PUBLIC_ переменные Data API; --write кладёт их в .env.local.")
|
|
203
|
+
.option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)")
|
|
204
|
+
.option("-w, --write", "записать в файл, а не печатать")
|
|
205
|
+
.option("--file <path>", "имя файла (по умолчанию .env.local)")
|
|
206
|
+
.addHelpText("after", "\nПримеры:\n" +
|
|
207
|
+
" $ layero data env # посмотреть\n" +
|
|
208
|
+
" $ layero data env --write # положить в .env.local\n" +
|
|
209
|
+
"\nОтдаётся только ПУБЛИЧНЫЙ ключ — тот, что и так уезжает в бандл.\n" +
|
|
210
|
+
"Секретный ключ платформа не хранит и не отдаёт: он для сервера.")
|
|
211
|
+
.action(async (opts) => dataEnvCmd({ ...opts, json: program.opts().json }));
|
|
196
212
|
const analytics = program
|
|
197
213
|
.command("analytics")
|
|
198
214
|
.description("Яндекс.Метрика: подключение и статистика сайта.");
|
|
@@ -278,7 +294,8 @@ async function main() {
|
|
|
278
294
|
program
|
|
279
295
|
.command("deploy")
|
|
280
296
|
.description("Pack the current directory and deploy it. Framework, build command and output directory are auto-detected.")
|
|
281
|
-
.option("-t, --type <preset>", "
|
|
297
|
+
.option("-t, --type <preset>", "type override — static preset (vite | vitepress | next | astro | cra | sveltekit | nuxt | gatsby | docusaurus | storybook | eleventy | hugo | static) " +
|
|
298
|
+
"or runtime kind for apps the platform RUNS (node_web | python_web | flask | streamlit | gradio | ssr_next; aliases: express, fastapi, django, node, python)")
|
|
282
299
|
.option("--name <name>", "project name (only used on first deploy)")
|
|
283
300
|
.option("--project <id_or_slug>", "deploy into an existing project, ignoring local config")
|
|
284
301
|
.option("-y, --yes", "non-interactive: accept defaults and skip --prod confirmation")
|
|
@@ -292,6 +309,11 @@ async function main() {
|
|
|
292
309
|
"environments come from pushes to a connected repository, not from " +
|
|
293
310
|
"this flag. Kept for backwards compatibility.")
|
|
294
311
|
.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.")
|
|
312
|
+
// Осознанное подтверждение выкатки, когда предыдущие сборки подряд падают
|
|
313
|
+
// с ОДНОЙ И ТОЙ ЖЕ ошибкой. Намеренно НЕ покрывается `--yes`: смысл стопа
|
|
314
|
+
// в том, чтобы прервать автоматический цикл, а `--yes` в скриптах уже
|
|
315
|
+
// стоит по умолчанию и снял бы стоп, ничего не остановив.
|
|
316
|
+
.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)")
|
|
295
317
|
.addHelpText("after", "\nExamples:\n" +
|
|
296
318
|
" $ layero deploy # preview deploy (CLI pseudo-branch), auto-detect framework\n" +
|
|
297
319
|
" $ layero deploy --prod # production deploy (interactive confirm)\n" +
|
|
@@ -299,6 +321,7 @@ async function main() {
|
|
|
299
321
|
" $ layero deploy --promote # preview deploy + pin apex (one-shot publish)\n" +
|
|
300
322
|
" $ layero deploy --branch=staging # preview on a specific branch\n" +
|
|
301
323
|
" $ layero deploy --type vite # force a framework preset\n" +
|
|
324
|
+
" $ layero deploy --type express # Node backend: platform RUNS it, not serves files\n" +
|
|
302
325
|
" $ layero deploy --json # machine-readable output for agents")
|
|
303
326
|
.action(async (opts) => {
|
|
304
327
|
await deployCmd(opts);
|
|
@@ -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
|
+
}
|
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,13 +241,139 @@ 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
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Русская форма числительного. Шесть строк вместо зависимости: в CLI своего
|
|
255
|
+
* плюрализатора нет, а «2 сборок» в стоп-сообщении читается как небрежность
|
|
256
|
+
* ровно там, где нужно доверие. Порог настраивается платформой, поэтому
|
|
257
|
+
* подставить одну форму нельзя — при пороге 2 и при пороге 5 они разные.
|
|
258
|
+
*/
|
|
259
|
+
function pluralRu(n, one, few, many) {
|
|
260
|
+
const a = Math.abs(n) % 100;
|
|
261
|
+
if (a >= 11 && a <= 14)
|
|
262
|
+
return many;
|
|
263
|
+
const b = a % 10;
|
|
264
|
+
if (b === 1)
|
|
265
|
+
return one;
|
|
266
|
+
if (b >= 2 && b <= 4)
|
|
267
|
+
return few;
|
|
268
|
+
return many;
|
|
269
|
+
}
|
|
270
|
+
function parseRepeatedFailure(err) {
|
|
271
|
+
if (!(err instanceof ApiError) || err.status !== 409)
|
|
272
|
+
return null;
|
|
273
|
+
try {
|
|
274
|
+
const detail = JSON.parse(err.body)?.detail;
|
|
275
|
+
return detail?.code === "repeated_failure" ? detail : null;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Запуск сборки со стопом на повторяющейся ошибке.
|
|
283
|
+
*
|
|
284
|
+
* СМЫСЛ СТОПА — ОСТАНОВИТЬ ТОГО, КТО ГОНИТ ВЫКАТКУ ВСЛЕПУЮ. Агент, десятый раз
|
|
285
|
+
* собирающий одно и то же, ошибку обычно НЕ читает: она приходит в конце
|
|
286
|
+
* длинного лога сборки, а он смотрит на код возврата и запускает снова. Поэтому
|
|
287
|
+
* здесь текст ошибки печатается ПЕРВЫМ и отдельно от всего остального, и лишь
|
|
288
|
+
* потом объясняется, что делать.
|
|
289
|
+
*
|
|
290
|
+
* В интерактивном терминале спрашиваем прямо; без TTY (агент, CI) — падаем с
|
|
291
|
+
* отдельным кодом ошибки. Автоматически продолжать нельзя: это ровно тот цикл,
|
|
292
|
+
* который правило и разрывает.
|
|
293
|
+
*/
|
|
294
|
+
async function startWithRepeatedFailureGuard(api, sessionId, commitSha, opts) {
|
|
295
|
+
try {
|
|
296
|
+
return await api.startDeploySession(sessionId, {
|
|
297
|
+
commit_sha: commitSha,
|
|
298
|
+
confirm_repeated_failure: opts.confirmRepeatedFailure === true,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
const detail = parseRepeatedFailure(err);
|
|
303
|
+
if (!detail)
|
|
304
|
+
throw err;
|
|
305
|
+
// Машиночитаемое событие — для агентов в режиме --json.
|
|
306
|
+
// `scope` отвечает на вопрос, который агент задаст первым: «я собирал в
|
|
307
|
+
// этом проекте три раза, откуда десять?». Серия могла набраться суммой по
|
|
308
|
+
// нескольким его проектам — перенос в новый проект правило не обходит.
|
|
309
|
+
const scope = detail.scope === "owner" ? "owner" : "project";
|
|
310
|
+
emit({
|
|
311
|
+
event: "repeated_failure_guard",
|
|
312
|
+
streak: detail.streak,
|
|
313
|
+
threshold: detail.threshold,
|
|
314
|
+
scope,
|
|
315
|
+
failure_stage: detail.failure_stage ?? undefined,
|
|
316
|
+
error: detail.error ?? undefined,
|
|
317
|
+
});
|
|
318
|
+
const errorText = (detail.error || "").trim();
|
|
319
|
+
console.error("");
|
|
320
|
+
console.error(chalk.red.bold(`Стоп: ${detail.streak} ${pluralRu(detail.streak, "сборка", "сборки", "сборок")} ` +
|
|
321
|
+
(scope === "owner"
|
|
322
|
+
? "в ваших проектах упали с одной и той же ошибкой."
|
|
323
|
+
: "подряд упали с одной и той же ошибкой.")));
|
|
324
|
+
if (errorText) {
|
|
325
|
+
console.error("");
|
|
326
|
+
console.error(chalk.red(errorText));
|
|
327
|
+
}
|
|
328
|
+
if (detail.failure_stage) {
|
|
329
|
+
console.error(chalk.dim(`Стадия: ${detail.failure_stage}`));
|
|
330
|
+
}
|
|
331
|
+
console.error("");
|
|
332
|
+
if (scope === "owner") {
|
|
333
|
+
console.error("Ошибка повторяется в разных проектах — значит, дело не в конкретном");
|
|
334
|
+
console.error("проекте, а в коде приложения или в настройках сборки.");
|
|
335
|
+
console.error("");
|
|
336
|
+
}
|
|
337
|
+
console.error("Повторная выкатка без изменений даст тот же результат. Исправьте причину —");
|
|
338
|
+
console.error("или подтвердите, что изменили что-то, влияющее на неё.");
|
|
339
|
+
console.error("");
|
|
340
|
+
const mode = detectMode();
|
|
341
|
+
if (mode.interactive) {
|
|
342
|
+
const rl = readline.createInterface({
|
|
343
|
+
input: process.stdin,
|
|
344
|
+
output: process.stdout,
|
|
345
|
+
});
|
|
346
|
+
try {
|
|
347
|
+
const answer = (await rl.question("Всё равно выкатить? [y/N]: ")).trim().toLowerCase();
|
|
348
|
+
if (answer !== "y" && answer !== "yes") {
|
|
349
|
+
throw new LayeroError("repeated_failure_declined", "deploy cancelled: the same error keeps failing the build", "fix the error above, then run `layero deploy` again");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
rl.close();
|
|
354
|
+
}
|
|
355
|
+
return await api.startDeploySession(sessionId, {
|
|
356
|
+
commit_sha: commitSha,
|
|
357
|
+
confirm_repeated_failure: true,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
throw new LayeroError("repeated_failure", `deploy stopped: the last ${detail.streak} deploys failed with the SAME error` +
|
|
361
|
+
(errorText ? ` — ${errorText}` : ""), "read the error above and fix its cause. Re-running the same deploy will " +
|
|
362
|
+
"fail the same way. If you changed something that affects it, re-run with " +
|
|
363
|
+
"`--confirm-repeated-failure`.");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
201
366
|
export async function deployCmd(opts) {
|
|
202
367
|
const mode = detectMode();
|
|
203
|
-
if (opts.type &&
|
|
204
|
-
|
|
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(", ")})`);
|
|
205
377
|
}
|
|
206
378
|
let cliCfg = await loadConfig();
|
|
207
379
|
if (!cliCfg.token) {
|
|
@@ -340,6 +512,34 @@ export async function deployCmd(opts) {
|
|
|
340
512
|
slug: project.slug,
|
|
341
513
|
});
|
|
342
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
|
+
}
|
|
343
543
|
await persistProjectLinking(cwd, {
|
|
344
544
|
project_id: project.id,
|
|
345
545
|
slug: project.slug,
|
|
@@ -387,14 +587,20 @@ export async function deployCmd(opts) {
|
|
|
387
587
|
expires_in: session.expires_in,
|
|
388
588
|
}, pack.archivePath);
|
|
389
589
|
emit({ event: "uploaded", archive_key: session.source_archive_key });
|
|
390
|
-
const started = await api
|
|
391
|
-
commit_sha: pack.sha256,
|
|
392
|
-
});
|
|
590
|
+
const started = await startWithRepeatedFailureGuard(api, session.session_id, pack.sha256, opts);
|
|
393
591
|
if (!started.deploy_id) {
|
|
394
592
|
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");
|
|
395
593
|
}
|
|
396
594
|
emit({ event: "deploy_started", deploy_id: started.deploy_id });
|
|
397
595
|
const final = await streamDeployLogs(api, started.deploy_id);
|
|
596
|
+
// Отмена — не отказ. С выделением 'cancelled' в отдельный статус
|
|
597
|
+
// (08.08.2026) прежняя строка напечатала бы «deploy failed (cancelled)»:
|
|
598
|
+
// деплой одновременно и упал, и отменён. Чаще всего причина — вытеснение
|
|
599
|
+
// более новым пушем, и совет «посмотрите логи» тут не по адресу: смотреть
|
|
600
|
+
// надо на деплой-преемник, а не на этот.
|
|
601
|
+
if (final.status === "cancelled") {
|
|
602
|
+
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");
|
|
603
|
+
}
|
|
398
604
|
if (final.status !== "ready") {
|
|
399
605
|
throw new LayeroError(`deploy_${final.status}`, `deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`, `inspect logs at ${projectUrl(cliCfg.apiUrl, project.id)}`);
|
|
400
606
|
}
|
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"}
|