@modesto_ma/dsh-guanxiang 0.2.0-beta.3 → 0.2.0-beta.4

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/lib/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { z } from "zod";
3
- import { homedir, tmpdir } from "node:os";
3
+ import { homedir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
- import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { createTwoFilesPatch, structuredPatch } from "diff";
6
7
  import { parse } from "yaml";
7
8
  import { createWriteStream } from "node:fs";
8
9
  import { Readable, Transform } from "node:stream";
9
10
  import { pipeline } from "node:stream/promises";
10
- import { execFile } from "node:child_process";
11
11
  //#region src/settings/contract.ts
12
12
  /**
13
13
  * 设置面板与 Host 路由共用的路由常量和 JSON 结构。浏览器端 bundle 也会
@@ -21,20 +21,22 @@ const STATE_ROUTE = "/guanxiang/state";
21
21
  const SETTINGS_ROUTE = "/guanxiang/settings";
22
22
  /** POST:立即重新拉取全部仓库,或 body `{ url }` 指定的一个仓库。 */
23
23
  const SYNC_ROUTE = "/guanxiang/sync";
24
- /** PUT:body `{ tokenEnv, value }` 把令牌存入 DSH 凭据库;DELETE:body `{ tokenEnv }` 删除已存的值。只接受已保存的访问令牌配置引用的名称和分析接口令牌的名称。 */
24
+ /** PUT:body `{ tokenEnv, value }` 把分析接口令牌存入 DSH 凭据库;DELETE:body `{ tokenEnv }` 删除已存的值。只接受分析接口令牌的名称。 */
25
25
  const TOKEN_ROUTE = "/guanxiang/token";
26
+ /** POST:body {@link HostAddRequest} 经 dsh-repo 添加主机;DELETE:body `{ url }` 删除面板添加的主机。 */
27
+ const HOSTS_ROUTE = "/guanxiang/hosts";
28
+ /** PUT:body `{ url, value }` 经 dsh-repo 保存主机令牌;DELETE:body `{ url }` 清除已存的令牌。 */
29
+ const HOST_TOKEN_ROUTE = "/guanxiang/host-token";
26
30
  /**
27
31
  * POST:日志平台分析接口(Bearer 令牌鉴权,不走 DSH 登录)。它在 DSH 的
28
32
  * `/api` 命名空间之外,网关可以把它和需要登录的 DSH 接口分开暴露。
29
33
  */
30
34
  const ANALYZE_ROUTE = "/open-api/logalyze";
31
- /** Credential reference grammar of `@deepseek-ai/dsh-credentials` (a POSIX shell identifier), which {@link GitCredentialSpec.tokenEnv} follows. */
35
+ /** Credential reference grammar of `@deepseek-ai/dsh-credentials` (a POSIX shell identifier). */
32
36
  const TOKEN_ENV_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
33
37
  /** Keys of {@link EditableSettings}, in panel order. */
34
38
  const EDITABLE_KEYS = [
35
- "reposDir",
36
39
  "repos",
37
- "gitCredentials",
38
40
  "fetchIntervalMs",
39
41
  "maxAppsPerRequest",
40
42
  "dispatchTimeoutMs",
@@ -402,94 +404,6 @@ function mountAnalyzeRoute(ctx, gate) {
402
404
  attach(ctx);
403
405
  }
404
406
  //#endregion
405
- //#region src/repos/credentials.ts
406
- /** Environment variables the helper reads; set only on git child processes. */
407
- const USERNAME_VARIABLE = "GUANXIANG_GIT_USERNAME";
408
- const TOKEN_VARIABLE = "GUANXIANG_GIT_TOKEN";
409
- /** Answers git's `get` request from the two variables and ignores `store` and `erase`. */
410
- const HELPER = `!f() { test "$1" = get || exit 0; printf 'username=%s\\npassword=%s\\n' "$${USERNAME_VARIABLE}" "$${TOKEN_VARIABLE}"; }; f`;
411
- /** Longest accepted token; personal access tokens are far shorter. */
412
- const MAX_TOKEN_LENGTH = 4096;
413
- /**
414
- * Normalize an access-token address prefix to `scheme://host[:port][/path]`
415
- * with a lowercase host and no trailing slash.
416
- * @param value - candidate prefix.
417
- * @returns the normalized prefix, or undefined when it is not an `https`/`http` URL without credentials, query, or fragment.
418
- */
419
- function normalizeCredentialUrl(value) {
420
- let url;
421
- try {
422
- url = new URL(value);
423
- } catch {
424
- return;
425
- }
426
- if (url.protocol !== "https:" && url.protocol !== "http:") return void 0;
427
- if (url.host === "" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return void 0;
428
- return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
429
- }
430
- /**
431
- * Whether a token value can be handed to git: non-empty, bounded, and free of
432
- * control characters, which would break the credential helper protocol.
433
- * @param value - candidate token.
434
- * @returns whether it is accepted.
435
- */
436
- function isAcceptedToken(value) {
437
- return value.length > 0 && value.length <= 4096 && !/[\u0000-\u001f\u007f]/.test(value);
438
- }
439
- /**
440
- * The access-token configuration for a repository address: the entry whose
441
- * prefix has the same scheme and host and the longest path that contains the
442
- * repository path. scp-like addresses, SSH URLs, and local paths never match.
443
- * @param url - repository address.
444
- * @param specs - configured access tokens with normalized prefixes.
445
- * @returns the matching entry, if any.
446
- */
447
- function credentialFor(url, specs) {
448
- let repo;
449
- try {
450
- repo = new URL(url);
451
- } catch {
452
- return;
453
- }
454
- let best;
455
- for (const spec of specs) {
456
- const prefix = new URL(spec.url);
457
- if (prefix.protocol !== repo.protocol || prefix.host !== repo.host) continue;
458
- const path = prefix.pathname.replace(/\/+$/, "");
459
- if (path !== "" && repo.pathname !== path && !repo.pathname.startsWith(`${path}/`)) continue;
460
- if (best === void 0 || path.length > best.length) best = {
461
- spec,
462
- length: path.length
463
- };
464
- }
465
- return best?.spec;
466
- }
467
- /**
468
- * Environment that makes one git process answer the credential request for
469
- * the token's host with the given token. Existing `GIT_CONFIG_COUNT` entries
470
- * are kept; the helper list for that host is reset first so a stale password
471
- * from another helper cannot shadow the token.
472
- * @param spec - matching access-token configuration.
473
- * @param token - token value.
474
- * @param env - environment of the git process before this call.
475
- * @returns variables to add to the git process environment.
476
- */
477
- function credentialEnvironment(spec, token, env = process.env) {
478
- const prefix = new URL(spec.url);
479
- const key = `credential.${prefix.protocol}//${prefix.host}.helper`;
480
- const parsed = Number(env.GIT_CONFIG_COUNT ?? "0");
481
- const base = Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
482
- return {
483
- GIT_CONFIG_COUNT: String(base + 2),
484
- [`GIT_CONFIG_KEY_${base}`]: key,
485
- [`GIT_CONFIG_VALUE_${base}`]: "",
486
- [`GIT_CONFIG_KEY_${base + 1}`]: key,
487
- [`GIT_CONFIG_VALUE_${base + 1}`]: HELPER,
488
- [USERNAME_VARIABLE]: spec.username,
489
- [TOKEN_VARIABLE]: token
490
- };
491
- }
492
- //#endregion
493
407
  //#region src/config.ts
494
408
  /**
495
409
  * 插件配置:cordis.yml 中的 `config`(经 Standard Schema 在加载时校验)与
@@ -537,12 +451,16 @@ const repoSchema = z.object({
537
451
  url: z.string().trim().min(1, "git 地址不能为空").refine(isAcceptedGitUrl, "不支持的 git 地址(支持 https/ssh/git/file URL、user@host:path 或绝对路径)"),
538
452
  branch: z.string().trim().optional().transform((value) => value === "" ? void 0 : value).refine((value) => value === void 0 || isAcceptedBranch(value), "分支名不合法")
539
453
  }).strict();
540
- const credentialSchema = z.object({
541
- url: z.string().trim().refine((value) => normalizeCredentialUrl(value) !== void 0, "令牌地址不合法:填 https://主机[:端口][/路径],不含用户名、查询参数或片段").transform((value) => normalizeCredentialUrl(value)),
542
- alias: z.string().trim().max(64, "令牌别名不能超过 64 个字符").regex(/^[^\u0000-\u001f\u007f]*$/, "令牌别名不能包含控制字符").optional().transform((value) => value === "" ? void 0 : value),
543
- username: z.string().trim().regex(/^\S+$/, "用户名不能为空或包含空白").default("oauth2"),
544
- tokenEnv: z.string().trim().regex(TOKEN_ENV_PATTERN, "令牌名称只能包含字母、数字和下划线,且不能以数字开头(它是凭据库中的键名,不是令牌本身)")
545
- }).strict();
454
+ /**
455
+ * An access token of earlier versions, which kept HTTPS tokens by address
456
+ * prefix. Read only to move each one to a dsh-repo host once; never written.
457
+ */
458
+ const legacyCredentialSchema = z.object({
459
+ url: z.string().trim().min(1),
460
+ alias: z.string().trim().optional(),
461
+ username: z.string().optional(),
462
+ tokenEnv: z.string().trim().regex(TOKEN_ENV_PATTERN)
463
+ }).strip();
546
464
  const hostSchema = z.string().trim().toLowerCase().transform((value) => value.replace(/^\*\./, "")).refine((value) => HOST_NAME.test(value), "域名不合法:只填主机名,例如 oss.example.com");
547
465
  const durationMs = (min) => z.number().int().min(min).max(MAX_TIMER_DELAY_MS);
548
466
  const downloadSchema = z.object({
@@ -562,7 +480,6 @@ const authorSchema = z.object({
562
480
  }).strict();
563
481
  /** Field validators shared by the cordis config and the panel overlay. */
564
482
  const editableFields = {
565
- reposDir: z.string().trim().min(1, "拉取目录不能为空"),
566
483
  repos: z.array(repoSchema).superRefine((repos, issues) => {
567
484
  const seen = /* @__PURE__ */ new Set();
568
485
  for (const [index, repo] of repos.entries()) {
@@ -574,17 +491,6 @@ const editableFields = {
574
491
  seen.add(repo.url);
575
492
  }
576
493
  }),
577
- gitCredentials: z.array(credentialSchema).superRefine((credentials, issues) => {
578
- const seen = /* @__PURE__ */ new Set();
579
- for (const [index, credential] of credentials.entries()) {
580
- if (seen.has(credential.url)) issues.addIssue({
581
- code: "custom",
582
- path: [index, "url"],
583
- message: `重复的令牌地址:${credential.url}`
584
- });
585
- seen.add(credential.url);
586
- }
587
- }),
588
494
  fetchIntervalMs: durationMs(0),
589
495
  maxAppsPerRequest: z.number().int().min(1).max(32),
590
496
  dispatchTimeoutMs: durationMs(1e3),
@@ -594,10 +500,11 @@ const editableFields = {
594
500
  };
595
501
  /** Standard Schema the Cordis loader validates `config` with before `apply()`. */
596
502
  const pluginConfigSchema = z.object({
597
- reposDir: editableFields.reposDir.optional(),
503
+ /** Ignored: dsh-repo's `mirror.dir` decides where repositories are mirrored. Accepted so older compositions still load. */
504
+ reposDir: z.string().optional(),
598
505
  repos: editableFields.repos.default([]),
599
- /** HTTPS access tokens by address prefix; each names a credential reference, never a token value. */
600
- gitCredentials: editableFields.gitCredentials.default([]),
506
+ /** Moved once to dsh-repo hosts at start; see {@link legacyCredentialSchema}. */
507
+ gitCredentials: z.array(legacyCredentialSchema).default([]),
601
508
  fetchIntervalMs: editableFields.fetchIntervalMs.default(0),
602
509
  maxAppsPerRequest: editableFields.maxAppsPerRequest.default(4),
603
510
  dispatchTimeoutMs: editableFields.dispatchTimeoutMs.default(1800 * 1e3),
@@ -616,8 +523,8 @@ const pluginConfigSchema = z.object({
616
523
  skillUpdateAuthor: authorSchema.optional(),
617
524
  /** Registered `ctx.subagents` provider the children start on. */
618
525
  subagentProvider: z.string().trim().min(1).default("spawn"),
619
- /** Timeout of one git command (clone, fetch, worktree), in milliseconds. */
620
- gitTimeoutMs: durationMs(1e3).default(600 * 1e3),
526
+ /** Ignored: dsh-repo's `mirror.gitTimeoutMs` bounds git commands. Accepted so older compositions still load. */
527
+ gitTimeoutMs: z.number().optional(),
621
528
  /**
622
529
  * How long loading `/guanxiang` waits for the repository refresh before it
623
530
  * renders the catalog it already has, in milliseconds; the refresh keeps
@@ -632,9 +539,11 @@ const pluginConfigSchema = z.object({
632
539
  }).strict().prefault({});
633
540
  /** Panel overlay: any subset of the editable settings. */
634
541
  const overlaySchema = z.object({
635
- reposDir: editableFields.reposDir.optional(),
542
+ /** Ignored legacy key; the next panel save drops it. */
543
+ reposDir: z.string().optional(),
636
544
  repos: editableFields.repos.optional(),
637
- gitCredentials: editableFields.gitCredentials.optional(),
545
+ /** Legacy access tokens, moved once to dsh-repo hosts; the next panel save drops them. */
546
+ gitCredentials: z.array(legacyCredentialSchema).optional(),
638
547
  fetchIntervalMs: editableFields.fetchIntervalMs.optional(),
639
548
  maxAppsPerRequest: editableFields.maxAppsPerRequest.optional(),
640
549
  dispatchTimeoutMs: editableFields.dispatchTimeoutMs.optional(),
@@ -675,42 +584,20 @@ function resolveDshHome(env = process.env) {
675
584
  const configured = env.DSH_HOME?.trim();
676
585
  return configured !== void 0 && configured !== "" ? configured : join(homedir(), ".dsh");
677
586
  }
678
- /**
679
- * Expand `~` and make a configured directory absolute.
680
- * @param path - configured directory.
681
- * @returns absolute directory.
682
- */
683
- function expandHome(path) {
684
- if (path === "~") return homedir();
685
- if (path.startsWith("~/")) return join(homedir(), path.slice(2));
686
- return resolve(path);
687
- }
688
587
  function copyRepos(repos) {
689
588
  return repos.map((repo) => repo.branch === void 0 ? { url: repo.url } : {
690
589
  url: repo.url,
691
590
  branch: repo.branch
692
591
  });
693
592
  }
694
- function copyCredentials(credentials) {
695
- return credentials.map((credential) => ({
696
- url: credential.url,
697
- ...credential.alias === void 0 ? {} : { alias: credential.alias },
698
- username: credential.username,
699
- tokenEnv: credential.tokenEnv
700
- }));
701
- }
702
593
  /**
703
- * The editable settings a configuration implies, with `reposDir` defaulted to
704
- * `$DSH_HOME/guanxiang/repos`.
594
+ * The editable settings a configuration implies.
705
595
  * @param config - validated plugin configuration.
706
- * @param env - process environment.
707
596
  * @returns complete editable settings.
708
597
  */
709
- function defaultsOf(config, env = process.env) {
598
+ function defaultsOf(config) {
710
599
  return {
711
- reposDir: config.reposDir ?? join(resolveDshHome(env), "guanxiang", "repos"),
712
600
  repos: copyRepos(config.repos),
713
- gitCredentials: copyCredentials(config.gitCredentials),
714
601
  fetchIntervalMs: config.fetchIntervalMs,
715
602
  maxAppsPerRequest: config.maxAppsPerRequest,
716
603
  dispatchTimeoutMs: config.dispatchTimeoutMs,
@@ -731,9 +618,7 @@ function defaultsOf(config, env = process.env) {
731
618
  */
732
619
  function applyOverlay(defaults, overlay) {
733
620
  return {
734
- reposDir: overlay.reposDir ?? defaults.reposDir,
735
621
  repos: overlay.repos === void 0 ? defaults.repos : copyRepos(overlay.repos),
736
- gitCredentials: overlay.gitCredentials === void 0 ? defaults.gitCredentials : copyCredentials(overlay.gitCredentials),
737
622
  fetchIntervalMs: overlay.fetchIntervalMs ?? defaults.fetchIntervalMs,
738
623
  maxAppsPerRequest: overlay.maxAppsPerRequest ?? defaults.maxAppsPerRequest,
739
624
  dispatchTimeoutMs: overlay.dispatchTimeoutMs ?? defaults.dispatchTimeoutMs,
@@ -743,6 +628,80 @@ function applyOverlay(defaults, overlay) {
743
628
  };
744
629
  }
745
630
  //#endregion
631
+ //#region src/repos/lookup.ts
632
+ /** Name dsh-repo provides the service under. */
633
+ const DSH_REPOS_SERVICE = "dshRepos";
634
+ /** Oldest dsh-repo release providing every method {@link REQUIRED_METHODS} lists. */
635
+ const DSH_REPO_MIN_RELEASE = "0.1.0-beta.3";
636
+ /** Methods this plugin calls; `getBranch` also marks a provider that honors `baseSha` and `newBranch` on commits. */
637
+ const REQUIRED_METHODS = [
638
+ "getBranch",
639
+ "commitFiles",
640
+ "createChange",
641
+ "listHosts",
642
+ "addHost",
643
+ "removeHost",
644
+ "setHostToken",
645
+ "mirrorSettings",
646
+ "syncMirror",
647
+ "readMirrorFile",
648
+ "listMirrorTree",
649
+ "acquireSnapshot"
650
+ ];
651
+ /**
652
+ * Classify the value `ctx.get('dshRepos')` returned. The provider is another
653
+ * package that may be older or newer than this one, so its version and
654
+ * methods are checked at run time.
655
+ * @param value - the looked-up service, or undefined.
656
+ * @returns the usable service, or why there is none.
657
+ */
658
+ function lookupRepos(value) {
659
+ if (value === void 0 || value === null) return { availability: "missing" };
660
+ const service = value;
661
+ if (service.version !== 1 || REQUIRED_METHODS.some((method) => typeof service[method] !== "function")) return { availability: "incompatible" };
662
+ return {
663
+ availability: "ready",
664
+ service: value
665
+ };
666
+ }
667
+ /**
668
+ * The failure class of a `dshRepos` rejection.
669
+ * @param error - the rejection.
670
+ * @returns its `code` when it is a `RepoError`.
671
+ */
672
+ function repoErrorCode(error) {
673
+ if (!(error instanceof Error) || error.name !== "RepoError") return void 0;
674
+ const code = error.code;
675
+ return typeof code === "string" ? code : void 0;
676
+ }
677
+ /**
678
+ * Why the service cannot be used, as one line for the panel and tool errors.
679
+ * @param availability - lookup outcome other than `ready`.
680
+ * @returns the reason and the fix.
681
+ */
682
+ function unavailableMessage(availability) {
683
+ return availability === "missing" ? `观象需要 dsh-repo 插件提供的 ${DSH_REPOS_SERVICE} 服务来拉取仓库和提交 SKILL 修改;请安装 dsh-repo` : `dsh-repo 的 ${DSH_REPOS_SERVICE} 服务与观象不兼容(需要服务版本 1,即 dsh-repo ${DSH_REPO_MIN_RELEASE} 及以上);请升级 dsh-repo`;
684
+ }
685
+ /**
686
+ * The usable service, or a thrown error saying why there is none.
687
+ * @param value - the looked-up service, or undefined.
688
+ * @returns the service.
689
+ * @throws Error with {@link unavailableMessage} when it is missing or incompatible.
690
+ */
691
+ function requireRepos(value) {
692
+ const lookup = lookupRepos(value);
693
+ if (lookup.availability !== "ready") throw new Error(unavailableMessage(lookup.availability));
694
+ return lookup.service;
695
+ }
696
+ /**
697
+ * Readable message of a `dshRepos` rejection or any other thrown value.
698
+ * @param error - the rejection.
699
+ * @returns its message.
700
+ */
701
+ function messageOf$1(error) {
702
+ return error instanceof Error ? error.message : String(error);
703
+ }
704
+ //#endregion
746
705
  //#region src/abort.ts
747
706
  /**
748
707
  * Cancellation helpers shared by the entry skill and the dispatch tool.
@@ -772,128 +731,58 @@ async function abortable(promise, signal) {
772
731
  }
773
732
  }
774
733
  //#endregion
775
- //#region src/skill-update/git.ts
734
+ //#region src/skill-update/change.ts
776
735
  /**
777
- * 在拉取仓库中用 git 底层命令检查 SKILL 修改:用临时索引文件在分析时的提交上
778
- * 替换文件、写出树对象,算出逐文件行数和 diff,不改动任何检出、快照或本地
779
- * 分支。树对象的前缀用作新分支名,提交本身经 dsh-repo 完成。
736
+ * 检查 SKILL 修改:从 dsh-repo 镜像读取分析时的提交中 SKILL 目录的文件,与提议
737
+ * 的文件比较,算出逐文件行数和 unified diff,不改动任何镜像或快照。提议内容的
738
+ * 摘要用作新分支名,提交本身经 dsh-repo 完成。
780
739
  *
781
- * @module dsh-guanxiang/skill-update/git
782
- */
783
- const DIFF_FLAGS = [
784
- "--no-color",
785
- "--no-ext-diff",
786
- "--no-renames"
787
- ];
788
- /**
789
- * Write the proposed files over the skill directory of `base` into a new tree.
790
- * @param commands - git runner and timeout.
791
- * @param dir - repository directory whose object database receives the objects.
792
- * @param base - commit the analysis ran on.
793
- * @param skillDir - skill directory relative to the repository root, `/`-separated.
794
- * @param files - proposed files relative to `skillDir`.
795
- * @returns the tree, per-file line counts, and the diff; `files` is empty when nothing changes.
796
- * @throws when a proposed path replaces a symbolic link or a directory, or a git command fails.
797
- */
798
- async function prepareChange(commands, dir, base, skillDir, files) {
799
- const { git, timeoutMs } = commands;
800
- const scratch = await mkdtemp(join(tmpdir(), "guanxiang-index-"));
801
- try {
802
- const env = { GIT_INDEX_FILE: join(scratch, "index") };
803
- await git(["read-tree", base], {
804
- cwd: dir,
805
- timeoutMs,
806
- env
807
- });
808
- const listing = await git([
809
- "ls-tree",
810
- "-r",
811
- "-t",
812
- "-z",
813
- base,
814
- "--",
815
- `${skillDir}/`
816
- ], {
817
- cwd: dir,
818
- timeoutMs
819
- });
820
- const modes = /* @__PURE__ */ new Map();
821
- for (const record of listing.split("\0")) {
822
- const tab = record.indexOf(" ");
823
- if (tab < 0) continue;
824
- modes.set(record.slice(tab + 1), record.slice(0, record.indexOf(" ")));
825
- }
826
- for (const file of files) {
827
- const path = `${skillDir}/${file.path}`;
828
- const existing = modes.get(path);
829
- if (existing === "120000") throw new Error(`${path} 在仓库中是符号链接,不能用提议覆盖`);
830
- if (existing === "040000") throw new Error(`${path} 在仓库中是目录,不能用文件覆盖`);
831
- const blob = (await git([
832
- "hash-object",
833
- "-w",
834
- "--stdin"
835
- ], {
836
- cwd: dir,
837
- timeoutMs,
838
- input: file.content
839
- })).trim();
840
- await git([
841
- "update-index",
842
- "--add",
843
- "--cacheinfo",
844
- `${existing === "100755" ? "100755" : "100644"},${blob},${path}`
845
- ], {
846
- cwd: dir,
847
- timeoutMs,
848
- env
849
- });
850
- }
851
- const tree = (await git(["write-tree"], {
852
- cwd: dir,
853
- timeoutMs,
854
- env
855
- })).trim();
856
- const stats = (await git([
857
- "diff",
858
- ...DIFF_FLAGS,
859
- "--numstat",
860
- base,
861
- tree,
862
- "--",
863
- `${skillDir}/`
864
- ], {
865
- cwd: dir,
866
- timeoutMs
867
- })).split("\n").filter((line) => line !== "").map((line) => {
868
- const [added = "-", removed = "-", ...path] = line.split(" ");
869
- return {
870
- path: path.join(" "),
871
- ...added === "-" ? {} : { added: Number(added) },
872
- ...removed === "-" ? {} : { removed: Number(removed) }
873
- };
874
- });
875
- return {
876
- base,
877
- tree,
878
- files: stats,
879
- diff: stats.length === 0 ? "" : await git([
880
- "diff",
881
- ...DIFF_FLAGS,
882
- base,
883
- tree,
884
- "--",
885
- `${skillDir}/`
886
- ], {
887
- cwd: dir,
888
- timeoutMs
889
- })
890
- };
891
- } finally {
892
- await rm(scratch, {
893
- recursive: true,
894
- force: true
740
+ * @module dsh-guanxiang/skill-update/change
741
+ */
742
+ /**
743
+ * Compare the proposed files with the skill directory of the base commit.
744
+ * @param repos - the `dshRepos` service holding the mirror.
745
+ * @param target - repository, base commit, and skill directory.
746
+ * @param files - proposed files relative to the skill directory.
747
+ * @param signal - cancellation.
748
+ * @returns per-file line counts, the diff, and the digest; `files` is empty when nothing changes.
749
+ * @throws when a proposed path replaces a symbolic link, a submodule, or a directory, or the mirror cannot be read.
750
+ */
751
+ async function prepareChange(repos, target, files, signal) {
752
+ const { url, base, skillDir } = target;
753
+ const listing = await repos.listMirrorTree(url, base, skillDir, signal === void 0 ? {} : { signal });
754
+ const types = new Map(listing.map((entry) => [entry.path, entry.type]));
755
+ const stats = [];
756
+ const patches = [];
757
+ const digest = createHash("sha256").update(`${base}\n`);
758
+ for (const file of [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
759
+ const path = `${skillDir}/${file.path}`;
760
+ const type = types.get(path);
761
+ if (type === "symlink") throw new Error(`${path} 在仓库中是符号链接,不能用提议覆盖`);
762
+ if (type === "dir" || type === "submodule") throw new Error(`${path} 在仓库中是目录,不能用文件覆盖`);
763
+ const before = type === "file" ? await repos.readMirrorFile(url, base, path, signal === void 0 ? {} : { signal }) ?? "" : void 0;
764
+ if (before === file.content) continue;
765
+ const structured = structuredPatch(path, path, before ?? "", file.content, void 0, void 0, { context: 3 });
766
+ let added = 0;
767
+ let removed = 0;
768
+ for (const hunk of structured.hunks) for (const line of hunk.lines) if (line.startsWith("+")) added += 1;
769
+ else if (line.startsWith("-")) removed += 1;
770
+ stats.push({
771
+ path,
772
+ added,
773
+ removed
895
774
  });
775
+ const header = `diff --git a/${path} b/${path}\n${before === void 0 ? "new file mode 100644\n" : ""}`;
776
+ const patch = createTwoFilesPatch(before === void 0 ? "/dev/null" : `a/${path}`, `b/${path}`, before ?? "", file.content, void 0, void 0, { context: 3 });
777
+ patches.push(header + patch.replace(/^=+\n/, ""));
778
+ digest.update(`${path}\0${file.content}\0`);
896
779
  }
780
+ return {
781
+ base,
782
+ digest: digest.digest("hex"),
783
+ files: stats,
784
+ diff: patches.join("")
785
+ };
897
786
  }
898
787
  //#endregion
899
788
  //#region src/repos/manifest.ts
@@ -1116,37 +1005,6 @@ async function readProposal(workDir, limits) {
1116
1005
  };
1117
1006
  }
1118
1007
  //#endregion
1119
- //#region src/skill-update/repos.ts
1120
- /** Name dsh-repo provides the service under. */
1121
- const DSH_REPOS_SERVICE = "dshRepos";
1122
- /**
1123
- * Classify the value `ctx.get('dshRepos')` returned. The provider is another
1124
- * package that may be older or newer than this one, so its version is checked
1125
- * at run time; `getBranch` marks a provider that also honors `baseSha` and
1126
- * `newBranch` on commits.
1127
- * @param value - the looked-up service, or undefined.
1128
- * @returns the usable service, or why there is none.
1129
- */
1130
- function lookupRepos(value) {
1131
- if (value === void 0 || value === null) return { availability: "missing" };
1132
- const service = value;
1133
- if (service.version !== 1 || typeof service.getBranch !== "function" || typeof service.commitFiles !== "function" || typeof service.createChange !== "function") return { availability: "incompatible" };
1134
- return {
1135
- availability: "ready",
1136
- service: value
1137
- };
1138
- }
1139
- /**
1140
- * The failure class of a `dshRepos` rejection.
1141
- * @param error - the rejection.
1142
- * @returns its `code` when it is a `RepoError`.
1143
- */
1144
- function repoErrorCode(error) {
1145
- if (!(error instanceof Error) || error.name !== "RepoError") return void 0;
1146
- const code = error.code;
1147
- return typeof code === "string" ? code : void 0;
1148
- }
1149
- //#endregion
1150
1008
  //#region src/skill-update/tool.ts
1151
1009
  /**
1152
1010
  * `guanxiang_propose_skill`:把某个派发任务的 SKILL 修改提议提交为合并请求。
@@ -1154,7 +1012,7 @@ function repoErrorCode(error) {
1154
1012
  * - 只接受本进程中由同一会话派发过的任务运行目录;应用、仓库和分析时的提交
1155
1013
  * 取自派发记录,不取自运行目录里可被 Agent 改写的内容。
1156
1014
  * - 只改应用 SKILL 目录 `.dsh/skills/<skill>/` 下的文件,提交以分析时的提交为
1157
- * 父提交,提交到新分支 `guanxiang/skill-<应用 id>-<树对象前缀>`,从不提交到
1015
+ * 父提交,提交到新分支 `guanxiang/skill-<应用 id>-<改动摘要前缀>`,从不提交到
1158
1016
  * 已有分支或默认分支。
1159
1017
  * - 提交和创建合并请求经 dsh-repo 的 `dshRepos` 服务完成,支持 GitLab 与
1160
1018
  * GitHub;令牌由 dsh-repo 按主机配置。没有该服务或版本不符时工具报错。
@@ -1225,7 +1083,11 @@ const OUTPUT_SCHEMA$1 = {
1225
1083
  items: {
1226
1084
  type: "object",
1227
1085
  additionalProperties: false,
1228
- required: ["path"],
1086
+ required: [
1087
+ "path",
1088
+ "added",
1089
+ "removed"
1090
+ ],
1229
1091
  properties: {
1230
1092
  path: { type: "string" },
1231
1093
  added: { type: "number" },
@@ -1244,7 +1106,7 @@ const OUTPUT_SCHEMA$1 = {
1244
1106
  * @returns one line.
1245
1107
  */
1246
1108
  function describeFiles(files) {
1247
- return files.map((file) => file.added === void 0 ? `${file.path}(二进制)` : `${file.path}(+${file.added} −${file.removed ?? 0})`).join("、");
1109
+ return files.map((file) => `${file.path}(+${file.added} −${file.removed})`).join("、");
1248
1110
  }
1249
1111
  /**
1250
1112
  * Model-facing rendering of a submission.
@@ -1265,9 +1127,6 @@ function renderProposeValue(value) {
1265
1127
  }[value.status]}\n改动:${describeFiles(value.files)}\ndiff:${value.diffPath}`
1266
1128
  }];
1267
1129
  }
1268
- function messageOf$2(error) {
1269
- return error instanceof Error ? error.message : String(error);
1270
- }
1271
1130
  /**
1272
1131
  * Write a file in an agent-writable directory without following whatever
1273
1132
  * entry the agent left at its path: the content goes to a new file created
@@ -1290,7 +1149,7 @@ function skillDirOf(app) {
1290
1149
  var SkillUpdater = class {
1291
1150
  deps;
1292
1151
  tasks = /* @__PURE__ */ new Map();
1293
- /** @param deps - registry, settings, git, and approval access. */
1152
+ /** @param deps - registry, settings, approval, and `dshRepos` access. */
1294
1153
  constructor(deps) {
1295
1154
  this.deps = deps;
1296
1155
  }
@@ -1317,7 +1176,7 @@ var SkillUpdater = class {
1317
1176
  } catch (error) {
1318
1177
  return {
1319
1178
  ok: false,
1320
- error: messageOf$2(error)
1179
+ error: messageOf$1(error)
1321
1180
  };
1322
1181
  }
1323
1182
  }
@@ -1332,19 +1191,17 @@ var SkillUpdater = class {
1332
1191
  /**
1333
1192
  * Read the proposal, build its tree, and write the diff file.
1334
1193
  * @returns undefined when the task wrote no proposal.
1335
- * @throws ProposalError or a git failure.
1194
+ * @throws ProposalError, or an error when dsh-repo is unavailable or the mirror cannot be read.
1336
1195
  */
1337
- async prepare(task) {
1196
+ async prepare(task, signal) {
1338
1197
  const proposal = await readProposal(task.workDir, this.deps.settings().skillUpdates);
1339
1198
  if (proposal === void 0) return void 0;
1340
- const commands = {
1341
- git: this.deps.git,
1342
- timeoutMs: this.deps.gitTimeoutMs
1343
- };
1344
- const { change, target } = await this.deps.registry.withClone(task.url, async (clone) => ({
1345
- change: await prepareChange(commands, clone.dir, task.commit, skillDirOf(task.app), proposal.files),
1346
- target: clone.branch
1347
- }));
1199
+ const target = this.deps.registry.branchOf(task.url);
1200
+ const change = await prepareChange(requireRepos(this.deps.repos()), {
1201
+ url: task.url,
1202
+ base: task.commit,
1203
+ skillDir: skillDirOf(task.app)
1204
+ }, proposal.files, signal);
1348
1205
  if (change.files.length === 0) throw new ProposalError(`提议的文件与提交 ${task.commit.slice(0, 12)} 中的 SKILL 相同,没有改动`);
1349
1206
  const diffPath = join(task.workDir, DIFF_FILE);
1350
1207
  await replaceFile(diffPath, change.diff);
@@ -1393,13 +1250,17 @@ var SkillUpdater = class {
1393
1250
  if (task.sessionId !== agent.session.header.id) throw new Error(`${parsed.data.workDir} 不是当前会话派发的任务`);
1394
1251
  const approval = this.deps.approval();
1395
1252
  if (approval === void 0) throw new Error("当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval),观象不会在未经授权时提交");
1396
- const lookup = lookupRepos(this.deps.repos());
1397
- if (lookup.availability !== "ready") throw new Error(lookup.availability === "missing" ? `提交 SKILL 修改需要 dsh-repo 插件提供的 ${DSH_REPOS_SERVICE} 服务;请安装 dsh-repo,并在它的 hosts 中配置该应用仓库所在的主机和令牌` : `dsh-repo 的 ${DSH_REPOS_SERVICE} 服务与观象不兼容(观象需要服务版本 1 且提供 getBranch,即 dsh-repo 0.1.0-beta.2 及以上);请升级 dsh-repo`);
1398
- const repos = lookup.service;
1399
- const prepared = await this.prepare(task);
1253
+ const repos = requireRepos(this.deps.repos());
1254
+ let prepared;
1255
+ try {
1256
+ prepared = await this.prepare(task, exec.signal);
1257
+ } catch (error) {
1258
+ exec.signal.throwIfAborted();
1259
+ throw error;
1260
+ }
1400
1261
  if (prepared === void 0) throw new Error(`${task.workDir} 中没有 SKILL 修改提议(${PROPOSAL_DIR}/${REASON_FILE} 与 ${PROPOSAL_DIR}/${FILES_DIR}/)`);
1401
1262
  const { change, target, diffPath, reason } = prepared;
1402
- const branch = `guanxiang/skill-${task.app.id}-${change.tree.slice(0, 10)}`;
1263
+ const branch = `guanxiang/skill-${task.app.id}-${change.digest.slice(0, 10)}`;
1403
1264
  const base = {
1404
1265
  app: task.app.id,
1405
1266
  repo: task.url,
@@ -1474,7 +1335,7 @@ var SkillUpdater = class {
1474
1335
  return {
1475
1336
  ...base,
1476
1337
  status: "pushed",
1477
- mergeRequestError: messageOf$2(error)
1338
+ mergeRequestError: messageOf$1(error)
1478
1339
  };
1479
1340
  }
1480
1341
  }
@@ -1874,7 +1735,7 @@ function isInside(child, parent) {
1874
1735
  function textOf(blocks) {
1875
1736
  return blocks.flatMap((block) => block.type === "text" && typeof block.text === "string" ? [block.text] : []).join("\n").trim();
1876
1737
  }
1877
- function messageOf$1(error) {
1738
+ function messageOf(error) {
1878
1739
  return error instanceof Error ? error.message : String(error);
1879
1740
  }
1880
1741
  /** Runs dispatches; one instance serves every registration of the tool. */
@@ -1930,7 +1791,7 @@ var Dispatcher = class {
1930
1791
  try {
1931
1792
  await stat(path);
1932
1793
  } catch (error) {
1933
- throw new Error(`日志路径不可读:${path}(${error.code ?? messageOf$1(error)})`);
1794
+ throw new Error(`日志路径不可读:${path}(${error.code ?? messageOf(error)})`);
1934
1795
  }
1935
1796
  return {
1936
1797
  path,
@@ -2037,7 +1898,7 @@ var Dispatcher = class {
2037
1898
  ...base,
2038
1899
  status: stop.aborted && !signal.aborted ? "timeout" : "failed",
2039
1900
  output: "",
2040
- diagnostic: messageOf$1(error)
1901
+ diagnostic: messageOf(error)
2041
1902
  };
2042
1903
  }
2043
1904
  activations[index] = activation;
@@ -2069,7 +1930,7 @@ var Dispatcher = class {
2069
1930
  childId: activation.childId,
2070
1931
  status: "error",
2071
1932
  output: "",
2072
- diagnostic: messageOf$1(error)
1933
+ diagnostic: messageOf(error)
2073
1934
  };
2074
1935
  }
2075
1936
  }));
@@ -2079,7 +1940,7 @@ var Dispatcher = class {
2079
1940
  await activation.dispose();
2080
1941
  } catch (error) {
2081
1942
  const result = results.find((entry) => entry.childId === activation.childId);
2082
- if (result !== void 0) result.diagnostic = [result.diagnostic, `子 Agent 清理失败:${messageOf$1(error)}`].filter(Boolean).join(";");
1943
+ if (result !== void 0) result.diagnostic = [result.diagnostic, `子 Agent 清理失败:${messageOf(error)}`].filter(Boolean).join(";");
2083
1944
  }
2084
1945
  }));
2085
1946
  signal.throwIfAborted();
@@ -2087,201 +1948,40 @@ var Dispatcher = class {
2087
1948
  }
2088
1949
  };
2089
1950
  //#endregion
2090
- //#region src/repos/git.ts
2091
- /**
2092
- * 本机 git 命令的调用封装:沿用本机已有凭据(SSH key 或 credential helper),
2093
- * 或由调用方经环境变量提供的访问令牌;禁止交互式提示,避免后台拉取卡在
2094
- * 密码输入上。
2095
- *
2096
- * @module dsh-guanxiang/repos/git
2097
- */
2098
- /** A failed git command with its combined output. */
2099
- var GitError = class extends Error {
2100
- args;
2101
- output;
2102
- /**
2103
- * @param args - git arguments that failed.
2104
- * @param output - trimmed stderr (or stdout when stderr is empty).
2105
- */
2106
- constructor(args, output) {
2107
- super(`git ${args.join(" ")} 失败:${output === "" ? "无输出" : output}`);
2108
- this.args = args;
2109
- this.output = output;
2110
- }
2111
- };
2112
- /**
2113
- * Environment for non-interactive git: no terminal prompts, and SSH in batch
2114
- * mode unless the user configured their own SSH command.
2115
- * @param env - base environment.
2116
- * @returns environment for git child processes.
2117
- */
2118
- function gitEnvironment(env = process.env) {
2119
- const next = {
2120
- ...env,
2121
- GIT_TERMINAL_PROMPT: "0"
2122
- };
2123
- if (env.GIT_SSH_COMMAND === void 0 && env.GIT_SSH === void 0) next.GIT_SSH_COMMAND = "ssh -o BatchMode=yes";
2124
- return next;
2125
- }
2126
- /**
2127
- * Default runner backed by the `git` executable on PATH.
2128
- * @param args - git arguments.
2129
- * @param options - working directory, timeout, extra environment, input, and output selection.
2130
- * @returns stdout, or stdout followed by stderr.
2131
- */
2132
- const runGit = (args, options) => new Promise((resolve, reject) => {
2133
- const child = execFile("git", [...args], {
2134
- cwd: options.cwd,
2135
- env: {
2136
- ...gitEnvironment(),
2137
- ...options.env
2138
- },
2139
- timeout: options.timeoutMs,
2140
- maxBuffer: 16 * 1024 * 1024,
2141
- windowsHide: true
2142
- }, (error, stdout, stderr) => {
2143
- if (error === null) {
2144
- resolve(options.output === "all" ? `${stdout}${stderr}` : stdout);
2145
- return;
2146
- }
2147
- const output = (options.output === "all" || stderr.trim() === "" ? `${stdout}${stderr}` : stderr).trim();
2148
- reject(new GitError(args, error.killed === true ? `超时(${options.timeoutMs} ms)后被终止${output === "" ? "" : `:${output}`}` : output || error.message));
2149
- });
2150
- child.stdin?.on("error", () => {});
2151
- child.stdin?.end(options.input);
2152
- });
2153
- //#endregion
2154
1951
  //#region src/repos/registry.ts
2155
- /**
2156
- * 应用注册表:把设置中的 git 地址拉取到 `<reposDir>/<仓库名>`,从远端分支最新
2157
- * 提交读取各仓库的应用描述,对外提供「已就绪的应用」,并为每次派发提供该
2158
- * 提交的只读代码快照。
2159
- *
2160
- * - 拉取目录只做 fetch,不检出:新地址 `git clone --no-checkout`(先克隆到临时
2161
- * 目录再改名,半成品不会被当成仓库),已有目录 `git fetch`。应用描述与 SKILL
2162
- * 直接从提交的 git 对象读取,所以随时可以更新,不受正在进行的分析影响。
2163
- * - 子 Agent 在 `<reposDir>/.snapshots/<仓库名>/` 下按提交创建的 `git worktree`
2164
- * 快照里工作。快照创建后不再改动;同一提交的派发共用一份,没有使用者且已不是
2165
- * 最新提交时删除。进程内首次同步某仓库时清掉上次运行遗留的快照。
2166
- * - 更新失败时继续使用已有提交,并在状态里给出原因;应用描述或 SKILL 不合法时
2167
- * 该应用不可用。
2168
- * - 多个仓库声明同一个应用 id 时,这些仓库全部不可用。
2169
- *
2170
- * @module dsh-guanxiang/repos/registry
2171
- */
2172
- /**
2173
- * Checkout directory name for a git address: its last path segment without
2174
- * `.git`, restricted to `[A-Za-z0-9._-]`.
2175
- * @param url - git address.
2176
- * @returns directory name.
2177
- */
2178
- function repoDirName(url) {
2179
- const name = (url.replace(/^[^@/]+@[^:/]+:/, "").replace(/[?#].*$/, "").replace(/\/+$/, "").split(/[/\\:]/).pop() ?? "").replace(/\.git$/, "").replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[.-]+/, "");
2180
- return name === "" ? "repo" : name;
2181
- }
2182
- /**
2183
- * Assign each address a distinct directory; colliding names get a short hash
2184
- * of their address appended.
2185
- * @param reposDir - parent directory (may start with `~`).
2186
- * @param urls - configured addresses.
2187
- * @returns absolute directory per address.
2188
- */
2189
- function assignDirs(reposDir, urls) {
2190
- const root = expandHome(reposDir);
2191
- const counts = /* @__PURE__ */ new Map();
2192
- for (const url of urls) counts.set(repoDirName(url), (counts.get(repoDirName(url)) ?? 0) + 1);
2193
- const dirs = /* @__PURE__ */ new Map();
2194
- for (const url of urls) {
2195
- const name = repoDirName(url);
2196
- const unique = (counts.get(name) ?? 0) > 1 ? `${name}-${createHash("sha1").update(url).digest("hex").slice(0, 8)}` : name;
2197
- dirs.set(url, join(root, unique));
2198
- }
2199
- return dirs;
2200
- }
2201
- /**
2202
- * Directory holding the commit snapshots of one clone.
2203
- * @param dir - clone directory `<reposDir>/<name>`.
2204
- * @returns `<reposDir>/.snapshots/<name>`.
2205
- */
2206
- function snapshotRoot(dir) {
2207
- return join(dirname(dir), ".snapshots", basename(dir));
2208
- }
2209
- /**
2210
- * Run a task after every task queued earlier under the same key.
2211
- * @param locks - tail of each key's queue.
2212
- * @param key - queue key.
2213
- * @param task - work to run.
2214
- * @returns the task's result.
2215
- */
2216
- function serialize(locks, key, task) {
2217
- const run = (locks.get(key) ?? Promise.resolve()).then(task);
2218
- const tail = run.then(() => void 0, () => void 0);
2219
- locks.set(key, tail);
2220
- tail.then(() => {
2221
- if (locks.get(key) === tail) locks.delete(key);
2222
- });
2223
- return run;
2224
- }
2225
- function messageOf(error) {
2226
- if (error instanceof GitError) return error.output === "" ? error.message : error.output;
2227
- return error instanceof Error ? error.message : String(error);
2228
- }
2229
- async function pathKind(path) {
2230
- try {
2231
- return (await stat(path)).isDirectory() ? "directory" : "other";
2232
- } catch (error) {
2233
- if (error.code === "ENOENT") return "missing";
2234
- throw error;
2235
- }
2236
- }
2237
- /** Clones, updates, and indexes the configured application repositories. */
1952
+ /** Mirrors, updates, and indexes the configured application repositories. */
2238
1953
  var RepoRegistry = class {
2239
1954
  deps;
2240
1955
  entries = [];
2241
- /** Orders clone and fetch work per clone directory. */
2242
- syncLocks = /* @__PURE__ */ new Map();
2243
- /** Orders `git worktree` work per clone directory. */
2244
- worktreeLocks = /* @__PURE__ */ new Map();
2245
- /** Snapshot roots already cleared of leftovers from an earlier run. */
2246
- clearedRoots = /* @__PURE__ */ new Set();
2247
1956
  now;
2248
- /** @param deps - git runner, timeout, and change notification. */
1957
+ /** @param deps - service lookup and change notification. */
2249
1958
  constructor(deps) {
2250
1959
  this.deps = deps;
2251
1960
  this.now = deps.now ?? Date.now;
2252
1961
  }
2253
1962
  /**
2254
1963
  * Replace the configured repositories. An address keeps its status when its
2255
- * branch and directory are unchanged; clones of removed addresses stay on
2256
- * disk, and their snapshots are deleted once no dispatch holds them.
2257
- * @param reposDir - parent directory of the clones.
1964
+ * branch is unchanged.
2258
1965
  * @param repos - configured repositories.
2259
- * @returns entries that are new or changed and therefore need a sync.
1966
+ * @returns addresses that are new or changed and therefore need a sync.
2260
1967
  */
2261
- configure(reposDir, repos) {
2262
- const dirs = assignDirs(reposDir, repos.map((repo) => repo.url));
1968
+ configure(repos) {
2263
1969
  const previous = new Map(this.entries.map((entry) => [entry.spec.url, entry]));
2264
1970
  const changed = [];
2265
1971
  this.entries = repos.map((spec) => {
2266
- const dir = dirs.get(spec.url);
2267
1972
  const kept = previous.get(spec.url);
2268
- if (kept !== void 0 && kept.dir === dir && kept.spec.branch === spec.branch) {
1973
+ if (kept !== void 0 && kept.spec.branch === spec.branch) {
2269
1974
  previous.delete(spec.url);
2270
1975
  return kept;
2271
1976
  }
2272
1977
  changed.push(spec.url);
2273
1978
  return {
2274
1979
  spec: { ...spec },
2275
- dir,
2276
- snapshots: /* @__PURE__ */ new Map(),
2277
1980
  activeRuns: 0,
2278
1981
  retired: false
2279
1982
  };
2280
1983
  });
2281
- for (const entry of previous.values()) {
2282
- entry.retired = true;
2283
- this.pruneSnapshots(entry);
2284
- }
1984
+ for (const entry of previous.values()) entry.retired = true;
2285
1985
  this.deps.onChange();
2286
1986
  return changed;
2287
1987
  }
@@ -2302,16 +2002,14 @@ var RepoRegistry = class {
2302
2002
  await Promise.all(this.entries.filter((entry) => wanted.has(entry.spec.url)).map((entry) => this.syncEntry(entry)));
2303
2003
  }
2304
2004
  /**
2305
- * Sync the configured repositories with the given addresses again after any
2306
- * sync already running for them, so the new sync sees settings changed since
2307
- * that sync started (an access token, for example). An idle repository
2308
- * starts syncing before this returns, so its status reads `syncing` at once.
2309
- * @param urls - addresses to sync.
2005
+ * Sync every configured repository again after any sync already running for
2006
+ * it, so the new sync sees changes made since that sync started (a host
2007
+ * token, for example). An idle repository starts syncing before this
2008
+ * returns, so its status reads `syncing` at once.
2310
2009
  * @returns when those syncs settled.
2311
2010
  */
2312
- async resyncUrls(urls) {
2313
- const wanted = new Set(urls);
2314
- await Promise.all(this.entries.filter((entry) => wanted.has(entry.spec.url)).map((entry) => entry.syncing === void 0 ? this.syncEntry(entry) : entry.syncing.then(() => void 0, () => void 0).then(() => this.syncEntry(entry))));
2011
+ async resyncAll() {
2012
+ await Promise.all(this.entries.map((entry) => entry.syncing === void 0 ? this.syncEntry(entry) : entry.syncing.then(() => void 0, () => void 0).then(() => this.syncEntry(entry))));
2315
2013
  }
2316
2014
  /**
2317
2015
  * Sync the repositories that never synced or whose last sync is at least
@@ -2342,7 +2040,7 @@ var RepoRegistry = class {
2342
2040
  return {
2343
2041
  url: entry.spec.url,
2344
2042
  ...entry.spec.branch === void 0 ? {} : { branch: entry.spec.branch },
2345
- dir: entry.dir,
2043
+ ...entry.dir === void 0 ? {} : { dir: entry.dir },
2346
2044
  phase,
2347
2045
  ...entry.commit === void 0 ? {} : { commit: entry.commit },
2348
2046
  ...entry.commitTime === void 0 ? {} : { commitTime: entry.commitTime },
@@ -2377,75 +2075,64 @@ var RepoRegistry = class {
2377
2075
  }
2378
2076
  /**
2379
2077
  * Hold a snapshot of each requested application's latest synced commit for
2380
- * one dispatch, creating the snapshots that do not exist yet. Every id must
2381
- * resolve to a usable application, or nothing is held.
2078
+ * one dispatch. Every id must resolve to a usable application, or nothing is
2079
+ * held.
2382
2080
  * @param ids - requested application ids (duplicates allowed).
2383
2081
  * @returns the lease.
2384
- * @throws when an id is unknown, its repository is not usable, or a snapshot cannot be created.
2082
+ * @throws when an id is unknown, its repository is not usable, dsh-repo is unavailable, or a snapshot cannot be created.
2385
2083
  */
2386
2084
  async acquire(ids) {
2387
2085
  const unique = [...new Set(ids)];
2388
2086
  const ready = new Map(this.readyEntries().map((item) => [item.ready.app.id, item]));
2389
2087
  const missing = unique.filter((id) => !ready.has(id));
2390
2088
  if (missing.length > 0) throw new Error(this.unknownMessage(missing));
2089
+ const service = requireRepos(this.deps.repos());
2391
2090
  const held = unique.map((id) => {
2392
2091
  const { entry, ready: app } = ready.get(id);
2393
- const snapshot = this.snapshotFor(entry, app.commit);
2394
- snapshot.users += 1;
2395
2092
  entry.activeRuns += 1;
2396
2093
  return {
2397
2094
  id,
2398
2095
  entry,
2399
2096
  app,
2400
- snapshot
2097
+ snapshot: service.acquireSnapshot(app.url, app.commit)
2401
2098
  };
2402
2099
  });
2403
2100
  this.deps.onChange();
2101
+ const outcomes = await Promise.allSettled(held.map((item) => item.snapshot));
2102
+ const leases = outcomes.flatMap((outcome) => outcome.status === "fulfilled" ? [outcome.value] : []);
2404
2103
  let released = false;
2405
2104
  const release = () => {
2406
2105
  if (released) return;
2407
2106
  released = true;
2408
- for (const { entry, snapshot } of held) {
2409
- snapshot.users -= 1;
2410
- entry.activeRuns -= 1;
2411
- }
2412
- for (const entry of new Set(held.map((item) => item.entry))) this.pruneSnapshots(entry);
2107
+ for (const lease of leases) lease.release();
2108
+ for (const { entry } of held) entry.activeRuns -= 1;
2413
2109
  this.deps.onChange();
2414
2110
  };
2415
- const created = await Promise.allSettled(held.map((item) => item.snapshot.created));
2416
- for (const [index, outcome] of created.entries()) {
2111
+ for (const [index, outcome] of outcomes.entries()) {
2417
2112
  if (outcome.status === "fulfilled") continue;
2418
2113
  release();
2419
- throw new Error(`应用「${held[index]?.id}」的代码快照创建失败:${messageOf(outcome.reason)}`);
2114
+ throw new Error(`应用「${held[index]?.id}」的代码快照创建失败:${messageOf$1(outcome.reason)}`);
2420
2115
  }
2421
2116
  return {
2422
- apps: new Map(held.map(({ id, app, snapshot }) => [id, {
2117
+ apps: new Map(held.map(({ id, app }, index) => [id, {
2423
2118
  ...app,
2424
- dir: snapshot.dir
2119
+ dir: leases[index].dir
2425
2120
  }])),
2426
2121
  release
2427
2122
  };
2428
2123
  }
2429
2124
  /**
2430
- * Run a task on the clone of a configured address after every sync queued
2431
- * before it, so the task never overlaps a fetch of the same clone.
2125
+ * The branch the last successful sync of a configured address resolved: the
2126
+ * configured branch, or the remote's default branch when none is configured.
2432
2127
  * @param url - configured repository address.
2433
- * @param task - work on the clone.
2434
- * @returns the task's result.
2435
- * @throws when the address is not configured, its token cannot be read, or the task fails.
2128
+ * @returns the branch.
2129
+ * @throws when the address is not configured or has not synced yet.
2436
2130
  */
2437
- async withClone(url, task) {
2131
+ branchOf(url) {
2438
2132
  const entry = this.entries.find((item) => item.spec.url === url);
2439
2133
  if (entry === void 0) throw new Error(`仓库 ${url} 已不在配置中`);
2440
- return await serialize(this.syncLocks, entry.dir, async () => {
2441
- if (entry.retired) throw new Error(`仓库 ${url} 已不在配置中`);
2442
- const env = await this.deps.auth?.(url) ?? {};
2443
- const branch = entry.spec.branch ?? (await this.remoteHead(entry.dir, env)).replace(/^refs\/remotes\/origin\//, "");
2444
- return await task({
2445
- dir: entry.dir,
2446
- branch
2447
- });
2448
- });
2134
+ if (entry.branch === void 0) throw new Error(`仓库 ${url} 还没有同步成功`);
2135
+ return entry.branch;
2449
2136
  }
2450
2137
  unknownMessage(ids) {
2451
2138
  const status = new Map(this.status().flatMap((repo) => repo.app === void 0 ? [] : [[repo.app.id, repo]]));
@@ -2466,7 +2153,7 @@ var RepoRegistry = class {
2466
2153
  }
2467
2154
  syncEntry(entry) {
2468
2155
  if (entry.syncing !== void 0) return entry.syncing;
2469
- const run = serialize(this.syncLocks, entry.dir, () => this.runSync(entry)).finally(() => {
2156
+ const run = this.runSync(entry).finally(() => {
2470
2157
  entry.syncing = void 0;
2471
2158
  this.deps.onChange();
2472
2159
  });
@@ -2476,16 +2163,20 @@ var RepoRegistry = class {
2476
2163
  }
2477
2164
  async runSync(entry) {
2478
2165
  if (entry.retired) return;
2166
+ let service;
2479
2167
  let head;
2480
2168
  try {
2481
- head = await this.update(entry);
2169
+ service = requireRepos(this.deps.repos());
2170
+ head = await service.syncMirror(entry.spec.url, entry.spec.branch === void 0 ? {} : { branch: entry.spec.branch });
2482
2171
  } catch (error) {
2483
2172
  entry.syncedAt = this.now();
2484
- entry.error = entry.commit !== void 0 && entry.app !== void 0 ? `更新失败,继续使用已有提交 ${entry.commit.slice(0, 8)}:${messageOf(error)}` : messageOf(error);
2173
+ entry.error = entry.commit !== void 0 && entry.app !== void 0 ? `更新失败,继续使用已有提交 ${entry.commit.slice(0, 8)}:${messageOf$1(error)}` : messageOf$1(error);
2485
2174
  return;
2486
2175
  }
2487
- const manifest = await checkAppManifest(this.commitReader(entry.dir, head.commit));
2176
+ const manifest = await checkAppManifest((path) => service.readMirrorFile(entry.spec.url, head.commit, path));
2488
2177
  entry.syncedAt = this.now();
2178
+ entry.dir = head.dir;
2179
+ entry.branch = head.branch;
2489
2180
  entry.commit = head.commit;
2490
2181
  entry.commitTime = head.commitTime;
2491
2182
  if (manifest.ok) {
@@ -2495,202 +2186,6 @@ var RepoRegistry = class {
2495
2186
  entry.app = void 0;
2496
2187
  entry.error = manifest.error;
2497
2188
  }
2498
- this.pruneSnapshots(entry);
2499
- }
2500
- /**
2501
- * Clone or fetch, and resolve the commit the configured branch points to.
2502
- * The first visit of a snapshot root in this process deletes the snapshots
2503
- * an earlier run left behind.
2504
- */
2505
- async update(entry) {
2506
- const { git, gitTimeoutMs: timeoutMs } = this.deps;
2507
- const { dir, spec } = entry;
2508
- const root = snapshotRoot(dir);
2509
- const firstVisit = !this.clearedRoots.has(root);
2510
- if (firstVisit) {
2511
- this.clearedRoots.add(root);
2512
- await rm(root, {
2513
- recursive: true,
2514
- force: true
2515
- });
2516
- }
2517
- const env = await this.deps.auth?.(spec.url) ?? {};
2518
- const kind = await pathKind(dir);
2519
- if (kind === "missing") {
2520
- await mkdir(dirname(dir), { recursive: true });
2521
- const partial = `${dir}.partial-${randomBytes(4).toString("hex")}`;
2522
- try {
2523
- await git([
2524
- "clone",
2525
- "--quiet",
2526
- "--no-checkout",
2527
- ...spec.branch === void 0 ? [] : ["--branch", spec.branch],
2528
- "--",
2529
- spec.url,
2530
- partial
2531
- ], {
2532
- timeoutMs,
2533
- env
2534
- });
2535
- await rename(partial, dir);
2536
- } catch (error) {
2537
- await rm(partial, {
2538
- recursive: true,
2539
- force: true
2540
- });
2541
- throw error;
2542
- }
2543
- } else {
2544
- if (kind !== "directory") throw new Error(`${dir} 已存在且不是目录`);
2545
- if (await pathKind(join(dir, ".git")) === "missing") {
2546
- if (!((await readdir(dir)).length === 0)) throw new Error(`${dir} 已存在且不是 git 仓库,请移走后重新拉取`);
2547
- await rm(dir, { recursive: true });
2548
- return await this.update(entry);
2549
- }
2550
- const origin = (await git([
2551
- "remote",
2552
- "get-url",
2553
- "origin"
2554
- ], {
2555
- cwd: dir,
2556
- timeoutMs
2557
- })).trim();
2558
- if (origin !== spec.url) throw new Error(`${dir} 已被另一个仓库占用(origin 为 ${origin})`);
2559
- await git([
2560
- "fetch",
2561
- "--quiet",
2562
- "--prune",
2563
- "origin"
2564
- ], {
2565
- cwd: dir,
2566
- timeoutMs,
2567
- env
2568
- });
2569
- if (firstVisit) await git(["worktree", "prune"], {
2570
- cwd: dir,
2571
- timeoutMs
2572
- });
2573
- }
2574
- const [commit = "", commitTime = ""] = (await git([
2575
- "log",
2576
- "-1",
2577
- "--format=%H%n%cI",
2578
- spec.branch === void 0 ? await this.remoteHead(dir, env) : `refs/remotes/origin/${spec.branch}`,
2579
- "--"
2580
- ], {
2581
- cwd: dir,
2582
- timeoutMs
2583
- })).trim().split("\n");
2584
- return {
2585
- commit,
2586
- commitTime
2587
- };
2588
- }
2589
- /**
2590
- * Reader for the files of one commit, from the clone's object database.
2591
- * Only regular files are read; a symbolic link is reported as unreadable.
2592
- */
2593
- commitReader(dir, commit) {
2594
- const { git, gitTimeoutMs: timeoutMs } = this.deps;
2595
- return async (path) => {
2596
- const listing = await git([
2597
- "ls-tree",
2598
- "-z",
2599
- commit,
2600
- "--",
2601
- path
2602
- ], {
2603
- cwd: dir,
2604
- timeoutMs
2605
- });
2606
- if (listing === "") return void 0;
2607
- const [mode, type, object] = listing.slice(0, listing.indexOf(" ")).split(" ");
2608
- if (type !== "blob" || mode === "120000" || object === void 0) throw new Error(`${path} 不是普通文件`);
2609
- return await git([
2610
- "cat-file",
2611
- "blob",
2612
- object
2613
- ], {
2614
- cwd: dir,
2615
- timeoutMs
2616
- });
2617
- };
2618
- }
2619
- /** The snapshot of `commit`, created in the background on first use. */
2620
- snapshotFor(entry, commit) {
2621
- const existing = entry.snapshots.get(commit);
2622
- if (existing !== void 0) return existing;
2623
- const dir = join(snapshotRoot(entry.dir), `${commit.slice(0, 12)}-${randomBytes(3).toString("hex")}`);
2624
- const created = serialize(this.worktreeLocks, entry.dir, async () => {
2625
- await mkdir(dirname(dir), { recursive: true });
2626
- await this.deps.git([
2627
- "worktree",
2628
- "add",
2629
- "--detach",
2630
- dir,
2631
- commit
2632
- ], {
2633
- cwd: entry.dir,
2634
- timeoutMs: this.deps.gitTimeoutMs
2635
- });
2636
- });
2637
- const snapshot = {
2638
- commit,
2639
- dir,
2640
- users: 0,
2641
- created
2642
- };
2643
- entry.snapshots.set(commit, snapshot);
2644
- created.catch(() => {
2645
- if (entry.snapshots.get(commit) === snapshot) entry.snapshots.delete(commit);
2646
- this.removeSnapshot(entry.dir, dir);
2647
- });
2648
- return snapshot;
2649
- }
2650
- /** Delete the snapshots no lease holds, except the latest commit's of a configured repository. */
2651
- pruneSnapshots(entry) {
2652
- for (const [commit, snapshot] of entry.snapshots) {
2653
- if (snapshot.users > 0 || commit === entry.commit && !entry.retired) continue;
2654
- entry.snapshots.delete(commit);
2655
- snapshot.created.then(() => this.removeSnapshot(entry.dir, snapshot.dir), () => void 0);
2656
- }
2657
- }
2658
- async removeSnapshot(clone, dir) {
2659
- try {
2660
- await serialize(this.worktreeLocks, clone, async () => {
2661
- await rm(dir, {
2662
- recursive: true,
2663
- force: true
2664
- });
2665
- await this.deps.git(["worktree", "prune"], {
2666
- cwd: clone,
2667
- timeoutMs: this.deps.gitTimeoutMs
2668
- });
2669
- });
2670
- } catch {}
2671
- }
2672
- async remoteHead(dir, env) {
2673
- const { git, gitTimeoutMs: timeoutMs } = this.deps;
2674
- const read = async () => (await git(["symbolic-ref", "refs/remotes/origin/HEAD"], {
2675
- cwd: dir,
2676
- timeoutMs
2677
- })).trim();
2678
- try {
2679
- return await read();
2680
- } catch (error) {
2681
- if (!(error instanceof GitError)) throw error;
2682
- await git([
2683
- "remote",
2684
- "set-head",
2685
- "origin",
2686
- "--auto"
2687
- ], {
2688
- cwd: dir,
2689
- timeoutMs,
2690
- env
2691
- });
2692
- return await read();
2693
- }
2694
2189
  }
2695
2190
  };
2696
2191
  //#endregion
@@ -2949,16 +2444,21 @@ function createEntrySkillProvider(source) {
2949
2444
  }
2950
2445
  //#endregion
2951
2446
  //#region src/service.ts
2952
- /**
2953
- * 观象运行时:持有生效设置(cordis.yml + 面板覆盖)、应用注册表、入口
2954
- * SKILL、派发工具与 SKILL 提议工具的注册,并为设置面板提供状态与写入口。
2955
- *
2956
- * @module dsh-guanxiang/service
2957
- */
2958
2447
  /** A settings write the panel reports as a field error. */
2959
2448
  var SettingsError = class extends Error {};
2449
+ /** Longest accepted token; personal access tokens are far shorter. */
2450
+ const MAX_TOKEN_LENGTH = 4096;
2451
+ const tokenValue = z.string().trim().refine((value) => value.length > 0 && value.length <= MAX_TOKEN_LENGTH && !/[\u0000-\u001f\u007f]/.test(value), `令牌不能为空、超过 ${MAX_TOKEN_LENGTH} 个字符或包含换行等控制字符`);
2960
2452
  const tokenClearSchema = z.object({ tokenEnv: z.string() }).strict();
2961
- const tokenSetSchema = tokenClearSchema.extend({ value: z.string().trim().refine(isAcceptedToken, `令牌不能为空、超过 ${MAX_TOKEN_LENGTH} 个字符或包含换行等控制字符`) }).strict();
2453
+ const tokenSetSchema = tokenClearSchema.extend({ value: tokenValue }).strict();
2454
+ const hostUrlSchema = z.object({ url: z.string().trim().min(1, "主机地址不能为空") }).strict();
2455
+ const hostTokenSchema = hostUrlSchema.extend({ value: tokenValue }).strict();
2456
+ const hostAddSchema = z.object({
2457
+ kind: z.enum(["github", "gitlab"]).optional(),
2458
+ url: z.string().trim().min(1, "主机地址不能为空"),
2459
+ alias: z.string().trim().max(64, "别名不能超过 64 个字符").optional().transform((value) => value === "" ? void 0 : value),
2460
+ token: tokenValue.optional()
2461
+ }).strict();
2962
2462
  /** The plugin's long-lived state. */
2963
2463
  var GuanxiangService = class {
2964
2464
  ctx;
@@ -2974,6 +2474,7 @@ var GuanxiangService = class {
2974
2474
  active = true;
2975
2475
  refreshWaitMs;
2976
2476
  analyzeTokenRef;
2477
+ legacyCredentials;
2977
2478
  booted = Promise.withResolvers();
2978
2479
  /**
2979
2480
  * @param ctx - plugin context.
@@ -2987,12 +2488,10 @@ var GuanxiangService = class {
2987
2488
  this.defaults = defaultsOf(config);
2988
2489
  this.refreshWaitMs = config.refreshWaitMs;
2989
2490
  this.analyzeTokenRef = config.analyzeTokenRef;
2491
+ this.legacyCredentials = config.gitCredentials;
2990
2492
  this.current = this.defaults;
2991
- const git = options.git ?? runGit;
2992
2493
  this.registry = new RepoRegistry({
2993
- git,
2994
- gitTimeoutMs: config.gitTimeoutMs,
2995
- auth: (url) => this.gitAuth(url),
2494
+ repos: () => ctx.get?.(DSH_REPOS_SERVICE),
2996
2495
  onChange: () => {
2997
2496
  this.onRegistryChange();
2998
2497
  }
@@ -3000,8 +2499,6 @@ var GuanxiangService = class {
3000
2499
  this.updater = new SkillUpdater({
3001
2500
  registry: this.registry,
3002
2501
  settings: () => this.current,
3003
- git,
3004
- gitTimeoutMs: config.gitTimeoutMs,
3005
2502
  ...config.skillUpdateAuthor === void 0 ? {} : { author: config.skillUpdateAuthor },
3006
2503
  approval: () => ctx.get?.("approval"),
3007
2504
  repos: () => ctx.get?.(DSH_REPOS_SERVICE)
@@ -3021,7 +2518,10 @@ var GuanxiangService = class {
3021
2518
  maxAppsPerRequest: () => this.current.maxAppsPerRequest,
3022
2519
  skillUpdates: () => this.current.skillUpdates.enabled,
3023
2520
  autoInvoke: config.autoInvoke,
3024
- inRepositories: (cwd) => isInside(cwd, expandHome(this.current.reposDir))
2521
+ inRepositories: (cwd) => {
2522
+ const lookup = lookupRepos(ctx.get?.(DSH_REPOS_SERVICE));
2523
+ return lookup.availability === "ready" && isInside(cwd, lookup.service.mirrorSettings().dir);
2524
+ }
3025
2525
  });
3026
2526
  }
3027
2527
  /** Register the entry skill provider; called once from `apply()`. */
@@ -3066,7 +2566,8 @@ var GuanxiangService = class {
3066
2566
  }
3067
2567
  }
3068
2568
  /**
3069
- * Load the panel overlay, configure the registry, and sync every repository.
2569
+ * Load the panel overlay, configure the registry, move legacy access tokens
2570
+ * to dsh-repo hosts, and sync every repository.
3070
2571
  * @returns when the first sync round settled.
3071
2572
  */
3072
2573
  async boot() {
@@ -3084,9 +2585,53 @@ var GuanxiangService = class {
3084
2585
  this.ctx.logger?.warn?.("guanxiang: 忽略面板设置:%s", read.error);
3085
2586
  }
3086
2587
  this.current = applyOverlay(this.defaults, this.overlay);
3087
- this.registry.configure(this.current.reposDir, this.current.repos);
2588
+ this.registry.configure(this.current.repos);
2589
+ await this.migrateCredentials();
3088
2590
  await this.registry.syncAll();
3089
2591
  }
2592
+ /**
2593
+ * React to dsh-repo becoming available (or reloading): move legacy tokens
2594
+ * and sync every repository again.
2595
+ * @returns when the syncs settled.
2596
+ */
2597
+ async reposAvailable() {
2598
+ await this.booted.promise;
2599
+ if (!this.active) return;
2600
+ await this.migrateCredentials();
2601
+ await this.registry.resyncAll();
2602
+ }
2603
+ /**
2604
+ * Add each access token of earlier versions whose address dsh-repo does not
2605
+ * know yet as a dsh-repo host, carrying the stored token value. The panel's
2606
+ * list replaces the cordis.yml list, as it did before. An entry whose token
2607
+ * cannot be resolved yet is left for a later start instead of creating a
2608
+ * host without a token. Failures are logged; the legacy entry stays and the
2609
+ * next start tries again.
2610
+ */
2611
+ async migrateCredentials() {
2612
+ const legacy = this.overlay.gitCredentials ?? this.legacyCredentials;
2613
+ if (legacy.length === 0) return;
2614
+ const lookup = lookupRepos(this.ctx.get?.(DSH_REPOS_SERVICE));
2615
+ if (lookup.availability !== "ready") return;
2616
+ const store = this.credentialStore();
2617
+ if (store === void 0) return;
2618
+ for (const credential of legacy) try {
2619
+ const token = (await store.resolve(credential.tokenEnv))?.value;
2620
+ if (token === void 0 || token === "") {
2621
+ this.ctx.logger?.warn?.("guanxiang: 访问令牌 %s 尚未设置,暂不迁移到 dsh-repo", credential.url);
2622
+ continue;
2623
+ }
2624
+ await lookup.service.addHost({
2625
+ url: credential.url,
2626
+ ...credential.alias === void 0 || credential.alias === "" ? {} : { alias: credential.alias },
2627
+ token
2628
+ });
2629
+ this.ctx.logger?.info?.("guanxiang: 已把访问令牌 %s 迁移为 dsh-repo 主机", credential.url);
2630
+ } catch (error) {
2631
+ if (repoErrorCode(error) === "conflict") continue;
2632
+ this.ctx.logger?.warn?.("guanxiang: 迁移访问令牌 %s 到 dsh-repo 失败:%s", credential.url, messageOf$1(error));
2633
+ }
2634
+ }
3090
2635
  /** Stop registering tools; in-flight syncs finish without side effects on the host. */
3091
2636
  shutdown() {
3092
2637
  this.active = false;
@@ -3104,7 +2649,8 @@ var GuanxiangService = class {
3104
2649
  overridden: overriddenKeys(this.overlay),
3105
2650
  ...this.overlayError === void 0 ? {} : { overlayError: this.overlayError },
3106
2651
  repos: this.registry.status(),
3107
- credentials: await this.credentialsState(),
2652
+ hosts: await this.hostsState(),
2653
+ credentialsAvailable: this.credentialStore() !== void 0,
3108
2654
  analyze: await this.analyzeState()
3109
2655
  };
3110
2656
  }
@@ -3135,42 +2681,109 @@ var GuanxiangService = class {
3135
2681
  credentialStore() {
3136
2682
  return this.ctx.get?.("credentials");
3137
2683
  }
3138
- async credentialsState() {
3139
- const store = this.credentialStore();
3140
- if (store === void 0) return {
3141
- available: false,
3142
- tokens: []
3143
- };
3144
- const names = [...new Set(this.current.gitCredentials.map((credential) => credential.tokenEnv))];
3145
- return {
3146
- available: true,
3147
- tokens: await Promise.all(names.map((tokenEnv) => describeToken(store, tokenEnv)))
2684
+ async hostsState() {
2685
+ const lookup = lookupRepos(this.ctx.get?.(DSH_REPOS_SERVICE));
2686
+ if (lookup.availability !== "ready") return {
2687
+ availability: lookup.availability,
2688
+ hosts: []
3148
2689
  };
2690
+ const mirrorDir = lookup.service.mirrorSettings().dir;
2691
+ try {
2692
+ return {
2693
+ availability: "ready",
2694
+ mirrorDir,
2695
+ hosts: (await lookup.service.listHosts()).map((host) => ({
2696
+ kind: host.kind,
2697
+ url: host.url,
2698
+ ...host.alias === void 0 ? {} : { alias: host.alias },
2699
+ origin: host.origin,
2700
+ token: {
2701
+ tokenEnv: host.tokenRef,
2702
+ ...host.token
2703
+ }
2704
+ }))
2705
+ };
2706
+ } catch (error) {
2707
+ return {
2708
+ availability: "ready",
2709
+ mirrorDir,
2710
+ hosts: [],
2711
+ error: messageOf$1(error)
2712
+ };
2713
+ }
3149
2714
  }
3150
2715
  /**
3151
- * Environment carrying the access token for one repository address; empty
3152
- * when no access-token configuration matches it.
3153
- * @param url - repository address.
3154
- * @returns variables for its network git commands.
3155
- * @throws when a configuration matches but its token cannot be read.
2716
+ * Add a dsh-repo host, optionally with its token, then sync every repository.
2717
+ * @param raw - request body `{ kind?, url, alias?, token? }`.
2718
+ * @returns the new panel state.
2719
+ * @throws SettingsError when the body is invalid, dsh-repo is unavailable, or it refuses the host.
3156
2720
  */
3157
- async gitAuth(url) {
3158
- const spec = credentialFor(url, this.current.gitCredentials);
3159
- if (spec === void 0) return {};
3160
- const store = this.credentialStore();
3161
- if (store === void 0) throw new Error(`地址匹配访问令牌 ${spec.url},但当前 DSH 组合没有凭据服务,无法读取 ${spec.tokenEnv}`);
3162
- const token = await store.resolve(spec.tokenEnv);
3163
- if (token === void 0) throw new Error(`地址匹配访问令牌 ${spec.url},但 ${spec.tokenEnv} 未设置:请在观象面板「访问令牌」中保存令牌,或设置同名环境变量`);
3164
- if (!isAcceptedToken(token.value)) throw new Error(`${spec.tokenEnv} 的值不能用作令牌:不能超过 ${MAX_TOKEN_LENGTH} 个字符或包含换行等控制字符`);
3165
- return credentialEnvironment(spec, token.value);
2721
+ async addHost(raw) {
2722
+ const parsed = hostAddSchema.safeParse(raw);
2723
+ if (!parsed.success) throw new SettingsError(describeIssue(parsed.error));
2724
+ const { kind, url, alias, token } = parsed.data;
2725
+ await this.withRepos((repos) => repos.addHost({
2726
+ url,
2727
+ ...kind === void 0 ? {} : { kind },
2728
+ ...alias === void 0 ? {} : { alias },
2729
+ ...token === void 0 ? {} : { token }
2730
+ }));
2731
+ return await this.state();
2732
+ }
2733
+ /**
2734
+ * Remove a host added from a panel, and its stored token.
2735
+ * @param raw - request body `{ url }`.
2736
+ * @returns the new panel state.
2737
+ * @throws SettingsError when the body is invalid, dsh-repo is unavailable, or the host is unknown or configured in cordis.yml.
2738
+ */
2739
+ async removeHost(raw) {
2740
+ const parsed = hostUrlSchema.safeParse(raw);
2741
+ if (!parsed.success) throw new SettingsError(describeIssue(parsed.error));
2742
+ await this.withRepos((repos) => repos.removeHost(parsed.data.url));
2743
+ return await this.state();
3166
2744
  }
3167
2745
  /**
3168
- * Store the token of one access-token configuration in DSH's credential
3169
- * store, then re-sync the repositories that use it; or store the analyze
3170
- * API's bearer token (`analyzeTokenRef`), which applies from the next request.
2746
+ * Store a host's token through dsh-repo, then sync every repository.
2747
+ * @param raw - request body `{ url, value }`.
2748
+ * @returns the new panel state.
2749
+ * @throws SettingsError when the body is invalid, dsh-repo is unavailable, or the host is unknown.
2750
+ */
2751
+ async setHostToken(raw) {
2752
+ const parsed = hostTokenSchema.safeParse(raw);
2753
+ if (!parsed.success) throw new SettingsError(describeIssue(parsed.error));
2754
+ await this.withRepos((repos) => repos.setHostToken(parsed.data.url, parsed.data.value));
2755
+ return await this.state();
2756
+ }
2757
+ /**
2758
+ * Remove a host's stored token; an environment variable of the same name applies again.
2759
+ * @param raw - request body `{ url }`.
2760
+ * @returns the new panel state.
2761
+ * @throws SettingsError on the same conditions as {@link setHostToken}.
2762
+ */
2763
+ async clearHostToken(raw) {
2764
+ const parsed = hostUrlSchema.safeParse(raw);
2765
+ if (!parsed.success) throw new SettingsError(describeIssue(parsed.error));
2766
+ await this.withRepos((repos) => repos.setHostToken(parsed.data.url, void 0));
2767
+ return await this.state();
2768
+ }
2769
+ /** Run one host change and start a resync, reporting dsh-repo failures as field errors. */
2770
+ async withRepos(change) {
2771
+ const lookup = lookupRepos(this.ctx.get?.(DSH_REPOS_SERVICE));
2772
+ if (lookup.availability !== "ready") throw new SettingsError(unavailableMessage(lookup.availability));
2773
+ try {
2774
+ await change(lookup.service);
2775
+ } catch (error) {
2776
+ if (repoErrorCode(error) !== void 0) throw new SettingsError(messageOf$1(error));
2777
+ throw error;
2778
+ }
2779
+ this.registry.resyncAll();
2780
+ }
2781
+ /**
2782
+ * Store the analyze API's bearer token (`analyzeTokenRef`) in DSH's
2783
+ * credential store; it applies from the next request.
3171
2784
  * @param raw - request body `{ tokenEnv, value }`.
3172
2785
  * @returns the new panel state.
3173
- * @throws SettingsError when the body is invalid, the name is neither referenced by the saved settings nor `analyzeTokenRef`, or no credential store is loaded.
2786
+ * @throws SettingsError when the body is invalid, the name is not `analyzeTokenRef`, or no credential store is loaded.
3174
2787
  */
3175
2788
  async setToken(raw) {
3176
2789
  const parsed = tokenSetSchema.safeParse(raw);
@@ -3178,8 +2791,8 @@ var GuanxiangService = class {
3178
2791
  return await this.writeToken(parsed.data.tokenEnv, parsed.data.value);
3179
2792
  }
3180
2793
  /**
3181
- * Remove the stored token of one access-token configuration; an environment
3182
- * variable of the same name applies again afterwards.
2794
+ * Remove the stored analyze token; an environment variable of the same name
2795
+ * applies again afterwards.
3183
2796
  * @param raw - request body `{ tokenEnv }`.
3184
2797
  * @returns the new panel state.
3185
2798
  * @throws SettingsError on the same conditions as {@link setToken}.
@@ -3190,17 +2803,16 @@ var GuanxiangService = class {
3190
2803
  return await this.writeToken(parsed.data.tokenEnv, void 0);
3191
2804
  }
3192
2805
  async writeToken(tokenEnv, value) {
3193
- if (tokenEnv !== this.analyzeTokenRef && !this.current.gitCredentials.some((credential) => credential.tokenEnv === tokenEnv)) throw new SettingsError(`${tokenEnv} 不是已保存的访问令牌配置引用的名称`);
2806
+ if (tokenEnv !== this.analyzeTokenRef) throw new SettingsError(`${tokenEnv} 不是分析接口令牌的名称`);
3194
2807
  const store = this.credentialStore();
3195
2808
  if (store === void 0) throw new SettingsError("当前 DSH 组合没有凭据服务(@deepseek-ai/dsh-credentials-local),无法保存令牌");
3196
2809
  if (value === void 0) await store.unset(tokenEnv);
3197
2810
  else await store.set(tokenEnv, value);
3198
- this.registry.resyncUrls(this.current.repos.map((repo) => repo.url).filter((url) => credentialFor(url, this.current.gitCredentials)?.tokenEnv === tokenEnv));
3199
2811
  return await this.state();
3200
2812
  }
3201
2813
  /**
3202
2814
  * Validate and persist a complete settings payload from the panel, then
3203
- * sync repositories whose address, branch, or directory changed.
2815
+ * sync repositories whose address or branch changed.
3204
2816
  * @param raw - request body.
3205
2817
  * @returns the new panel state.
3206
2818
  * @throws SettingsError when the payload is invalid.
@@ -3231,20 +2843,12 @@ var GuanxiangService = class {
3231
2843
  if (url !== void 0 && !this.registry.has(url)) throw new SettingsError(`未配置的仓库:${url}`);
3232
2844
  url === void 0 ? this.registry.syncAll() : this.registry.syncUrls([url]);
3233
2845
  }
3234
- /**
3235
- * Make an overlay effective and sync the repositories whose address, branch,
3236
- * directory, or matching access-token configuration changed.
3237
- */
2846
+ /** Make an overlay effective and sync the repositories whose address or branch changed. */
3238
2847
  apply(overlay) {
3239
- const previous = this.current.gitCredentials;
3240
2848
  this.overlay = overlay;
3241
2849
  this.overlayError = void 0;
3242
2850
  this.current = applyOverlay(this.defaults, overlay);
3243
- const changed = this.registry.configure(this.current.reposDir, this.current.repos);
3244
- const next = this.current.gitCredentials;
3245
- const reauthorized = this.current.repos.map((repo) => repo.url).filter((url) => JSON.stringify(credentialFor(url, previous)) !== JSON.stringify(credentialFor(url, next)));
3246
- this.registry.syncUrls(changed);
3247
- this.registry.resyncUrls(reauthorized.filter((url) => !changed.includes(url)));
2851
+ this.registry.syncUrls(this.registry.configure(this.current.repos));
3248
2852
  }
3249
2853
  onRegistryChange() {
3250
2854
  if (!this.active) return;
@@ -3423,6 +3027,28 @@ function registerSettingsRoutes(server, gate, service) {
3423
3027
  state: req.method?.toUpperCase() === "DELETE" ? await service.clearToken(body) : await service.setToken(body)
3424
3028
  });
3425
3029
  })
3030
+ }),
3031
+ server.register({
3032
+ kind: "exact",
3033
+ path: HOSTS_ROUTE,
3034
+ handler: guarded(gate, ["POST", "DELETE"], async (req, res) => {
3035
+ const body = await readJsonBody(req, false);
3036
+ sendJson(res, 200, {
3037
+ ok: true,
3038
+ state: req.method?.toUpperCase() === "DELETE" ? await service.removeHost(body) : await service.addHost(body)
3039
+ });
3040
+ })
3041
+ }),
3042
+ server.register({
3043
+ kind: "exact",
3044
+ path: HOST_TOKEN_ROUTE,
3045
+ handler: guarded(gate, ["PUT", "DELETE"], async (req, res) => {
3046
+ const body = await readJsonBody(req, false);
3047
+ sendJson(res, 200, {
3048
+ ok: true,
3049
+ state: req.method?.toUpperCase() === "DELETE" ? await service.clearHostToken(body) : await service.setHostToken(body)
3050
+ });
3051
+ })
3426
3052
  })
3427
3053
  ];
3428
3054
  return () => {
@@ -3474,7 +3100,7 @@ const inject = [
3474
3100
  ];
3475
3101
  /**
3476
3102
  * Install the entry skill, the dispatch and SKILL proposal tools, the settings
3477
- * routes, and the analyze route, then clone or update the configured
3103
+ * routes, and the analyze route, then mirror or update the configured
3478
3104
  * repositories in the background.
3479
3105
  * @param ctx - plugin context.
3480
3106
  * @param config - loader-validated configuration.
@@ -3494,8 +3120,13 @@ function apply(ctx, config, options = {}) {
3494
3120
  service.shutdown();
3495
3121
  };
3496
3122
  }, "guanxiang: repositories");
3123
+ ctx.inject?.([DSH_REPOS_SERVICE], () => {
3124
+ service.reposAvailable().catch((error) => {
3125
+ ctx.logger?.error?.("guanxiang: 同步仓库失败:%s", error instanceof Error ? error.message : String(error));
3126
+ });
3127
+ });
3497
3128
  }
3498
3129
  //#endregion
3499
- export { ANALYZE_ROUTE, pluginConfigSchema as Config, pluginConfigSchema, DISPATCH_TOOL_NAME, Dispatcher, ENTRY_SKILL_NAME, GuanxiangService, MANIFEST_PATH, PROPOSE_TOOL_NAME, RepoRegistry, SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE, SkillUpdater, analyzeRequestHash, apply, inject, name, parseAnalyzeRequest, readAppManifest, renderChildPrompt, renderEntrySkill, resolveConfig };
3130
+ export { ANALYZE_ROUTE, pluginConfigSchema as Config, pluginConfigSchema, DISPATCH_TOOL_NAME, Dispatcher, ENTRY_SKILL_NAME, GuanxiangService, HOSTS_ROUTE, HOST_TOKEN_ROUTE, MANIFEST_PATH, PROPOSE_TOOL_NAME, RepoRegistry, SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE, SkillUpdater, analyzeRequestHash, apply, inject, name, parseAnalyzeRequest, readAppManifest, renderChildPrompt, renderEntrySkill, resolveConfig };
3500
3131
 
3501
3132
  //# sourceMappingURL=index.js.map