layero 0.11.7 → 0.11.9

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 CHANGED
@@ -125,11 +125,9 @@ Run `layero <cmd> --help` for full options.
125
125
  - `--claim` — deploy without an account: a temporary site for 1 hour
126
126
  plus a `claim_url` for a human to take it over. Static sites and SPAs only:
127
127
  a server app (SSR, fullstack, container) is refused before anything is
128
- uploaded (`claim_static_only`, exit 4). Turns on by itself when
129
- there is no token, the run is non-interactive (an agent, not CI), `--yes`
130
- is passed and the project is new: no `--project`, and the folder is not
131
- linked to an account project. An existing project without a token means
132
- signing in (`auth_required`). With `--project` it is refused
128
+ uploaded (`claim_static_only`, exit 4). Only with this flag: without a
129
+ token and without `--claim`, `deploy` asks for a login (`auth_required`),
130
+ in an agent environment too. With `--project` it is refused
133
131
  (`claim_with_project`, exit 4). In CI a missing `LAYERO_TOKEN` stays an error.
134
132
  - `--org <slug>` — organization for first-time project creation.
135
133
  - `--yes` / `-y` — non-interactive mode.
@@ -155,9 +153,13 @@ closed to search engines: `robots.txt` with `Disallow: /` and
155
153
  `X-Robots-Tag: noindex, nofollow`. A human opens `claim_url`, signs in and
156
154
  takes the project into their account — the CLI cannot accept a claim by
157
155
  itself.
158
- The claim code is saved in `.layero/project.json`; the temporary token stays
159
- in `~/.layero/config.json`, so `layero deploy` in the same directory keeps
160
- updating the same site until the claim expires or is accepted.
156
+ The temporary token, the claim code and the link are saved in
157
+ `~/.layero/config.json` (readable by you only); `.layero/project.json` gets
158
+ only the folder's link to the project, because that file goes to git and a
159
+ claim code there would let anyone take the site. `layero deploy` in the same
160
+ directory keeps updating the same site until the claim expires or is
161
+ accepted. Claiming the site clears what was set up without an account —
162
+ environment variables, deploy hooks and build settings.
161
163
  `layero claim status` shows where things stand; `layero claim accept` opens
162
164
  the page in a browser (in agent mode it prints the link).
163
165
 
