layero 0.8.13 → 0.8.15
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/README.md +9 -1
- package/dist/_detection/detect_core.js +393 -0
- package/dist/_detection/spec.js +664 -0
- package/dist/api.js +93 -0
- package/dist/auth.js +1 -1
- package/dist/bin/layero.js +92 -3
- package/dist/commands/analytics.js +167 -0
- package/dist/commands/deploy.js +154 -233
- package/dist/commands/diagnose.js +121 -0
- package/dist/commands/domains.js +220 -0
- package/dist/commands/env.js +113 -0
- package/dist/commands/perf.js +99 -0
- package/dist/commands/token.js +1 -1
- package/dist/generated/api-types.js +5 -0
- package/package.json +2 -2
package/dist/commands/deploy.js
CHANGED
|
@@ -4,12 +4,13 @@ import readline from "node:readline/promises";
|
|
|
4
4
|
import chalk from "chalk";
|
|
5
5
|
import { ApiClient, ApiError, uploadArchive } from "../api.js";
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
|
-
import { loadProjectConfig, persistProjectLinking,
|
|
7
|
+
import { loadProjectConfig, persistProjectLinking, } from "../project-config.js";
|
|
8
8
|
import { packCwd, packDirectory } from "../pack.js";
|
|
9
9
|
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
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
14
|
const VALID_TYPES = new Set([
|
|
14
15
|
"vite",
|
|
15
16
|
"next",
|
|
@@ -104,115 +105,6 @@ function deployTargeting(opts) {
|
|
|
104
105
|
}
|
|
105
106
|
return { target: opts.prod ? "production" : "preview" };
|
|
106
107
|
}
|
|
107
|
-
async function resolveOrCreateProject(api, cwd, opts, existing) {
|
|
108
|
-
const mode = detectMode();
|
|
109
|
-
if (opts.project) {
|
|
110
|
-
const all = await api.listProjects();
|
|
111
|
-
const match = all.find((p) => p.id === opts.project) ??
|
|
112
|
-
all.find((p) => p.slug === opts.project);
|
|
113
|
-
if (!match) {
|
|
114
|
-
throw new LayeroError("project_not_found", `no project with id/slug "${opts.project}"`, "run `layero projects list` to see available projects");
|
|
115
|
-
}
|
|
116
|
-
return { project: match, createdNow: false };
|
|
117
|
-
}
|
|
118
|
-
// Treat the local config as a real link only if it actually carries a
|
|
119
|
-
// project_id. `layero init` scaffolds .layero/project.json with the
|
|
120
|
-
// detected framework/build/output but NO project_id (the project isn't
|
|
121
|
-
// created until the first deploy). Without this guard, deploy would treat
|
|
122
|
-
// that scaffold as an existing link and call GET /projects/undefined → 422
|
|
123
|
-
// (B1 — the documented init→deploy path was broken). When there's no id,
|
|
124
|
-
// fall through to the create path below, which preserves the scaffold's
|
|
125
|
-
// setup fields via persistProjectLinking().
|
|
126
|
-
if (existing && existing.project_id) {
|
|
127
|
-
try {
|
|
128
|
-
const project = await api.getProject(existing.project_id);
|
|
129
|
-
return { project, createdNow: false };
|
|
130
|
-
}
|
|
131
|
-
catch (err) {
|
|
132
|
-
if (err instanceof ApiError && err.status === 404) {
|
|
133
|
-
throw new LayeroError("project_unlinked", `linked project ${existing.project_id} no longer exists or isn't on your account`, `delete ${projectConfigPath(cwd)} and re-run, or run \`layero link <id_or_slug>\``);
|
|
134
|
-
}
|
|
135
|
-
throw err;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
const me = await api.me();
|
|
139
|
-
if (!me.username) {
|
|
140
|
-
throw new LayeroError("username_missing", "no username set on your account", "open https://app.layero.ru/onboarding to pick one, then re-run");
|
|
141
|
-
}
|
|
142
|
-
let organizationSlug = opts.org;
|
|
143
|
-
if (organizationSlug) {
|
|
144
|
-
const orgs = await api.listOrganizations();
|
|
145
|
-
if (!orgs.some((o) => o.slug === organizationSlug)) {
|
|
146
|
-
throw new LayeroError("org_membership_missing", `you're not a member of organization "${organizationSlug}"`, `available: ${orgs.map((o) => o.slug).join(", ") || "(none)"}`);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
const orgs = await api.listOrganizations();
|
|
151
|
-
if (orgs.length === 0) {
|
|
152
|
-
throw new LayeroError("no_organization", "no organization found on your account", "finish onboarding at https://app.layero.ru/onboarding");
|
|
153
|
-
}
|
|
154
|
-
else if (orgs.length === 1) {
|
|
155
|
-
organizationSlug = orgs[0].slug;
|
|
156
|
-
}
|
|
157
|
-
else if (opts.yes || opts.config || !mode.interactive) {
|
|
158
|
-
// Non-interactive: prefer personal, fall back to first.
|
|
159
|
-
organizationSlug =
|
|
160
|
-
orgs.find((o) => o.kind === "personal")?.slug ?? orgs[0].slug;
|
|
161
|
-
}
|
|
162
|
-
else {
|
|
163
|
-
console.log(chalk.cyan("which organization?"));
|
|
164
|
-
orgs.forEach((o, i) => {
|
|
165
|
-
const tag = o.kind === "personal" ? "personal" : "team";
|
|
166
|
-
console.log(` ${i + 1}. ${o.slug} (${tag}, ${o.my_role})`);
|
|
167
|
-
});
|
|
168
|
-
const choiceRaw = await prompt("choose number", "1");
|
|
169
|
-
const idx = Number(choiceRaw) - 1;
|
|
170
|
-
if (Number.isNaN(idx) || idx < 0 || idx >= orgs.length) {
|
|
171
|
-
throw new LayeroError("invalid_choice", `invalid org choice "${choiceRaw}"`, "enter a number from the list");
|
|
172
|
-
}
|
|
173
|
-
organizationSlug = orgs[idx].slug;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
const fallbackName = path.basename(cwd);
|
|
177
|
-
const name = opts.name ??
|
|
178
|
-
(opts.yes || opts.config || !mode.interactive
|
|
179
|
-
? fallbackName
|
|
180
|
-
: await prompt("project name", fallbackName));
|
|
181
|
-
const project = await api.createCliProject({
|
|
182
|
-
name,
|
|
183
|
-
framework_hint: opts.type,
|
|
184
|
-
organization_slug: organizationSlug,
|
|
185
|
-
});
|
|
186
|
-
return { project, createdNow: true };
|
|
187
|
-
}
|
|
188
|
-
async function packAndUpload(api, cwd, project, prebuiltDir) {
|
|
189
|
-
const pack = prebuiltDir
|
|
190
|
-
? await packDirectory(path.resolve(cwd, prebuiltDir), project.slug)
|
|
191
|
-
: await packCwd(cwd, project.slug);
|
|
192
|
-
emit({
|
|
193
|
-
event: "packing",
|
|
194
|
-
files: pack.fileCount,
|
|
195
|
-
bytes: pack.size,
|
|
196
|
-
sha256: pack.sha256,
|
|
197
|
-
...(prebuiltDir ? { prebuilt_dir: prebuiltDir } : {}),
|
|
198
|
-
});
|
|
199
|
-
// A gitignored lockfile is force-included so the build can do a frozen
|
|
200
|
-
// install; warn (to stderr, off the --json stdout stream) that the ignore
|
|
201
|
-
// rule was overridden for it.
|
|
202
|
-
if (pack.forcedLockfiles?.length) {
|
|
203
|
-
process.stderr.write(chalk.yellow(`! including gitignored lockfile(s) so the build uses a frozen install: ` +
|
|
204
|
-
`${pack.forcedLockfiles.join(", ")}\n`));
|
|
205
|
-
}
|
|
206
|
-
const init = await api.initUpload(project.id);
|
|
207
|
-
emit({ event: "uploading" });
|
|
208
|
-
await uploadArchive(init, pack.archivePath);
|
|
209
|
-
emit({ event: "uploaded", archive_key: init.source_archive_key });
|
|
210
|
-
return {
|
|
211
|
-
archive_key: init.source_archive_key,
|
|
212
|
-
commit_sha: pack.sha256,
|
|
213
|
-
archivePath: pack.archivePath,
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
108
|
const PREBUILT_AUTO_DIRS = [
|
|
217
109
|
"dist",
|
|
218
110
|
"build",
|
|
@@ -318,35 +210,13 @@ export async function deployCmd(opts) {
|
|
|
318
210
|
throw new LayeroError("auth_required", "No credentials in CI. Create a token at https://app.layero.ru/settings/cli " +
|
|
319
211
|
"and pass it as the LAYERO_TOKEN environment variable.", "set_layero_token");
|
|
320
212
|
}
|
|
321
|
-
// Not authenticated yet. Kick off the browser device-login flow inline
|
|
322
|
-
// and poll, exactly as the docs describe (B5/I4) — no separate `layero
|
|
323
|
-
// login` step required. In JSON/agent mode this emits `auth_required`
|
|
324
|
-
// so the caller can render the link and keep waiting.
|
|
325
213
|
cliCfg = await runDeviceLogin(cliCfg);
|
|
326
214
|
}
|
|
327
215
|
const api = new ApiClient(cliCfg);
|
|
328
216
|
const cwd = process.cwd();
|
|
329
217
|
const existing = await loadProjectConfig(cwd);
|
|
330
|
-
|
|
331
|
-
let project = created;
|
|
332
|
-
if (project.cli_deploys_enabled === false) {
|
|
333
|
-
throw new LayeroError("cli_deploys_disabled", `CLI deploys are disabled on project "${project.slug}"`, "enable them in project settings, or remove --project to use a different project");
|
|
334
|
-
}
|
|
335
|
-
// Prompt before overwriting production — only in interactive mode.
|
|
336
|
-
if (opts.prod && !opts.yes && !opts.branch && mode.interactive) {
|
|
337
|
-
const ok = await confirm(`deploy to production (https://${project.apex_hostname})?`);
|
|
338
|
-
if (!ok) {
|
|
339
|
-
console.log(chalk.yellow("aborted."));
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
// Prebuilt deploys: caller has already built the artifact and points at
|
|
344
|
-
// its directory. Setup-config detection is skipped (we report a synthetic
|
|
345
|
-
// static setup so completeSetup gets *some* values and the dashboard
|
|
346
|
-
// shows the project is configured).
|
|
218
|
+
// --- Всё, что требует файловой системы, делаем сами. Остальное — сервер.
|
|
347
219
|
const prebuiltDir = await resolvePrebuiltDir(cwd, opts.prebuilt);
|
|
348
|
-
// Resolve setup config (auto-detect + overrides). Done eagerly so the
|
|
349
|
-
// user sees what we detected before any network I/O.
|
|
350
220
|
const setup = prebuiltDir
|
|
351
221
|
? {
|
|
352
222
|
framework_hint: "static",
|
|
@@ -358,145 +228,196 @@ export async function deployCmd(opts) {
|
|
|
358
228
|
if (prebuiltDir) {
|
|
359
229
|
emit({ event: "prebuilt", dir: prebuiltDir });
|
|
360
230
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
231
|
+
// Организацию выбирает человек — но только когда есть из чего выбирать и
|
|
232
|
+
// есть кому отвечать. В агентском режиме и в CI спрашивать некого, там
|
|
233
|
+
// политику применяет сервер (одна организация → она, несколько → личная).
|
|
234
|
+
const willCreate = !opts.project && !existing?.project_id;
|
|
235
|
+
let organizationSlug = opts.org;
|
|
236
|
+
if (willCreate && !organizationSlug && mode.interactive && !opts.yes) {
|
|
237
|
+
const orgs = await api.listOrganizations();
|
|
238
|
+
if (orgs.length > 1) {
|
|
239
|
+
console.log(chalk.cyan("which organization?"));
|
|
240
|
+
orgs.forEach((o, i) => {
|
|
241
|
+
const tag = o.kind === "personal" ? "personal" : "team";
|
|
242
|
+
console.log(` ${i + 1}. ${o.slug} (${tag}, ${o.my_role})`);
|
|
243
|
+
});
|
|
244
|
+
const choiceRaw = await prompt("choose number", "1");
|
|
245
|
+
const idx = Number(choiceRaw) - 1;
|
|
246
|
+
if (Number.isNaN(idx) || idx < 0 || idx >= orgs.length) {
|
|
247
|
+
throw new LayeroError("invalid_choice", `invalid org choice "${choiceRaw}"`, "enter a number from the list");
|
|
248
|
+
}
|
|
249
|
+
organizationSlug = orgs[idx].slug;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const fallbackName = path.basename(cwd);
|
|
253
|
+
const name = opts.name ??
|
|
254
|
+
(willCreate && mode.interactive && !opts.yes && !opts.config
|
|
255
|
+
? await prompt("project name", fallbackName)
|
|
256
|
+
: fallbackName);
|
|
257
|
+
const targeting = deployTargeting(opts);
|
|
258
|
+
// --- Одно обращение вместо пяти: резолв или создание проекта, настройка,
|
|
259
|
+
// тип рантайма, root и выдача адреса для загрузки архива.
|
|
260
|
+
let session;
|
|
261
|
+
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
|
+
});
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
if (err instanceof ApiError && err.status === 404 && opts.project) {
|
|
300
|
+
// Явно указанный проект не найден. Код ошибки сохраняем прежним
|
|
301
|
+
// (`project_not_found`): на него смотрят агенты и наш Action, и
|
|
302
|
+
// подменять его на `internal` значит ломать их обработку.
|
|
303
|
+
throw new LayeroError("project_not_found", `no project with id/slug "${opts.project}"`, "run `layero projects list` to see available projects");
|
|
304
|
+
}
|
|
305
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
306
|
+
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
|
+
}
|
|
308
|
+
throw err;
|
|
309
|
+
}
|
|
310
|
+
const project = session.project;
|
|
311
|
+
if (project.cli_deploys_enabled === false) {
|
|
312
|
+
throw new LayeroError("cli_deploys_disabled", `CLI deploys are disabled on project "${project.slug}"`, "enable them in project settings, or remove --project to use a different project");
|
|
313
|
+
}
|
|
314
|
+
emit(session.created_project
|
|
315
|
+
? {
|
|
369
316
|
event: "project_created",
|
|
370
317
|
project_id: project.id,
|
|
371
318
|
slug: project.slug,
|
|
372
319
|
organization: project.organization.slug,
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
else {
|
|
376
|
-
emit({
|
|
320
|
+
}
|
|
321
|
+
: {
|
|
377
322
|
event: "project_linked",
|
|
378
323
|
project_id: project.id,
|
|
379
324
|
slug: project.slug,
|
|
380
325
|
});
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
// that drops a next.config later keeps user's choice.
|
|
402
|
-
if (setup.runtime_kind) {
|
|
403
|
-
try {
|
|
404
|
-
project = await api.setRuntimeType(project.id, setup.runtime_kind);
|
|
405
|
-
emit({ event: "runtime_type_applied", project_type: setup.runtime_kind });
|
|
406
|
-
}
|
|
407
|
-
catch (err) {
|
|
408
|
-
// Non-fatal: the build will fall back to the dashboard suggestion
|
|
409
|
-
// flow exactly as it did before this CLI fix. Tell the user what
|
|
410
|
-
// happened so they don't burn a deploy on a surprise crash.
|
|
411
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
412
|
-
emit({ event: "runtime_type_apply_failed", error: msg });
|
|
413
|
-
}
|
|
326
|
+
emit({ event: "setup_applied" });
|
|
327
|
+
await persistProjectLinking(cwd, {
|
|
328
|
+
project_id: project.id,
|
|
329
|
+
slug: project.slug,
|
|
330
|
+
organization_slug: project.organization.slug,
|
|
331
|
+
apex_hostname: project.apex_hostname,
|
|
332
|
+
}, setup.framework_hint);
|
|
333
|
+
// Подтверждение спрашиваем ЗДЕСЬ, а не раньше: адрес апекса известен
|
|
334
|
+
// только от сервера, а сборка ещё не запущена — отказ ничего не стоит.
|
|
335
|
+
// Для только что созданного проекта вопрос бессмыслен: перезаписывать
|
|
336
|
+
// нечего.
|
|
337
|
+
if (opts.prod &&
|
|
338
|
+
!opts.yes &&
|
|
339
|
+
!opts.branch &&
|
|
340
|
+
mode.interactive &&
|
|
341
|
+
!session.created_project) {
|
|
342
|
+
const ok = await confirm(`deploy to production (https://${project.apex_hostname})?`);
|
|
343
|
+
if (!ok) {
|
|
344
|
+
console.log(chalk.yellow("aborted."));
|
|
345
|
+
return;
|
|
414
346
|
}
|
|
415
347
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
// GitHub-pushed or hook-triggered build uses the new subdir.
|
|
419
|
-
project = await api.updateProject(project.id, { root_directory: opts.root });
|
|
420
|
-
}
|
|
421
|
-
let upload = null;
|
|
348
|
+
// --- Упаковка и загрузка: единственное, что клиент обязан делать сам.
|
|
349
|
+
let archivePath = null;
|
|
422
350
|
try {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
351
|
+
const pack = prebuiltDir
|
|
352
|
+
? await packDirectory(path.resolve(cwd, prebuiltDir), project.slug)
|
|
353
|
+
: await packCwd(cwd, project.slug);
|
|
354
|
+
archivePath = pack.archivePath;
|
|
355
|
+
emit({
|
|
356
|
+
event: "packing",
|
|
357
|
+
files: pack.fileCount,
|
|
358
|
+
bytes: pack.size,
|
|
359
|
+
sha256: pack.sha256,
|
|
360
|
+
...(prebuiltDir ? { prebuilt_dir: prebuiltDir } : {}),
|
|
433
361
|
});
|
|
434
|
-
|
|
435
|
-
|
|
362
|
+
if (pack.forcedLockfiles?.length) {
|
|
363
|
+
process.stderr.write(chalk.yellow(`! including gitignored lockfile(s) so the build uses a frozen install: ` +
|
|
364
|
+
`${pack.forcedLockfiles.join(", ")}\n`));
|
|
365
|
+
}
|
|
366
|
+
emit({ event: "uploading" });
|
|
367
|
+
await uploadArchive({
|
|
368
|
+
upload_url: session.upload_url,
|
|
369
|
+
headers: session.upload_headers,
|
|
370
|
+
source_archive_key: session.source_archive_key,
|
|
371
|
+
expires_in: session.expires_in,
|
|
372
|
+
}, pack.archivePath);
|
|
373
|
+
emit({ event: "uploaded", archive_key: session.source_archive_key });
|
|
374
|
+
const started = await api.startDeploySession(session.session_id, {
|
|
375
|
+
commit_sha: pack.sha256,
|
|
376
|
+
});
|
|
377
|
+
if (!started.deploy_id) {
|
|
378
|
+
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
|
+
}
|
|
380
|
+
emit({ event: "deploy_started", deploy_id: started.deploy_id });
|
|
381
|
+
const final = await streamDeployLogs(api, started.deploy_id);
|
|
436
382
|
if (final.status !== "ready") {
|
|
437
383
|
throw new LayeroError(`deploy_${final.status}`, `deploy failed (${final.status})${final.error_message ? `: ${final.error_message}` : ""}`, `inspect logs at ${projectUrl(cliCfg.apiUrl, project.id)}`);
|
|
438
384
|
}
|
|
439
|
-
// --promote:
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
// command. Best-effort: a failure here doesn't fail the deploy
|
|
443
|
-
// (the artifact is good; promote can be retried via `layero promote`).
|
|
385
|
+
// --promote: пин апекса на эту сборку. Промоут остаётся на клиенте
|
|
386
|
+
// осознанно — сборка асинхронна, а сервер применяет промоут только
|
|
387
|
+
// после активации (см. AGENT-01, известные ограничения).
|
|
444
388
|
let promoted = false;
|
|
445
389
|
if (opts.promote) {
|
|
446
390
|
try {
|
|
447
|
-
await api.promoteDeploy(project.id,
|
|
391
|
+
await api.promoteDeploy(project.id, started.deploy_id);
|
|
448
392
|
promoted = true;
|
|
449
393
|
}
|
|
450
394
|
catch (err) {
|
|
451
395
|
const msg = err instanceof Error ? err.message : String(err);
|
|
452
396
|
console.warn(chalk.yellow(`warning: deploy succeeded but promote failed — ${msg}.\n` +
|
|
453
|
-
` retry with: layero promote ${
|
|
397
|
+
` retry with: layero promote ${started.deploy_id.slice(0, 8)}`));
|
|
454
398
|
}
|
|
455
399
|
}
|
|
456
|
-
// Where is this build actually reachable? Ask the backend rather than
|
|
457
|
-
// guessing — it knows the live public URL (apex once it's the production
|
|
458
|
-
// pointer), the off-CDN preview URL that's reachable immediately, and
|
|
459
|
-
// how far along CDN propagation is. Note: a plain `layero deploy` of a
|
|
460
|
-
// CLI project auto-promotes to the apex on activate (no `--prod` /
|
|
461
|
-
// `promote` needed), so the apex IS the destination for the common case.
|
|
462
400
|
const dashboardUrl = projectUrl(cliCfg.apiUrl, project.id);
|
|
463
401
|
const apexUrl = `https://${project.apex_hostname}`;
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
// отдавали off-CDN превью, потому что свежий апекс 10-15 минут отвечал
|
|
469
|
-
// 404, а runtime-приложения на апексе вообще не принимали POST (CDN резал
|
|
470
|
-
// не-GET). После EDGE-02 CDN перед пользовательскими зонами нет: апекс
|
|
471
|
-
// валиден с момента создания проекта (wildcard-запись + wildcard-серт), а
|
|
472
|
-
// `cdn_ready` бекенд отдаёт false НАВСЕГДА — строки в `cdn_hostnames` у
|
|
473
|
-
// новых проектов не появляется вовсе.
|
|
474
|
-
//
|
|
475
|
-
// Из-за этого условие не выполнялось никогда, и после успешной публикации
|
|
476
|
-
// CLI выдавал ссылку на дашборд вместо адреса сайта. Проверено вживую
|
|
477
|
-
// 2026-07-26: `layero deploy` вернул `url: https://app.layero.ru/projects/…`.
|
|
478
|
-
//
|
|
479
|
-
// Апекс — адрес по умолчанию; превью остаётся для деплоя ветки, которую
|
|
480
|
-
// не промоутили: там апекс ведёт на другую сборку.
|
|
402
|
+
const deployRow = await api.getDeploy(started.deploy_id);
|
|
403
|
+
const probe = deployRow.environment_id
|
|
404
|
+
? await resolveReachability(api, deployRow.environment_id)
|
|
405
|
+
: null;
|
|
481
406
|
const liveUrl = promoted || !opts.branch
|
|
482
407
|
? probe?.canonical_url ?? apexUrl
|
|
483
408
|
: probe?.preview_url ?? probe?.canonical_url ?? apexUrl;
|
|
484
409
|
emit({
|
|
485
410
|
event: "ready",
|
|
486
411
|
url: liveUrl,
|
|
487
|
-
deploy_id:
|
|
412
|
+
deploy_id: started.deploy_id,
|
|
488
413
|
preview_url: probe?.preview_url ?? undefined,
|
|
489
414
|
dashboard_url: dashboardUrl,
|
|
490
|
-
// `available` от пробы, а НЕ `cdn_ready`: последний после EDGE-02
|
|
491
|
-
// остаётся false навсегда, и потребитель JSON-вывода ждал бы события,
|
|
492
|
-
// которого не будет. ETA пропагации убран по той же причине —
|
|
493
|
-
// распространять больше нечего.
|
|
494
415
|
edge_ready: probe ? probe.available : undefined,
|
|
495
416
|
});
|
|
496
417
|
}
|
|
497
418
|
finally {
|
|
498
|
-
if (
|
|
499
|
-
await fs.unlink(
|
|
419
|
+
if (archivePath) {
|
|
420
|
+
await fs.unlink(archivePath).catch(() => undefined);
|
|
500
421
|
}
|
|
501
422
|
}
|
|
502
423
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { ApiClient, ApiError } from "../api.js";
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
4
|
+
import { loadProjectConfig } from "../project-config.js";
|
|
5
|
+
import { LayeroError, detectMode, emit } from "../agent.js";
|
|
6
|
+
async function resolveDeployId(api, opts, cwd) {
|
|
7
|
+
if (opts.deploy) {
|
|
8
|
+
// Явно указанный деплой: проект знать не обязательно, ручка работает
|
|
9
|
+
// по самому id.
|
|
10
|
+
return { deployId: opts.deploy, projectId: "" };
|
|
11
|
+
}
|
|
12
|
+
const linked = await loadProjectConfig(cwd);
|
|
13
|
+
const ref = opts.project ?? linked?.project_id;
|
|
14
|
+
if (!ref) {
|
|
15
|
+
throw new LayeroError("project_unknown", "не понятно, какой проект смотреть", "запусти из каталога проекта, либо передай --project <id|slug> или --deploy <id>");
|
|
16
|
+
}
|
|
17
|
+
const project = await api.getProject(ref).catch((err) => {
|
|
18
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
19
|
+
throw new LayeroError("project_not_found", `нет проекта с id/slug "${ref}"`, "посмотри список: layero projects list");
|
|
20
|
+
}
|
|
21
|
+
throw err;
|
|
22
|
+
});
|
|
23
|
+
const deploys = await api.listProjectDeploys(project.id);
|
|
24
|
+
if (!deploys.length) {
|
|
25
|
+
throw new LayeroError("no_deploys", `у проекта "${project.slug}" ещё нет ни одного деплоя`, "запусти `layero deploy`");
|
|
26
|
+
}
|
|
27
|
+
// Берём САМЫЙ СВЕЖИЙ деплой — он и есть текущее состояние проекта.
|
|
28
|
+
//
|
|
29
|
+
// Соблазнительно искать «последний неуспешный»: вопрос-то звучит как
|
|
30
|
+
// «почему упало». Но так команда врёт после починки: пользователь
|
|
31
|
+
// исправил код, передеплоил успешно, спрашивает — и снова видит старую
|
|
32
|
+
// ошибку, которой уже нет. Поймано живым прогоном.
|
|
33
|
+
//
|
|
34
|
+
// Разобрать конкретную старую сборку по-прежнему можно: --deploy <id>.
|
|
35
|
+
const target = deploys[0];
|
|
36
|
+
return { deployId: target.id, projectId: project.id };
|
|
37
|
+
}
|
|
38
|
+
export async function diagnoseCmd(opts) {
|
|
39
|
+
const mode = detectMode();
|
|
40
|
+
const cfg = await loadConfig();
|
|
41
|
+
if (!cfg.token) {
|
|
42
|
+
throw new LayeroError("auth_required", "нужен вход", "выполни `layero login` или задай LAYERO_TOKEN");
|
|
43
|
+
}
|
|
44
|
+
const api = new ApiClient(cfg);
|
|
45
|
+
const { deployId } = await resolveDeployId(api, opts, process.cwd());
|
|
46
|
+
const d = await api.getDeployDiagnosis(deployId);
|
|
47
|
+
if (mode.json) {
|
|
48
|
+
emit({ event: "diagnosis", ...d });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const head = d.status === "ready"
|
|
52
|
+
? chalk.green(`деплой ${deployId.slice(0, 8)} — успешен`)
|
|
53
|
+
: chalk.red(`деплой ${deployId.slice(0, 8)} — ${d.status ?? "неизвестно"}`);
|
|
54
|
+
console.log(head + (d.stage ? chalk.dim(` (этап: ${d.stage})`) : ""));
|
|
55
|
+
if (d.verdict) {
|
|
56
|
+
console.log(`\n${chalk.bold("причина:")} ${d.verdict}`);
|
|
57
|
+
}
|
|
58
|
+
if (d.runtime_state) {
|
|
59
|
+
const idle = d.runtime_state === "paused" || d.runtime_state === "suspended";
|
|
60
|
+
console.log(`${chalk.bold("рантайм:")} ${d.runtime_state}` +
|
|
61
|
+
(idle ? chalk.dim(" — приложение спит, это штатно (scale-to-zero)") : ""));
|
|
62
|
+
}
|
|
63
|
+
if (d.build_log_excerpt?.length) {
|
|
64
|
+
console.log(`\n${chalk.bold("лог сборки")}${d.build_error_found ? chalk.dim(" (вокруг ошибки)") : chalk.dim(" (хвост)")}:`);
|
|
65
|
+
for (const line of d.build_log_excerpt)
|
|
66
|
+
console.log(" " + line);
|
|
67
|
+
}
|
|
68
|
+
if (d.runtime_log_excerpt?.length) {
|
|
69
|
+
console.log(`\n${chalk.bold("лог приложения")}:`);
|
|
70
|
+
for (const line of d.runtime_log_excerpt)
|
|
71
|
+
console.log(" " + line);
|
|
72
|
+
}
|
|
73
|
+
if (d.truncated) {
|
|
74
|
+
console.log(chalk.dim("\n(вывод укорочен; полный лог — в панели проекта)"));
|
|
75
|
+
}
|
|
76
|
+
if (d.status !== "ready") {
|
|
77
|
+
console.log(chalk.dim(`\nчто дальше: ${(d.next_actions ?? []).join(", ")}`));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function logsCmd(opts) {
|
|
81
|
+
const mode = detectMode();
|
|
82
|
+
const cfg = await loadConfig();
|
|
83
|
+
if (!cfg.token) {
|
|
84
|
+
throw new LayeroError("auth_required", "нужен вход", "выполни `layero login` или задай LAYERO_TOKEN");
|
|
85
|
+
}
|
|
86
|
+
const api = new ApiClient(cfg);
|
|
87
|
+
const { deployId } = await resolveDeployId(api, opts, process.cwd());
|
|
88
|
+
if (opts.runtime) {
|
|
89
|
+
const rt = await api.getRuntimeLogs(deployId, opts.tail ?? 100);
|
|
90
|
+
if (mode.json) {
|
|
91
|
+
emit({ event: "runtime_logs", status: rt.status, lines: rt.lines });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!rt.lines.length) {
|
|
95
|
+
console.log(chalk.dim(rt.status === "cold"
|
|
96
|
+
? "приложение ещё ни разу не запускалось — логов нет"
|
|
97
|
+
: "логов нет"));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
for (const l of rt.lines) {
|
|
101
|
+
console.log(l.ts ? `${chalk.dim(l.ts)} ${l.text}` : l.text);
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// Лог сборки. Читаем разом, без long-poll: `layero deploy` уже стримит
|
|
106
|
+
// живую сборку, а эта команда нужна, когда всё закончилось.
|
|
107
|
+
const out = await api.pollLogs(deployId, 0);
|
|
108
|
+
if (mode.json) {
|
|
109
|
+
emit({ event: "build_logs", status: out.status, lines: out.lines });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!out.lines.length) {
|
|
113
|
+
console.log(chalk.dim("логов нет"));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
for (const l of out.lines)
|
|
117
|
+
console.log(l.line);
|
|
118
|
+
if (out.error_message) {
|
|
119
|
+
console.log(chalk.red(`\n${out.error_message}`));
|
|
120
|
+
}
|
|
121
|
+
}
|