layero 0.8.12 → 0.8.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -59,6 +59,20 @@ export class ApiClient {
59
59
  organization_slug: input.organization_slug,
60
60
  });
61
61
  }
62
+ /**
63
+ * Открыть сессию деплоя (AGENT-01/04).
64
+ *
65
+ * Один вызов вместо пяти: платформа сама решает, создавать ли проект,
66
+ * применяет настройку и выдаёт адрес для загрузки архива. `commit_sha`
67
+ * здесь не передаём — он известен только после упаковки, а паковать до
68
+ * проверки прав значит зря жечь время пользователя на отказе.
69
+ */
70
+ createDeploySession(input) {
71
+ return this.request("POST", "/deploy-sessions", input);
72
+ }
73
+ startDeploySession(sessionId, input) {
74
+ return this.request("POST", `/deploy-sessions/${sessionId}/start`, input);
75
+ }
62
76
  initUpload(projectId) {
63
77
  return this.request("POST", `/projects/${projectId}/uploads`);
64
78
  }
@@ -83,6 +97,85 @@ export class ApiClient {
83
97
  probeEnvironment(environmentId) {
84
98
  return this.request("GET", `/environments/${environmentId}/probe`);
85
99
  }
100
+ /** Одна сборка. Нужна, чтобы узнать её окружение для probe. */
101
+ getDeploy(deployId) {
102
+ return this.request("GET", `/deploys/${deployId}`);
103
+ }
104
+ /**
105
+ * Диагностика деплоя (AGENT-07): разобранная причина + окрестность
106
+ * ошибки. Не сырой лог — платформа уже выбрала из него значимое.
107
+ */
108
+ getDeployDiagnosis(deployId) {
109
+ return this.request("GET", `/deploys/${deployId}/diagnosis`);
110
+ }
111
+ getRuntimeLogs(deployId, tail = 100) {
112
+ return this.request("GET", `/deploys/${deployId}/runtime/logs?tail=${tail}`);
113
+ }
114
+ // --- Переменные окружения (AGENT-13) --------------------------------
115
+ /**
116
+ * Платформа отдаёт маску, длину и короткий префикс — plaintext не
117
+ * возвращается ни при каких условиях.
118
+ */
119
+ listEnvVars(projectId) {
120
+ return this.request("GET", `/projects/${projectId}/env`);
121
+ }
122
+ /**
123
+ * Значение `null` = «оставить как есть». Благодаря этому добавить одну
124
+ * переменную можно, не читая остальные, — то есть не имея доступа к
125
+ * чужим секретам.
126
+ */
127
+ replaceEnvVars(projectId, vars) {
128
+ return this.request("PUT", `/projects/${projectId}/env`, { vars });
129
+ }
130
+ // --- Метрика (AGENT-12) ---------------------------------------------
131
+ getMetrikaIntegration(projectId) {
132
+ return this.request("GET", `/projects/${projectId}/integrations/metrika`);
133
+ }
134
+ /**
135
+ * Возвращает ссылку на OAuth Яндекса. Открыть её должен ЧЕЛОВЕК —
136
+ * ни CLI, ни агент за него авторизоваться не могут.
137
+ */
138
+ connectMetrika(projectId, branch) {
139
+ return this.request("POST", `/projects/${projectId}/integrations/metrika/connect`, branch ? { branch_name: branch } : {});
140
+ }
141
+ getMetrikaStats(projectId, period = "7d") {
142
+ return this.request("GET", `/projects/${projectId}/integrations/metrika/stats?period=${encodeURIComponent(period)}`);
143
+ }
144
+ disconnectMetrika(projectId) {
145
+ return this.request("DELETE", `/projects/${projectId}/integrations/metrika`);
146
+ }
147
+ // --- Замеры (AGENT-11) ----------------------------------------------
148
+ /** Запустить замер активного деплоя. Не ждёт: прогон асинхронный. */
149
+ startPerfCheck(projectId) {
150
+ return this.request("POST", `/projects/${projectId}/perf-check`);
151
+ }
152
+ /** Результат последнего замера со сравнением с предыдущим деплоем. */
153
+ getPerfCheck(projectId) {
154
+ return this.request("GET", `/projects/${projectId}/perf-check`);
155
+ }
156
+ // --- Домены (AGENT-09) ---------------------------------------------
157
+ listDomains(projectId) {
158
+ return this.request("GET", `/projects/${projectId}/domains`);
159
+ }
160
+ /**
161
+ * Схему и путь бэкенд стрипает сам, так что вставленный из адресной
162
+ * строки `https://shop.example.com/page` доедет как `shop.example.com`.
163
+ */
164
+ addDomain(projectId, domain) {
165
+ return this.request("POST", `/projects/${projectId}/domains`, { domain });
166
+ }
167
+ getDomainInstructions(projectId, domainId) {
168
+ return this.request("GET", `/projects/${projectId}/domains/${domainId}/instructions`);
169
+ }
170
+ verifyDomain(projectId, domainId) {
171
+ return this.request("POST", `/projects/${projectId}/domains/${domainId}/verify`);
172
+ }
173
+ makeDomainPrimary(projectId, domainId) {
174
+ return this.request("POST", `/projects/${projectId}/domains/${domainId}/primary`);
175
+ }
176
+ removeDomain(projectId, domainId) {
177
+ return this.request("DELETE", `/projects/${projectId}/domains/${domainId}`);
178
+ }
86
179
  pollLogs(deployId, afterId) {
87
180
  return this.request("GET", `/deploys/${deployId}/logs?after_id=${afterId}`);
88
181
  }
package/dist/auth.js CHANGED
@@ -48,7 +48,7 @@ export async function runDeviceLogin(cfg) {
48
48
  cfg.token = poll.token;
49
49
  const probe = new ApiClient(cfg);
50
50
  const me = await probe.me();
51
- cfg.user = { id: me.id, username: me.username, email: me.email };
51
+ cfg.user = { id: me.id, username: me.username ?? null, email: me.email };
52
52
  await saveConfig(cfg);
53
53
  if (mode.json) {
54
54
  emit({ event: "authorized", user: me.username ?? me.email ?? me.id });
@@ -16,6 +16,11 @@ import { hooksCreateCmd, hooksDeleteCmd, hooksListCmd } from "../commands/hooks.
16
16
  import { loginCmd } from "../commands/login.js";
17
17
  import { orgsListCmd } from "../commands/orgs.js";
18
18
  import { initCmd } from "../commands/init.js";
19
+ import { diagnoseCmd, logsCmd } from "../commands/diagnose.js";
20
+ import { perfCheckCmd, perfShowCmd } from "../commands/perf.js";
21
+ import { envListCmd, envSetCmd, envUnsetCmd } from "../commands/env.js";
22
+ import { analyticsConnectCmd, analyticsDisconnectCmd, analyticsStatsCmd, analyticsStatusCmd, } from "../commands/analytics.js";
23
+ import { domainsAddCmd, domainsListCmd, domainsPrimaryCmd, domainsRemoveCmd, domainsVerifyCmd, } from "../commands/domains.js";
19
24
  import { LayeroError, detectMode, emit } from "../agent.js";
20
25
  import { notifyIfOutdated } from "../update-notifier.js";
21
26
  // Read version from the shipped package.json (two levels up from dist/bin/).
@@ -144,7 +149,10 @@ async function main() {
144
149
  });
145
150
  program
146
151
  .command("rollback")
147
- .description("Re-activate the previous successful deploy on the project's default branch.")
152
+ .description("Re-activate the previous successful deploy. Since 27 Jul 2026 it also " +
153
+ "moves the production pointer, so the apex comes back too when it is " +
154
+ "served by this branch. For a SPECIFIC older deploy use " +
155
+ "`layero promote <commit-sha>`.")
148
156
  .option("--project <id_or_slug>", "target project (default: linked .layero/project.json)")
149
157
  .option("--branch <name>", "branch to roll back (default: project's default_branch)")
150
158
  .option("--deploy <id_or_sha>", "explicit deploy id or commit sha prefix to roll back to")
@@ -157,6 +165,89 @@ async function main() {
157
165
  .action(async (opts) => {
158
166
  await rollbackCmd(opts);
159
167
  });
168
+ const env = program
169
+ .command("env")
170
+ .description("Переменные окружения проекта. Значения не показываются — платформа их не отдаёт.");
171
+ const envProject = (c) => c.option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)");
172
+ envProject(env.command("list").description("Имена переменных и длина значений."))
173
+ .action(async (opts) => envListCmd({ ...opts, json: program.opts().json }));
174
+ envProject(env
175
+ .command("set <pairs...>")
176
+ .description("Задать переменные: KEY=value. Остальные остаются нетронутыми.")).action(async (pairs, opts) => envSetCmd(pairs, { ...opts, json: program.opts().json }));
177
+ envProject(env
178
+ .command("unset <keys...>")
179
+ .description("Удалить переменные.")
180
+ .option("-y, --yes", "не спрашивать подтверждение")).action(async (keys, opts) => envUnsetCmd(keys, { ...opts, json: program.opts().json }));
181
+ const analytics = program
182
+ .command("analytics")
183
+ .description("Яндекс.Метрика: подключение и статистика сайта.");
184
+ const withProject = (c) => c.option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)");
185
+ withProject(analytics.command("status").description("Подключена ли Метрика и к какой ветке."))
186
+ .action(async (opts) => analyticsStatusCmd({ ...opts, json: program.opts().json }));
187
+ withProject(analytics
188
+ .command("connect")
189
+ .description("Подключить Метрику. Печатает ссылку — открыть её и разрешить доступ должен человек.")
190
+ .option("--branch <name>", "ветка, чей адрес получит счётчик (по умолчанию основная)")).action(async (opts) => analyticsConnectCmd({ ...opts, json: program.opts().json }));
191
+ withProject(analytics
192
+ .command("stats")
193
+ .description("Посещаемость: итоги, тренд и топ источников/устройств/страниц.")
194
+ .option("--period <7d|30d|90d>", "период (по умолчанию 7d)")).action(async (opts) => analyticsStatsCmd({ ...opts, json: program.opts().json }));
195
+ withProject(analytics
196
+ .command("disconnect")
197
+ .description("Отключить Метрику от проекта.")
198
+ .option("-y, --yes", "не спрашивать подтверждение")).action(async (opts) => analyticsDisconnectCmd({ ...opts, json: program.opts().json }));
199
+ const perf = program
200
+ .command("perf")
201
+ .description("Замер производительности сайта со сравнением с предыдущим деплоем.");
202
+ perf
203
+ .command("check")
204
+ .description("Запустить замер активного деплоя. Прогон асинхронный — с --wait команда дождётся результата.")
205
+ .option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)")
206
+ .option("--wait", "дождаться результата (до 4 минут)")
207
+ .action(async (opts) => perfCheckCmd({ ...opts, json: program.opts().json }));
208
+ perf
209
+ .command("show")
210
+ .description("Показать последний замер и сравнение с предыдущим деплоем.")
211
+ .option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)")
212
+ .action(async (opts) => perfShowCmd({ ...opts, json: program.opts().json }));
213
+ const domains = program
214
+ .command("domains")
215
+ .description("Свои домены проекта: привязать, проверить DNS, сделать основным, снять.");
216
+ const domainOpts = (c) => c.option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)");
217
+ domainOpts(domains.command("list").description("Показать домены проекта."))
218
+ .action(async (opts) => domainsListCmd({ ...opts, json: program.opts().json }));
219
+ domainOpts(domains
220
+ .command("add <domain>")
221
+ .description("Привязать домен. Печатает DNS-записи, которые нужно вписать у регистратора; "
222
+ + "готовности НЕ ждёт — распространение DNS занимает от минут до часа.")).action(async (domain, opts) => domainsAddCmd(domain, { ...opts, json: program.opts().json }));
223
+ domainOpts(domains.command("verify <domain>").description("Проверить DNS сейчас, не дожидаясь фоновой перепроверки.")).action(async (domain, opts) => domainsVerifyCmd(domain, { ...opts, json: program.opts().json }));
224
+ domainOpts(domains.command("primary <domain>").description("Сделать домен основным: платформенный адрес станет 301-редиректом на него.")).action(async (domain, opts) => domainsPrimaryCmd(domain, { ...opts, json: program.opts().json }));
225
+ domainOpts(domains
226
+ .command("remove <domain>")
227
+ .description("Снять домен с проекта. Необратимо и рвёт живой трафик; требует токена со scope admin.")
228
+ .option("-y, --yes", "не спрашивать подтверждение")).action(async (domain, opts) => domainsRemoveCmd(domain, { ...opts, json: program.opts().json }));
229
+ program
230
+ .command("diagnose")
231
+ .description("Разобрать, почему деплой в таком состоянии: причина человеческим языком, "
232
+ + "окрестность ошибки в логе сборки и состояние приложения. Без --deploy берёт "
233
+ + "последний неуспешный деплой проекта.")
234
+ .option("--project <id_or_slug>", "проект (по умолчанию — залинкованный в .layero/project.json)")
235
+ .option("--deploy <id>", "конкретный деплой")
236
+ .addHelpText("after", "\nПримеры:\n $ layero diagnose\n $ layero diagnose --deploy 8da10ee6")
237
+ .action(async (opts) => {
238
+ await diagnoseCmd({ ...opts, json: program.opts().json });
239
+ });
240
+ program
241
+ .command("logs")
242
+ .description("Показать логи деплоя: сборки (по умолчанию) или приложения (--runtime).")
243
+ .option("--project <id_or_slug>", "проект (по умолчанию — залинкованный)")
244
+ .option("--deploy <id>", "конкретный деплой")
245
+ .option("--runtime", "логи запущенного приложения вместо логов сборки")
246
+ .option("--tail <n>", "сколько последних строк приложения (по умолчанию 100)", (v) => Number(v))
247
+ .addHelpText("after", "\nПримеры:\n $ layero logs\n $ layero logs --runtime --tail 200")
248
+ .action(async (opts) => {
249
+ await logsCmd({ ...opts, json: program.opts().json });
250
+ });
160
251
  program
