@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.
- package/bundle/ccv.mjs +804 -131
- package/package.json +1 -1
- package/web/assets/{codemirror-02hKfv-c.js → codemirror-4gviWJRD.js} +1 -1
- package/web/assets/{index-BybHqs37.js → index-B-8FWzpe.js} +1 -1
- package/web/assets/{index-DRMnPfA3.js → index-BiBGEAPz.js} +1 -1
- package/web/assets/{index-Bm6PUpXb.js → index-BjCwOpLW.js} +1 -1
- package/web/assets/{index-BBkJ4cEk.js → index-BleqvvQk.js} +1 -1
- package/web/assets/{index-BxcnR7Nj.js → index-Bv8vIAVO.js} +1 -1
- package/web/assets/index-Bzq-v0Dg.js +134 -0
- package/web/assets/{index-sQrmYdbi.css → index-CE1G7QxT.css} +1 -1
- package/web/assets/{index-VSDVirVT.js → index-CJo5aeRL.js} +1 -1
- package/web/assets/{index-C-8irjwh.js → index-CKz5fHXP.js} +1 -1
- package/web/assets/{index-BC8gj6P7.js → index-CR_zbJ-Z.js} +1 -1
- package/web/assets/{index-C2nhZ-ID.js → index-CYwf9t3p.js} +1 -1
- package/web/assets/{index-DZXdWq9L.js → index-C_LuD9HT.js} +1 -1
- package/web/assets/{index-ChKrBnaf.js → index-Cid3pp7b.js} +1 -1
- package/web/assets/{index-Clyg5z5Z.js → index-CkSGxu2i.js} +1 -1
- package/web/assets/{index-KEWrG50i.js → index-CrX9ldma.js} +1 -1
- package/web/assets/{index-BorlqgNX.js → index-Depy6k3N.js} +1 -1
- package/web/assets/{index-zDoB4KM7.js → index-DklW2LtN.js} +1 -1
- package/web/assets/{index-B5hgDwts.js → index-DmEjP2AP.js} +1 -1
- package/web/assets/{index-DpZcQOqI.js → index-fWVyTiSX.js} +1 -1
- package/web/assets/{index-D0UkSEon.js → index-hElc3lLb.js} +1 -1
- package/web/assets/{index-i-F6wQDa.js → index-kMdLDVpb.js} +1 -1
- package/web/index.html +2 -2
- 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
|
|
6
|
-
import
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
2463
|
-
|
|
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
|
|
2819
|
+
const path19 = status === "deleted" ? oldPath : newPath || oldPath;
|
|
2611
2820
|
files.push({
|
|
2612
|
-
path:
|
|
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
|
|
2625
|
-
if (
|
|
2833
|
+
const path19 = unquote(value.trim());
|
|
2834
|
+
if (path19 === "/dev/null")
|
|
2626
2835
|
return "";
|
|
2627
|
-
return
|
|
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
|
|
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(
|
|
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
|
|
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:
|
|
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 || !
|
|
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:
|
|
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 =
|
|
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 =
|
|
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
|
|
2926
|
-
import
|
|
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 =
|
|
3371
|
+
this.#storePath = path9.join(dataDir, "workspaces.json");
|
|
2933
3372
|
}
|
|
2934
3373
|
async load() {
|
|
2935
3374
|
try {
|
|
2936
|
-
const raw = await
|
|
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
|
|
2947
|
-
await
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
3448
|
+
await mkdir3(target);
|
|
3010
3449
|
const warnings = [];
|
|
3011
3450
|
if (init.claudeMd) {
|
|
3012
|
-
await step(warnings, "CLAUDE.md", () =>
|
|
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", () =>
|
|
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:
|
|
3087
|
-
hasClaudeMd: existsSync2(
|
|
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
|
|
3093
|
-
import
|
|
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 =
|
|
3538
|
+
this.#storePath = path10.join(dataDir, "session-archive.json");
|
|
3100
3539
|
}
|
|
3101
3540
|
async load() {
|
|
3102
3541
|
try {
|
|
3103
|
-
const raw = await
|
|
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
|
|
3193
|
-
await
|
|
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
|
|
3202
|
-
import
|
|
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 =
|
|
3646
|
+
this.#storePath = path11.join(dataDir, "session-pins.json");
|
|
3208
3647
|
}
|
|
3209
3648
|
async load() {
|
|
3210
3649
|
try {
|
|
3211
|
-
const raw = await
|
|
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
|
|
3310
|
-
await
|
|
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
|
|
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
|
|
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 =
|
|
3794
|
+
this.#storePath = path12.join(dataDir, "app-settings.json");
|
|
3356
3795
|
}
|
|
3357
3796
|
async load() {
|
|
3358
3797
|
try {
|
|
3359
|
-
const raw = await
|
|
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
|
|
3380
|
-
await
|
|
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
|
|
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 =
|
|
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
|
|
3875
|
+
import { readFile as readFile10 } from "node:fs/promises";
|
|
3437
3876
|
import { homedir as homedir4 } from "node:os";
|
|
3438
|
-
import
|
|
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 ||
|
|
3976
|
+
const root = market.installLocation || path13.join(homedir4(), ".claude", "plugins", "marketplaces", market.name);
|
|
3538
3977
|
let manifest;
|
|
3539
3978
|
try {
|
|
3540
|
-
const text = await
|
|
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(
|
|
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" :
|
|
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
|
|
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
|
-
|
|
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:
|
|
3737
|
-
email:
|
|
3738
|
-
organization:
|
|
3739
|
-
plan:
|
|
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
|
|
4211
|
+
function str2(value) {
|
|
3743
4212
|
return typeof value === "string" ? value : "";
|
|
3744
4213
|
}
|
|
3745
|
-
function
|
|
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
|
|
3752
|
-
import
|
|
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 =
|
|
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
|
|
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
|
|
3888
|
-
await
|
|
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}: ${
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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.#
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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(
|
|
4811
|
+
const dirs = (process.env.PATH ?? "").split(path15.delimiter).filter(Boolean);
|
|
4269
4812
|
for (const dir of dirs) {
|
|
4270
|
-
const candidate =
|
|
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
|
|
4282
|
-
import
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
4317
|
-
const settingsPath =
|
|
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 =
|
|
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
|
|
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 ${
|
|
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
|
|
4381
|
-
await
|
|
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
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
this.#
|
|
4914
|
-
|
|
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
|
-
|
|
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 =
|
|
5684
|
-
const target = requested.startsWith(webRoot) && existsSync3(requested) && statSync3(requested).isFile() ? requested :
|
|
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[
|
|
6383
|
+
return types[path17.extname(file)] ?? "application/octet-stream";
|
|
5711
6384
|
}
|
|
5712
6385
|
|
|
5713
6386
|
// dist/ccv.js
|
|
5714
|
-
var HERE =
|
|
5715
|
-
var PACKAGE_ROOT =
|
|
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 ??
|
|
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
|
|
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";
|