@i14ks/ccv 0.6.2 → 0.7.1

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.
Files changed (26) hide show
  1. package/bundle/ccv.mjs +804 -131
  2. package/package.json +1 -1
  3. package/web/assets/{codemirror-02hKfv-c.js → codemirror-4gviWJRD.js} +1 -1
  4. package/web/assets/{index-BybHqs37.js → index-B-8FWzpe.js} +1 -1
  5. package/web/assets/{index-DRMnPfA3.js → index-BiBGEAPz.js} +1 -1
  6. package/web/assets/{index-Bm6PUpXb.js → index-BjCwOpLW.js} +1 -1
  7. package/web/assets/{index-BBkJ4cEk.js → index-BleqvvQk.js} +1 -1
  8. package/web/assets/{index-BxcnR7Nj.js → index-Bv8vIAVO.js} +1 -1
  9. package/web/assets/index-Bzq-v0Dg.js +134 -0
  10. package/web/assets/{index-sQrmYdbi.css → index-CE1G7QxT.css} +1 -1
  11. package/web/assets/{index-VSDVirVT.js → index-CJo5aeRL.js} +1 -1
  12. package/web/assets/{index-C-8irjwh.js → index-CKz5fHXP.js} +1 -1
  13. package/web/assets/{index-BC8gj6P7.js → index-CR_zbJ-Z.js} +1 -1
  14. package/web/assets/{index-C2nhZ-ID.js → index-CYwf9t3p.js} +1 -1
  15. package/web/assets/{index-DZXdWq9L.js → index-C_LuD9HT.js} +1 -1
  16. package/web/assets/{index-ChKrBnaf.js → index-Cid3pp7b.js} +1 -1
  17. package/web/assets/{index-Clyg5z5Z.js → index-CkSGxu2i.js} +1 -1
  18. package/web/assets/{index-KEWrG50i.js → index-CrX9ldma.js} +1 -1
  19. package/web/assets/{index-BorlqgNX.js → index-Depy6k3N.js} +1 -1
  20. package/web/assets/{index-zDoB4KM7.js → index-DklW2LtN.js} +1 -1
  21. package/web/assets/{index-B5hgDwts.js → index-DmEjP2AP.js} +1 -1
  22. package/web/assets/{index-DpZcQOqI.js → index-fWVyTiSX.js} +1 -1
  23. package/web/assets/{index-D0UkSEon.js → index-hElc3lLb.js} +1 -1
  24. package/web/assets/{index-i-F6wQDa.js → index-kMdLDVpb.js} +1 -1
  25. package/web/index.html +2 -2
  26. package/web/assets/index-DFJbTCVa.js +0 -129
package/bundle/ccv.mjs CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  // dist/ccv.js
4
4
  import { spawn as spawn2 } from "node:child_process";
5
- import { readFile as readFile11 } from "node:fs/promises";
6
- import path16 from "node:path";
5
+ import { readFile as readFile13 } from "node:fs/promises";
6
+ import path18 from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { parseArgs } from "node:util";
9
9
 
