@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/README.md +19 -29
- package/lib/client.js +172 -150
- package/lib/client.js.map +1 -1
- package/lib/index.js +425 -794
- package/lib/index.js.map +1 -1
- package/package.json +3 -2
- package/src/client/api.ts +43 -6
- package/src/client/page.tsx +63 -98
- package/src/config.ts +26 -43
- package/src/index.ts +9 -2
- package/src/repos/lookup.ts +92 -0
- package/src/repos/registry.ts +57 -293
- package/src/service.ts +165 -60
- package/src/settings/contract.ts +50 -26
- package/src/settings/routes.ts +19 -1
- package/src/skill-update/change.ts +81 -0
- package/src/skill-update/tool.ts +20 -30
- package/src/repos/credentials.ts +0 -104
- package/src/repos/git.ts +0 -74
- package/src/skill-update/git.ts +0 -84
- package/src/skill-update/repos.ts +0 -51
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
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
-
import { lstat, mkdir,
|
|
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 }`
|
|
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)
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
600
|
-
gitCredentials:
|
|
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
|
-
/**
|
|
620
|
-
gitTimeoutMs:
|
|
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
|
-
|
|
542
|
+
/** Ignored legacy key; the next panel save drops it. */
|
|
543
|
+
reposDir: z.string().optional(),
|
|
636
544
|
repos: editableFields.repos.optional(),
|
|
637
|
-
|
|
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
|
|
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
|
|
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/
|
|
734
|
+
//#region src/skill-update/change.ts
|
|
776
735
|
/**
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
736
|
+
* 检查 SKILL 修改:从 dsh-repo 镜像读取分析时的提交中 SKILL 目录的文件,与提议
|
|
737
|
+
* 的文件比较,算出逐文件行数和 unified diff,不改动任何镜像或快照。提议内容的
|
|
738
|
+
* 摘要用作新分支名,提交本身经 dsh-repo 完成。
|
|
780
739
|
*
|
|
781
|
-
* @module dsh-guanxiang/skill-update/
|
|
782
|
-
*/
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
*
|
|
790
|
-
* @
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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: [
|
|
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) =>
|
|
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,
|
|
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$
|
|
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
|
|
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
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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.
|
|
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$
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 -
|
|
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
|
|
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
|
|
1966
|
+
* @returns addresses that are new or changed and therefore need a sync.
|
|
2260
1967
|
*/
|
|
2261
|
-
configure(
|
|
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.
|
|
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
|
|
2306
|
-
*
|
|
2307
|
-
*
|
|
2308
|
-
*
|
|
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
|
|
2313
|
-
|
|
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
|
|
2381
|
-
*
|
|
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
|
|
2409
|
-
|
|
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
|
|
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,
|
|
2117
|
+
apps: new Map(held.map(({ id, app }, index) => [id, {
|
|
2423
2118
|
...app,
|
|
2424
|
-
dir:
|
|
2119
|
+
dir: leases[index].dir
|
|
2425
2120
|
}])),
|
|
2426
2121
|
release
|
|
2427
2122
|
};
|
|
2428
2123
|
}
|
|
2429
2124
|
/**
|
|
2430
|
-
*
|
|
2431
|
-
*
|
|
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
|
-
* @
|
|
2434
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
2441
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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) =>
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
|
3139
|
-
const
|
|
3140
|
-
if (
|
|
3141
|
-
|
|
3142
|
-
|
|
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
|
-
*
|
|
3152
|
-
*
|
|
3153
|
-
* @
|
|
3154
|
-
* @
|
|
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
|
|
3158
|
-
const
|
|
3159
|
-
if (
|
|
3160
|
-
const
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
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
|
|
3169
|
-
*
|
|
3170
|
-
*
|
|
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
|
|
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
|
|
3182
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|