package/dist/agent.js CHANGED
@@ -342,8 +342,8 @@ function renderHuman(event) {
342
342
  process.stdout.write(`✓ Проект ${event.slug} удалён (очистка ресурсов идёт в фоне)\n`);
343
343
  break;
344
344
  case "claimable":
345
- process.stdout.write(`→ Временный проект ${event.slug}: ${event.url}\n`);
346
- process.stdout.write(` Сайт живёт до ${event.expires_at} (час). Забрать в аккаунт: ${event.claim_url}\n`);
345
+ process.stdout.write(`→ Временный сайт ${event.slug}: ${event.preview_url ?? event.url}\n`);
346
+ process.stdout.write(` Живёт до ${event.expires_at} (час). Забрать в аккаунт: ${event.claim_url}\n`);
347
347
  break;
348
348
  case "claim_status":
349
349
  process.stdout.write(`Код ${event.code}: ${event.status}${event.claimed ? " (забран)" : ""}` +
@@ -456,8 +456,7 @@ async function main() {
456
456
  .option("--claim", "deploy without an account: a temporary site for 1 hour plus a claim_url for a human to take it over. " +
457
457
  "Static sites and SPAs only — a server app (SSR, fullstack, container) is refused (claim_static_only, exit 4). " +
458
458
  "The address is random and closed to search engines. " +
459
- "Turns on by itself when there is no token, the run is non-interactive (agent, not CI), --yes is passed " +
460
- "and the project is new (no --project, folder not linked to an account project). " +
459
+ "Only with this flag: without a token and without --claim, deploy asks for a login. " +
461
460
  "Refused together with --project (claim_with_project, exit 4).")
462
461
  .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.")
463
462
  // Осознанное подтверждение выкатки, когда предыдущие сборки подряд падают
@@ -1,7 +1,7 @@
1
1
  import open from "open";
2
2
  import { ApiClient, ApiError } from "../api.js";
3
- import { loadConfig, saveConfig } from "../config.js";
4
- import { loadProjectConfig, persistProjectLinking } from "../project-config.js";
3
+ import { forgetSandbox, loadConfig, saveClaim, saveConfig } from "../config.js";
4
+ import { loadProjectConfig, persistProjectLinking, unlinkProject } from "../project-config.js";
5
5
  import { LayeroError, detectMode, emit } from "../agent.js";
6
6
  import { dashboardOrigin } from "../urls.js";
7
7
  /**
@@ -32,9 +32,10 @@ export function claimCodeOf(created) {
32
32
  throw new LayeroError("claimable_unavailable", "платформа не вернула код заявки", "повторите позже; если повторяется — сообщите на https://docs.layero.ru/contacts/");
33
33
  }
34
34
  /**
35
- * Создать claimable-проект и запомнить его: токен — в `~/.layero/config.json`
36
- * (по проекту), код и ссылку — в `.layero/project.json`. Возвращает конфиг
37
- * с токеном заявки, которым идёт деплой.
35
+ * Создать claimable-проект и запомнить его: токен, код и ссылку — в
36
+ * `~/.layero/config.json` (по проекту, файл 0600); в `.layero/project.json` —
37
+ * только привязка папки. Возвращает конфиг с токеном заявки, которым идёт
38
+ * деплой.
38
39
  */
39
40
  export async function createClaimable(cfg, cwd, input) {
40
41
  const api = new ApiClient(cfg);
@@ -53,23 +54,84 @@ export async function createClaimable(cfg, cwd, input) {
53
54
  throw err;
54
55
  }
55
56
  const code = claimCodeOf(created);
57
+ const claim = { code, claim_url: created.claim_url, expires_at: created.expires_at };
56
58
  const next = {
57
59
  ...cfg,
58
60
  token: created.token,
59
61
  claim_tokens: { ...(cfg.claim_tokens ?? {}), [created.project_id]: created.token },
62
+ claims: { ...(cfg.claims ?? {}), [created.project_id]: claim },
60
63
  };
61
- // В файл — только карту токенов заявок, не сам токен как «вход»: иначе
62
- // следующий `layero whoami` считал бы временный токен аккаунтом.
63
- await saveConfig({ ...cfg, claim_tokens: next.claim_tokens });
64
+ // В файл — карты заявок, не сам токен как «вход»: иначе следующий
65
+ // `layero whoami` считал бы временный токен аккаунтом.
66
+ await saveConfig({ ...cfg, claim_tokens: next.claim_tokens, claims: next.claims });
67
+ // 🚨 Код забора в project.json НЕ пишется (T-20260921): файл уходит в git.
64
68
  await persistProjectLinking(cwd, {
65
69
  project_id: created.project_id,
66
70
  slug: created.slug,
67
71
  organization_slug: created.organization,
68
72
  apex_hostname: created.url ? new URL(created.url).hostname : `${created.slug}.layero.app`,
69
- claim: { code, claim_url: created.claim_url, expires_at: created.expires_at },
70
73
  });
71
74
  return { cfg: next, created, code };
72
75
  }
76
+ /**
77
+ * Жива ли песочница папки, прежде чем выкатывать в неё её токеном (T-20260919-3).
78
+ *
79
+ * Токен песочницы умирает в двух случаях: сайт забрали в аккаунт (токен
80
+ * отозван при заборе) или вышел срок — час, после чего уборка удаляет проект
81
+ * и держателя. Раньше агент узнавал об этом сырым 401 посреди выкатки и не
82
+ * знал, что делать дальше. Публичный статус по коду заявки отвечает до
83
+ * упаковки: `claimed` → `sandbox_claimed` (войти тем аккаунтом, привязка
84
+ * остаётся — проект тот же), `expired` или кода больше нет → `sandbox_expired`
85
+ * (мёртвые токен, заявка и привязка убираются, `--claim` заведёт новую).
86
+ * Сеть или платформа не ответили — не отказываем: решит сама выкатка.
87
+ */
88
+ export async function assertSandboxAlive(cfg, cwd, linked) {
89
+ const claim = claimFor(cfg, linked);
90
+ if (!claim?.code || !linked?.project_id)
91
+ return;
92
+ let status;
93
+ try {
94
+ status = (await new ApiClient({ apiUrl: cfg.apiUrl }).getClaimStatus(claim.code)).status;
95
+ }
96
+ catch (err) {
97
+ if (err instanceof ApiError && (err.status === 404 || err.status === 410))
98
+ status = "expired";
99
+ else
100
+ return;
101
+ }
102
+ const site = linked.slug ? ` (${linked.slug})` : "";
103
+ if (status === "claimed") {
104
+ await forgetSandbox(linked.project_id);
105
+ throw new LayeroError("sandbox_claimed", `сайт этой папки${site} забрали в аккаунт — токен песочницы больше не действует`, "the person took the site into their account: sign in with that account " +
106
+ "(`npx layero@latest login`) and run the same deploy — the folder stays linked to the project");
107
+ }
108
+ if (status === "expired") {
109
+ await forgetSandbox(linked.project_id);
110
+ await unlinkProject(cwd);
111
+ throw new LayeroError("sandbox_expired", `песочница этой папки${site} истекла и удалена`, "a new site without an account: `npx layero@latest deploy --claim` (static sites and SPAs, 1 hour); " +
112
+ "to keep a site, sign in and deploy into the account");
113
+ }
114
+ }
115
+ /**
116
+ * Код забора из `.layero/project.json` старого CLI (до 0.11.8) — в конфиг.
117
+ * Файл папки после этого перепишет `persistProjectLinking` уже без кода.
118
+ */
119
+ export async function keepLegacyClaim(cfg, linked) {
120
+ if (!linked?.project_id || !linked.claim?.code)
121
+ return;
122
+ if (cfg.claims?.[linked.project_id])
123
+ return;
124
+ await saveClaim(linked.project_id, linked.claim);
125
+ }
126
+ /**
127
+ * Заявка песочницы для проекта папки: из `~/.layero/config.json`, а у папок,
128
+ * выложенных CLI до 0.11.8, — из старого поля `claim` в `.layero/project.json`.
129
+ */
130
+ export function claimFor(cfg, linked) {
131
+ if (!linked?.project_id)
132
+ return undefined;
133
+ return cfg.claims?.[linked.project_id] ?? linked.claim ?? undefined;
134
+ }
73
135
  /** Токен заявки для проекта из `.layero/project.json`, если он есть. */
74
136
  export function claimTokenFor(cfg, projectId) {
75
137
  if (!projectId)
@@ -100,19 +162,19 @@ export async function configForFolder(opts, cwd) {
100
162
  function claimUrlFor(cfg, code) {
101
163
  return `${dashboardOrigin(cfg.apiUrl)}/claim?code=${encodeURIComponent(code)}`;
102
164
  }
103
- async function resolveCode(cwd, explicit) {
165
+ async function resolveCode(cfg, cwd, explicit) {
104
166
  if (explicit)
105
167
  return { code: explicit.trim(), claim_url: null };
106
- const linked = await loadProjectConfig(cwd);
107
- if (linked?.claim?.code)
108
- return { code: linked.claim.code, claim_url: linked.claim.claim_url };
168
+ const claim = claimFor(cfg, await loadProjectConfig(cwd));
169
+ if (claim?.code)
170
+ return { code: claim.code, claim_url: claim.claim_url };
109
171
  throw new LayeroError("claim_unknown", "в этой папке нет claimable-проекта", "передайте код: `layero claim status <code>` — или создайте проект без аккаунта: `layero deploy --claim`");
110
172
  }
111
173
  /** `layero claim status [code]` — что с заявкой: жива, забрана, истекла. */
112
174
  export async function claimStatusCmd(code, opts) {
113
175
  const cwd = process.cwd();
114
176
  const cfg = await loadConfig();
115
- const ref = await resolveCode(cwd, code);
177
+ const ref = await resolveCode(cfg, cwd, code);
116
178
  // Без токена намеренно: статус заявки — публичный по коду, как и сама ссылка.
117
179
  const api = new ApiClient({ apiUrl: cfg.apiUrl });
118
180
  let status;
@@ -145,7 +207,7 @@ export async function claimStatusCmd(code, opts) {
145
207
  export async function claimAcceptCmd(code, opts) {
146
208
  const cwd = process.cwd();
147
209
  const cfg = await loadConfig();
148
- const ref = await resolveCode(cwd, code);
210
+ const ref = await resolveCode(cfg, cwd, code);
149
211
  const claimUrl = ref.claim_url ?? claimUrlFor(cfg, ref.code);
150
212
  const mode = detectMode();
151
213
  let opened = false;
@@ -13,7 +13,7 @@ import { detectProject, detectedEvent } from "../detect.js";
13
13
  import { runDeviceLogin } from "../auth.js";
14
14
  import { LayeroError, detectMode, emit, isCiEnv } from "../agent.js";
15
15
  import { ensureUsername } from "../username.js";
16
- import { claimTokenFor, createClaimable } from "./claim.js";
16
+ import { assertSandboxAlive, claimFor, claimTokenFor, createClaimable, keepLegacyClaim } from "./claim.js";
17
17
  const VALID_TYPES = new Set([
18
18
  "vite",
19
19
  "next",
@@ -209,6 +209,13 @@ async function assertSandboxServesFiles(cwd, opts, existing, detectFolder) {
209
209
  return;
210
210
  throw new LayeroError("claim_static_only", `деплой без аккаунта (--claim) выкладывает только статику и SPA, а эта папка — серверное приложение (${kind})`, "sign in and deploy from the account: `npx layero@latest login`, then run the same deploy without --claim");
211
211
  }
212
+ /** Обёртка временного сайта по его адресу: `<панель>/preview/<метка>`.
213
+ * Считается здесь для повторной выкатки — платформа отдаёт поле только при
214
+ * создании песочницы. */
215
+ function sandboxPreviewUrl(siteUrl) {
216
+ const label = new URL(siteUrl).hostname.split(".")[0];
217
+ return `https://app.layero.ru/preview/${label}`;
218
+ }
212
219
  /**
213
220
  * 🚨 ДЕТЕКТ — ОДИН НА ВЫКАТКУ. Гейт core «входов анализа не становится
214
221
  * больше» считает запуски детекта в этом файле: каждый лишний — ещё один
@@ -560,7 +567,7 @@ export async function deployCmd(opts) {
560
567
  const projectIsLinkedOne = !opts.project ||
561
568
  (existing?.project_id !== undefined &&
562
569
  (opts.project === existing.project_id || opts.project === existing.slug));
563
- const linkedIsClaimable = Boolean(existing?.project_id && existing.claim);
570
+ const linkedIsClaimable = Boolean(existing?.project_id && (claimFor(cliCfg, existing) || claimTokenFor(cliCfg, existing.project_id)));
564
571
  const boundToAccountProject = opts.project
565
572
  ? !(projectIsLinkedOne && linkedIsClaimable)
566
573
  : Boolean(existing?.project_id) && !linkedIsClaimable;
@@ -572,18 +579,20 @@ export async function deployCmd(opts) {
572
579
  ? claimTokenFor(cliCfg, existing?.project_id)
573
580
  : undefined;
574
581
  if (reuse) {
582
+ await assertSandboxAlive(cliCfg, cwd, existing);
575
583
  await assertSandboxServesFiles(cwd, opts, existing, detectFolder);
576
584
  cliCfg = { ...cliCfg, token: reuse };
577
585
  // Повторная выкатка песочницы: ссылка «забрать» нужна агенту и здесь —
578
586
  // без неё он искал её в `.layero/project.json` (симуляция 18.09.2026).
579
- if (existing?.claim)
580
- reusedClaim = existing.claim;
587
+ reusedClaim = claimFor(cliCfg, existing) ?? null;
581
588
  }
582
589
  else if (
583
- // Явный `--claim` — всегда новая песочница (с `--project` он отклонён
584
- // выше). Сам режим включается только для нового проекта.
585
- opts.claim ||
586
- (!boundToAccountProject && !mode.interactive && !isCiEnv() && opts.yes)) {
590
+ // 🚨 ТОЛЬКО ЯВНЫЙ `--claim` (T-20260921). До 0.11.8 режим включался сам —
591
+ // нет токена, агентская среда, `--yes` — и агент молча публиковал папку
592
+ // в открытый интернет: человек не решал публиковать и не принимал
593
+ // условий. Теперь без флага — обычный вход (`auth_required`). С
594
+ // `--project` флаг отклонён выше: песочница — только новый проект.
595
+ opts.claim) {
587
596
  // 🚨 CI СЮДА НЕ ПОПАДАЕТ НАМЕРЕННО. Раннер без LAYERO_TOKEN — это
588
597
  // забытый секрет, и правильный ответ ему — отказ, а не сайт на
589
598
  // временном адресе, который через час исчезнет вместе с «зелёным»
@@ -595,6 +604,9 @@ export async function deployCmd(opts) {
595
604
  cliCfg = r.cfg;
596
605
  claimable = {
597
606
  claim_url: r.created.claim_url,
607
+ // Обёртка панели — ссылка «для людей»: голый адрес песочницы браузеру
608
+ // отвечает переходом на неё. Старая платформа поля не отдаёт.
609
+ preview_url: r.created.preview_url ?? null,
598
610
  expires_at: r.created.expires_at,
599
611
  project_id: r.created.project_id,
600
612
  slug: r.created.slug,
@@ -817,6 +829,9 @@ export async function deployCmd(opts) {
817
829
  }
818
830
  // Только связка папки с проектом. Фреймворк сюда больше не пишется: поле
819
831
  // этого файла CLI читает как выбор человека (см. `resolveSetupConfig`).
832
+ // Код забора старого CLI сначала уезжает в конфиг, потом файл
833
+ // переписывается уже без него (T-20260921).
834
+ await keepLegacyClaim(cliCfg, existing);
820
835
  await persistProjectLinking(cwd, {
821
836
  project_id: project.id,
822
837
  slug: project.slug,
@@ -926,13 +941,16 @@ export async function deployCmd(opts) {
926
941
  emit({ event: "preview_evicted", evicted });
927
942
  }
928
943
  // Claimable: ссылка «забрать» — ДО `ready`, потому что после `ready`
929
- // агент не читает, а без этой ссылки сайт исчезнет через 72 часа.
944
+ // агент не читает, а без этой ссылки сайт исчезнет через час.
945
+ // `preview_url` — обёртка панели: пока сайт временный, человеку
946
+ // открывается только она; `url` остаётся для проверок и агентов.
930
947
  if (claimable) {
931
948
  emit({
932
949
  event: "claimable",
933
950
  project_id: claimable.project_id,
934
951
  slug: claimable.slug,
935
952
  url: liveUrl,
953
+ preview_url: claimable.preview_url ?? sandboxPreviewUrl(liveUrl),
936
954
  claim_url: claimable.claim_url,
937
955
  expires_at: claimable.expires_at,
938
956
  });
@@ -943,6 +961,7 @@ export async function deployCmd(opts) {
943
961
  project_id: project.id,
944
962
  slug: project.slug,
945
963
  url: liveUrl,
964
+ preview_url: sandboxPreviewUrl(liveUrl),
946
965
  claim_url: reusedClaim.claim_url,
947
966
  expires_at: reusedClaim.expires_at,
948
967
  });
package/dist/config.js CHANGED
@@ -27,6 +27,7 @@ export async function loadConfig() {
27
27
  // выводе врала бы. Кто мы — узнаем у API.
28
28
  user: ENV_TOKEN ? undefined : parsed.user,
29
29
  claim_tokens: parsed.claim_tokens,
30
+ claims: parsed.claims,
30
31
  };
31
32
  }
32
33
  catch (err) {
@@ -47,6 +48,57 @@ export async function saveConfig(cfg) {
47
48
  await fs.chmod(CONFIG_FILE, 0o600);
48
49
  }
49
50
  }
51
+ /**
52
+ * Записать заявку песочницы в файл конфига, не трогая остального.
53
+ *
54
+ * Читает и пишет САМ файл, а не результат `loadConfig`: тот подставляет
55
+ * `LAYERO_TOKEN` из окружения, и запись через него унесла бы токен CI в
56
+ * файл на диске.
57
+ */
58
+ export async function saveClaim(projectId, claim) {
59
+ let raw = {};
60
+ try {
61
+ raw = JSON.parse(await fs.readFile(CONFIG_FILE, "utf-8"));
62
+ }
63
+ catch (err) {
64
+ if (err?.code !== "ENOENT")
65
+ throw err;
66
+ }
67
+ const claims = { ...(raw.claims ?? {}), [projectId]: claim };
68
+ await ensureDir();
69
+ await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...raw, claims }, null, 2), {
70
+ encoding: "utf-8",
71
+ mode: 0o600,
72
+ });
73
+ if (process.platform !== "win32")
74
+ await fs.chmod(CONFIG_FILE, 0o600);
75
+ }
76
+ /**
77
+ * Забыть песочницу: её токен и заявку (T-20260919-3). Зовётся, когда
78
+ * песочницы больше нет или её забрали, — мёртвый токен иначе подхватывала бы
79
+ * каждая следующая выкатка из этой папки. Пишет сам файл (см. `saveClaim`).
80
+ */
81
+ export async function forgetSandbox(projectId) {
82
+ let raw;
83
+ try {
84
+ raw = JSON.parse(await fs.readFile(CONFIG_FILE, "utf-8"));
85
+ }
86
+ catch (err) {
87
+ if (err?.code === "ENOENT")
88
+ return;
89
+ throw err;
90
+ }
91
+ const tokens = { ...(raw.claim_tokens ?? {}) };
92
+ const claims = { ...(raw.claims ?? {}) };
93
+ delete tokens[projectId];
94
+ delete claims[projectId];
95
+ await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...raw, claim_tokens: tokens, claims }, null, 2), {
96
+ encoding: "utf-8",
97
+ mode: 0o600,
98
+ });
99
+ if (process.platform !== "win32")
100
+ await fs.chmod(CONFIG_FILE, 0o600);
101
+ }
50
102
  export async function clearConfig() {
51
103
  try {
52
104
  await fs.unlink(CONFIG_FILE);
@@ -19,8 +19,9 @@
19
19
  * `check-error-codes.py`), и класс выхода становится его частью. Новый код,
20
20
  * не попавший ни в один список, даёт 1 — честно «прочее», а не наугад.
21
21
  */
22
- const AUTH = new Set(["auth_required", "auth_expired", "auth_timeout"]);
22
+ const AUTH = new Set(["auth_required", "auth_expired", "auth_timeout", "sandbox_claimed"]);
23
23
  const NOT_FOUND = new Set([
24
+ "sandbox_expired",
24
25
  "project_unknown",
25
26
  "project_not_found",
26
27
  "org_unknown",
@@ -67,8 +67,9 @@ export async function persistProjectLinking(cwd, linking, fallbackHint) {
67
67
  };
68
68
  if (linking.api_url !== undefined)
69
69
  merged.api_url = linking.api_url;
70
- if (linking.claim !== undefined)
71
- merged.claim = linking.claim;
70
+ // Код забора от CLI до 0.11.8 — вон из файла, который коммитят
71
+ // (T-20260921). Живую заявку вызывающий перед этим переносит в конфиг.
72
+ delete merged.claim;
72
73
  // Only seed framework_hint if the user hasn't set one yet — once it's in
73
74
  // the file (manually or from a prior --type), leave it alone.
74
75
  if (fallbackHint != null &&
@@ -79,3 +80,30 @@ export async function persistProjectLinking(cwd, linking, fallbackHint) {
79
80
  await fs.writeFile(file, JSON.stringify(merged, null, 2), "utf-8");
80
81
  return merged;
81
82
  }
83
+ /**
84
+ * Отвязать папку от проекта, сохранив поля, которые человек вписал сам.
85
+ *
86
+ * Зовётся, когда проекта больше нет — песочница истекла и удалена
87
+ * (T-20260919-3): мёртвая привязка иначе уводила бы каждую следующую выкатку
88
+ * в несуществующий проект. Пустой после этого файл удаляется.
89
+ */
90
+ export async function unlinkProject(cwd) {
91
+ const file = projectConfigPath(cwd);
92
+ let raw;
93
+ try {
94
+ raw = JSON.parse(await fs.readFile(file, "utf-8"));
95
+ }
96
+ catch (err) {
97
+ if (err?.code === "ENOENT")
98
+ return;
99
+ throw err;
100
+ }
101
+ for (const key of ["project_id", "slug", "organization_slug", "owner_slug", "apex_hostname", "api_url", "claim"]) {
102
+ delete raw[key];
103
+ }
104
+ if (Object.keys(raw).length === 0) {
105
+ await fs.unlink(file);
106
+ return;
107
+ }
108
+ await fs.writeFile(file, JSON.stringify(raw, null, 2), "utf-8");
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layero",
3
- "version": "0.11.7",
3
+ "version": "0.11.9",
4
4
  "description": "Layero CLI — deploy a directory or a repo to hosting with build servers in Russia. JSON events and exit codes for AI agents and CI.",
5
5
  "license": "MIT",
6
6
  "type": "module",