@@ -146,7 +146,7 @@ function envDefaults() {
146
146
  // ../host/dist/server.js
147
147
  import { createServer } from "node:http";
148
148
  import { createReadStream, existsSync as existsSync3, statSync as statSync3 } from "node:fs";
149
- import path15 from "node:path";
149
+ import path17 from "node:path";
150
150
  import { homedir as homedir5 } from "node:os";
151
151
  import { randomUUID as randomUUID3 } from "node:crypto";
152
152
  import { WebSocketServer } from "ws";
@@ -2246,7 +2246,11 @@ function contentToText2(content) {
2246
2246
 
2247
2247
  // ../host/dist/git.js
2248
2248
  import { execFile as execFile2 } from "node:child_process";
2249
+ import { mkdtemp, readFile as readFile4, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
2250
+ import { tmpdir } from "node:os";
2251
+ import path6 from "node:path";
2249
2252
  var TIMEOUT_MS = 2e4;
2253
+ var NET_TIMEOUT_MS = 5 * 6e4;
2250
2254
  var MAX_BUFFER = 32 * 1024 * 1024;
2251
2255
  var LOG_LIMIT = 150;
2252
2256
  var UNPUSHED_LIMIT = 2e3;
@@ -2255,6 +2259,8 @@ var MAX_FILE_PATCH = 8e4;
2255
2259
  var UNIT = "";
2256
2260
  var RECORD = "";
2257
2261
  var SAFE_REF = /^[A-Za-z0-9][A-Za-z0-9._/@^~-]*$/;
2262
+ var SAFE_URL = /^(?:https?:\/\/|ssh:\/\/|git:\/\/|[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:)[^\s]+$/;
2263
+ var CANDIDATE_LIMIT = 2e3;
2258
2264
  var GitRepo = class {
2259
2265
  cwd;
2260
2266
  constructor(cwd) {
@@ -2271,11 +2277,13 @@ var GitRepo = class {
2271
2277
  const blank = {
2272
2278
  available: false,
2273
2279
  message: "",
2280
+ noRepo: false,
2274
2281
  branch: "",
2275
2282
  head: "",
2276
2283
  detached: false,
2277
2284
  upstream: "",
2278
2285
  hasRemote: false,
2286
+ remoteUrl: "",
2279
2287
  ahead: 0,
2280
2288
  behind: 0,
2281
2289
  changed: [],
@@ -2285,7 +2293,8 @@ var GitRepo = class {
2285
2293
  try {
2286
2294
  await this.#run(["rev-parse", "--is-inside-work-tree"]);
2287
2295
  } catch (error) {
2288
- return { ...blank, message: describe2(error) };
2296
+ const runnable2 = await this.#run(["--version"]).then(() => true, () => false);
2297
+ return { ...blank, message: describe2(error), noRepo: runnable2 };
2289
2298
  }
2290
2299
  const branchRaw = await this.#soft(["rev-parse", "--abbrev-ref", "HEAD"]);
2291
2300
  const detached = branchRaw === "HEAD" || branchRaw === "";
@@ -2301,11 +2310,14 @@ var GitRepo = class {
2301
2310
  return {
2302
2311
  available: true,
2303
2312
  message: "",
2313
+ // Репозиторий есть — заводить нечего.
2314
+ noRepo: false,
2304
2315
  branch: detached ? "" : branchRaw,
2305
2316
  head: await this.#soft(["rev-parse", "HEAD"]),
2306
2317
  detached,
2307
2318
  upstream,
2308
2319
  hasRemote,
2320
+ remoteUrl: await this.#soft(["remote", "get-url", "origin"]),
2309
2321
  ahead: Number(ahead) || 0,
2310
2322
  behind: Number(behind) || 0,
2311
2323
  changed: parseStatus(await this.#soft(["status", "--porcelain=v1"])),
@@ -2432,12 +2444,11 @@ var GitRepo = class {
2432
2444
  async checkIgnored(paths) {
2433
2445
  if (paths.length === 0)
2434
2446
  return /* @__PURE__ */ new Set();
2435
- const raw = await this.#run(
2436
- ["check-ignore", "--stdin", "-z"],
2447
+ const raw = await this.#run(["check-ignore", "--stdin", "-z"], {
2437
2448
  // `-z` переводит на NUL и вход тоже — иначе путь с переводом строки в
2438
2449
  // имени разъехался бы на две записи.
2439
- paths.join("\0")
2440
- ).catch(() => "");
2450
+ input: paths.join("\0")
2451
+ }).catch(() => "");
2441
2452
  return new Set(raw.split("\0").filter(Boolean));
2442
2453
  }
2443
2454
  /**
@@ -2455,24 +2466,195 @@ var GitRepo = class {
2455
2466
  const out = await this.#run(["checkout", requireRef(target), "--"]);
2456
2467
  return out || `\u041F\u0435\u0440\u0435\u043A\u043B\u044E\u0447\u0435\u043D\u043E \u043D\u0430 ${target}`;
2457
2468
  }
2469
+ /**
2470
+ * Отправить текущую ветку.
2471
+ *
2472
+ * Ветка берётся здесь, а не приходит параметром: отправить можно только то,
2473
+ * на чём стоит HEAD, и принимать её снаружи значило бы дать окну отправить
2474
+ * не ту ветку, которую человек видит перед собой.
2475
+ *
2476
+ * Первый push ветки добавляет `--set-upstream`: без него git отправит
2477
+ * коммиты, но связь с удалённой веткой не заведёт, и панель после успешной
2478
+ * отправки продолжит показывать «не отправлено». Дальше аргументов нет
2479
+ * вовсе — `git push` сам знает, куда, и уважает `push.default` из настроек
2480
+ * пользователя.
2481
+ */
2482
+ async push(token) {
2483
+ const branch = await this.#soft(["rev-parse", "--abbrev-ref", "HEAD"]);
2484
+ if (branch === "" || branch === "HEAD") {
2485
+ throw new Error("HEAD \u043E\u0442\u0434\u0435\u043B\u0451\u043D \u043E\u0442 \u0432\u0435\u0442\u043A\u0438 \u2014 \u0441\u043D\u0430\u0447\u0430\u043B\u0430 \u043F\u0435\u0440\u0435\u043A\u043B\u044E\u0447\u0438\u0442\u0435\u0441\u044C \u043D\u0430 \u0432\u0435\u0442\u043A\u0443");
2486
+ }
2487
+ const upstream = await this.#soft([
2488
+ "rev-parse",
2489
+ "--abbrev-ref",
2490
+ "--symbolic-full-name",
2491
+ "@{upstream}"
2492
+ ]);
2493
+ const out = await this.#run(upstream ? ["push"] : ["push", "--set-upstream", "origin", branch], { token, timeout: NET_TIMEOUT_MS });
2494
+ return out || `\u041E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043E: ${branch}`;
2495
+ }
2496
+ /**
2497
+ * Забрать с удалённого репозитория.
2498
+ *
2499
+ * `merge: false` — только `fetch`: счётчики «отстаём/опережаем» обновятся, а
2500
+ * рабочее дерево останется каким было. Это безопасное действие, и оно же
2501
+ * единственный способ узнать, что на remote что-то появилось.
2502
+ *
2503
+ * `merge: true` — `pull --ff-only`. Только перемотка: настоящее слияние
2504
+ * создаёт коммит и умеет оставить дерево в конфликте, а разбирать конфликт
2505
+ * из панели истории нечем. Когда перемотка невозможна, git отказывается сам,
2506
+ * и его текст объясняет это лучше нашего.
2507
+ */
2508
+ async pull(merge, token) {
2509
+ if (!merge) {
2510
+ const out2 = await this.#run(["fetch", "--all", "--prune"], {
2511
+ token,
2512
+ timeout: NET_TIMEOUT_MS
2513
+ });
2514
+ return out2 || "\u0418\u0437\u043C\u0435\u043D\u0435\u043D\u0438\u044F \u0437\u0430\u0431\u0440\u0430\u043D\u044B \u2014 \u0438\u0441\u0442\u043E\u0440\u0438\u044F remote \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430";
2515
+ }
2516
+ const out = await this.#run(["pull", "--ff-only"], { token, timeout: NET_TIMEOUT_MS });
2517
+ return out || "\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u043E";
2518
+ }
2519
+ /**
2520
+ * Прописать `origin`.
2521
+ *
2522
+ * `set-url`, если он уже есть: «добавить существующее» — обычная причина
2523
+ * сюда попасть (адрес поменялся, репозиторий переехал), и отвечать на неё
2524
+ * ошибкой git «remote origin already exists» значило бы требовать удалить
2525
+ * remote где-то ещё, а такого места в приложении нет.
2526
+ */
2527
+ async setRemote(url2) {
2528
+ const target = requireUrl(url2);
2529
+ const existing = await this.#soft(["remote", "get-url", "origin"]);
2530
+ await this.#run(existing ? ["remote", "set-url", "origin", target] : ["remote", "add", "origin", target]);
2531
+ return existing ? `origin \u0442\u0435\u043F\u0435\u0440\u044C ${target}` : `origin \u2014 ${target}`;
2532
+ }
2533
+ /**
2534
+ * Что можно положить в первый коммит.
2535
+ *
2536
+ * `--others --cached` — файлы, которых нет в истории: неотслеживаемые и уже
2537
+ * добавленные в индекс. `--exclude-standard` уважает существующий
2538
+ * `.gitignore`: если человек уже описал, что в репозиторий не нужно,
2539
+ * спрашивать его об этом второй раз галочками незачем.
2540
+ *
2541
+ * `-z` — потому что путь с переводом строки в имени иначе разъехался бы на
2542
+ * две записи, и в коммит попал бы файл с выдуманным именем.
2543
+ */
2544
+ async candidates() {
2545
+ const args = ["ls-files", "-z", "--others", "--cached", "--exclude-standard"];
2546
+ const inside = await this.#run(["rev-parse", "--is-inside-work-tree"]).then(() => true, () => false);
2547
+ const raw = inside ? await this.#soft(args) : await this.#withoutRepo(args);
2548
+ const all = raw.split("\0").filter(Boolean);
2549
+ return { files: all.slice(0, CANDIDATE_LIMIT), truncated: all.length > CANDIDATE_LIMIT };
2550
+ }
2551
+ /**
2552
+ * Спросить git о каталоге, который репозиторием ещё не стал.
2553
+ *
2554
+ * Нужно ровно для одного: показать список файлов до публикации проекта, у
2555
+ * которого нет даже `.git`. Просто позвать `git init` заранее нельзя —
2556
+ * открытие окна не должно оставлять после себя репозиторий, если человек
2557
+ * передумал и закрыл его.
2558
+ *
2559
+ * Поэтому репозиторий заводится пустой и во временном каталоге, а рабочим
2560
+ * деревом ему назначается проект: `--exclude-standard` при этом читает
2561
+ * настоящий `.gitignore` проекта, и список получается тот же, что и после
2562
+ * `git init`. Свой обход каталога дал бы другой — правила игнорирования
2563
+ * пришлось бы разбирать самим, и они разошлись бы с git ровно там, где это
2564
+ * важнее всего (см. `checkIgnored`).
2565
+ */
2566
+ async #withoutRepo(args) {
2567
+ const dir = await mkdtemp(path6.join(tmpdir(), "ccv-git-"));
2568
+ try {
2569
+ await this.#run(["init", "--bare", "--quiet", dir]);
2570
+ return await this.#soft([`--git-dir=${dir}`, `--work-tree=${this.cwd}`, ...args]);
2571
+ } finally {
2572
+ await rm2(dir, { recursive: true, force: true }).catch(() => void 0);
2573
+ }
2574
+ }
2575
+ /**
2576
+ * Первый коммит: дописать `.gitignore`, добавить выбранное, закоммитить.
2577
+ *
2578
+ * Ровно тот порядок, что и в «Publish to GitHub» у VS Code, и он важен:
2579
+ * `.gitignore` должен попасть в тот же коммит, что и файлы, иначе первый же
2580
+ * `git status` после публикации покажет его неотслеживаемым.
2581
+ *
2582
+ * Пути уходят через stdin (`--pathspec-from-file=-`), а не аргументами: их
2583
+ * тут могут быть тысячи, и в командную строку Windows они не влезут. `--nul`
2584
+ * к тому же снимает вопрос о путях с пробелами и переводами строк, а заодно
2585
+ * лишает строку магии pathspec — `:(exclude)` из браузера здесь не сработает.
2586
+ */
2587
+ async commit(message, include, ignore) {
2588
+ const text = message.trim();
2589
+ if (!text)
2590
+ throw new Error("\u041F\u0443\u0441\u0442\u043E\u0435 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435 \u043A\u043E\u043C\u043C\u0438\u0442\u0430");
2591
+ const paths = include.map(requirePath);
2592
+ if (paths.length === 0)
2593
+ throw new Error("\u041D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D\u043E \u043D\u0438 \u043E\u0434\u043D\u043E\u0433\u043E \u0444\u0430\u0439\u043B\u0430 \u0434\u043B\u044F \u043F\u0435\u0440\u0432\u043E\u0433\u043E \u043A\u043E\u043C\u043C\u0438\u0442\u0430");
2594
+ const ignored = ignore.map(requirePath);
2595
+ if (ignored.length > 0) {
2596
+ await this.#appendGitignore(ignored);
2597
+ paths.push(".gitignore");
2598
+ }
2599
+ await this.#run(["add", "--pathspec-from-file=-", "--pathspec-file-nul"], {
2600
+ input: paths.join("\0")
2601
+ });
2602
+ return await this.#run(["commit", "-m", text]);
2603
+ }
2604
+ /**
2605
+ * Дописать в `.gitignore` то, что не выбрали.
2606
+ *
2607
+ * Дописать, а не переписать: файл мог быть уже — с правилами, которые сюда
2608
+ * не приходили (их пути `--exclude-standard` из списка и убрал), и затереть
2609
+ * их значило бы вернуть в репозиторий ровно то, что человек однажды из него
2610
+ * исключил. Повторы отсеиваются, чтобы файл не рос от каждой публикации.
2611
+ */
2612
+ async #appendGitignore(paths) {
2613
+ const file = path6.join(this.cwd, ".gitignore");
2614
+ const existing = await readFile4(file, "utf8").catch(() => "");
2615
+ const known = new Set(existing.split(/\r?\n/).map((line) => line.trim()));
2616
+ const lines = paths.map(ignorePattern).filter((line) => !known.has(line));
2617
+ if (lines.length === 0)
2618
+ return;
2619
+ const prefix = existing === "" || existing.endsWith("\n") ? "" : "\n";
2620
+ await writeFile2(file, `${existing}${prefix}${lines.join("\n")}
2621
+ `, "utf8");
2622
+ }
2458
2623
  /** Команда, чей провал — обычное дело (нет upstream, нет коммитов, detached). */
2459
2624
  async #soft(args) {
2460
2625
  return this.#run(args).catch(() => "");
2461
2626
  }
2462
- /** `input` — то, что команда читает со stdin; без него stdin сразу закрывается. */
2463
- #run(args, input) {
2627
+ /**
2628
+ * Запустить git.
2629
+ *
2630
+ * `input` — то, что команда читает со stdin; без него stdin сразу
2631
+ * закрывается. `token` — учётные данные GitHub на одну команду (см.
2632
+ * `credentialArgs`). `timeout` переопределяется у тех команд, что ходят в
2633
+ * сеть: обычный потолок для них слишком мал.
2634
+ */
2635
+ #run(args, options2 = {}) {
2636
+ const token = options2.token ?? "";
2464
2637
  return new Promise((resolve, reject) => {
2465
2638
  const child = execFile2(
2466
2639
  "git",
2467
2640
  // core.quotepath=false — иначе кириллические пути приезжают в виде
2468
2641
  // «\320\277\321\200…» и в панели читаются как мусор.
2469
- ["-c", "core.quotepath=false", ...args],
2642
+ ["-c", "core.quotepath=false", ...credentialArgs(token), ...args],
2470
2643
  {
2471
2644
  cwd: this.cwd,
2472
- timeout: TIMEOUT_MS,
2645
+ timeout: options2.timeout ?? TIMEOUT_MS,
2473
2646
  maxBuffer: MAX_BUFFER,
2474
2647
  windowsHide: true,
2475
- encoding: "utf8"
2648
+ encoding: "utf8",
2649
+ env: {
2650
+ ...process.env,
2651
+ // Токен уходит окружением, а не аргументом: аргументы видны в
2652
+ // списке процессов любому пользователю машины.
2653
+ ...token ? { CCV_GIT_TOKEN: token } : {},
2654
+ // Хост не сидит в терминале: спроси git пароль — и он будет ждать
2655
+ // ответа от stdin, которого никто не даст, до самого таймаута.
2656
+ GIT_TERMINAL_PROMPT: "0"
2657
+ }
2476
2658
  },
2477
2659
  (error, stdout, stderr) => {
2478
2660
  if (!error)
@@ -2486,10 +2668,20 @@ var GitRepo = class {
2486
2668
  );
2487
2669
  child.stdin?.on("error", () => {
2488
2670
  });
2489
- child.stdin?.end(input ?? "");
2671
+ child.stdin?.end(options2.input ?? "");
2490
2672
  });
2491
2673
  }
2492
2674
  };
2675
+ function credentialArgs(token) {
2676
+ if (!token)
2677
+ return [];
2678
+ return [
2679
+ "-c",
2680
+ "credential.helper=",
2681
+ "-c",
2682
+ 'credential.helper=!f() { echo username=x-access-token; echo "password=$CCV_GIT_TOKEN"; }; f'
2683
+ ];
2684
+ }
2493
2685
  function requireRef(value) {
2494
2686
  const trimmed = value.trim();
2495
2687
  if (!SAFE_REF.test(trimmed) || trimmed.includes("..")) {
@@ -2497,6 +2689,23 @@ function requireRef(value) {
2497
2689
  }
2498
2690
  return trimmed;
2499
2691
  }
2692
+ function requireUrl(value) {
2693
+ const trimmed = value.trim();
2694
+ if (!SAFE_URL.test(trimmed)) {
2695
+ throw new Error(`\u041D\u0435 \u043F\u043E\u0445\u043E\u0436\u0435 \u043D\u0430 \u0430\u0434\u0440\u0435\u0441 \u0440\u0435\u043F\u043E\u0437\u0438\u0442\u043E\u0440\u0438\u044F: \xAB${value}\xBB`);
2696
+ }
2697
+ return trimmed;
2698
+ }
2699
+ function requirePath(value) {
2700
+ const trimmed = value.trim().replace(/\\/g, "/");
2701
+ const bad = trimmed === "" || trimmed.startsWith("-") || trimmed.startsWith("/") || trimmed.includes(":") || trimmed.split("/").includes("..");
2702
+ if (bad)
2703
+ throw new Error(`\u041D\u0435\u0434\u043E\u043F\u0443\u0441\u0442\u0438\u043C\u044B\u0439 \u043F\u0443\u0442\u044C: \xAB${value}\xBB`);
2704
+ return trimmed;
2705
+ }
2706
+ function ignorePattern(relPath) {
2707
+ return `/${relPath.replace(/[\\*?[\]]/g, (char) => `\\${char}`)}`;
2708
+ }
2500
2709
  function describe2(error) {
2501
2710
  return error instanceof Error ? error.message : String(error);
2502
2711
  }
@@ -2607,9 +2816,9 @@ function parsePatch(raw) {
2607
2816
  body = "";
2608
2817
  }
2609
2818
  total += body.length;
2610
- const path17 = status === "deleted" ? oldPath : newPath || oldPath;
2819
+ const path19 = status === "deleted" ? oldPath : newPath || oldPath;
2611
2820
  files.push({
2612
- path: path17,
2821
+ path: path19,
2613
2822
  oldPath: status === "renamed" ? oldPath : "",
2614
2823
  status,
2615
2824
  added: count(body, "+"),
@@ -2621,10 +2830,10 @@ function parsePatch(raw) {
2621
2830
  return { files, truncated };
2622
2831
  }
2623
2832
  function stripPrefix(value) {
2624
- const path17 = unquote(value.trim());
2625
- if (path17 === "/dev/null")
2833
+ const path19 = unquote(value.trim());
2834
+ if (path19 === "/dev/null")
2626
2835
  return "";
2627
- return path17.replace(/^[ab]\//, "");
2836
+ return path19.replace(/^[ab]\//, "");
2628
2837
  }
2629
2838
  function count(body, mark) {
2630
2839
  let n = 0;
@@ -2635,11 +2844,241 @@ function count(body, mark) {
2635
2844
  return n;
2636
2845
  }
2637
2846
 
2847
+ // ../host/dist/github.js
2848
+ import { execFile as execFile3 } from "node:child_process";
2849
+ import { mkdir as mkdir2, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
2850
+ import path7 from "node:path";
2851
+ var API = "https://api.github.com";
2852
+ var API_VERSION = "2022-11-28";
2853
+ var API_TIMEOUT_MS = 2e4;
2854
+ var GH_TIMEOUT_MS = 15e3;
2855
+ var ORG_LIMIT = 100;
2856
+ var GitHubAccount = class {
2857
+ #storePath;
2858
+ #token = "";
2859
+ /**
2860
+ * Есть ли `gh` в PATH. `null` — ещё не проверяли.
2861
+ *
2862
+ * Промах не запоминается навсегда по той же причине, что и в поиске `claude`
2863
+ * (см. [`claude-cli.ts`](./claude-cli.ts)): CLI могли поставить уже после
2864
+ * запуска хоста, и требовать за это перезапуск было бы грубо. Здесь для
2865
+ * этого хватает сброса при каждом чтении статуса — его спрашивают тогда,
2866
+ * когда человек стоит перед окном публикации и готов подождать одну попытку
2867
+ * запуска процесса.
2868
+ */
2869
+ #ghInstalled = null;
2870
+ constructor(dataDir) {
2871
+ this.#storePath = path7.join(dataDir, "github.json");
2872
+ }
2873
+ async load() {
2874
+ try {
2875
+ const raw = JSON.parse(await readFile5(this.#storePath, "utf8"));
2876
+ const token = raw.token;
2877
+ this.#token = typeof token === "string" ? token.trim() : "";
2878
+ } catch {
2879
+ this.#token = "";
2880
+ }
2881
+ }
2882
+ /** Сохранить токен или стереть его пустой строкой. */
2883
+ async setToken(token) {
2884
+ this.#token = token.trim();
2885
+ await mkdir2(path7.dirname(this.#storePath), { recursive: true });
2886
+ await writeFile3(this.#storePath, JSON.stringify({ token: this.#token }, null, 2), {
2887
+ encoding: "utf8",
2888
+ mode: 384
2889
+ });
2890
+ }
2891
+ /**
2892
+ * Кто подключён.
2893
+ *
2894
+ * Не «есть ли токен», а «принял ли его GitHub»: просроченный или отозванный
2895
+ * токен лежит в файле ровно так же, как рабочий, и разница видна только по
2896
+ * ответу на запрос. Поэтому здесь всегда поход в сеть — зато окно публикации
2897
+ * не предложит создать репозиторий тем, чем создать его не выйдет.
2898
+ */
2899
+ async status() {
2900
+ const ghInstalled = await this.#hasGh();
2901
+ const base = {
2902
+ connected: false,
2903
+ source: "none",
2904
+ login: "",
2905
+ ghInstalled,
2906
+ hasToken: this.#token !== "",
2907
+ message: ""
2908
+ };
2909
+ try {
2910
+ const credential = await this.credential();
2911
+ return { ...base, connected: true, source: credential.source, login: credential.login };
2912
+ } catch (error) {
2913
+ return { ...base, message: describe3(error) };
2914
+ }
2915
+ }
2916
+ /**
2917
+ * Чем работать с GitHub. Бросает, если ни один источник не подошёл.
2918
+ *
2919
+ * Логин приходит тем же запросом, что и проверка токена: `GET /user` всё
2920
+ * равно единственный способ узнать, жив ли токен, и спрашивать имя вторым
2921
+ * запросом было бы лишним походом в сеть за тем, что уже пришло.
2922
+ */
2923
+ async credential() {
2924
+ const ghToken = await this.#ghToken();
2925
+ if (ghToken) {
2926
+ const login = await this.#login(ghToken).catch(() => "");
2927
+ if (login)
2928
+ return { token: ghToken, source: "gh", login };
2929
+ }
2930
+ if (this.#token) {
2931
+ const login = await this.#login(this.#token);
2932
+ return { token: this.#token, source: "token", login };
2933
+ }
2934
+ throw new Error(ghToken ? "GitHub CLI \u0431\u043E\u043B\u044C\u0448\u0435 \u043D\u0435 \u0430\u0432\u0442\u043E\u0440\u0438\u0437\u043E\u0432\u0430\u043D \u2014 \u0432\u044B\u043F\u043E\u043B\u043D\u0438\u0442\u0435 `gh auth login` \u0438\u043B\u0438 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u043E\u043A\u0435\u043D" : "\u041D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A GitHub \u2014 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u043E\u043A\u0435\u043D \u0438\u043B\u0438 \u0432\u044B\u043F\u043E\u043B\u043D\u0438\u0442\u0435 `gh auth login`");
2935
+ }
2936
+ /**
2937
+ * Куда можно создать репозиторий: сам пользователь и его организации.
2938
+ *
2939
+ * Организации, в которых нет права заводить репозитории, GitHub всё равно
2940
+ * вернёт — прав он по этому запросу не сообщает. Отсеивать их здесь нечем, и
2941
+ * ошибку в таком случае показывает уже создание: она приходит от GitHub
2942
+ * текстом, который объясняет причину лучше, чем наша догадка.
2943
+ */
2944
+ async owners() {
2945
+ const { token, login } = await this.credential();
2946
+ const orgs = await this.#api(token, `/user/orgs?per_page=${ORG_LIMIT}`).catch(
2947
+ // Токен без scope `read:org` организаций не покажет. Это не повод не дать
2948
+ // создать репозиторий у себя — а именно это и нужно в большинстве случаев.
2949
+ () => []
2950
+ );
2951
+ return [
2952
+ { login, kind: "user" },
2953
+ ...orgs.map((org) => typeof org.login === "string" ? org.login : "").filter(Boolean).map((org) => ({ login: org, kind: "org" }))
2954
+ ];
2955
+ }
2956
+ /**
2957
+ * Создать репозиторий.
2958
+ *
2959
+ * Пустой — без README, .gitignore и лицензии: у нас на диске уже лежит
2960
+ * проект, и любой файл, созданный на стороне GitHub, превратил бы первый же
2961
+ * push в конфликт историй.
2962
+ */
2963
+ async createRepo(options2) {
2964
+ const { token, login } = await this.credential();
2965
+ const endpoint = options2.owner === login ? "/user/repos" : `/orgs/${encodeURIComponent(options2.owner)}/repos`;
2966
+ const created = await this.#api(token, endpoint, {
2967
+ method: "POST",
2968
+ body: {
2969
+ name: options2.name,
2970
+ description: options2.description,
2971
+ private: options2.private,
2972
+ auto_init: false
2973
+ }
2974
+ });
2975
+ const cloneUrl = str(created.clone_url);
2976
+ if (!cloneUrl)
2977
+ throw new Error("GitHub \u0441\u043E\u0437\u0434\u0430\u043B \u0440\u0435\u043F\u043E\u0437\u0438\u0442\u043E\u0440\u0438\u0439, \u043D\u043E \u043D\u0435 \u0432\u0435\u0440\u043D\u0443\u043B \u0430\u0434\u0440\u0435\u0441 \u0434\u043B\u044F git");
2978
+ return {
2979
+ cloneUrl,
2980
+ htmlUrl: str(created.html_url) || cloneUrl.replace(/\.git$/, ""),
2981
+ fullName: str(created.full_name) || `${options2.owner}/${options2.name}`
2982
+ };
2983
+ }
2984
+ // ---------------------------------------------------------------- внутреннее
2985
+ /** Логин владельца токена; заодно это и есть проверка токена на живость. */
2986
+ async #login(token) {
2987
+ const user = await this.#api(token, "/user");
2988
+ const login = str(user.login);
2989
+ if (!login)
2990
+ throw new Error("GitHub \u043D\u0435 \u043D\u0430\u0437\u0432\u0430\u043B \u0443\u0447\u0451\u0442\u043D\u0443\u044E \u0437\u0430\u043F\u0438\u0441\u044C \u2014 \u0442\u043E\u043A\u0435\u043D \u043D\u0435 \u043F\u043E\u0434\u043E\u0448\u0451\u043B");
2991
+ return login;
2992
+ }
2993
+ async #api(token, endpoint, init) {
2994
+ let response;
2995
+ try {
2996
+ response = await fetch(`${API}${endpoint}`, {
2997
+ method: init?.method ?? "GET",
2998
+ headers: {
2999
+ Accept: "application/vnd.github+json",
3000
+ Authorization: `Bearer ${token}`,
3001
+ "X-GitHub-Api-Version": API_VERSION,
3002
+ // GitHub отвечает 403 на запрос без User-Agent — это его требование,
3003
+ // а не вежливость.
3004
+ "User-Agent": "claude-code-visual",
3005
+ ...init ? { "Content-Type": "application/json" } : {}
3006
+ },
3007
+ body: init ? JSON.stringify(init.body) : void 0,
3008
+ signal: AbortSignal.timeout(API_TIMEOUT_MS)
3009
+ });
3010
+ } catch (error) {
3011
+ throw new Error(`GitHub \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D: ${describe3(error)}`);
3012
+ }
3013
+ const text = await response.text();
3014
+ if (!response.ok)
3015
+ throw new Error(apiError(response.status, text));
3016
+ try {
3017
+ return JSON.parse(text);
3018
+ } catch {
3019
+ throw new Error(`GitHub \u043E\u0442\u0432\u0435\u0442\u0438\u043B \u043D\u0435 JSON (${response.status})`);
3020
+ }
3021
+ }
3022
+ /** Токен GitHub CLI; пустая строка — `gh` нет или вход в нём не выполнен. */
3023
+ async #ghToken() {
3024
+ if (this.#ghInstalled === false)
3025
+ return "";
3026
+ const token = await gh(["auth", "token"]).catch(() => "");
3027
+ return token.trim();
3028
+ }
3029
+ async #hasGh() {
3030
+ const version2 = await gh(["--version"]).catch(() => "");
3031
+ this.#ghInstalled = version2 !== "";
3032
+ return this.#ghInstalled;
3033
+ }
3034
+ };
3035
+ function gh(args) {
3036
+ return new Promise((resolve, reject) => {
3037
+ execFile3("gh", args, { timeout: GH_TIMEOUT_MS, windowsHide: true, encoding: "utf8" }, (error, stdout, stderr) => {
3038
+ if (error)
3039
+ return reject(new Error(stderr.trim() || error.message));
3040
+ resolve(stdout);
3041
+ });
3042
+ });
3043
+ }
3044
+ function apiError(status, body) {
3045
+ let message = "";
3046
+ let details = "";
3047
+ try {
3048
+ const parsed = JSON.parse(body);
3049
+ message = str(parsed.message);
3050
+ if (Array.isArray(parsed.errors)) {
3051
+ details = parsed.errors.map((item) => str(item.message)).filter(Boolean).join("; ");
3052
+ }
3053
+ } catch {
3054
+ message = body.slice(0, 200).trim();
3055
+ }
3056
+ const text = [message, details].filter(Boolean).join(" \u2014 ");
3057
+ switch (status) {
3058
+ case 401:
3059
+ return "GitHub \u043E\u0442\u043A\u043B\u043E\u043D\u0438\u043B \u0442\u043E\u043A\u0435\u043D (401) \u2014 \u043E\u043D \u0438\u0441\u0442\u0451\u043A \u0438\u043B\u0438 \u043E\u0442\u043E\u0437\u0432\u0430\u043D";
3060
+ case 403:
3061
+ return `GitHub \u043E\u0442\u043A\u0430\u0437\u0430\u043B (403): ${text || "\u043D\u0435\u0442 \u043F\u0440\u0430\u0432 \u0438\u043B\u0438 \u043F\u0440\u0435\u0432\u044B\u0448\u0435\u043D \u043B\u0438\u043C\u0438\u0442 \u0437\u0430\u043F\u0440\u043E\u0441\u043E\u0432"}`;
3062
+ case 404:
3063
+ return `GitHub \u043E\u0442\u0432\u0435\u0442\u0438\u043B 404: ${text || "\u043D\u0435\u0442 \u0442\u0430\u043A\u043E\u0439 \u043E\u0440\u0433\u0430\u043D\u0438\u0437\u0430\u0446\u0438\u0438 \u0438\u043B\u0438 \u0443 \u0442\u043E\u043A\u0435\u043D\u0430 \u043D\u0435\u0442 \u043F\u0440\u0430\u0432 \u043D\u0430 \u043D\u0435\u0451"}`;
3064
+ case 422:
3065
+ return `GitHub \u043E\u0442\u043A\u0430\u0437\u0430\u043B\u0441\u044F \u0441\u043E\u0437\u0434\u0430\u0432\u0430\u0442\u044C: ${text || "\u0442\u0430\u043A\u043E\u0435 \u0438\u043C\u044F \u0443\u0436\u0435 \u0437\u0430\u043D\u044F\u0442\u043E"}`;
3066
+ default:
3067
+ return `GitHub \u043E\u0442\u0432\u0435\u0442\u0438\u043B ${status}${text ? `: ${text}` : ""}`;
3068
+ }
3069
+ }
3070
+ function str(value) {
3071
+ return typeof value === "string" ? value : "";
3072
+ }
3073
+ function describe3(error) {
3074
+ return error instanceof Error ? error.message : String(error);
3075
+ }
3076
+
2638
3077
  // ../host/dist/finder.js
2639
3078
  import { existsSync, statSync } from "node:fs";
2640
3079
  import { readdir as readdir2 } from "node:fs/promises";
2641
3080
  import os2 from "node:os";
2642
- import path6 from "node:path";
3081
+ import path8 from "node:path";
2643
3082
  import { listSessions } from "@anthropic-ai/claude-agent-sdk";
2644
3083
  var CACHE_TTL = 6e4;
2645
3084
  var FIND_LIMIT = 50;
@@ -2678,7 +3117,7 @@ var ProjectFinder = class {
2678
3117
  if (needles.length === 0)
2679
3118
  return { candidates: [], truncated: false };
2680
3119
  const open = new Map(this.registry.list().map((workspace) => [workspace.path.toLowerCase(), workspace]));
2681
- const byName = this.#dirs.filter((dir) => matches(path6.basename(dir.path), needles));
3120
+ const byName = this.#dirs.filter((dir) => matches(path8.basename(dir.path), needles));
2682
3121
  const matched = byName.length > 0 ? byName : this.#dirs.filter((dir) => matches(dir.path, needles));
2683
3122
  const sorted = [...matched].sort((a, b) => {
2684
3123
  const aOpen = open.has(a.path.toLowerCase());
@@ -2689,7 +3128,7 @@ var ProjectFinder = class {
2689
3128
  return a.sessions > 0 ? -1 : 1;
2690
3129
  if (a.lastUsedAt !== b.lastUsedAt)
2691
3130
  return b.lastUsedAt - a.lastUsedAt;
2692
- return path6.basename(a.path).localeCompare(path6.basename(b.path), void 0, {
3131
+ return path8.basename(a.path).localeCompare(path8.basename(b.path), void 0, {
2693
3132
  numeric: true,
2694
3133
  sensitivity: "base"
2695
3134
  });
@@ -2697,7 +3136,7 @@ var ProjectFinder = class {
2697
3136
  return {
2698
3137
  candidates: sorted.slice(0, FIND_LIMIT).map((dir) => ({
2699
3138
  path: dir.path,
2700
- name: path6.basename(dir.path) || dir.path,
3139
+ name: path8.basename(dir.path) || dir.path,
2701
3140
  sessions: dir.sessions,
2702
3141
  lastUsedAt: dir.lastUsedAt || null,
2703
3142
  open: open.has(dir.path.toLowerCase()),
@@ -2721,7 +3160,7 @@ var ProjectFinder = class {
2721
3160
  const candidates = [];
2722
3161
  for (const entry of named) {
2723
3162
  const absolute = entry.path.trim();
2724
- if (!absolute || !path6.isAbsolute(absolute))
3163
+ if (!absolute || !path8.isAbsolute(absolute))
2725
3164
  continue;
2726
3165
  const key = absolute.toLowerCase();
2727
3166
  if (seen.has(key))
@@ -2732,7 +3171,7 @@ var ProjectFinder = class {
2732
3171
  const record = known.get(key);
2733
3172
  candidates.push({
2734
3173
  path: record?.path ?? absolute,
2735
- name: path6.basename(absolute) || absolute,
3174
+ name: path8.basename(absolute) || absolute,
2736
3175
  sessions: record?.sessions ?? 0,
2737
3176
  lastUsedAt: record?.lastUsedAt || null,
2738
3177
  open: open.has(key),
@@ -2789,7 +3228,7 @@ var ProjectFinder = class {
2789
3228
  if (!dirent.isDirectory() || SKIP_NAMES.has(dirent.name) || dirent.name.startsWith(".")) {
2790
3229
  continue;
2791
3230
  }
2792
- const full = path6.join(parent, dirent.name);
3231
+ const full = path8.join(parent, dirent.name);
2793
3232
  const key = full.toLowerCase();
2794
3233
  if (dirs.has(key))
2795
3234
  continue;
@@ -2810,7 +3249,7 @@ var ProjectFinder = class {
2810
3249
  #parents(dirs) {
2811
3250
  const counted = /* @__PURE__ */ new Map();
2812
3251
  for (const dir of dirs) {
2813
- const parent = path6.dirname(dir.path);
3252
+ const parent = path8.dirname(dir.path);
2814
3253
  if (parent === dir.path || !usable(parent))
2815
3254
  continue;
2816
3255
  const key = parent.toLowerCase();
@@ -2922,18 +3361,18 @@ function fromText(text) {
2922
3361
 
2923
3362
  // ../host/dist/workspaces.js
2924
3363
  import { existsSync as existsSync2, statSync as statSync2 } from "node:fs";
2925
- import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
2926
- import path7 from "node:path";
3364
+ import { mkdir as mkdir3, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
3365
+ import path9 from "node:path";
2927
3366
  import { listSessions as listSessions2 } from "@anthropic-ai/claude-agent-sdk";
2928
3367
  var WorkspaceRegistry = class {
2929
3368
  #paths = [];
2930
3369
  #storePath;
2931
3370
  constructor(dataDir) {
2932
- this.#storePath = path7.join(dataDir, "workspaces.json");
3371
+ this.#storePath = path9.join(dataDir, "workspaces.json");
2933
3372
  }
2934
3373
  async load() {
2935
3374
  try {
2936
- const raw = await readFile4(this.#storePath, "utf8");
3375
+ const raw = await readFile6(this.#storePath, "utf8");
2937
3376
  const parsed = JSON.parse(raw);
2938
3377
  if (Array.isArray(parsed)) {
2939
3378
  this.#paths = parsed.filter((p) => typeof p === "string" && existsSync2(p));
@@ -2943,8 +3382,8 @@ var WorkspaceRegistry = class {
2943
3382
  }
2944
3383
  }
2945
3384
  async #persist() {
2946
- await mkdir2(path7.dirname(this.#storePath), { recursive: true });
2947
- await writeFile2(this.#storePath, JSON.stringify(this.#paths, null, 2), "utf8");
3385
+ await mkdir3(path9.dirname(this.#storePath), { recursive: true });
3386
+ await writeFile4(this.#storePath, JSON.stringify(this.#paths, null, 2), "utf8");
2948
3387
  }
2949
3388
  list() {
2950
3389
  return this.#paths.map(toInfo);
@@ -2953,7 +3392,7 @@ var WorkspaceRegistry = class {
2953
3392
  return this.list().find((w) => w.id === id);
2954
3393
  }
2955
3394
  async open(rawPath) {
2956
- const absolute = path7.resolve(rawPath);
3395
+ const absolute = path9.resolve(rawPath);
2957
3396
  if (!existsSync2(absolute)) {
2958
3397
  throw new Error(`\u041A\u0430\u0442\u0430\u043B\u043E\u0433 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: ${absolute}`);
2959
3398
  }
@@ -2995,24 +3434,24 @@ var WorkspaceRegistry = class {
2995
3434
  async create(parentRaw, nameRaw, init) {
2996
3435
  const name = nameRaw.trim();
2997
3436
  requireName(name);
2998
- const parent = path7.resolve(parentRaw.trim());
3437
+ const parent = path9.resolve(parentRaw.trim());
2999
3438
  if (!existsSync2(parent)) {
3000
3439
  throw new Error(`\u041A\u0430\u0442\u0430\u043B\u043E\u0433 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: ${parent}`);
3001
3440
  }
3002
3441
  if (!statSync2(parent).isDirectory()) {
3003
3442
  throw new Error(`\u042D\u0442\u043E \u043D\u0435 \u043A\u0430\u0442\u0430\u043B\u043E\u0433: ${parent}`);
3004
3443
  }
3005
- const target = path7.join(parent, name);
3444
+ const target = path9.join(parent, name);
3006
3445
  if (existsSync2(target)) {
3007
3446
  throw new Error(`\u0423\u0436\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442: ${target} \u2014 \u043E\u0442\u043A\u0440\u043E\u0439\u0442\u0435 \u0435\u0433\u043E \u043A\u0430\u043A \u043F\u0440\u043E\u0435\u043A\u0442`);
3008
3447
  }
3009
- await mkdir2(target);
3448
+ await mkdir3(target);
3010
3449
  const warnings = [];
3011
3450
  if (init.claudeMd) {
3012
- await step(warnings, "CLAUDE.md", () => writeFile2(path7.join(target, "CLAUDE.md"), claudeMdStub(name), "utf8"));
3451
+ await step(warnings, "CLAUDE.md", () => writeFile4(path9.join(target, "CLAUDE.md"), claudeMdStub(name), "utf8"));
3013
3452
  }
3014
3453
  if (init.readme) {
3015
- await step(warnings, "README.md", () => writeFile2(path7.join(target, "README.md"), readmeStub(name), "utf8"));
3454
+ await step(warnings, "README.md", () => writeFile4(path9.join(target, "README.md"), readmeStub(name), "utf8"));
3016
3455
  }
3017
3456
  if (init.git) {
3018
3457
  await step(warnings, "git init", () => new GitRepo(target).init());
@@ -3083,24 +3522,24 @@ function toInfo(absolute) {
3083
3522
  return {
3084
3523
  id: workspaceIdFromPath(absolute),
3085
3524
  path: absolute,
3086
- name: path7.basename(absolute) || absolute,
3087
- hasClaudeMd: existsSync2(path7.join(absolute, "CLAUDE.md"))
3525
+ name: path9.basename(absolute) || absolute,
3526
+ hasClaudeMd: existsSync2(path9.join(absolute, "CLAUDE.md"))
3088
3527
  };
3089
3528
  }
3090
3529
 
3091
3530
  // ../host/dist/archive.js
3092
- import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
3093
- import path8 from "node:path";
3531
+ import { mkdir as mkdir4, readFile as readFile7, writeFile as writeFile5 } from "node:fs/promises";
3532
+ import path10 from "node:path";
3094
3533
  var SessionArchive = class {
3095
3534
  #archived = /* @__PURE__ */ new Map();
3096
3535
  #restored = /* @__PURE__ */ new Map();
3097
3536
  #storePath;
3098
3537
  constructor(dataDir) {
3099
- this.#storePath = path8.join(dataDir, "session-archive.json");
3538
+ this.#storePath = path10.join(dataDir, "session-archive.json");
3100
3539
  }
3101
3540
  async load() {
3102
3541
  try {
3103
- const raw = await readFile5(this.#storePath, "utf8");
3542
+ const raw = await readFile7(this.#storePath, "utf8");
3104
3543
  const parsed = JSON.parse(raw);
3105
3544
  this.#archived = new Map(Object.entries(parsed.archived ?? {}).filter(([, r]) => isRecord(r)));
3106
3545
  this.#restored = new Map(Object.entries(parsed.restored ?? {}).filter((entry) => typeof entry[1] === "number"));
@@ -3189,8 +3628,8 @@ var SessionArchive = class {
3189
3628
  archived: Object.fromEntries(this.#archived),
3190
3629
  restored: Object.fromEntries(this.#restored)
3191
3630
  };
3192
- await mkdir3(path8.dirname(this.#storePath), { recursive: true });
3193
- await writeFile3(this.#storePath, JSON.stringify(file, null, 2), "utf8");
3631
+ await mkdir4(path10.dirname(this.#storePath), { recursive: true });
3632
+ await writeFile5(this.#storePath, JSON.stringify(file, null, 2), "utf8");
3194
3633
  }
3195
3634
  };
3196
3635
  function isRecord(value) {
@@ -3198,17 +3637,17 @@ function isRecord(value) {
3198
3637
  }
3199
3638
 
3200
3639
  // ../host/dist/pins.js
3201
- import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
3202
- import path9 from "node:path";
3640
+ import { mkdir as mkdir5, readFile as readFile8, writeFile as writeFile6 } from "node:fs/promises";
3641
+ import path11 from "node:path";
3203
3642
  var SessionPins = class {
3204
3643
  #pinned = /* @__PURE__ */ new Map();
3205
3644
  #storePath;
3206
3645
  constructor(dataDir) {
3207
- this.#storePath = path9.join(dataDir, "session-pins.json");
3646
+ this.#storePath = path11.join(dataDir, "session-pins.json");
3208
3647
  }
3209
3648
  async load() {
3210
3649
  try {
3211
- const raw = await readFile6(this.#storePath, "utf8");
3650
+ const raw = await readFile8(this.#storePath, "utf8");
3212
3651
  const parsed = JSON.parse(raw);
3213
3652
  this.#pinned = new Map(Object.entries(parsed.pinned ?? {}).filter(([, r]) => isRecord2(r)));
3214
3653
  } catch {
@@ -3306,8 +3745,8 @@ var SessionPins = class {
3306
3745
  }
3307
3746
  async #persist() {
3308
3747
  const file = { pinned: Object.fromEntries(this.#pinned) };
3309
- await mkdir4(path9.dirname(this.#storePath), { recursive: true });
3310
- await writeFile4(this.#storePath, JSON.stringify(file, null, 2), "utf8");
3748
+ await mkdir5(path11.dirname(this.#storePath), { recursive: true });
3749
+ await writeFile6(this.#storePath, JSON.stringify(file, null, 2), "utf8");
3311
3750
  }
3312
3751
  };
3313
3752
  function isRecord2(value) {
@@ -3315,9 +3754,9 @@ function isRecord2(value) {
3315
3754
  }
3316
3755
 
3317
3756
  // ../host/dist/app-settings.js
3318
- import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile5 } from "node:fs/promises";
3757
+ import { mkdir as mkdir6, readFile as readFile9, writeFile as writeFile7 } from "node:fs/promises";
3319
3758
  import { homedir as homedir3 } from "node:os";
3320
- import path10 from "node:path";
3759
+ import path12 from "node:path";
3321
3760
  var DEFAULT_AUTO_ARCHIVE_AFTER_MS = 2 * 24 * 60 * 60 * 1e3;
3322
3761
  var MIN_AUTO_ARCHIVE_AFTER_MS = 60 * 60 * 1e3;
3323
3762
  var MIN_AUTO_SAVE_AFTER_PAUSE_MS = 500;
@@ -3352,11 +3791,11 @@ var AppSettingsStore = class {
3352
3791
  #settings = { ...DEFAULT_APP_SETTINGS };
3353
3792
  #storePath;
3354
3793
  constructor(dataDir) {
3355
- this.#storePath = path10.join(dataDir, "app-settings.json");
3794
+ this.#storePath = path12.join(dataDir, "app-settings.json");
3356
3795
  }
3357
3796
  async load() {
3358
3797
  try {
3359
- const raw = await readFile7(this.#storePath, "utf8");
3798
+ const raw = await readFile9(this.#storePath, "utf8");
3360
3799
  this.#settings = sanitize(JSON.parse(raw), DEFAULT_APP_SETTINGS);
3361
3800
  } catch {
3362
3801
  this.#settings = { ...DEFAULT_APP_SETTINGS };
@@ -3376,8 +3815,8 @@ var AppSettingsStore = class {
3376
3815
  async update(patch) {
3377
3816
  const next = sanitize(patch, this.#settings);
3378
3817
  this.#settings = next;
3379
- await mkdir5(path10.dirname(this.#storePath), { recursive: true });
3380
- await writeFile5(this.#storePath, JSON.stringify(next, null, 2), "utf8");
3818
+ await mkdir6(path12.dirname(this.#storePath), { recursive: true });
3819
+ await writeFile7(this.#storePath, JSON.stringify(next, null, 2), "utf8");
3381
3820
  return this.current;
3382
3821
  }
3383
3822
  };
@@ -3415,7 +3854,7 @@ function sanitize(value, fallback) {
3415
3854
  return result;
3416
3855
  }
3417
3856
  function claudeHome() {
3418
- return path10.join(homedir3(), ".claude");
3857
+ return path12.join(homedir3(), ".claude");
3419
3858
  }
3420
3859
  function writableRoots(mode, cwd, workspacePaths) {
3421
3860
  if (mode === "all")
@@ -3425,7 +3864,7 @@ function writableRoots(mode, cwd, workspacePaths) {
3425
3864
  return [...roots];
3426
3865
  for (const workspace of [cwd, ...workspacePaths]) {
3427
3866
  roots.add(workspace);
3428
- const parent = path10.dirname(workspace);
3867
+ const parent = path12.dirname(workspace);
3429
3868
  if (parent !== workspace)
3430
3869
  roots.add(parent);
3431
3870
  }
@@ -3433,9 +3872,9 @@ function writableRoots(mode, cwd, workspacePaths) {
3433
3872
  }
3434
3873
 
3435
3874
  // ../host/dist/plugins.js
3436
- import { readFile as readFile8 } from "node:fs/promises";
3875
+ import { readFile as readFile10 } from "node:fs/promises";
3437
3876
  import { homedir as homedir4 } from "node:os";
3438
- import path11 from "node:path";
3877
+ import path13 from "node:path";
3439
3878
  var TIMEOUT_MS2 = 12e4;
3440
3879
  var SAFE_ARG = /^[A-Za-z0-9][A-Za-z0-9._@/-]*$/;
3441
3880
  var PluginCli = class {
@@ -3534,10 +3973,10 @@ async function readCatalog(markets, installed) {
3534
3973
  return lists.flat().sort((a, b) => a.name.localeCompare(b.name));
3535
3974
  }
3536
3975
  async function readMarketplace(market, installed) {
3537
- const root = market.installLocation || path11.join(homedir4(), ".claude", "plugins", "marketplaces", market.name);
3976
+ const root = market.installLocation || path13.join(homedir4(), ".claude", "plugins", "marketplaces", market.name);
3538
3977
  let manifest;
3539
3978
  try {
3540
- const text = await readFile8(path11.join(root, ".claude-plugin", "marketplace.json"), "utf8");
3979
+ const text = await readFile10(path13.join(root, ".claude-plugin", "marketplace.json"), "utf8");
3541
3980
  manifest = JSON.parse(text);
3542
3981
  } catch {
3543
3982
  return [];
@@ -3624,7 +4063,7 @@ var AuthCli = class extends EventEmitter2 {
3624
4063
  if (this.#child)
3625
4064
  throw new Error("\u0412\u0445\u043E\u0434 \u0443\u0436\u0435 \u0438\u0434\u0451\u0442 \u2014 \u0434\u043E\u0436\u0434\u0438\u0442\u0435\u0441\u044C \u0438\u043B\u0438 \u043E\u0442\u043C\u0435\u043D\u0438\u0442\u0435 \u0435\u0433\u043E");
3626
4065
  if (switchAccount) {
3627
- const out = await run(["auth", "logout"], QUICK_TIMEOUT_MS).catch(describe3);
4066
+ const out = await run(["auth", "logout"], QUICK_TIMEOUT_MS).catch(describe4);
3628
4067
  this.#push({ ...IDLE, active: true, output: `${out}
3629
4068
  ` });
3630
4069
  this.emit("changed");
@@ -3644,7 +4083,7 @@ var AuthCli = class extends EventEmitter2 {
3644
4083
  child.stdout?.on("data", (chunk) => this.#absorb(chunk.toString()));
3645
4084
  child.stderr?.on("data", (chunk) => this.#absorb(chunk.toString()));
3646
4085
  child.on("error", (error) => {
3647
- this.#fail(error.code === "ENOENT" ? "\u041D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D claude \u0432 PATH \u2014 \u0432\u0445\u043E\u0434 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D" : describe3(error));
4086
+ this.#fail(error.code === "ENOENT" ? "\u041D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D claude \u0432 PATH \u2014 \u0432\u0445\u043E\u0434 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D" : describe4(error));
3648
4087
  });
3649
4088
  child.on("close", (code) => {
3650
4089
  if (this.#child !== child)
@@ -3659,14 +4098,20 @@ var AuthCli = class extends EventEmitter2 {
3659
4098
  this.emit("changed");
3660
4099
  });
3661
4100
  }
3662
- /** Отдать процессу код, принесённый из браузера. */
4101
+ /**
4102
+ * Отдать процессу код, принесённый из браузера.
4103
+ *
4104
+ * `awaitingCode` намеренно остаётся поднятым: отправка кода не конец входа, а
4105
+ * попытка. CLI отвечает на неё либо успехом и выходом (тогда поле уберёт
4106
+ * `close`), либо строкой «Invalid code» — и во втором случае человеку нужно то
4107
+ * же самое поле, чтобы вставить код целиком.
4108
+ */
3663
4109
  submitCode(code) {
3664
4110
  const child = this.#child;
3665
4111
  if (!child?.stdin?.writable)
3666
4112
  throw new Error("\u0412\u0445\u043E\u0434 \u043D\u0435 \u0438\u0434\u0451\u0442 \u2014 \u0432\u0432\u043E\u0434\u0438\u0442\u044C \u043A\u043E\u0434 \u043D\u0435\u043A\u0443\u0434\u0430");
3667
- child.stdin.write(`${code.trim()}
4113
+ child.stdin.write(`${normalizeAuthCode(code)}
3668
4114
  `);
3669
- this.#push({ ...this.#state, awaitingCode: false });
3670
4115
  }
3671
4116
  /** Прервать вход. Учётные данные при этом остаются прежними. */
3672
4117
  cancel() {
@@ -3691,7 +4136,14 @@ var AuthCli = class extends EventEmitter2 {
3691
4136
  url: this.#state.url || URL_RE.exec(output)?.[0] || "",
3692
4137
  // Приглашение ищем в хвосте: раньше в выводе оно встретиться не может, а
3693
4138
  // так мы не примем за него упоминание кода в старом сообщении.
3694
- awaitingCode: CODE_PROMPT_RE.test(output.slice(-200))
4139
+ //
4140
+ // И не гасим его обратно: приглашение CLI печатает ровно один раз, а
4141
+ // читает строки со stdin до самого конца входа. Отвяжи мы поле от этой
4142
+ // однократной печати — первый же неверный код («Invalid code…» в хвосте
4143
+ // перестаёт подходить под выражение) убирал бы поле с экрана, и
4144
+ // исправить опечатку стало бы негде: вход ещё идёт, а ввести в него
4145
+ // нечего.
4146
+ awaitingCode: this.#state.awaitingCode || CODE_PROMPT_RE.test(output.slice(-200))
3695
4147
  });
3696
4148
  }
3697
4149
  #fail(message) {
@@ -3713,6 +4165,23 @@ var AuthCli = class extends EventEmitter2 {
3713
4165
  function run(args, timeout) {
3714
4166
  return runClaude(args, { timeout, missing: "\u0443\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435 \u0430\u043A\u043A\u0430\u0443\u043D\u0442\u043E\u043C \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u043D\u043E" });
3715
4167
  }
4168
+ function normalizeAuthCode(raw) {
4169
+ const value = raw.trim();
4170
+ if (!/^https?:\/\//i.test(value))
4171
+ return value;
4172
+ let url2;
4173
+ try {
4174
+ url2 = new URL(value);
4175
+ } catch {
4176
+ return value;
4177
+ }
4178
+ const params = new URLSearchParams(url2.search.slice(1) || url2.hash.replace(/^#/, ""));
4179
+ const code = params.get("code");
4180
+ const state = params.get("state");
4181
+ if (!code)
4182
+ return value;
4183
+ return state ? `${code}#${state}` : code;
4184
+ }
3716
4185
  function parseStatus2(raw) {
3717
4186
  const empty = {
3718
4187
  loggedIn: false,
@@ -3733,23 +4202,23 @@ function parseStatus2(raw) {
3733
4202
  }
3734
4203
  return {
3735
4204
  loggedIn: parsed.loggedIn === true,
3736
- method: str(parsed.authMethod) || "none",
3737
- email: str(parsed.email),
3738
- organization: str(parsed.orgName),
3739
- plan: str(parsed.subscriptionType)
4205
+ method: str2(parsed.authMethod) || "none",
4206
+ email: str2(parsed.email),
4207
+ organization: str2(parsed.orgName),
4208
+ plan: str2(parsed.subscriptionType)
3740
4209
  };
3741
4210
  }
3742
- function str(value) {
4211
+ function str2(value) {
3743
4212
  return typeof value === "string" ? value : "";
3744
4213
  }
3745
- function describe3(error) {
4214
+ function describe4(error) {
3746
4215
  return error instanceof Error ? error.message : String(error);
3747
4216
  }
3748
4217
 
3749
4218
  // ../host/dist/relay-link.js
3750
4219
  import { EventEmitter as EventEmitter3 } from "node:events";
3751
- import { readFile as readFile9, writeFile as writeFile6, rm as rm2, mkdir as mkdir6 } from "node:fs/promises";
3752
- import path12 from "node:path";
4220
+ import { readFile as readFile11, writeFile as writeFile8, rm as rm3, mkdir as mkdir7 } from "node:fs/promises";
4221
+ import path14 from "node:path";
3753
4222
  import { hostname } from "node:os";
3754
4223
  import WebSocket from "ws";
3755
4224
 
@@ -3760,6 +4229,9 @@ var RELAY_BROADCAST = "*";
3760
4229
  // ../host/dist/relay-link.js
3761
4230
  var RETRY_MIN_MS = 1e3;
3762
4231
  var RETRY_MAX_MS = 6e4;
4232
+ var HANDSHAKE_TIMEOUT_MS = 15e3;
4233
+ var CONNECT_TIMEOUT_MS = 3e4;
4234
+ var SILENCE_TIMEOUT_MS = 95e3;
3763
4235
  function jitter(delay) {
3764
4236
  return Math.round(delay * (0.8 + Math.random() * 0.4));
3765
4237
  }
@@ -3768,6 +4240,17 @@ var RelayLink = class extends EventEmitter3 {
3768
4240
  #socket = null;
3769
4241
  #retry = RETRY_MIN_MS;
3770
4242
  #retryTimer = null;
4243
+ /**
4244
+ * Сторож живого соединения — один таймер на три фазы жизни сокета.
4245
+ *
4246
+ * До `open` он сторожит подключение, после — тишину, и переставляется каждым
4247
+ * пришедшим кадром. Один таймер, а не три: у сокета в каждый момент есть
4248
+ * ровно один способ подвести, и держать под него отдельные заводные механизмы
4249
+ * значило бы гасить их по очереди в каждой ветке.
4250
+ */
4251
+ #watchdog = null;
4252
+ /** Сокет рвём мы сами — чтобы не подменить свою причину отказа причиной от `ws`. */
4253
+ #aborting = false;
3771
4254
  #clients = /* @__PURE__ */ new Set();
3772
4255
  #error = null;
3773
4256
  #closed = false;
@@ -3790,7 +4273,7 @@ var RelayLink = class extends EventEmitter3 {
3790
4273
  #log;
3791
4274
  constructor(options2) {
3792
4275
  super();
3793
- this.#file = path12.join(options2.dataDir, "relay.json");
4276
+ this.#file = path14.join(options2.dataDir, "relay.json");
3794
4277
  this.#defaultUrl = options2.defaultUrl.replace(/\/+$/, "");
3795
4278
  this.#log = options2.log ?? ((msg) => console.log(`[relay] ${msg}`));
3796
4279
  }
@@ -3871,7 +4354,7 @@ var RelayLink = class extends EventEmitter3 {
3871
4354
  }
3872
4355
  async #read() {
3873
4356
  try {
3874
- const raw = await readFile9(this.#file, "utf8");
4357
+ const raw = await readFile11(this.#file, "utf8");
3875
4358
  const parsed = JSON.parse(raw);
3876
4359
  if (typeof parsed !== "object" || parsed === null)
3877
4360
  return null;
@@ -3884,8 +4367,8 @@ var RelayLink = class extends EventEmitter3 {
3884
4367
  }
3885
4368
  }
3886
4369
  async #write(credentials) {
3887
- await mkdir6(path12.dirname(this.#file), { recursive: true });
3888
- await writeFile6(this.#file, `${JSON.stringify(credentials, null, 2)}
4370
+ await mkdir7(path14.dirname(this.#file), { recursive: true });
4371
+ await writeFile8(this.#file, `${JSON.stringify(credentials, null, 2)}
3889
4372
  `, {
3890
4373
  encoding: "utf8",
3891
4374
  mode: 384
@@ -3909,7 +4392,7 @@ var RelayLink = class extends EventEmitter3 {
3909
4392
  body: JSON.stringify({ code, name: machine, platform: process.platform })
3910
4393
  });
3911
4394
  } catch (error) {
3912
- throw new Error(`\u043D\u0435 \u0434\u043E\u0437\u0432\u043E\u043D\u0438\u043B\u0438\u0441\u044C \u0434\u043E ${url2}: ${describe4(error)}`);
4395
+ throw new Error(`\u043D\u0435 \u0434\u043E\u0437\u0432\u043E\u043D\u0438\u043B\u0438\u0441\u044C \u0434\u043E ${url2}: ${describe5(error)}`);
3913
4396
  }
3914
4397
  const data = await response.json().catch(() => ({}));
3915
4398
  if (!response.ok || !data.token || !data.hostId) {
@@ -3937,7 +4420,7 @@ var RelayLink = class extends EventEmitter3 {
3937
4420
  this.#error = null;
3938
4421
  this.#clients.clear();
3939
4422
  this.#shell = { enabled: false, pending: false, email: null, confirmedAt: null };
3940
- await rm2(this.#file, { force: true });
4423
+ await rm3(this.#file, { force: true });
3941
4424
  this.#changed();
3942
4425
  this.#log("\u0441\u043E\u043F\u0440\u044F\u0436\u0435\u043D\u0438\u0435 \u0441\u043D\u044F\u0442\u043E");
3943
4426
  }
@@ -3953,10 +4436,13 @@ var RelayLink = class extends EventEmitter3 {
3953
4436
  clearTimeout(this.#retryTimer);
3954
4437
  this.#retryTimer = null;
3955
4438
  }
4439
+ this.#disarm();
3956
4440
  const socket = this.#socket;
3957
4441
  this.#socket = null;
3958
4442
  if (socket) {
3959
4443
  socket.removeAllListeners();
4444
+ socket.on("error", () => {
4445
+ });
3960
4446
  socket.close();
3961
4447
  }
3962
4448
  for (const clientId of this.#clients)
@@ -3968,9 +4454,12 @@ var RelayLink = class extends EventEmitter3 {
3968
4454
  if (!credentials || this.#closed || this.#socket)
3969
4455
  return;
3970
4456
  const wsUrl = `${credentials.url.replace(/^http/, "ws")}/relay/host`;
3971
- const socket = new WebSocket(wsUrl);
4457
+ this.#aborting = false;
4458
+ const socket = new WebSocket(wsUrl, { handshakeTimeout: HANDSHAKE_TIMEOUT_MS });
3972
4459
  this.#socket = socket;
4460
+ this.#arm(socket, CONNECT_TIMEOUT_MS, "relay \u043D\u0435 \u043E\u0442\u0432\u0435\u0442\u0438\u043B \u043D\u0430 \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0435\u043D\u0438\u0435");
3973
4461
  socket.on("open", () => {
4462
+ this.#arm(socket, SILENCE_TIMEOUT_MS, "relay \u0437\u0430\u043C\u043E\u043B\u0447\u0430\u043B");
3974
4463
  const hello = {
3975
4464
  t: "host.hello",
3976
4465
  protocol: RELAY_PROTOCOL_VERSION,
@@ -3980,13 +4469,19 @@ var RelayLink = class extends EventEmitter3 {
3980
4469
  };
3981
4470
  socket.send(JSON.stringify(hello));
3982
4471
  });
3983
- socket.on("message", (raw) => this.#onMessage(socket, String(raw)));
4472
+ socket.on("message", (raw) => {
4473
+ this.#arm(socket, SILENCE_TIMEOUT_MS, "relay \u0437\u0430\u043C\u043E\u043B\u0447\u0430\u043B");
4474
+ this.#onMessage(socket, String(raw));
4475
+ });
3984
4476
  socket.on("error", (error) => {
3985
- this.#error = describe4(error);
4477
+ if (this.#aborting)
4478
+ return;
4479
+ this.#error = humanize(error);
3986
4480
  });
3987
4481
  socket.on("close", (code, reason) => {
3988
4482
  if (this.#socket !== socket)
3989
4483
  return;
4484
+ this.#disarm();
3990
4485
  this.#socket = null;
3991
4486
  for (const clientId of this.#clients)
3992
4487
  this.emit("detached", clientId);
@@ -4000,6 +4495,34 @@ var RelayLink = class extends EventEmitter3 {
4000
4495
  this.#scheduleRetry(rejected);
4001
4496
  });
4002
4497
  }
4498
+ /**
4499
+ * Взвести сторожа: не дождались — рвём сокет и уходим на общий круг повтора.
4500
+ *
4501
+ * `terminate()`, а не `close()`: закрытие по правилам ждёт ответного кадра от
4502
+ * стороны, которая как раз и не отвечает, — а нам нужен `close` на своей
4503
+ * стороне здесь и сейчас, потому что именно он запускает переподключение.
4504
+ * Для сокета, застрявшего в CONNECTING, `ws` тем же вызовом обрывает
4505
+ * рукопожатие — и это тот самый случай, ради которого всё написано.
4506
+ */
4507
+ #arm(socket, ms, reason) {
4508
+ this.#disarm();
4509
+ this.#watchdog = setTimeout(() => {
4510
+ this.#watchdog = null;
4511
+ if (this.#socket !== socket)
4512
+ return;
4513
+ this.#error = `${reason} \u0437\u0430 ${Math.round(ms / 1e3)} \u0441 \u2014 \u043F\u0440\u043E\u0431\u0443\u0435\u043C \u0441\u043D\u043E\u0432\u0430`;
4514
+ this.#log(this.#error);
4515
+ this.#aborting = true;
4516
+ socket.terminate();
4517
+ }, ms);
4518
+ this.#watchdog.unref();
4519
+ }
4520
+ #disarm() {
4521
+ if (!this.#watchdog)
4522
+ return;
4523
+ clearTimeout(this.#watchdog);
4524
+ this.#watchdog = null;
4525
+ }
4003
4526
  #scheduleRetry(rejected) {
4004
4527
  if (this.#closed || !this.#credentials || this.#retryTimer)
4005
4528
  return;
@@ -4088,14 +4611,21 @@ var RelayLink = class extends EventEmitter3 {
4088
4611
  socket.send(JSON.stringify(frame));
4089
4612
  }
4090
4613
  };
4091
- function describe4(error) {
4614
+ function describe5(error) {
4092
4615
  return error instanceof Error ? error.message : String(error);
4093
4616
  }
4617
+ function humanize(error) {
4618
+ const message = describe5(error);
4619
+ if (/handshake has timed out/i.test(message)) {
4620
+ return `relay \u043D\u0435 \u043E\u0442\u0432\u0435\u0442\u0438\u043B \u043D\u0430 \u0440\u0443\u043A\u043E\u043F\u043E\u0436\u0430\u0442\u0438\u0435 \u0437\u0430 ${Math.round(HANDSHAKE_TIMEOUT_MS / 1e3)} \u0441 \u2014 \u043F\u0440\u043E\u0431\u0443\u0435\u043C \u0441\u043D\u043E\u0432\u0430`;
4621
+ }
4622
+ return message;
4623
+ }
4094
4624
 
4095
4625
  // ../host/dist/terminal.js
4096
4626
  import { EventEmitter as EventEmitter4 } from "node:events";
4097
4627
  import { access as access2, constants as constants2 } from "node:fs/promises";
4098
- import path13 from "node:path";
4628
+ import path15 from "node:path";
4099
4629
  import { randomUUID as randomUUID2 } from "node:crypto";
4100
4630
  var SCROLLBACK_BYTES = 256 * 1024;
4101
4631
  var FLUSH_MS = 12;
@@ -4224,12 +4754,25 @@ var TerminalHub = class extends EventEmitter4 {
4224
4754
  #pty() {
4225
4755
  this.#module ??= import("@lydell/node-pty").then((module) => module).catch((error) => {
4226
4756
  this.#module = null;
4227
- const reason = error instanceof Error ? error.message : String(error);
4228
- throw new Error(`\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D \u043D\u0430 \u044D\u0442\u043E\u0439 \u043C\u0430\u0448\u0438\u043D\u0435: ${reason}`);
4757
+ throw new Error(explainMissingPty(error));
4229
4758
  });
4230
4759
  return this.#module;
4231
4760
  }
4232
4761
  };
4762
+ function explainMissingPty(error) {
4763
+ const reason = error instanceof Error ? error.message : String(error);
4764
+ const platformPackageMissing = reason.includes("could not find the platform-specific package") || /Cannot find module '@lydell\/node-pty-/.test(reason);
4765
+ if (!platformPackageMissing) {
4766
+ return `\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D \u043D\u0430 \u044D\u0442\u043E\u0439 \u043C\u0430\u0448\u0438\u043D\u0435: ${reason}`;
4767
+ }
4768
+ return [
4769
+ `\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D: \u0432 \u0443\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\u043D\u043E\u043C ccv \u043D\u0435\u0442 \u0441\u0431\u043E\u0440\u043A\u0438 PTY \u043F\u043E\u0434 ${process.platform}-${process.arch}.`,
4770
+ "\u041E\u0431\u044B\u0447\u043D\u043E \u044D\u0442\u043E \u0437\u043D\u0430\u0447\u0438\u0442, \u0447\u0442\u043E \u043F\u0430\u043A\u0435\u0442 \u0441\u0442\u0430\u0432\u0438\u043B\u0438 \u0441 \xAB--omit=optional\xBB \u043B\u0438\u0431\u043E node_modules \u043F\u0435\u0440\u0435\u0435\u0445\u0430\u043B \u0441 \u0434\u0440\u0443\u0433\u043E\u0439",
4771
+ "\u0441\u0438\u0441\u0442\u0435\u043C\u044B. \u041B\u0435\u0447\u0438\u0442\u0441\u044F \u043F\u0435\u0440\u0435\u0443\u0441\u0442\u0430\u043D\u043E\u0432\u043A\u043E\u0439: npm i -g @i14ks/ccv \u2014 \u0438 \u043F\u0435\u0440\u0435\u0437\u0430\u043F\u0443\u0441\u0442\u0438\u0442\u0435 ccv.",
4772
+ "",
4773
+ reason
4774
+ ].join("\n");
4775
+ }
4233
4776
  function clampSize(value, min, max, fallback) {
4234
4777
  if (!Number.isFinite(value))
4235
4778
  return fallback;
@@ -4252,22 +4795,22 @@ async function defaultShell() {
4252
4795
  if (pwsh)
4253
4796
  return { file: pwsh, args: [], title: "pwsh" };
4254
4797
  const system = process.env.SystemRoot ?? "C:\\Windows";
4255
- const powershell = path13.join(system, "System32/WindowsPowerShell/v1.0/powershell.exe");
4798
+ const powershell = path15.join(system, "System32/WindowsPowerShell/v1.0/powershell.exe");
4256
4799
  if (await exists2(powershell))
4257
4800
  return { file: powershell, args: [], title: "powershell" };
4258
4801
  return { file: process.env.COMSPEC ?? "cmd.exe", args: [], title: "cmd" };
4259
4802
  }
4260
4803
  const shell = process.env.SHELL;
4261
4804
  if (shell && await exists2(shell))
4262
- return { file: shell, args: [], title: path13.basename(shell) };
4805
+ return { file: shell, args: [], title: path15.basename(shell) };
4263
4806
  if (await exists2("/bin/bash"))
4264
4807
  return { file: "/bin/bash", args: [], title: "bash" };
4265
4808
  return { file: "/bin/sh", args: [], title: "sh" };
4266
4809
  }
4267
4810
  async function findOnPath(name) {
4268
- const dirs = (process.env.PATH ?? "").split(path13.delimiter).filter(Boolean);
4811
+ const dirs = (process.env.PATH ?? "").split(path15.delimiter).filter(Boolean);
4269
4812
  for (const dir of dirs) {
4270
- const candidate = path13.join(dir, name);
4813
+ const candidate = path15.join(dir, name);
4271
4814
  if (await exists2(candidate))
4272
4815
  return candidate;
4273
4816
  }
@@ -4278,10 +4821,10 @@ function exists2(file) {
4278
4821
  }
4279
4822
 
4280
4823
  // ../host/dist/mcp-config.js
4281
- import { readFile as readFile10, writeFile as writeFile7, mkdir as mkdir7 } from "node:fs/promises";
4282
- import path14 from "node:path";
4824
+ import { readFile as readFile12, writeFile as writeFile9, mkdir as mkdir8 } from "node:fs/promises";
4825
+ import path16 from "node:path";
4283
4826
  var MCP_FILE = ".mcp.json";
4284
- var LOCAL_SETTINGS2 = path14.join(".claude", "settings.local.json");
4827
+ var LOCAL_SETTINGS2 = path16.join(".claude", "settings.local.json");
4285
4828
  var queues = /* @__PURE__ */ new Map();
4286
4829
  function serialize(cwd, work) {
4287
4830
  const previous = queues.get(cwd) ?? Promise.resolve();
@@ -4290,7 +4833,7 @@ function serialize(cwd, work) {
4290
4833
  return next;
4291
4834
  }
4292
4835
  async function readProjectServers(cwd) {
4293
- const file = await readJson(path14.join(cwd, MCP_FILE));
4836
+ const file = await readJson(path16.join(cwd, MCP_FILE));
4294
4837
  const servers = file?.mcpServers;
4295
4838
  if (!servers || typeof servers !== "object")
4296
4839
  return {};
@@ -4298,23 +4841,23 @@ async function readProjectServers(cwd) {
4298
4841
  }
4299
4842
  function addProjectServer(cwd, name, config) {
4300
4843
  return serialize(cwd, async () => {
4301
- const file = await readJson(path14.join(cwd, MCP_FILE)) ?? {};
4844
+ const file = await readJson(path16.join(cwd, MCP_FILE)) ?? {};
4302
4845
  const servers = file.mcpServers ?? {};
4303
4846
  servers[name] = config;
4304
4847
  file.mcpServers = servers;
4305
- await writeJson(path14.join(cwd, MCP_FILE), file);
4848
+ await writeJson(path16.join(cwd, MCP_FILE), file);
4306
4849
  await approve(cwd, name);
4307
4850
  });
4308
4851
  }
4309
4852
  function removeProjectServer(cwd, name) {
4310
4853
  return serialize(cwd, async () => {
4311
- const file = await readJson(path14.join(cwd, MCP_FILE));
4854
+ const file = await readJson(path16.join(cwd, MCP_FILE));
4312
4855
  const servers = file?.mcpServers;
4313
4856
  if (!servers || !(name in servers))
4314
4857
  return false;
4315
4858
  delete servers[name];
4316
- await writeJson(path14.join(cwd, MCP_FILE), file);
4317
- const settingsPath = path14.join(cwd, LOCAL_SETTINGS2);
4859
+ await writeJson(path16.join(cwd, MCP_FILE), file);
4860
+ const settingsPath = path16.join(cwd, LOCAL_SETTINGS2);
4318
4861
  const settings = await readJson(settingsPath);
4319
4862
  const enabled = settings?.enabledMcpjsonServers;
4320
4863
  if (settings && Array.isArray(enabled) && enabled.includes(name)) {
@@ -4325,7 +4868,7 @@ function removeProjectServer(cwd, name) {
4325
4868
  });
4326
4869
  }
4327
4870
  async function approve(cwd, name) {
4328
- const file = path14.join(cwd, LOCAL_SETTINGS2);
4871
+ const file = path16.join(cwd, LOCAL_SETTINGS2);
4329
4872
  const settings = await readJson(file) ?? {};
4330
4873
  const enabled = Array.isArray(settings.enabledMcpjsonServers) ? settings.enabledMcpjsonServers : [];
4331
4874
  if (!enabled.includes(name))
@@ -4364,21 +4907,21 @@ function describeConfig(config) {
4364
4907
  }
4365
4908
  async function readJson(file) {
4366
4909
  try {
4367
- const raw = await readFile10(file, "utf8");
4910
+ const raw = await readFile12(file, "utf8");
4368
4911
  const parsed = JSON.parse(raw);
4369
4912
  return parsed && typeof parsed === "object" ? parsed : null;
4370
4913
  } catch (error) {
4371
4914
  if (error.code === "ENOENT")
4372
4915
  return null;
4373
4916
  if (error instanceof SyntaxError) {
4374
- throw new Error(`\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0440\u0430\u0437\u043E\u0431\u0440\u0430\u0442\u044C ${path14.basename(file)}: ${error.message}`);
4917
+ throw new Error(`\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0440\u0430\u0437\u043E\u0431\u0440\u0430\u0442\u044C ${path16.basename(file)}: ${error.message}`);
4375
4918
  }
4376
4919
  throw error;
4377
4920
  }
4378
4921
  }
4379
4922
  async function writeJson(file, data) {
4380
- await mkdir7(path14.dirname(file), { recursive: true });
4381
- await writeFile7(file, `${JSON.stringify(data, null, 2)}
4923
+ await mkdir8(path16.dirname(file), { recursive: true });
4924
+ await writeFile9(file, `${JSON.stringify(data, null, 2)}
4382
4925
  `, "utf8");
4383
4926
  }
4384
4927
 
@@ -4414,6 +4957,16 @@ var HostServer = class {
4414
4957
  #auth = new AuthCli();
4415
4958
  #relay;
4416
4959
  #terminals = new TerminalHub();
4960
+ #github;
4961
+ /**
4962
+ * Проекты, в которых прямо сейчас идёт сетевая операция git.
4963
+ *
4964
+ * Замок, а не индикатор: рабочее дерево у вкладок общее, и второй `push`
4965
+ * поверх первого — это два процесса git на один `.git`, то есть блокировка
4966
+ * индекса и половина операции. Ключ — `workspaceId`: параллельная работа с
4967
+ * разными проектами при этом остаётся возможной.
4968
+ */
4969
+ #gitBusy = /* @__PURE__ */ new Set();
4417
4970
  constructor(options2) {
4418
4971
  this.#options = options2;
4419
4972
  this.#workspaces = new WorkspaceRegistry(options2.dataDir);
@@ -4422,6 +4975,7 @@ var HostServer = class {
4422
4975
  this.#settings = new AppSettingsStore(options2.dataDir);
4423
4976
  this.#finder = new ProjectFinder(this.#workspaces);
4424
4977
  this.#relay = new RelayLink({ dataDir: options2.dataDir, defaultUrl: options2.relayUrl });
4978
+ this.#github = new GitHubAccount(options2.dataDir);
4425
4979
  this.#wireRelay();
4426
4980
  this.#wireTerminals();
4427
4981
  this.#auth.on("login", (state) => {
@@ -4436,6 +4990,7 @@ var HostServer = class {
4436
4990
  await this.#archive.load();
4437
4991
  await this.#pins.load();
4438
4992
  await this.#settings.load();
4993
+ await this.#github.load();
4439
4994
  await warmClaudeLauncher();
4440
4995
  void this.#finder.warm();
4441
4996
  const httpServer = createServer((req, res) => this.#serveHttp(req, res));
@@ -4899,24 +5454,47 @@ var HostServer = class {
4899
5454
  detail: await this.#git(frame.workspaceId).show(frame.ref)
4900
5455
  });
4901
5456
  return;
4902
- case "git.checkout": {
4903
- const repo = this.#git(frame.workspaceId);
4904
- let ok = true;
4905
- let message;
4906
- try {
4907
- message = await repo.checkout(frame.target);
4908
- } catch (error) {
4909
- ok = false;
4910
- message = error instanceof Error ? error.message : String(error);
4911
- }
4912
- this.#broadcast({ type: "git.result", workspaceId: frame.workspaceId, ok, message });
4913
- this.#broadcast({
4914
- type: "git.state",
5457
+ case "git.checkout":
5458
+ await this.#gitOperation(frame.workspaceId, async (repo) => ({
5459
+ message: await repo.checkout(frame.target)
5460
+ }));
5461
+ return;
5462
+ case "git.remote":
5463
+ await this.#gitOperation(frame.workspaceId, async (repo) => ({
5464
+ message: await repo.setRemote(frame.url)
5465
+ }));
5466
+ return;
5467
+ case "git.push":
5468
+ await this.#gitNetwork(frame.workspaceId, "\u041E\u0442\u043F\u0440\u0430\u0432\u043B\u044F\u0435\u043C\u2026", async (repo) => ({
5469
+ message: await repo.push(await this.#gitToken())
5470
+ }));
5471
+ return;
5472
+ case "git.pull":
5473
+ await this.#gitNetwork(frame.workspaceId, frame.merge ? "\u0417\u0430\u0431\u0438\u0440\u0430\u0435\u043C\u2026" : "\u041F\u0440\u043E\u0432\u0435\u0440\u044F\u0435\u043C remote\u2026", async (repo) => ({ message: await repo.pull(frame.merge, await this.#gitToken()) }));
5474
+ return;
5475
+ case "git.publish":
5476
+ await this.#gitNetwork(frame.workspaceId, "\u041F\u0443\u0431\u043B\u0438\u043A\u0443\u0435\u043C \u043D\u0430 GitHub\u2026", (repo) => this.#publish(repo, frame));
5477
+ return;
5478
+ case "git.files": {
5479
+ const { files, truncated } = await this.#git(frame.workspaceId).candidates();
5480
+ this.#send(socket, {
5481
+ type: "git.files",
4915
5482
  workspaceId: frame.workspaceId,
4916
- state: await repo.state()
5483
+ files,
5484
+ truncated
4917
5485
  });
4918
5486
  return;
4919
5487
  }
5488
+ case "github.status":
5489
+ this.#send(socket, { type: "github.status", status: await this.#github.status() });
5490
+ return;
5491
+ case "github.token":
5492
+ await this.#github.setToken(frame.token);
5493
+ this.#broadcast({ type: "github.status", status: await this.#github.status() });
5494
+ return;
5495
+ case "github.owners":
5496
+ this.#send(socket, { type: "github.owners", owners: await this.#github.owners() });
5497
+ return;
4920
5498
  case "fs.list":
4921
5499
  this.#send(socket, {
4922
5500
  type: "fs.dir",
@@ -5523,6 +6101,101 @@ var HostServer = class {
5523
6101
  throw new Error(`\u0412\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: ${workspaceId}`);
5524
6102
  return new GitRepo(workspace.path);
5525
6103
  }
6104
+ /**
6105
+ * Пишущая операция панели: её итог и новое состояние получают все клиенты.
6106
+ *
6107
+ * Broadcast, а не ответ спросившему, потому что рабочее дерево у вкладок
6108
+ * общее: после переключения ветки или появления `origin` вторая вкладка
6109
+ * показывала бы прежнюю картину, ничем не отличимую от настоящей.
6110
+ *
6111
+ * Ошибка сюда приходит текстом git и уходит текстом же — панель показывает
6112
+ * его как есть. Своей формулировки у нас нет и быть не должно: git объясняет
6113
+ * отказ («ваши правки будут перезаписаны», «обновлений нет») точнее.
6114
+ */
6115
+ async #gitOperation(workspaceId, run2) {
6116
+ let ok = true;
6117
+ let message = "";
6118
+ let url2;
6119
+ try {
6120
+ const outcome = await run2(this.#git(workspaceId));
6121
+ message = outcome.message;
6122
+ url2 = outcome.url;
6123
+ } catch (error) {
6124
+ ok = false;
6125
+ message = error instanceof Error ? error.message : String(error);
6126
+ }
6127
+ this.#broadcast({ type: "git.result", workspaceId, ok, message, ...url2 ? { url: url2 } : {} });
6128
+ const state = await this.#git(workspaceId).state().catch(() => null);
6129
+ if (state)
6130
+ this.#broadcast({ type: "git.state", workspaceId, state });
6131
+ }
6132
+ /**
6133
+ * То же, но операция ходит в сеть: под замком и с пометкой «идёт».
6134
+ *
6135
+ * Замок — не про удобство, а про целостность: два `push` на один `.git`
6136
+ * встретятся на блокировке индекса, и второй упадёт посреди работы. Пометка
6137
+ * рассылается всем по той же причине, по которой рассылается итог, — вкладки
6138
+ * смотрят в один каталог.
6139
+ */
6140
+ async #gitNetwork(workspaceId, label, run2) {
6141
+ if (this.#gitBusy.has(workspaceId)) {
6142
+ this.#broadcast({
6143
+ type: "git.result",
6144
+ workspaceId,
6145
+ ok: false,
6146
+ message: "\u041E\u043F\u0435\u0440\u0430\u0446\u0438\u044F \u0441 remote \u0443\u0436\u0435 \u0438\u0434\u0451\u0442 \u2014 \u0434\u043E\u0436\u0434\u0438\u0442\u0435\u0441\u044C \u0435\u0451"
6147
+ });
6148
+ return;
6149
+ }
6150
+ this.#gitBusy.add(workspaceId);
6151
+ this.#broadcast({ type: "git.busy", workspaceId, label });
6152
+ try {
6153
+ await this.#gitOperation(workspaceId, run2);
6154
+ } finally {
6155
+ this.#gitBusy.delete(workspaceId);
6156
+ this.#broadcast({ type: "git.busy", workspaceId, label: "" });
6157
+ }
6158
+ }
6159
+ /**
6160
+ * Токен GitHub для команды git — или пустая строка, если аккаунта нет.
6161
+ *
6162
+ * Пустая строка здесь законна: push на не-GitHub remote (или на GitHub через
6163
+ * уже настроенный ssh-ключ) должен работать ровно так же, как из терминала.
6164
+ * Требовать учётную запись GitHub ради него значило бы сломать то, что и без
6165
+ * нас работало.
6166
+ */
6167
+ async #gitToken() {
6168
+ return await this.#github.credential().then((credential) => credential.token, () => "");
6169
+ }
6170
+ /**
6171
+ * Опубликовать проект: коммит (если истории ещё нет), репозиторий, remote, push.
6172
+ *
6173
+ * Порядок не переставить. Коммит идёт до создания репозитория: не настроен
6174
+ * `user.email` — git откажет, и лучше это узнать до того, как на GitHub
6175
+ * появится пустой репозиторий, который придётся удалять руками. Репозиторий
6176
+ * создаётся до `origin`, потому что адрес приходит вместе с ним.
6177
+ */
6178
+ async #publish(repo, frame) {
6179
+ const credential = await this.#github.credential();
6180
+ if (!(await repo.state()).available)
6181
+ await repo.init();
6182
+ if (frame.message)
6183
+ await repo.commit(frame.message, frame.include, frame.ignore);
6184
+ const created = await this.#github.createRepo({
6185
+ owner: frame.owner,
6186
+ name: frame.name,
6187
+ description: frame.description,
6188
+ private: frame.private
6189
+ });
6190
+ await repo.setRemote(created.cloneUrl);
6191
+ try {
6192
+ await repo.push(credential.token);
6193
+ } catch (error) {
6194
+ const detail = error instanceof Error ? error.message : String(error);
6195
+ throw new Error(`\u0420\u0435\u043F\u043E\u0437\u0438\u0442\u043E\u0440\u0438\u0439 ${created.fullName} \u0441\u043E\u0437\u0434\u0430\u043D, \u043D\u043E \u043E\u0442\u043F\u0440\u0430\u0432\u0438\u0442\u044C \u043D\u0435 \u0432\u044B\u0448\u043B\u043E: ${detail}`);
6196
+ }
6197
+ return { message: `\u041E\u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u043D\u043E: ${created.fullName}`, url: created.htmlUrl };
6198
+ }
5526
6199
  // -------------------------------------------------------------------------
5527
6200
  // Файлы проекта
5528
6201
  // -------------------------------------------------------------------------
@@ -5680,8 +6353,8 @@ var HostServer = class {
5680
6353
  return;
5681
6354
  }
5682
6355
  const url2 = new URL(req.url ?? "/", "http://localhost");
5683
- const requested = path15.join(webRoot, path15.normalize(url2.pathname));
5684
- const target = requested.startsWith(webRoot) && existsSync3(requested) && statSync3(requested).isFile() ? requested : path15.join(webRoot, "index.html");
6356
+ const requested = path17.join(webRoot, path17.normalize(url2.pathname));
6357
+ const target = requested.startsWith(webRoot) && existsSync3(requested) && statSync3(requested).isFile() ? requested : path17.join(webRoot, "index.html");
5685
6358
  if (!existsSync3(target)) {
5686
6359
  res.writeHead(404).end("not found");
5687
6360
  return;
@@ -5707,12 +6380,12 @@ function contentType(file) {
5707
6380
  ".svg": "image/svg+xml",
5708
6381
  ".woff2": "font/woff2"
5709
6382
  };
5710
- return types[path15.extname(file)] ?? "application/octet-stream";
6383
+ return types[path17.extname(file)] ?? "application/octet-stream";
5711
6384
  }
5712
6385
 
5713
6386
  // dist/ccv.js
5714
- var HERE = path16.dirname(fileURLToPath(import.meta.url));
5715
- var PACKAGE_ROOT = path16.resolve(HERE, "..");
6387
+ var HERE = path18.dirname(fileURLToPath(import.meta.url));
6388
+ var PACKAGE_ROOT = path18.resolve(HERE, "..");
5716
6389
  var USAGE = `ccv \u2014 Claude Code Visual: \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0439 \u0445\u043E\u0441\u0442 Claude Code \u0441 \u0438\u043D\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u043E\u043C \u0432 \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u0435.
5717
6390
 
5718
6391
  \u0418\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u043D\u0438\u0435:
@@ -5779,7 +6452,7 @@ var server = new HostServer({
5779
6452
  dataDir: defaults.dataDir,
5780
6453
  // UI лежит в пакете рядом с бандлом — это и есть разница между установленной
5781
6454
  // утилитой и запуском из репозитория, где сборка живёт в `apps/web/dist`.
5782
- webRoot: process.env.CCV_WEB_ROOT ?? path16.join(PACKAGE_ROOT, "web"),
6455
+ webRoot: process.env.CCV_WEB_ROOT ?? path18.join(PACKAGE_ROOT, "web"),
5783
6456
  version,
5784
6457
  relayUrl: defaults.relayUrl
5785
6458
  });
@@ -5808,7 +6481,7 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
5808
6481
  }
5809
6482
  async function packageVersion() {
5810
6483
  try {
5811
- const text = await readFile11(path16.join(PACKAGE_ROOT, "package.json"), "utf8");
6484
+ const text = await readFile13(path18.join(PACKAGE_ROOT, "package.json"), "utf8");
5812
6485
  return JSON.parse(text).version ?? "0.0.0";
5813
6486
  } catch {
5814
6487
  return "0.0.0";