@modesto_ma/dsh-guanxiang 0.2.0-beta.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modesto_ma/dsh-guanxiang",
3
- "version": "0.2.0-beta.2",
3
+ "version": "0.2.0-beta.4",
4
4
  "description": "观象 GuanXiang:DeepSeek Harness (DSH) 日志分析插件。/guanxiang 识别日志所属应用,把分析派发给工作目录为该应用代码仓库的子 Agent;Web 左侧栏提供「观象」设置面板",
5
5
  "repository": {
6
6
  "type": "git",
@@ -65,6 +65,7 @@
65
65
  "access": "public"
66
66
  },
67
67
  "dependencies": {
68
+ "diff": "^8.0.4",
68
69
  "yaml": "^2.9.1",
69
70
  "zod": "^4.6.7"
70
71
  },
@@ -90,6 +91,7 @@
90
91
  "@types/node": "^26.6.5",
91
92
  "@types/react": "~18.3.31",
92
93
  "conventional-changelog-conventionalcommits": "9.3.1",
94
+ "dsh-repo": "^0.1.0-beta.3",
93
95
  "react": "18.3.1",
94
96
  "semantic-release": "^25.0.9",
95
97
  "tsdown": "0.22.2",
package/src/client/api.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  * @module dsh-guanxiang/client/api
5
5
  */
6
6
 
7
- import { SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE, TOKEN_ROUTE } from '../settings/contract.ts'
8
- import type { EditableSettings, MutationError, MutationOk, StateWire } from '../settings/contract.ts'
7
+ import { HOST_TOKEN_ROUTE, HOSTS_ROUTE, SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE, TOKEN_ROUTE } from '../settings/contract.ts'
8
+ import type { EditableSettings, HostAddRequest, MutationError, MutationOk, StateWire } from '../settings/contract.ts'
9
9
 
10
10
  /**
11
11
  * Error text of a value thrown by a request.
@@ -77,8 +77,8 @@ export async function syncRepos(url?: string): Promise<StateWire> {
77
77
  }
78
78
 
79
79
  /**
80
- * Store an access token in DSH's credential store.
81
- * @param tokenEnv - credential reference named by a saved access-token configuration.
80
+ * Store the analyze token in DSH's credential store.
81
+ * @param tokenEnv - the analyze token's credential reference.
82
82
  * @param value - token value.
83
83
  * @returns the new state, which reports only whether the token is set.
84
84
  */
@@ -87,10 +87,47 @@ export async function saveToken(tokenEnv: string, value: string): Promise<StateW
87
87
  }
88
88
 
89
89
  /**
90
- * Remove a stored access token.
91
- * @param tokenEnv - credential reference named by a saved access-token configuration.
90
+ * Remove the stored analyze token.
91
+ * @param tokenEnv - the analyze token's credential reference.
92
92
  * @returns the new state.
93
93
  */
94
94
  export async function clearToken(tokenEnv: string): Promise<StateWire> {
95
95
  return (await request<MutationOk>(TOKEN_ROUTE, { method: 'DELETE', body: JSON.stringify({ tokenEnv }) })).state
96
96
  }
97
+
98
+ /**
99
+ * Add a dsh-repo host, with its token.
100
+ * @param host - kind, address, alias, and token.
101
+ * @returns the new state.
102
+ */
103
+ export async function addHost(host: HostAddRequest): Promise<StateWire> {
104
+ return (await request<MutationOk>(HOSTS_ROUTE, { method: 'POST', body: JSON.stringify(host) })).state
105
+ }
106
+
107
+ /**
108
+ * Remove a host added from a panel.
109
+ * @param url - host address.
110
+ * @returns the new state.
111
+ */
112
+ export async function removeHost(url: string): Promise<StateWire> {
113
+ return (await request<MutationOk>(HOSTS_ROUTE, { method: 'DELETE', body: JSON.stringify({ url }) })).state
114
+ }
115
+
116
+ /**
117
+ * Store a host's token.
118
+ * @param url - host address.
119
+ * @param value - token value.
120
+ * @returns the new state, which reports only whether the token is set.
121
+ */
122
+ export async function saveHostToken(url: string, value: string): Promise<StateWire> {
123
+ return (await request<MutationOk>(HOST_TOKEN_ROUTE, { method: 'PUT', body: JSON.stringify({ url, value }) })).state
124
+ }
125
+
126
+ /**
127
+ * Remove a host's stored token.
128
+ * @param url - host address.
129
+ * @returns the new state.
130
+ */
131
+ export async function clearHostToken(url: string): Promise<StateWire> {
132
+ return (await request<MutationOk>(HOST_TOKEN_ROUTE, { method: 'DELETE', body: JSON.stringify({ url }) })).state
133
+ }
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * 左侧栏「观象」主区页面:应用仓库、访问令牌、日志下载、分析接口、高级(含 SKILL 改进)五个
3
- * 分页,共用一份草稿和底部保存条;令牌值不进草稿,单独存入 DSH 凭据库。
3
+ * 分页,共用一份草稿和底部保存条。访问令牌分页管理 dsh-repo 的主机,改动立即
4
+ * 生效,不进草稿;令牌值存入 DSH 凭据库。
4
5
  * 仓库拉取中时每 2 秒刷新状态。
5
6
  *
6
7
  * @module dsh-guanxiang/client/page
7
8
  */
8
9
 
9
10
  import { useCallback, useEffect, useMemo, useState } from 'react'
