@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/src/service.ts CHANGED
@@ -5,37 +5,45 @@
5
5
  * @module dsh-guanxiang/service
6
6
  */
7
7
 
8
+ import type { DshRepoService } from 'dsh-repo/contract'
8
9
  import { z } from 'zod'
9
- import { applyOverlay, defaultsOf, describeIssue, editableSettingsSchema, expandHome } from './config.ts'
10
- import type { GuanxiangConfig, SettingsOverlay } from './config.ts'
10
+ import { applyOverlay, defaultsOf, describeIssue, editableSettingsSchema } from './config.ts'
11
+ import type { GuanxiangConfig, LegacyCredential, SettingsOverlay } from './config.ts'
11
12
  import { abortable } from './abort.ts'
12
13
  import type { AnalyzeGate } from './analyze/server.ts'
13
14
  import { FEISHU_TOPICS_SERVICE, lookupTopics } from './analyze/topics.ts'
14
15
  import { Dispatcher, isDelegatedAgent, isInside } from './dispatch/tool.ts'
15
16
  import type { HostApproval, HostContext, HostCredentials, ToolActivationContext, WorkingDirectoryService } from './host.ts'
16
- import { credentialEnvironment, credentialFor, isAcceptedToken, MAX_TOKEN_LENGTH } from './repos/credentials.ts'
17
- import { runGit } from './repos/git.ts'
18
- import type { GitRunner } from './repos/git.ts'
17
+ import { DSH_REPOS_SERVICE, lookupRepos, messageOf, repoErrorCode, unavailableMessage } from './repos/lookup.ts'
19
18
  import { RepoRegistry } from './repos/registry.ts'
20
19
  import { ANALYZE_ROUTE } from './settings/contract.ts'
21
- import type { AnalyzeWire, CredentialsWire, EditableSettings, StateWire, TokenStatusWire } from './settings/contract.ts'
20
+ import type { AnalyzeWire, EditableSettings, HostsWire, StateWire, TokenStatusWire } from './settings/contract.ts'
22
21
  import { diffOverlay, overriddenKeys } from './settings/overlay.ts'
23
22
  import type { OverlayStore } from './settings/overlay.ts'
24
23
  import { createEntrySkillProvider } from './skill/entry.ts'
25
- import { DSH_REPOS_SERVICE } from './skill-update/repos.ts'
26
24
  import { SkillUpdater } from './skill-update/tool.ts'
27
25
 
28
26
  /** A settings write the panel reports as a field error. */
29
27
  export class SettingsError extends Error {}
30
28
 
29
+ /** Longest accepted token; personal access tokens are far shorter. */
30
+ const MAX_TOKEN_LENGTH = 4096
31
+
32
+ const tokenValue = z.string().trim()
33
+ .refine(value => value.length > 0 && value.length <= MAX_TOKEN_LENGTH && !/[\u0000-\u001f\u007f]/.test(value), `令牌不能为空、超过 ${MAX_TOKEN_LENGTH} 个字符或包含换行等控制字符`)
31
34
  const tokenClearSchema = z.object({ tokenEnv: z.string() }).strict()