161
252
  .command("link <id_or_slug>")
162
253
  .description("Link the current directory to an existing project.")
@@ -181,7 +272,10 @@ async function main() {
181
272
  .option("--root <dir>", "monorepo: subdirectory inside the repo that the builder treats as the app root (saved on the project; future GitHub-push and hook triggers use the same value)")
182
273
  .option("--prod", "deploy to production (replaces apex_hostname's active deploy). Without this flag, deploys go to the project's CLI preview pseudo-branch.")
183
274
  .option("--promote", "pin the project apex to this deploy after a successful build (V071). Works for any branch — e.g. `--promote` without --prod publishes a CLI preview straight to production.")
184
- .option("--branch <name>", "deploy to a specific branch's environment. Wins over --prod.")
275
+ .option("--branch <name>", "ACCEPTED BUT IGNORED for direct uploads: the backend files every " +
276
+ "archive deploy under the reserved `cli` environment. Branch " +
277
+ "environments come from pushes to a connected repository, not from " +
278
+ "this flag. Kept for backwards compatibility.")
185
279
  .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.")
186
280
  .addHelpText("after", "\nExamples:\n" +
187
281
  " $ layero deploy # preview deploy (CLI pseudo-branch), auto-detect framework\n" +
@@ -0,0 +1,167 @@
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 projectRef(api, opts) {
7
+ const linked = await loadProjectConfig(process.cwd());
8
+ const ref = opts.project ?? linked?.project_id;
9
+ if (!ref) {
10
+ throw new LayeroError("project_unknown", "не понятно, о каком проекте речь", "запусти из каталога проекта или передай --project <id|slug>");
11
+ }
12
+ const p = await api.getProject(ref);
13
+ return { id: p.id, slug: p.slug };
14
+ }
15
+ export async function analyticsStatusCmd(opts) {
16
+ const mode = detectMode();
17
+ const api = new ApiClient(await loadConfig());
18
+ const project = await projectRef(api, opts);
19
+ const s = await api.getMetrikaIntegration(project.id);
20
+ if (mode.json) {
21
+ emit({
22
+ event: "analytics_status",
23
+ connected: s.connected,
24
+ counter_id: s.counter_id ?? undefined,
25
+ status: s.status,
26
+ injection_mode: s.injection_mode,
27
+ tracked_branch: s.tracked_branch_name ?? undefined,
28
+ });
29
+ return;
30
+ }
31
+ if (!s.connected) {
32
+ console.log(chalk.dim(`Метрика к "${project.slug}" не подключена`));
33
+ console.log(chalk.dim("подключить: layero analytics connect"));
34
+ return;
35
+ }
36
+ console.log(chalk.green(`Метрика подключена — счётчик ${s.counter_id}`));
37
+ console.log(chalk.dim(` ветка: ${s.tracked_branch_name ?? "—"} · режим: ${s.injection_mode}`));
38
+ if (s.injection_mode === "manual" && s.snippet) {
39
+ console.log(chalk.yellow("\n это SSR-проект: счётчик не вшивается автоматически — вставь сниппет в разметку"));
40
+ }
41
+ if (s.metrika_url)
42
+ console.log(chalk.dim(` ${s.metrika_url}`));
43
+ }
44
+ export async function analyticsConnectCmd(opts) {
45
+ const mode = detectMode();
46
+ const api = new ApiClient(await loadConfig());
47
+ const project = await projectRef(api, opts);
48
+ let out;
49
+ try {
50
+ out = await api.connectMetrika(project.id, opts.branch);
51
+ }
52
+ catch (err) {
53
+ if (err instanceof ApiError && err.status === 503) {
54
+ throw new LayeroError("oauth_unavailable", "вход через Яндекс сейчас недоступен", "это на нашей стороне — попробуй позже");
55
+ }
56
+ if (err instanceof ApiError && err.status === 404) {
57
+ throw new LayeroError("branch_without_env", `у ветки нет окружения — счётчику некуда указывать`, "сначала задеплой эту ветку, потом подключай Метрику");
58
+ }
59
+ throw err;
60
+ }
61
+ if (mode.json) {
62
+ emit({
63
+ event: "analytics_connect_started",
64
+ oauth_url: out.oauth_url,
65
+ // Ждать здесь нечего: между ссылкой и подключением стоит человек,
66
+ // который открывает её в браузере и жмёт «Разрешить».
67
+ next_action: "open_url_then_check_status",
68
+ });
69
+ return;
70
+ }
71
+ console.log("Открой ссылку и разреши доступ — Метрика подключится сама:\n");
72
+ console.log(" " + chalk.cyan(out.oauth_url));
73
+ console.log(chalk.dim("\nпотом проверь: layero analytics status"));
74
+ }
75
+ function trend(series) {
76
+ if (!series || series.length < 4)
77
+ return null;
78
+ const half = Math.floor(series.length / 2);
79
+ const sum = (xs) => xs.reduce((a, b) => a + (b.visits ?? 0), 0);
80
+ const older = sum(series.slice(0, half));
81
+ const newer = sum(series.slice(half));
82
+ if (older === 0 && newer === 0)
83
+ return null;
84
+ if (older === 0)
85
+ return "растёт";
86
+ const ratio = newer / older;
87
+ if (ratio >= 1.25)
88
+ return `растёт (+${Math.round((ratio - 1) * 100)}%)`;
89
+ if (ratio <= 0.8)
90
+ return `падает (−${Math.round((1 - ratio) * 100)}%)`;
91
+ return "ровно";
92
+ }
93
+ export async function analyticsStatsCmd(opts) {
94
+ const mode = detectMode();
95
+ const api = new ApiClient(await loadConfig());
96
+ const project = await projectRef(api, opts);
97
+ const period = opts.period ?? "7d";
98
+ const s = await api.getMetrikaStats(project.id, period);
99
+ if (s.state === "disconnected") {
100
+ throw new LayeroError("analytics_not_connected", `Метрика к "${project.slug}" не подключена`, "подключить: layero analytics connect");
101
+ }
102
+ // Сжимаем для агента: временной ряд превращаем в направление, разбивки
103
+ // режем до пятёрки. На периоде 90d сырой ответ — это девяносто точек и
104
+ // десятки строк разбивок, то есть контекст, потраченный на данные,
105
+ // которые всё равно нужно свернуть в вывод.
106
+ const b = s.breakdowns ?? {};
107
+ const top = (xs) => (xs ?? []).slice(0, 5);
108
+ const compact = {
109
+ period,
110
+ state: s.state,
111
+ totals: s.totals,
112
+ trend: trend(s.series),
113
+ sources: top(b.sources),
114
+ devices: top(b.devices),
115
+ pages: top(b.pages),
116
+ };
117
+ if (mode.json) {
118
+ emit({ event: "analytics_stats", ...compact });
119
+ return;
120
+ }
121
+ if (s.state === "no_data_yet") {
122
+ console.log(chalk.dim("счётчик подключён, но визитов пока нет"));
123
+ return;
124
+ }
125
+ const t = s.totals ?? {};
126
+ console.log(`${chalk.bold(project.slug)} за ${period}`);
127
+ console.log(` ${t.visits} визитов · ${t.users} пользователей · ${t.pageviews} просмотров` +
128
+ (compact.trend ? chalk.dim(` (${compact.trend})`) : ""));
129
+ console.log(chalk.dim(` отказы ${t.bounce_rate}% · среднее время ${Math.round(t.avg_visit_duration)}с`));
130
+ const line = (label, xs) => xs.length
131
+ ? console.log(chalk.dim(` ${label}: `) + xs.map((x) => `${x.name ?? x.label} ${x.visits ?? x.value}`).join(", "))
132
+ : undefined;
133
+ line("источники", compact.sources);
134
+ line("устройства", compact.devices);
135
+ line("страницы", compact.pages);
136
+ if (s.state === "stale") {
137
+ console.log(chalk.yellow("\n данные могут быть устаревшими: Метрика не ответила"));
138
+ }
139
+ }
140
+ export async function analyticsDisconnectCmd(opts) {
141
+ const mode = detectMode();
142
+ const api = new ApiClient(await loadConfig());
143
+ const project = await projectRef(api, opts);
144
+ if (!opts.yes && mode.interactive) {
145
+ const readline = await import("node:readline/promises");
146
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
147
+ try {
148
+ const a = (await rl.question(`отключить Метрику от "${project.slug}"? счётчик перестанет собирать данные [y/N]: `))
149
+ .trim()
150
+ .toLowerCase();
151
+ if (a !== "y" && a !== "yes") {
152
+ console.log(chalk.yellow("отменено."));
153
+ return;
154
+ }
155
+ }
156
+ finally {
157
+ rl.close();
158
+ }
159
+ }
160
+ await api.disconnectMetrika(project.id);
161
+ if (mode.json) {
162
+ emit({ event: "analytics_disconnected", project: project.slug });
163
+ return;
164
+ }
165
+ console.log(chalk.green("Метрика отключена"));
166
+ console.log(chalk.dim("счётчик в Метрике остался — удалить его можно в её интерфейсе"));
167
+ }