10
- import type { AnalyzeWire, EditableKey, EditableSettings, GitCredentialSpec, RepoSpec, RepoStatusWire, StateWire, TokenStatusWire } from '../settings/contract.ts'
11
- import { clearToken, fetchState, messageOf, resetSettings, saveSettings, saveToken, syncRepos } from './api.ts'
11
+ import type { AnalyzeWire, EditableKey, EditableSettings, HostAddRequest, HostKind, RepoSpec, RepoStatusWire, StateWire, TokenStatusWire } from '../settings/contract.ts'
12
+ import { addHost, clearHostToken, clearToken, fetchState, messageOf, removeHost, resetSettings, saveHostToken, saveSettings, saveToken, syncRepos } from './api.ts'
12
13
 
13
14
  /** Sidebar label and page title. */
14
15
  export const PANEL_TITLE = '观象'
@@ -29,9 +30,7 @@ const TABS: readonly { id: TabId; label: string }[] = [
29
30
 
30
31
  /** Editable text form of the settings. */
31
32
  interface Draft {
32
- reposDir: string
33
33
  repos: RepoSpec[]
34
- gitCredentials: GitCredentialSpec[]
35
34
  fetchIntervalMin: string
36
35
  maxAppsPerRequest: string
37
36
  dispatchTimeoutMin: string
@@ -50,9 +49,7 @@ function trimNumber(value: number): string {
50
49
 
51
50
  function toDraft(settings: EditableSettings): Draft {
52
51
  return {
53
- reposDir: settings.reposDir,
54
52
  repos: settings.repos.map(repo => ({ ...repo })),
55
- gitCredentials: settings.gitCredentials.map(credential => ({ ...credential })),
56
53
  fetchIntervalMin: trimNumber(settings.fetchIntervalMs / MINUTE),
57
54
  maxAppsPerRequest: String(settings.maxAppsPerRequest),
58
55
  dispatchTimeoutMin: trimNumber(settings.dispatchTimeoutMs / MINUTE),
@@ -78,14 +75,7 @@ function fromDraft(draft: Draft): EditableSettings {
78
75
  const maxFiles = positive(draft.skillMaxFiles, '提议文件数上限')
79
76
  if (!Number.isInteger(maxFiles)) throw new Error('提议文件数上限必须是整数')
80
77
  return {
81
- reposDir: draft.reposDir.trim(),
82
78
  repos: draft.repos.map(repo => repo.branch === undefined || repo.branch.trim() === '' ? { url: repo.url.trim() } : { url: repo.url.trim(), branch: repo.branch.trim() }),
83
- gitCredentials: draft.gitCredentials.map(credential => ({
84
- url: credential.url.trim(),
85
- ...credential.alias === undefined || credential.alias.trim() === '' ? {} : { alias: credential.alias.trim() },
86
- username: credential.username.trim(),
87
- tokenEnv: credential.tokenEnv.trim(),
88
- })),
89
79
  fetchIntervalMs: Math.round(positive(draft.fetchIntervalMin, '更新间隔', true) * MINUTE),
90
80
  maxAppsPerRequest: maxApps,
91
81
  dispatchTimeoutMs: Math.round(positive(draft.dispatchTimeoutMin, '派发超时') * MINUTE),
@@ -242,19 +232,12 @@ export function GuanxiangPanel(): JSX.Element {
242
232
  {tab === 'repos' ? <ReposTab draft={draft} setDraft={setDraft} state={state} busy={busy || dirty} onSync={(url) => { void run(() => syncRepos(url), url === undefined ? '已开始重新拉取全部仓库' : '已开始重新拉取', false) }} /> : null}
243
233
  {tab === 'tokens'
244
234
  ? <TokensTab
245
- draft={draft}
246
- setDraft={setDraft}
247
235
  state={state}
248
236
  busy={busy}
249
- onAdd={async (credential, token) => {
250
- const saved = await run(() => saveSettings({ ...state.settings, gitCredentials: [...state.settings.gitCredentials, credential] }), token === '' ? `已添加 ${credential.url},请填入令牌` : `已添加 ${credential.url}`, false)
251
- const added = saved?.settings.gitCredentials[state.settings.gitCredentials.length]
252
- if (added === undefined) return false
253
- setDraft(current => current === undefined || current.gitCredentials.some(item => item.url === added.url) ? current : { ...current, gitCredentials: [...current.gitCredentials, added] })
254
- return token === '' || await run(() => saveToken(added.tokenEnv, token), `已添加 ${added.url} 并保存令牌,使用它的仓库会重新拉取`, false) !== undefined
255
- }}
256
- onSave={async (tokenEnv, value) => await run(() => saveToken(tokenEnv, value), `已保存 ${tokenEnv},使用它的仓库会重新拉取`, false) !== undefined}
257
- onClear={(tokenEnv) => { void run(() => clearToken(tokenEnv), `已清除面板保存的 ${tokenEnv}`, false) }}
237
+ onAdd={async host => await run(() => addHost(host), `已添加 ${host.url},仓库会重新拉取`, false) !== undefined}
238
+ onSave={async (url, value) => await run(() => saveHostToken(url, value), `已保存 ${url} 的令牌,仓库会重新拉取`, false) !== undefined}
239
+ onClear={(url) => { void run(() => clearHostToken(url), `已清除面板保存的 ${url} 令牌`, false) }}
240
+ onRemove={(url) => { void run(() => removeHost(url), `已删除 ${url}`, false) }}
258
241
  />
259
242
  : null}
260
243
  {tab === 'download' ? <DownloadTab draft={draft} setDraft={setDraft} overridden={state.overridden} /> : null}
@@ -263,7 +246,7 @@ export function GuanxiangPanel(): JSX.Element {
263
246
  draft={draft}
264
247
  setDraft={setDraft}
265
248
  analyze={state.analyze}
266
- credentialsAvailable={state.credentials.available}
249
+ credentialsAvailable={state.credentialsAvailable}
267
250
  overridden={state.overridden}
268
251
  busy={busy}
269
252
  onSave={async value => await run(() => saveToken(state.analyze.tokenEnv, value), '已保存分析接口令牌,下一个请求起生效', false) !== undefined}
@@ -319,7 +302,7 @@ function ReposTab({ draft, setDraft, state, busy, onSync }: TabProps & { state:
319
302
  : null}
320
303
  {saved && current.app !== undefined ? <div className="gx__rowSummary">{current.app.description}</div> : null}
321
304
  {saved && current.commit !== undefined
322
- ? <div className="gx__rowSummary">提交 <span className="gx__mono">{current.commit.slice(0, 8)}</span>({formatTime(current.commitTime)})· 拉取于 {formatTime(current.syncedAt)} · <span className="gx__mono">{current.dir}</span></div>
305
+ ? <div className="gx__rowSummary">提交 <span className="gx__mono">{current.commit.slice(0, 8)}</span>({formatTime(current.commitTime)})· 拉取于 {formatTime(current.syncedAt)}{current.dir === undefined ? null : <> · <span className="gx__mono">{current.dir}</span></>}</div>
323
306
  : null}
324
307
  {saved && current.error !== undefined ? <div className="gx__rowError">{current.error}</div> : null}
325
308
  </div>
@@ -336,19 +319,21 @@ function ReposTab({ draft, setDraft, state, busy, onSync }: TabProps & { state:
336
319
  <button type="button" className="gx__btn" disabled={url.trim() === ''} onClick={add}>添加</button>
337
320
  </div>
338
321
  </div>
339
- <p className="gx__secFoot">使用本机已有的 git 凭据(SSH key 或 credential helper)拉取;https 地址可在「访问令牌」中配置个人访问令牌。不留分支时跟随远端默认分支。每个仓库需包含 <span className="gx__mono">.dsh/guanxiang.yaml</span> 与其中声明的日志分析 SKILL。删除地址不会删除本地目录。</p>
322
+ {state.hosts.availability === 'ready' ? null : <div className="gx__notice gx__notice--error">{state.hosts.availability === 'missing' ? '没有加载 dsh-repo 插件,仓库无法拉取。请安装 dsh-repo。' : 'dsh-repo 版本过旧,仓库无法拉取。请升级 dsh-repo。'}</div>}
323
+ <p className="gx__secFoot">由 dsh-repo 拉取到它的镜像目录{state.hosts.mirrorDir === undefined ? '' : `(${state.hosts.mirrorDir})`}。https 地址使用「访问令牌」中对应主机的令牌,ssh 地址和其他主机使用本机已有的 git 凭据(SSH key 或 credential helper)。不留分支时跟随远端默认分支。每个仓库需包含 <span className="gx__mono">.dsh/guanxiang.yaml</span> 与其中声明的日志分析 SKILL。删除地址不会删除本地镜像。</p>
340
324
  {state.repos.length > 0 ? <div><button type="button" className="gx__btn" disabled={busy} onClick={() => { onSync() }}>重新拉取全部</button></div> : null}
341
325
  </section>
342
326
  )
343
327
  }
344
328
 
345
- const DEFAULT_USERNAME = 'oauth2'
346
- /** Prefix of the credential keys the panel allocates; namespaced so an unrelated `GITLAB_TOKEN` in the environment is not picked up. */
347
- const DEFAULT_TOKEN_ENV = 'GUANXIANG_GITLAB_TOKEN'
348
-
349
329
  /** Credential store layers of `@deepseek-ai/dsh-credentials-local` other than its own file. */
350
330
  const TOKEN_SOURCES: Readonly<Record<string, string>> = { 'env': '启动环境变量', 'project-env': '项目 .env', 'user-env': '$DSH_HOME/.env' }
351
331
 
332
+ const HOST_KINDS: readonly { id: HostKind; label: string }[] = [
333
+ { id: 'gitlab', label: 'GitLab' },
334
+ { id: 'github', label: 'GitHub' },
335
+ ]
336
+
352
337
  function tokenPill(token: TokenStatusWire | undefined): JSX.Element {
353
338
  if (token === undefined) return <span className="gx__pill"><span className="gx__dot" />保存后设置令牌</span>
354
339
  if (token.error !== undefined) return <span className="gx__pill gx__pill--bad"><span className="gx__dot" />无法读取</span>
@@ -357,105 +342,92 @@ function tokenPill(token: TokenStatusWire | undefined): JSX.Element {
357
342
  return <span className="gx__pill gx__pill--ok"><span className="gx__dot" />已设置{source === undefined ? '' : `(来自${source})`}</span>
358
343
  }
359
344
 
360
- /**
361
- * First of `GUANXIANG_GITLAB_TOKEN`, `GUANXIANG_GITLAB_TOKEN_2`, … that no
362
- * saved or drafted entry uses.
363
- * @param used - keys already in use.
364
- * @returns a free key.
365
- */
366
- function freeTokenEnv(used: ReadonlySet<string>): string {
367
- if (!used.has(DEFAULT_TOKEN_ENV)) return DEFAULT_TOKEN_ENV
368
- let index = 2
369
- while (used.has(`${DEFAULT_TOKEN_ENV}_${index}`)) index += 1
370
- return `${DEFAULT_TOKEN_ENV}_${index}`
371
- }
372
-
373
- function TokensTab({ draft, setDraft, state, busy, onAdd, onSave, onClear }: TabProps & {
345
+ function TokensTab({ state, busy, onAdd, onSave, onClear, onRemove }: {
374
346
  state: StateWire
375
347
  busy: boolean
376
- /** Saves the entry, then its token; resolves whether both succeeded. */
377
- onAdd: (credential: GitCredentialSpec, token: string) => Promise<boolean>
378
- onSave: (tokenEnv: string, value: string) => Promise<boolean>
379
- onClear: (tokenEnv: string) => void
348
+ /** Adds the host with its token; resolves whether it succeeded. */
349
+ onAdd: (host: HostAddRequest) => Promise<boolean>
350
+ onSave: (url: string, value: string) => Promise<boolean>
351
+ onClear: (url: string) => void
352
+ onRemove: (url: string) => void
380
353
  }): JSX.Element {
354
+ const [kind, setKind] = useState<HostKind>('gitlab')
381
355
  const [url, setUrl] = useState('')
382
356
  const [alias, setAlias] = useState('')
383
357
  const [token, setToken] = useState('')
384
- const [addError, setAddError] = useState<string | undefined>()
385
- // Spans both requests of one addition; `busy` drops between them.
386
- const [adding, setAdding] = useState(false)
387
358
  const [values, setValues] = useState<Record<string, string>>({})
388
- const tokens = useMemo(() => new Map(state.credentials.tokens.map(item => [item.tokenEnv, item])), [state.credentials.tokens])
389
- const saved = useMemo(() => new Set(state.settings.gitCredentials.map(credential => credential.tokenEnv)), [state.settings.gitCredentials])
359
+ const { availability, hosts, error } = state.hosts
390
360
  const complete = url.trim() !== '' && alias.trim() !== '' && token.trim() !== ''
391
361
  const add = (): void => {
392
- if (!complete || busy || adding) return
393
- const address = url.trim()
394
- if (state.settings.gitCredentials.some(credential => credential.url === address)) {
395
- setAddError('这个地址已经添加过了,请在上面的列表里更新它的令牌。')
396
- return
397
- }
398
- setAddError(undefined)
399
- setAdding(true)
400
- const tokenEnv = freeTokenEnv(new Set([...saved, ...draft.gitCredentials.map(credential => credential.tokenEnv)]))
401
- void onAdd({ url: address, alias: alias.trim(), username: DEFAULT_USERNAME, tokenEnv }, token.trim()).then((ok) => {
362
+ if (!complete || busy) return
363
+ void onAdd({ kind, url: url.trim(), alias: alias.trim(), token: token.trim() }).then((ok) => {
402
364
  if (!ok) return
403
365
  setUrl('')
404
366
  setAlias('')
405
367
  setToken('')
406
- }).finally(() => { setAdding(false) })
368
+ })
407
369
  }
408
- const save = (name: string): void => {
409
- const value = values[name]?.trim() ?? ''
410
- if (value === '' || busy || adding) return
411
- void onSave(name, value).then((ok) => { if (ok) setValues(current => ({ ...current, [name]: '' })) })
370
+ const save = (address: string): void => {
371
+ const value = values[address]?.trim() ?? ''
372
+ if (value === '' || busy) return
373
+ void onSave(address, value).then((ok) => { if (ok) setValues(current => ({ ...current, [address]: '' })) })
412
374
  }
413
375
  const submitOnEnter = (event: { key: string }): void => { if (event.key === 'Enter') add() }
376
+ if (availability !== 'ready') {
377
+ return (
378
+ <section className="gx__sec">
379
+ <h2 className="gx__secTitle">访问令牌</h2>
380
+ <div className="gx__notice gx__notice--error">{availability === 'missing' ? '访问令牌由 dsh-repo 插件管理,当前没有加载 dsh-repo。请安装 dsh-repo。' : 'dsh-repo 版本过旧,不能在这里管理访问令牌。请升级 dsh-repo。'}</div>
381
+ </section>
382
+ )
383
+ }
414
384
  return (
415
385
  <section className="gx__sec">
416
- <h2 className="gx__secTitle">GitLab 访问令牌 <Overridden keys={state.overridden} name="gitCredentials" /></h2>
417
- {state.credentials.available ? null : <div className="gx__notice gx__notice--error">当前 DSH 组合没有加载凭据服务(@deepseek-ai/dsh-credentials-local),无法保存或读取令牌。</div>}
386
+ <h2 className="gx__secTitle">访问令牌</h2>
387
+ {state.credentialsAvailable ? null : <div className="gx__notice gx__notice--error">当前 DSH 组合没有加载凭据服务(@deepseek-ai/dsh-credentials-local),无法保存或读取令牌。</div>}
388
+ {error === undefined ? null : <div className="gx__notice gx__notice--error">无法读取 dsh-repo 的主机:{error}</div>}
418
389
  <div className="gx__list">
419
- {draft.gitCredentials.length === 0 ? <div className="gx__emptyRow">还没有访问令牌。用 https 地址拉取 GitLab 私有仓库时,在下面填入 GitLab 地址、令牌别名和令牌。</div> : null}
420
- {draft.gitCredentials.map((credential, index) => {
421
- const status = saved.has(credential.tokenEnv) ? tokens.get(credential.tokenEnv) : undefined
422
- const value = values[credential.tokenEnv] ?? ''
390
+ {hosts.length === 0 ? <div className="gx__emptyRow">还没有主机。用 https 地址拉取私有仓库时,在下面填入 GitLab 或 GitHub 地址、别名和令牌。</div> : null}
391
+ {hosts.map((host) => {
392
+ const value = values[host.url] ?? ''
423
393
  return (
424
- <div key={credential.url} className="gx__row gx__row--top">
394
+ <div key={host.url} className="gx__row gx__row--top">
425
395
  <div className="gx__rowText">
426
396
  <div className="gx__rowLabel">
427
- <span>{credential.alias ?? credential.url}</span>
428
- {tokenPill(status)}
397
+ <span>{host.alias ?? host.url}</span>
398
+ <span className="gx__tag">{host.kind === 'github' ? 'GitHub' : 'GitLab'}</span>
399
+ {host.origin === 'config' ? <span className="gx__tag">cordis.yml</span> : null}
400
+ {tokenPill(host.token)}
429
401
  </div>
430
- {credential.alias === undefined ? null : <div className="gx__rowSummary gx__mono">{credential.url}</div>}
431
- {status?.error === undefined ? null : <div className="gx__rowError">{status.error}</div>}
432
- {status !== undefined && status.writable
402
+ {host.alias === undefined ? null : <div className="gx__rowSummary gx__mono">{host.url}</div>}
403
+ {host.token.error === undefined ? null : <div className="gx__rowError">{host.token.error}</div>}
404
+ {host.token.writable
433
405
  ? (
434
406
  <div className="gx__addRow gx__addRow--flush">
435
- <input className="gx__input gx__mono" type="password" autoComplete="new-password" spellCheck={false} placeholder={status.configured ? '输入新令牌以替换' : '粘贴令牌'} value={value} onChange={(event) => { setValues({ ...values, [credential.tokenEnv]: event.target.value }) }} onKeyDown={(event) => { if (event.key === 'Enter') save(credential.tokenEnv) }} />
436
- <button type="button" className="gx__btn" disabled={busy || adding || value.trim() === ''} onClick={() => { save(credential.tokenEnv) }}>保存令牌</button>
437
- {status.configured && status.source === 'file' ? <button type="button" className="gx__btn gx__btn--ghost" disabled={busy} onClick={() => { onClear(credential.tokenEnv) }}>清除</button> : null}
407
+ <input className="gx__input gx__mono" type="password" autoComplete="new-password" spellCheck={false} placeholder={host.token.configured ? '输入新令牌以替换' : '粘贴令牌'} value={value} onChange={(event) => { setValues({ ...values, [host.url]: event.target.value }) }} onKeyDown={(event) => { if (event.key === 'Enter') save(host.url) }} />
408
+ <button type="button" className="gx__btn" disabled={busy || value.trim() === ''} onClick={() => { save(host.url) }}>保存令牌</button>
409
+ {host.token.configured && host.token.source === 'file' ? <button type="button" className="gx__btn gx__btn--ghost" disabled={busy} onClick={() => { onClear(host.url) }}>清除</button> : null}
438
410
  </div>
439
411
  )
440
412
  : null}
441
413
  </div>
442
414
  <div className="gx__rowCtl">
443
- <button type="button" className="gx__btn gx__btn--danger" onClick={() => { setDraft({ ...draft, gitCredentials: draft.gitCredentials.filter((_, position) => position !== index) }) }}>删除</button>
415
+ {host.origin === 'added' ? <button type="button" className="gx__btn gx__btn--danger" disabled={busy} onClick={() => { onRemove(host.url) }}>删除</button> : null}
444
416
  </div>
445
417
  </div>
446
418
  )
447
419
  })}
448
420
  <div className="gx__addForm">
449
421
  <div className="gx__addRow gx__addRow--flush">
450
- <label className="gx__field">GitLab 地址<input className="gx__input" placeholder="https://gitlab.example.com" value={url} onChange={(event) => { setUrl(event.target.value) }} onKeyDown={submitOnEnter} /></label>
451
- <label className="gx__field">令牌别名<input className="gx__input" placeholder="例如 guanxiang" value={alias} onChange={(event) => { setAlias(event.target.value) }} onKeyDown={submitOnEnter} /></label>
422
+ <label className="gx__field">类型<select className="gx__input" value={kind} onChange={(event) => { setKind(event.target.value as HostKind) }}>{HOST_KINDS.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}</select></label>
423
+ <label className="gx__field">地址<input className="gx__input" placeholder={kind === 'github' ? 'https://github.com' : 'https://gitlab.example.com'} value={url} onChange={(event) => { setUrl(event.target.value) }} onKeyDown={submitOnEnter} /></label>
424
+ <label className="gx__field">别名<input className="gx__input" placeholder="例如 公司 GitLab" value={alias} onChange={(event) => { setAlias(event.target.value) }} onKeyDown={submitOnEnter} /></label>
452
425
  <label className="gx__field">令牌<input className="gx__input gx__mono" type="password" autoComplete="new-password" spellCheck={false} placeholder="粘贴个人访问令牌" value={token} onChange={(event) => { setToken(event.target.value) }} onKeyDown={submitOnEnter} /></label>
453
- <button type="button" className="gx__btn gx__btn--end" disabled={busy || adding || !complete} onClick={add}>添加</button>
426
+ <button type="button" className="gx__btn gx__btn--end" disabled={busy || !complete} onClick={add}>添加</button>
454
427
  </div>
455
- {addError === undefined ? null : <div className="gx__rowError">{addError}</div>}
456
428
  </div>
457
429
  </div>
458
- <p className="gx__secFoot">地址在这个前缀下的 https 仓库使用对应令牌(多个匹配时取路径最长的),ssh 地址不受影响。拉取需要 read_repository 权限,开启 SKILL 改进时还需要 write_repository。令牌保存在 DSH 凭据库(默认 $DSH_HOME/.credentials.yaml,仅本机用户可读),面板只显示是否已设置;令牌只经环境变量交给 git,不写入设置文件、仓库配置、日志或模型上下文。</p>
430
+ <p className="gx__secFoot">主机和令牌由 dsh-repo 管理,添加、删除和保存令牌立即生效,不需要点保存。该主机下的 https 仓库使用对应令牌,ssh 地址不受影响。GitLab 拉取需要 read_repository 权限,开启 SKILL 改进时需要 api 权限;GitHub 需要 Contents 与 Pull requests 读写权限。令牌保存在 DSH 凭据库(默认 $DSH_HOME/.credentials.yaml,仅本机用户可读),面板只显示是否已设置。cordis.yml 中配置的主机只能在这里更新令牌。</p>
459
431
  </section>
460
432
  )
461
433
  }
@@ -572,13 +544,6 @@ function AdvancedTab({ draft, setDraft, overridden, busy, onReset }: TabProps &
572
544
  <section className="gx__sec">
573
545
  <h2 className="gx__secTitle">仓库</h2>
574
546
  <div className="gx__list">
575
- <div className="gx__row">
576
- <div className="gx__rowText">
577
- <div className="gx__rowLabel">拉取目录 <Overridden keys={overridden} name="reposDir" /></div>
578
- <div className="gx__rowSummary">每个仓库拉取到该目录下的同名子目录,观象只读使用。</div>
579
- </div>
580
- <div className="gx__rowCtl"><input className="gx__input gx__input--wide gx__mono" value={draft.reposDir} onChange={(event) => { setDraft({ ...draft, reposDir: event.target.value }) }} /></div>
581
- </div>
582
547
  <NumberRow label="更新间隔" summary="输入 /guanxiang 时,距上次拉取超过该时间的仓库会先更新;0 表示每次都更新。" unit="分钟" value={draft.fetchIntervalMin} overridden={overridden.includes('fetchIntervalMs')} onChange={(value) => { setDraft({ ...draft, fetchIntervalMin: value }) }} />
583
548
  </div>
584
549
  </section>
@@ -595,7 +560,7 @@ function AdvancedTab({ draft, setDraft, overridden, busy, onReset }: TabProps &
595
560
  <div className="gx__row">
596
561
  <div className="gx__rowText">
597
562
  <div className="gx__rowLabel">允许提出 SKILL 修改 <Overridden keys={overridden} name="skillUpdates" /></div>
598
- <div className="gx__rowSummary">子 Agent 发现应用的日志分析 SKILL 有欠缺时可以写出修改提议;经用户授权后,观象用访问令牌把它推送到新分支并创建合并请求(GitLab,令牌需要 write_repository 权限)。</div>
563
+ <div className="gx__rowSummary">子 Agent 发现应用的日志分析 SKILL 有欠缺时可以写出修改提议;经用户授权后,观象经 dsh-repo 把它提交到新分支并创建合并请求(GitLab 或 GitHub,需要安装 dsh-repo 并配置该仓库主机的令牌)。</div>
599
564
  </div>
600
565
  <div className="gx__rowCtl"><input type="checkbox" className="gx__check" aria-label="允许提出 SKILL 修改" checked={draft.skillUpdatesEnabled} onChange={(event) => { setDraft({ ...draft, skillUpdatesEnabled: event.target.checked }) }} /></div>
601
566
  </div>
package/src/config.ts CHANGED
@@ -8,9 +8,8 @@
8
8
  import { homedir } from 'node:os'
9
9
  import { isAbsolute, join, resolve } from 'node:path'
10
10
  import { z } from 'zod'
11
- import { normalizeCredentialUrl } from './repos/credentials.ts'
12
11
  import { TOKEN_ENV_PATTERN } from './settings/contract.ts'
13
- import type { EditableSettings, GitCredentialSpec, RepoSpec } from './settings/contract.ts'
12
+ import type { EditableSettings, RepoSpec } from './settings/contract.ts'
14
13
 
15
14
  /** Largest delay Node's `setTimeout` honors (2^31 − 1 ms). */
16
15
  export const MAX_TIMER_DELAY_MS = 2_147_483_647
@@ -58,15 +57,19 @@ const repoSchema = z.object({
58
57
  .refine(value => value === undefined || isAcceptedBranch(value), '分支名不合法'),
59
58
  }).strict()
60
59
 
61
- const credentialSchema = z.object({
62
- url: z.string().trim()
63
- .refine(value => normalizeCredentialUrl(value) !== undefined, '令牌地址不合法:填 https://主机[:端口][/路径],不含用户名、查询参数或片段')
64
- .transform(value => normalizeCredentialUrl(value) as string),
65
- alias: z.string().trim().max(64, '令牌别名不能超过 64 个字符').regex(/^[^\u0000-\u001f\u007f]*$/, '令牌别名不能包含控制字符').optional()
66
- .transform(value => value === '' ? undefined : value),
67
- username: z.string().trim().regex(/^\S+$/, '用户名不能为空或包含空白').default('oauth2'),
68
- tokenEnv: z.string().trim().regex(TOKEN_ENV_PATTERN, '令牌名称只能包含字母、数字和下划线,且不能以数字开头(它是凭据库中的键名,不是令牌本身)'),
69
- }).strict()
60
+ /**
61
+ * An access token of earlier versions, which kept HTTPS tokens by address
62
+ * prefix. Read only to move each one to a dsh-repo host once; never written.
63
+ */
64
+ const legacyCredentialSchema = z.object({
65
+ url: z.string().trim().min(1),
66
+ alias: z.string().trim().optional(),
67
+ username: z.string().optional(),
68
+ tokenEnv: z.string().trim().regex(TOKEN_ENV_PATTERN),
69
+ }).strip()
70
+
71
+ /** A legacy access token; see {@link legacyCredentialSchema}. */
72
+ export type LegacyCredential = z.output<typeof legacyCredentialSchema>
70
73
 
71
74
  const hostSchema = z.string().trim().toLowerCase()
72
75
  .transform(value => value.replace(/^\*\./, ''))
@@ -97,7 +100,6 @@ const authorSchema = z.object({
97
100
 
98
101
  /** Field validators shared by the cordis config and the panel overlay. */
99
102
  export const editableFields = {
100
- reposDir: z.string().trim().min(1, '拉取目录不能为空'),
101
103
  repos: z.array(repoSchema).superRefine((repos, issues) => {
102
104
  const seen = new Set<string>()
103
105
  for (const [index, repo] of repos.entries()) {
@@ -105,13 +107,6 @@ export const editableFields = {
105
107
  seen.add(repo.url)
106
108
  }
107
109
  }),
108
- gitCredentials: z.array(credentialSchema).superRefine((credentials, issues) => {
109
- const seen = new Set<string>()
110
- for (const [index, credential] of credentials.entries()) {
111
- if (seen.has(credential.url)) issues.addIssue({ code: 'custom', path: [index, 'url'], message: `重复的令牌地址:${credential.url}` })
112
- seen.add(credential.url)
113
- }
114
- }),
115
110
  fetchIntervalMs: durationMs(0),
116
111
  maxAppsPerRequest: z.number().int().min(1).max(32),
117
112
  dispatchTimeoutMs: durationMs(1000),
@@ -121,10 +116,11 @@ export const editableFields = {
121
116
  }
122
117
 
123
118
  const configSchema = z.object({
124
- reposDir: editableFields.reposDir.optional(),
119
+ /** Ignored: dsh-repo's `mirror.dir` decides where repositories are mirrored. Accepted so older compositions still load. */
120
+ reposDir: z.string().optional(),
125
121
  repos: editableFields.repos.default([]),
126
- /** HTTPS access tokens by address prefix; each names a credential reference, never a token value. */
127
- gitCredentials: editableFields.gitCredentials.default([]),
122
+ /** Moved once to dsh-repo hosts at start; see {@link legacyCredentialSchema}. */
123
+ gitCredentials: z.array(legacyCredentialSchema).default([]),
128
124
  fetchIntervalMs: editableFields.fetchIntervalMs.default(0),
129
125
  maxAppsPerRequest: editableFields.maxAppsPerRequest.default(4),
130
126
  dispatchTimeoutMs: editableFields.dispatchTimeoutMs.default(30 * 60 * 1000),
@@ -137,14 +133,14 @@ const configSchema = z.object({
137
133
  */
138
134
  analyzeTokenRef: z.string().trim().regex(TOKEN_ENV_PATTERN, '令牌名称只能包含字母、数字和下划线,且不能以数字开头').default('GUANXIANG_ANALYZE_TOKEN'),
139
135
  /**
140
- * Author and committer of SKILL improvement commits; omitted uses the git
141
- * identity configured on the host (`user.name` / `user.email`).
136
+ * Author of SKILL improvement commits; omitted uses the user of the token
137
+ * dsh-repo holds for the repository's host, who is always the committer.
142
138
  */
143
139
  skillUpdateAuthor: authorSchema.optional(),
144
140
  /** Registered `ctx.subagents` provider the children start on. */
145
141
  subagentProvider: z.string().trim().min(1).default('spawn'),
146
- /** Timeout of one git command (clone, fetch, worktree), in milliseconds. */
147
- gitTimeoutMs: durationMs(1000).default(10 * 60 * 1000),
142
+ /** Ignored: dsh-repo's `mirror.gitTimeoutMs` bounds git commands. Accepted so older compositions still load. */
143
+ gitTimeoutMs: z.number().optional(),
148
144
  /**
149
145
  * How long loading `/guanxiang` waits for the repository refresh before it
150
146
  * renders the catalog it already has, in milliseconds; the refresh keeps
@@ -166,9 +162,11 @@ export const pluginConfigSchema = configSchema.prefault({})
166
162
 
167
163
  /** Panel overlay: any subset of the editable settings. */
168
164
  export const overlaySchema = z.object({
169
- reposDir: editableFields.reposDir.optional(),
165
+ /** Ignored legacy key; the next panel save drops it. */
166
+ reposDir: z.string().optional(),
170
167
  repos: editableFields.repos.optional(),
171
- gitCredentials: editableFields.gitCredentials.optional(),
168
+ /** Legacy access tokens, moved once to dsh-repo hosts; the next panel save drops them. */
169
+ gitCredentials: z.array(legacyCredentialSchema).optional(),
172
170
  fetchIntervalMs: editableFields.fetchIntervalMs.optional(),
173
171
  maxAppsPerRequest: editableFields.maxAppsPerRequest.optional(),
174
172
  dispatchTimeoutMs: editableFields.dispatchTimeoutMs.optional(),
@@ -233,27 +231,14 @@ function copyRepos(repos: readonly RepoSpec[]): RepoSpec[] {
233
231
  return repos.map(repo => repo.branch === undefined ? { url: repo.url } : { url: repo.url, branch: repo.branch })
234
232
  }
235
233
 
236
- function copyCredentials(credentials: readonly GitCredentialSpec[]): GitCredentialSpec[] {
237
- return credentials.map(credential => ({
238
- url: credential.url,
239
- ...credential.alias === undefined ? {} : { alias: credential.alias },
240
- username: credential.username,
241
- tokenEnv: credential.tokenEnv,
242
- }))
243
- }
244
-
245
234
  /**
246
- * The editable settings a configuration implies, with `reposDir` defaulted to
247
- * `$DSH_HOME/guanxiang/repos`.
235
+ * The editable settings a configuration implies.
248
236
  * @param config - validated plugin configuration.
249
- * @param env - process environment.
250
237
  * @returns complete editable settings.
251
238
  */
252
- export function defaultsOf(config: GuanxiangConfig, env: NodeJS.ProcessEnv = process.env): EditableSettings {
239
+ export function defaultsOf(config: GuanxiangConfig): EditableSettings {
253
240
  return {
254
- reposDir: config.reposDir ?? join(resolveDshHome(env), 'guanxiang', 'repos'),
255
241
  repos: copyRepos(config.repos),
256
- gitCredentials: copyCredentials(config.gitCredentials),
257
242
  fetchIntervalMs: config.fetchIntervalMs,
258
243
  maxAppsPerRequest: config.maxAppsPerRequest,
259
244
  dispatchTimeoutMs: config.dispatchTimeoutMs,
@@ -272,9 +257,7 @@ export function defaultsOf(config: GuanxiangConfig, env: NodeJS.ProcessEnv = pro
272
257
  */
273
258
  export function applyOverlay(defaults: EditableSettings, overlay: SettingsOverlay): EditableSettings {
274
259
  return {
275
- reposDir: overlay.reposDir ?? defaults.reposDir,
276
260
  repos: overlay.repos === undefined ? defaults.repos : copyRepos(overlay.repos),
277
- gitCredentials: overlay.gitCredentials === undefined ? defaults.gitCredentials : copyCredentials(overlay.gitCredentials),
278
261
  fetchIntervalMs: overlay.fetchIntervalMs ?? defaults.fetchIntervalMs,
279
262
  maxAppsPerRequest: overlay.maxAppsPerRequest ?? defaults.maxAppsPerRequest,
280
263
  dispatchTimeoutMs: overlay.dispatchTimeoutMs ?? defaults.dispatchTimeoutMs,
package/src/index.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * 任务匹配已注册应用时由顶层 Agent 自行加载)和
6
6
  * `guanxiang_dispatch` 工具(按应用派发子 Agent,工作目录为该应用的代码
7
7
  * 仓库,首条消息用 `/<skill>` 加载仓库里的日志分析 SKILL),以及开启 SKILL
8
- * 改进时的 `guanxiang_propose_skill` 工具(经用户授权后把子 Agent 的 SKILL
9
- * 修改提议推送到新分支并创建合并请求)。应用仓库在设置
8
+ * 改进时的 `guanxiang_propose_skill` 工具(经用户授权后经 dsh-repo 把子 Agent
9
+ * 的 SKILL 修改提议提交到新分支并创建合并请求)。应用仓库在设置
10
10
  * 面板中只填 git 地址,由插件拉取;应用描述来自各仓库的
11
11
  * `.dsh/guanxiang.yaml`。另提供日志平台分析接口 `POST /open-api/logalyze`,
12
12
  * 经 dsh-channels 的 `feishuTopics` 服务在飞书群话题中开始分析。
@@ -17,6 +17,7 @@
17
17
  import { mountAnalyzeRoute } from './analyze/server.ts'
18
18
  import { resolveConfig } from './config.ts'
19
19
  import type { HostContext } from './host.ts'
20
+ import { DSH_REPOS_SERVICE } from './repos/lookup.ts'
20
21
  import { GuanxiangService } from './service.ts'
21
22
  import type { ServiceOptions } from './service.ts'
22
23
  import { defaultOverlayPath, OverlayStore } from './settings/overlay.ts'
@@ -34,7 +35,7 @@ export { ENTRY_SKILL_NAME, renderEntrySkill } from './skill/entry.ts'
34
35
  export { RepoRegistry } from './repos/registry.ts'
35
36
  export { readAppManifest, MANIFEST_PATH } from './repos/manifest.ts'
36
37
  export { GuanxiangService } from './service.ts'
37
- export { ANALYZE_ROUTE, SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE } from './settings/contract.ts'
38
+ export { ANALYZE_ROUTE, HOST_TOKEN_ROUTE, HOSTS_ROUTE, SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE } from './settings/contract.ts'
38
39
  export { analyzeRequestHash, parseAnalyzeRequest } from './analyze/contract.ts'
39
40
  export type { AnalyzeAccepted, AnalyzeRefused, AnalyzeRequest } from './analyze/contract.ts'
40
41
 
@@ -55,7 +56,7 @@ export interface GuanxiangPluginOptions extends ServiceOptions {
55
56
 
56
57
  /**
57
58
  * Install the entry skill, the dispatch and SKILL proposal tools, the settings
58
- * routes, and the analyze route, then clone or update the configured
59
+ * routes, and the analyze route, then mirror or update the configured
59
60
  * repositories in the background.
60
61
  * @param ctx - plugin context.
61
62
  * @param config - loader-validated configuration.
@@ -73,4 +74,10 @@ export function apply(ctx: HostContext, config?: unknown, options: GuanxiangPlug
73
74
  })
74
75
  return () => { service.shutdown() }
75
76
  }, 'guanxiang: repositories')
77
+ // Sync again whenever dsh-repo's service appears, including after it reloads.
78
+ ctx.inject?.([DSH_REPOS_SERVICE], () => {
79
+ void service.reposAvailable().catch((error: unknown) => {
80
+ ctx.logger?.error?.('guanxiang: 同步仓库失败:%s', error instanceof Error ? error.message : String(error))
81
+ })
82
+ })
76
83
  }