32
- const tokenSetSchema = tokenClearSchema.extend({
33
- value: z.string().trim().refine(isAcceptedToken, `令牌不能为空、超过 ${MAX_TOKEN_LENGTH} 个字符或包含换行等控制字符`),
35
+ const tokenSetSchema = tokenClearSchema.extend({ value: tokenValue }).strict()
36
+ const hostUrlSchema = z.object({ url: z.string().trim().min(1, '主机地址不能为空') }).strict()
37
+ const hostTokenSchema = hostUrlSchema.extend({ value: tokenValue }).strict()
38
+ const hostAddSchema = z.object({
39
+ kind: z.enum(['github', 'gitlab']).optional(),
40
+ url: z.string().trim().min(1, '主机地址不能为空'),
41
+ alias: z.string().trim().max(64, '别名不能超过 64 个字符').optional().transform(value => value === '' ? undefined : value),
42
+ token: tokenValue.optional(),
34
43
  }).strict()
35
44
 
36
45
  /** Optional seams for tests. */
37
46
  export interface ServiceOptions {
38
- readonly git?: GitRunner
39
47
  readonly fetch?: typeof fetch
40
48
  }
41
49
 
@@ -52,6 +60,7 @@ export class GuanxiangService {
52
60
  private active = true
53
61
  private readonly refreshWaitMs: number
54
62
  private readonly analyzeTokenRef: string
63
+ private readonly legacyCredentials: readonly LegacyCredential[]
55
64
  private readonly booted = Promise.withResolvers<void>()
56
65
 
57
66
  /**
@@ -69,19 +78,15 @@ export class GuanxiangService {
69
78
  this.defaults = defaultsOf(config)
70
79
  this.refreshWaitMs = config.refreshWaitMs
71
80
  this.analyzeTokenRef = config.analyzeTokenRef
81
+ this.legacyCredentials = config.gitCredentials
72
82
  this.current = this.defaults
73
- const git = options.git ?? runGit
74
83
  this.registry = new RepoRegistry({
75
- git,
76
- gitTimeoutMs: config.gitTimeoutMs,
77
- auth: url => this.gitAuth(url),
84
+ repos: () => ctx.get?.(DSH_REPOS_SERVICE),
78
85
  onChange: () => { this.onRegistryChange() },
79
86
  })
80
87
  this.updater = new SkillUpdater({
81
88
  registry: this.registry,
82
89
  settings: () => this.current,
83
- git,
84
- gitTimeoutMs: config.gitTimeoutMs,
85
90
  ...config.skillUpdateAuthor === undefined ? {} : { author: config.skillUpdateAuthor },
86
91
  approval: () => ctx.get?.('approval') as HostApproval | undefined,
87
92
  repos: () => ctx.get?.(DSH_REPOS_SERVICE),
@@ -101,7 +106,10 @@ export class GuanxiangService {
101
106
  maxAppsPerRequest: () => this.current.maxAppsPerRequest,
102
107
  skillUpdates: () => this.current.skillUpdates.enabled,
103
108
  autoInvoke: config.autoInvoke,
104
- inRepositories: cwd => isInside(cwd, expandHome(this.current.reposDir)),
109
+ inRepositories: (cwd) => {
110
+ const lookup = lookupRepos(ctx.get?.(DSH_REPOS_SERVICE))
111
+ return lookup.availability === 'ready' && isInside(cwd, lookup.service.mirrorSettings().dir)
112
+ },
105
113
  })
106
114
  }
107
115
 
@@ -158,7 +166,8 @@ export class GuanxiangService {
158
166
  }
159
167
 
160
168
  /**
161
- * Load the panel overlay, configure the registry, and sync every repository.
169
+ * Load the panel overlay, configure the registry, move legacy access tokens
170
+ * to dsh-repo hosts, and sync every repository.
162
171
  * @returns when the first sync round settled.
163
172
  */
164
173
  async boot(): Promise<void> {
@@ -178,10 +187,54 @@ export class GuanxiangService {
178
187
  this.ctx.logger?.warn?.('guanxiang: 忽略面板设置:%s', read.error)
179
188
  }
180
189
  this.current = applyOverlay(this.defaults, this.overlay)
181
- this.registry.configure(this.current.reposDir, this.current.repos)
190
+ this.registry.configure(this.current.repos)
191
+ await this.migrateCredentials()
182
192
  await this.registry.syncAll()
183
193
  }
184
194
 
195
+ /**
196
+ * React to dsh-repo becoming available (or reloading): move legacy tokens
197
+ * and sync every repository again.
198
+ * @returns when the syncs settled.
199
+ */
200
+ async reposAvailable(): Promise<void> {
201
+ await this.booted.promise
202
+ if (!this.active) return
203
+ await this.migrateCredentials()
204
+ await this.registry.resyncAll()
205
+ }
206
+
207
+ /**
208
+ * Add each access token of earlier versions whose address dsh-repo does not
209
+ * know yet as a dsh-repo host, carrying the stored token value. The panel's
210
+ * list replaces the cordis.yml list, as it did before. An entry whose token
211
+ * cannot be resolved yet is left for a later start instead of creating a
212
+ * host without a token. Failures are logged; the legacy entry stays and the
213
+ * next start tries again.
214
+ */
215
+ private async migrateCredentials(): Promise<void> {
216
+ const legacy = this.overlay.gitCredentials ?? this.legacyCredentials
217
+ if (legacy.length === 0) return
218
+ const lookup = lookupRepos(this.ctx.get?.(DSH_REPOS_SERVICE))
219
+ if (lookup.availability !== 'ready') return
220
+ const store = this.credentialStore()
221
+ if (store === undefined) return
222
+ for (const credential of legacy) {
223
+ try {
224
+ const token = (await store.resolve(credential.tokenEnv))?.value
225
+ if (token === undefined || token === '') {
226
+ this.ctx.logger?.warn?.('guanxiang: 访问令牌 %s 尚未设置,暂不迁移到 dsh-repo', credential.url)
227
+ continue
228
+ }
229
+ await lookup.service.addHost({ url: credential.url, ...credential.alias === undefined || credential.alias === '' ? {} : { alias: credential.alias }, token })
230
+ this.ctx.logger?.info?.('guanxiang: 已把访问令牌 %s 迁移为 dsh-repo 主机', credential.url)
231
+ } catch (error) {
232
+ if (repoErrorCode(error) === 'conflict') continue
233
+ this.ctx.logger?.warn?.('guanxiang: 迁移访问令牌 %s 到 dsh-repo 失败:%s', credential.url, messageOf(error))
234
+ }
235
+ }
236
+ }
237
+
185
238
  /** Stop registering tools; in-flight syncs finish without side effects on the host. */
186
239
  shutdown(): void {
187
240
  this.active = false
@@ -201,7 +254,8 @@ export class GuanxiangService {
201
254
  overridden: overriddenKeys(this.overlay),
202
255
  ...this.overlayError === undefined ? {} : { overlayError: this.overlayError },
203
256
  repos: this.registry.status(),
204
- credentials: await this.credentialsState(),
257
+ hosts: await this.hostsState(),
258
+ credentialsAvailable: this.credentialStore() !== undefined,
205
259
  analyze: await this.analyzeState(),
206
260
  }
207
261
  }
@@ -236,39 +290,100 @@ export class GuanxiangService {
236
290
  return this.ctx.get?.('credentials') as HostCredentials | undefined
237
291
  }
238
292
 
239
- private async credentialsState(): Promise<CredentialsWire> {
240
- const store = this.credentialStore()
241
- if (store === undefined) return { available: false, tokens: [] }
242
- const names = [...new Set(this.current.gitCredentials.map(credential => credential.tokenEnv))]
243
- const tokens = await Promise.all(names.map(tokenEnv => describeToken(store, tokenEnv)))
244
- return { available: true, tokens }
293
+ private async hostsState(): Promise<HostsWire> {
294
+ const lookup = lookupRepos(this.ctx.get?.(DSH_REPOS_SERVICE))
295
+ if (lookup.availability !== 'ready') return { availability: lookup.availability, hosts: [] }
296
+ const mirrorDir = lookup.service.mirrorSettings().dir
297
+ try {
298
+ const hosts = await lookup.service.listHosts()
299
+ return {
300
+ availability: 'ready',
301
+ mirrorDir,
302
+ hosts: hosts.map(host => ({
303
+ kind: host.kind,
304
+ url: host.url,
305
+ ...host.alias === undefined ? {} : { alias: host.alias },
306
+ origin: host.origin,
307
+ token: { tokenEnv: host.tokenRef, ...host.token },
308
+ })),
309
+ }
310
+ } catch (error) {
311
+ return { availability: 'ready', mirrorDir, hosts: [], error: messageOf(error) }
312
+ }
245
313
  }
246
314
 
247
315
  /**
248
- * Environment carrying the access token for one repository address; empty
249
- * when no access-token configuration matches it.
250
- * @param url - repository address.
251
- * @returns variables for its network git commands.
252
- * @throws when a configuration matches but its token cannot be read.
316
+ * Add a dsh-repo host, optionally with its token, then sync every repository.
317
+ * @param raw - request body `{ kind?, url, alias?, token? }`.
318
+ * @returns the new panel state.
319
+ * @throws SettingsError when the body is invalid, dsh-repo is unavailable, or it refuses the host.
253
320
  */
254
- private async gitAuth(url: string): Promise<Record<string, string>> {
255
- const spec = credentialFor(url, this.current.gitCredentials)
256
- if (spec === undefined) return {}
257
- const store = this.credentialStore()
258
- if (store === undefined) throw new Error(`地址匹配访问令牌 ${spec.url},但当前 DSH 组合没有凭据服务,无法读取 ${spec.tokenEnv}`)
259
- const token = await store.resolve(spec.tokenEnv)
260
- if (token === undefined) throw new Error(`地址匹配访问令牌 ${spec.url},但 ${spec.tokenEnv} 未设置:请在观象面板「访问令牌」中保存令牌,或设置同名环境变量`)
261
- if (!isAcceptedToken(token.value)) throw new Error(`${spec.tokenEnv} 的值不能用作令牌:不能超过 ${MAX_TOKEN_LENGTH} 个字符或包含换行等控制字符`)
262
- return credentialEnvironment(spec, token.value)
321
+ async addHost(raw: unknown): Promise<StateWire> {
322
+ const parsed = hostAddSchema.safeParse(raw)
323
+ if (!parsed.success) throw new SettingsError(describeIssue(parsed.error))
324
+ const { kind, url, alias, token } = parsed.data
325
+ await this.withRepos(repos => repos.addHost({ url, ...kind === undefined ? {} : { kind }, ...alias === undefined ? {} : { alias }, ...token === undefined ? {} : { token } }))
326
+ return await this.state()
263
327
  }
264
328
 
265
329
  /**
266
- * Store the token of one access-token configuration in DSH's credential
267
- * store, then re-sync the repositories that use it; or store the analyze
268
- * API's bearer token (`analyzeTokenRef`), which applies from the next request.
330
+ * Remove a host added from a panel, and its stored token.
331
+ * @param raw - request body `{ url }`.
332
+ * @returns the new panel state.
333
+ * @throws SettingsError when the body is invalid, dsh-repo is unavailable, or the host is unknown or configured in cordis.yml.
334
+ */
335
+ async removeHost(raw: unknown): Promise<StateWire> {
336
+ const parsed = hostUrlSchema.safeParse(raw)
337
+ if (!parsed.success) throw new SettingsError(describeIssue(parsed.error))
338
+ await this.withRepos(repos => repos.removeHost(parsed.data.url))
339
+ return await this.state()
340
+ }
341
+
342
+ /**
343
+ * Store a host's token through dsh-repo, then sync every repository.
344
+ * @param raw - request body `{ url, value }`.
345
+ * @returns the new panel state.
346
+ * @throws SettingsError when the body is invalid, dsh-repo is unavailable, or the host is unknown.
347
+ */
348
+ async setHostToken(raw: unknown): Promise<StateWire> {
349
+ const parsed = hostTokenSchema.safeParse(raw)
350
+ if (!parsed.success) throw new SettingsError(describeIssue(parsed.error))
351
+ await this.withRepos(repos => repos.setHostToken(parsed.data.url, parsed.data.value))
352
+ return await this.state()
353
+ }
354
+
355
+ /**
356
+ * Remove a host's stored token; an environment variable of the same name applies again.
357
+ * @param raw - request body `{ url }`.
358
+ * @returns the new panel state.
359
+ * @throws SettingsError on the same conditions as {@link setHostToken}.
360
+ */
361
+ async clearHostToken(raw: unknown): Promise<StateWire> {
362
+ const parsed = hostUrlSchema.safeParse(raw)
363
+ if (!parsed.success) throw new SettingsError(describeIssue(parsed.error))
364
+ await this.withRepos(repos => repos.setHostToken(parsed.data.url, undefined))
365
+ return await this.state()
366
+ }
367
+
368
+ /** Run one host change and start a resync, reporting dsh-repo failures as field errors. */
369
+ private async withRepos(change: (repos: DshRepoService) => Promise<unknown>): Promise<void> {
370
+ const lookup = lookupRepos(this.ctx.get?.(DSH_REPOS_SERVICE))
371
+ if (lookup.availability !== 'ready') throw new SettingsError(unavailableMessage(lookup.availability))
372
+ try {
373
+ await change(lookup.service)
374
+ } catch (error) {
375
+ if (repoErrorCode(error) !== undefined) throw new SettingsError(messageOf(error))
376
+ throw error
377
+ }
378
+ void this.registry.resyncAll()
379
+ }
380
+
381
+ /**
382
+ * Store the analyze API's bearer token (`analyzeTokenRef`) in DSH's
383
+ * credential store; it applies from the next request.
269
384
  * @param raw - request body `{ tokenEnv, value }`.
270
385
  * @returns the new panel state.
271
- * @throws SettingsError when the body is invalid, the name is neither referenced by the saved settings nor `analyzeTokenRef`, or no credential store is loaded.
386
+ * @throws SettingsError when the body is invalid, the name is not `analyzeTokenRef`, or no credential store is loaded.
272
387
  */
273
388
  async setToken(raw: unknown): Promise<StateWire> {
274
389
  const parsed = tokenSetSchema.safeParse(raw)
@@ -277,8 +392,8 @@ export class GuanxiangService {
277
392
  }
278
393
 
279
394
  /**
280
- * Remove the stored token of one access-token configuration; an environment
281
- * variable of the same name applies again afterwards.
395
+ * Remove the stored analyze token; an environment variable of the same name
396
+ * applies again afterwards.
282
397
  * @param raw - request body `{ tokenEnv }`.
283
398
  * @returns the new panel state.
284
399
  * @throws SettingsError on the same conditions as {@link setToken}.
@@ -290,18 +405,17 @@ export class GuanxiangService {
290
405
  }
291
406
 
292
407
  private async writeToken(tokenEnv: string, value: string | undefined): Promise<StateWire> {
293
- if (tokenEnv !== this.analyzeTokenRef && !this.current.gitCredentials.some(credential => credential.tokenEnv === tokenEnv)) throw new SettingsError(`${tokenEnv} 不是已保存的访问令牌配置引用的名称`)
408
+ if (tokenEnv !== this.analyzeTokenRef) throw new SettingsError(`${tokenEnv} 不是分析接口令牌的名称`)
294
409
  const store = this.credentialStore()
295
410
  if (store === undefined) throw new SettingsError('当前 DSH 组合没有凭据服务(@deepseek-ai/dsh-credentials-local),无法保存令牌')
296
411
  if (value === undefined) await store.unset(tokenEnv)
297
412
  else await store.set(tokenEnv, value)
298
- void this.registry.resyncUrls(this.current.repos.map(repo => repo.url).filter(url => credentialFor(url, this.current.gitCredentials)?.tokenEnv === tokenEnv))
299
413
  return await this.state()
300
414
  }
301
415
 
302
416
  /**
303
417
  * Validate and persist a complete settings payload from the panel, then
304
- * sync repositories whose address, branch, or directory changed.
418
+ * sync repositories whose address or branch changed.
305
419
  * @param raw - request body.
306
420
  * @returns the new panel state.
307
421
  * @throws SettingsError when the payload is invalid.
@@ -335,21 +449,12 @@ export class GuanxiangService {
335
449
  void (url === undefined ? this.registry.syncAll() : this.registry.syncUrls([url]))
336
450
  }
337
451
 
338
- /**
339
- * Make an overlay effective and sync the repositories whose address, branch,
340
- * directory, or matching access-token configuration changed.
341
- */
452
+ /** Make an overlay effective and sync the repositories whose address or branch changed. */
342
453
  private apply(overlay: SettingsOverlay): void {
343
- const previous = this.current.gitCredentials
344
454
  this.overlay = overlay
345
455
  this.overlayError = undefined
346
456
  this.current = applyOverlay(this.defaults, overlay)
347
- const changed = this.registry.configure(this.current.reposDir, this.current.repos)
348
- const next = this.current.gitCredentials
349
- const reauthorized = this.current.repos.map(repo => repo.url)
350
- .filter(url => JSON.stringify(credentialFor(url, previous)) !== JSON.stringify(credentialFor(url, next)))
351
- void this.registry.syncUrls(changed)
352
- void this.registry.resyncUrls(reauthorized.filter(url => !changed.includes(url)))
457
+ void this.registry.syncUrls(this.registry.configure(this.current.repos))
353
458
  }
354
459
 
355
460
  private onRegistryChange(): void {
@@ -11,8 +11,12 @@ export const STATE_ROUTE = '/guanxiang/state'
11
11
  export const SETTINGS_ROUTE = '/guanxiang/settings'
12
12
  /** POST:立即重新拉取全部仓库,或 body `{ url }` 指定的一个仓库。 */
13
13
  export const SYNC_ROUTE = '/guanxiang/sync'
14
- /** PUT:body `{ tokenEnv, value }` 把令牌存入 DSH 凭据库;DELETE:body `{ tokenEnv }` 删除已存的值。只接受已保存的访问令牌配置引用的名称和分析接口令牌的名称。 */
14
+ /** PUT:body `{ tokenEnv, value }` 把分析接口令牌存入 DSH 凭据库;DELETE:body `{ tokenEnv }` 删除已存的值。只接受分析接口令牌的名称。 */
15
15
  export const TOKEN_ROUTE = '/guanxiang/token'
16
+ /** POST:body {@link HostAddRequest} 经 dsh-repo 添加主机;DELETE:body `{ url }` 删除面板添加的主机。 */
17
+ export const HOSTS_ROUTE = '/guanxiang/hosts'
18
+ /** PUT:body `{ url, value }` 经 dsh-repo 保存主机令牌;DELETE:body `{ url }` 清除已存的令牌。 */
19
+ export const HOST_TOKEN_ROUTE = '/guanxiang/host-token'
16
20
 
17
21
  /**
18
22
  * POST:日志平台分析接口(Bearer 令牌鉴权,不走 DSH 登录)。它在 DSH 的
@@ -22,25 +26,27 @@ export const ANALYZE_ROUTE = '/open-api/logalyze'
22
26
 
23
27
  /** One configured application repository. */
24
28
  export interface RepoSpec {
25
- /** Git remote address, cloned with the host machine's existing git credentials or a matching {@link GitCredentialSpec}. */
29
+ /** Git remote address, mirrored by dsh-repo with the token of its host or the machine's existing git credentials. */
26
30
  url: string
27
31
  /** Remote branch to follow; omitted follows the remote's default branch. */
28
32
  branch?: string
29
33
  }
30
34
 
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
  export const TOKEN_ENV_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
33
37
 
34
- /** HTTPS access token used for the repositories under one address prefix. */
35
- export interface GitCredentialSpec {
36
- /** `https://host[:port][/path]` (or `http://`) prefix; repository addresses under it use this token. */
38
+ /** Code host family of a dsh-repo host. */
39
+ export type HostKind = 'github' | 'gitlab'
40
+
41
+ /** Body of a {@link HOSTS_ROUTE} POST. */
42
+ export interface HostAddRequest {
43
+ /** Default `gitlab`. */
44
+ kind?: HostKind
45
+ /** `https://host[:port][/path]`. */
37
46
  url: string
38
- /** Display name the panel shows for this token; omitted shows the address. */
39
47
  alias?: string
40
- /** HTTP username sent with the token; GitLab personal access tokens accept `oauth2`. */
41
- username: string
42
- /** Credential reference (environment-variable name) whose value in DSH's credential store is the token. */
43
- tokenEnv: string
48
+ /** Token value; omitted leaves it unset. */
49
+ token?: string
44
50
  }
45
51
 
46
52
  /** Log URL download policy. */
@@ -71,12 +77,8 @@ export interface AnalyzeSettings {
71
77
 
72
78
  /** The settings the Web panel can override; every field is required on the wire. */
73
79
  export interface EditableSettings {
74
- /** Absolute directory holding the cloned repositories (`~` expands to the home directory). */
75
- reposDir: string
76
- /** Application repositories to clone and keep updated. */
80
+ /** Application repositories to mirror and keep updated. */
77
81
  repos: RepoSpec[]
78
- /** HTTPS access tokens by address prefix; the token values stay in DSH's credential store. */
79
- gitCredentials: GitCredentialSpec[]
80
82
  /** Minimum age of a repository's last sync before loading `/guanxiang` syncs it again, in milliseconds; 0 syncs on every load. */
81
83
  fetchIntervalMs: number
82
84
  /** Maximum number of child analyses one dispatch may start. */
@@ -92,7 +94,7 @@ export interface EditableSettings {
92
94
  }
93
95
 
94
96
  /** Keys of {@link EditableSettings}, in panel order. */
95
- export const EDITABLE_KEYS = ['reposDir', 'repos', 'gitCredentials', 'fetchIntervalMs', 'maxAppsPerRequest', 'dispatchTimeoutMs', 'download', 'skillUpdates', 'analyze'] as const satisfies readonly (keyof EditableSettings)[]
97
+ export const EDITABLE_KEYS = ['repos', 'fetchIntervalMs', 'maxAppsPerRequest', 'dispatchTimeoutMs', 'download', 'skillUpdates', 'analyze'] as const satisfies readonly (keyof EditableSettings)[]
96
98
 
97
99
  /** One editable key. */
98
100
  export type EditableKey = (typeof EDITABLE_KEYS)[number]
@@ -120,8 +122,8 @@ export type RepoPhase = 'pending' | 'syncing' | 'ready' | 'failed'
120
122
  export interface RepoStatusWire {
121
123
  url: string
122
124
  branch?: string
123
- /** Absolute directory of the fetch-only clone; children work in commit snapshots under `<reposDir>/.snapshots/`. */
124
- dir: string
125
+ /** Absolute directory of dsh-repo's fetch-only mirror, once a sync succeeded; children work in commit snapshots dsh-repo creates. */
126
+ dir?: string
125
127
  phase: RepoPhase
126
128
  /** Latest synced commit, the one the catalog describes and new dispatches use. */
127
129
  commit?: string
@@ -152,12 +154,32 @@ export interface TokenStatusWire {
152
154
  error?: string
153
155
  }
154
156
 
155
- /** Credential store view of the panel. */
156
- export interface CredentialsWire {
157
- /** Whether the DSH composition provides `ctx.credentials`; without it tokens cannot be stored or read. */
158
- available: boolean
159
- /** One entry per distinct `tokenEnv` of the effective settings; empty while unavailable. */
160
- tokens: TokenStatusWire[]
157
+ /** One dsh-repo host as the panel shows it. */
158
+ export interface HostWire {
159
+ kind: HostKind
160
+ url: string
161
+ alias?: string
162
+ /** `config`: from dsh-repo's cordis.yml entry, not removable here; `added`: added from a panel. */
163
+ origin: 'config' | 'added'
164
+ token: TokenStatusWire
165
+ }
166
+
167
+ /**
168
+ * Whether dsh-repo provides the `dshRepos` service this plugin mirrors
169
+ * repositories and submits proposals through: `missing` while it is not
170
+ * loaded, `incompatible` when it is too old.
171
+ */
172
+ export type ReposAvailability = 'ready' | 'missing' | 'incompatible'
173
+
174
+ /** dsh-repo view of the panel. */
175
+ export interface HostsWire {
176
+ availability: ReposAvailability
177
+ /** Configured hosts first; empty unless `ready`. */
178
+ hosts: HostWire[]
179
+ /** Mirror directory; absent unless `ready`. */
180
+ mirrorDir?: string
181
+ /** Why the hosts could not be listed. */
182
+ error?: string
161
183
  }
162
184
 
163
185
  /**
@@ -190,7 +212,9 @@ export interface StateWire {
190
212
  /** Why the overlay file was ignored (unreadable or invalid); panel saves replace it. */
191
213
  overlayError?: string
192
214
  repos: RepoStatusWire[]
193
- credentials: CredentialsWire
215
+ hosts: HostsWire
216
+ /** Whether the DSH composition provides `ctx.credentials`; without it the analyze token cannot be stored or read. */
217
+ credentialsAvailable: boolean
194
218
  analyze: AnalyzeWire
195
219
  }
196
220
 
@@ -10,7 +10,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
10
10
  import type { HostContext, HostRequestGate, HostWebServer } from '../host.ts'
11
11
  import { SettingsError } from '../service.ts'
12
12
  import type { GuanxiangService } from '../service.ts'
13
- import { SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE, TOKEN_ROUTE } from './contract.ts'
13
+ import { HOST_TOKEN_ROUTE, HOSTS_ROUTE, SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE, TOKEN_ROUTE } from './contract.ts'
14
14
 
15
15
  type WebHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
16
16
 
@@ -151,6 +151,24 @@ export function registerSettingsRoutes(server: HostWebServer, gate: HostRequestG
151
151
  sendJson(res, 200, { ok: true, state })
152
152
  }),
153
153
  }),
154
+ server.register({
155
+ kind: 'exact',
156
+ path: HOSTS_ROUTE,
157
+ handler: guarded(gate, ['POST', 'DELETE'], async (req, res) => {
158
+ const body = await readJsonBody(req, false)
159
+ const state = req.method?.toUpperCase() === 'DELETE' ? await service.removeHost(body) : await service.addHost(body)
160
+ sendJson(res, 200, { ok: true, state })
161
+ }),
162
+ }),
163
+ server.register({
164
+ kind: 'exact',
165
+ path: HOST_TOKEN_ROUTE,
166
+ handler: guarded(gate, ['PUT', 'DELETE'], async (req, res) => {
167
+ const body = await readJsonBody(req, false)
168
+ const state = req.method?.toUpperCase() === 'DELETE' ? await service.clearHostToken(body) : await service.setHostToken(body)
169
+ sendJson(res, 200, { ok: true, state })
170
+ }),
171
+ }),
154
172
  ]
155
173
  return () => { for (const dispose of disposers) dispose() }
156
174
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 检查 SKILL 修改:从 dsh-repo 镜像读取分析时的提交中 SKILL 目录的文件,与提议
3
+ * 的文件比较,算出逐文件行数和 unified diff,不改动任何镜像或快照。提议内容的
4
+ * 摘要用作新分支名,提交本身经 dsh-repo 完成。
5
+ *
6
+ * @module dsh-guanxiang/skill-update/change
7
+ */
8
+
9
+ import { createHash } from 'node:crypto'
10
+ import { createTwoFilesPatch, structuredPatch } from 'diff'
11
+ import type { DshRepoService } from 'dsh-repo/contract'
12
+ import type { ProposedFile } from './proposal.ts'
13
+
14
+ /** Line counts of one changed file. */
15
+ export interface FileStat {
16
+ /** Path relative to the repository root. */
17
+ readonly path: string
18
+ readonly added: number
19
+ readonly removed: number
20
+ }
21
+
22
+ /** A proposal compared with its base commit. */
23
+ export interface PreparedChange {
24
+ readonly base: string
25
+ /** Hexadecimal digest of the base commit and the changed files; names the branch. */
26
+ readonly digest: string
27
+ readonly files: readonly FileStat[]
28
+ /** Unified diff from the base commit to the proposal, limited to the skill directory. */
29
+ readonly diff: string
30
+ }
31
+
32
+ /** Where a proposal applies. */
33
+ export interface ChangeTarget {
34
+ readonly url: string
35
+ /** Commit the analysis ran on. */
36
+ readonly base: string
37
+ /** Skill directory relative to the repository root, `/`-separated. */
38
+ readonly skillDir: string
39
+ }
40
+
41
+ /**
42
+ * Compare the proposed files with the skill directory of the base commit.
43
+ * @param repos - the `dshRepos` service holding the mirror.
44
+ * @param target - repository, base commit, and skill directory.
45
+ * @param files - proposed files relative to the skill directory.
46
+ * @param signal - cancellation.
47
+ * @returns per-file line counts, the diff, and the digest; `files` is empty when nothing changes.
48
+ * @throws when a proposed path replaces a symbolic link, a submodule, or a directory, or the mirror cannot be read.
49
+ */
50
+ export async function prepareChange(repos: DshRepoService, target: ChangeTarget, files: readonly ProposedFile[], signal?: AbortSignal): Promise<PreparedChange> {
51
+ const { url, base, skillDir } = target
52
+ const listing = await repos.listMirrorTree(url, base, skillDir, signal === undefined ? {} : { signal })
53
+ const types = new Map(listing.map(entry => [entry.path, entry.type]))
54
+ const stats: FileStat[] = []
55
+ const patches: string[] = []
56
+ const digest = createHash('sha256').update(`${base}\n`)
57
+ for (const file of [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
58
+ const path = `${skillDir}/${file.path}`
59
+ const type = types.get(path)
60
+ if (type === 'symlink') throw new Error(`${path} 在仓库中是符号链接,不能用提议覆盖`)
61
+ if (type === 'dir' || type === 'submodule') throw new Error(`${path} 在仓库中是目录,不能用文件覆盖`)
62
+ const before = type === 'file' ? await repos.readMirrorFile(url, base, path, signal === undefined ? {} : { signal }) ?? '' : undefined
63
+ if (before === file.content) continue
64
+ const structured = structuredPatch(path, path, before ?? '', file.content, undefined, undefined, { context: 3 })
65
+ let added = 0
66
+ let removed = 0
67
+ for (const hunk of structured.hunks) {
68
+ for (const line of hunk.lines) {
69
+ if (line.startsWith('+')) added += 1
70
+ else if (line.startsWith('-')) removed += 1
71
+ }
72
+ }
73
+ stats.push({ path, added, removed })
74
+ const header = `diff --git a/${path} b/${path}\n${before === undefined ? 'new file mode 100644\n' : ''}`
75
+ const patch = createTwoFilesPatch(before === undefined ? '/dev/null' : `a/${path}`, `b/${path}`, before ?? '', file.content, undefined, undefined, { context: 3 })
76
+ // jsdiff starts with an `Index:`-style separator line that git diffs do not have.
77
+ patches.push(header + patch.replace(/^=+\n/, ''))
78
+ digest.update(`${path}\0${file.content}\0`)
79
+ }
80
+ return { base, digest: digest.digest('hex'), files: stats, diff: patches.join('') }
81
+ }