@modesto_ma/dsh-guanxiang 0.1.0 → 0.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modesto_ma/dsh-guanxiang",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-beta.1",
4
4
  "description": "观象 GuanXiang:DeepSeek Harness (DSH) 日志分析插件。/guanxiang 识别日志所属应用,把分析派发给工作目录为该应用代码仓库的子 Agent;Web 左侧栏提供「观象」设置面板",
5
5
  "repository": {
6
6
  "type": "git",
@@ -77,7 +77,12 @@ function fromDraft(draft: Draft): EditableSettings {
77
77
  return {
78
78
  reposDir: draft.reposDir.trim(),
79
79
  repos: draft.repos.map(repo => repo.branch === undefined || repo.branch.trim() === '' ? { url: repo.url.trim() } : { url: repo.url.trim(), branch: repo.branch.trim() }),
80
- gitCredentials: draft.gitCredentials.map(credential => ({ url: credential.url.trim(), username: credential.username.trim(), tokenEnv: credential.tokenEnv.trim() })),
80
+ gitCredentials: draft.gitCredentials.map(credential => ({
81
+ url: credential.url.trim(),
82
+ ...credential.alias === undefined || credential.alias.trim() === '' ? {} : { alias: credential.alias.trim() },
83
+ username: credential.username.trim(),
84
+ tokenEnv: credential.tokenEnv.trim(),
85
+ })),
81
86
  fetchIntervalMs: Math.round(positive(draft.fetchIntervalMin, '更新间隔', true) * MINUTE),
82
87
  maxAppsPerRequest: maxApps,
83
88
  dispatchTimeoutMs: Math.round(positive(draft.dispatchTimeoutMin, '派发超时') * MINUTE),
@@ -174,16 +179,17 @@ export function GuanxiangPanel(): JSX.Element {
174
179
 
175
180
  const dirty = state !== undefined && draft !== undefined && !sameSettings(draft, state.settings)
176
181
 
177
- const run = async (action: () => Promise<StateWire>, success: string, resetDraft: boolean): Promise<boolean> => {
182
+ const run = async (action: () => Promise<StateWire>, success: string, resetDraft: boolean): Promise<StateWire | undefined> => {
178
183
  setBusy(true)
179
184
  setNotice(undefined)
180
185
  try {
181
- adopt(await action(), resetDraft)
186
+ const next = await action()
187
+ adopt(next, resetDraft)
182
188
  setNotice({ kind: 'ok', text: success })
183
- return true
189
+ return next
184
190
  } catch (error) {
185
191
  setNotice({ kind: 'error', text: messageOf(error) })
186
- return false
192
+ return undefined
187
193
  } finally {
188
194
  setBusy(false)
189
195
  }
@@ -236,8 +242,15 @@ export function GuanxiangPanel(): JSX.Element {
236
242
  setDraft={setDraft}
237
243
  state={state}
238
244
  busy={busy}
239
- onSave={(tokenEnv, value) => run(() => saveToken(tokenEnv, value), `已保存 ${tokenEnv},使用它的仓库会重新拉取`, false)}
240
- onClear={(tokenEnv) => run(() => clearToken(tokenEnv), `已清除面板保存的 ${tokenEnv}`, false)}
245
+ onAdd={async (credential, token) => {
246
+ const saved = await run(() => saveSettings({ ...state.settings, gitCredentials: [...state.settings.gitCredentials, credential] }), token === '' ? `已添加 ${credential.url},请填入令牌` : `已添加 ${credential.url}`, false)
247
+ const added = saved?.settings.gitCredentials[state.settings.gitCredentials.length]
248
+ if (added === undefined) return false
249
+ setDraft(current => current === undefined || current.gitCredentials.some(item => item.url === added.url) ? current : { ...current, gitCredentials: [...current.gitCredentials, added] })
250
+ return token === '' || await run(() => saveToken(added.tokenEnv, token), `已添加 ${added.url} 并保存令牌,使用它的仓库会重新拉取`, false) !== undefined
251
+ }}
252
+ onSave={async (tokenEnv, value) => await run(() => saveToken(tokenEnv, value), `已保存 ${tokenEnv},使用它的仓库会重新拉取`, false) !== undefined}
253
+ onClear={(tokenEnv) => { void run(() => clearToken(tokenEnv), `已清除面板保存的 ${tokenEnv}`, false) }}
241
254
  />
242
255
  : null}
243
256
  {tab === 'download' ? <DownloadTab draft={draft} setDraft={setDraft} overridden={state.overridden} /> : null}
@@ -314,7 +327,8 @@ function ReposTab({ draft, setDraft, state, busy, onSync }: TabProps & { state:
314
327
  }
315
328
 
316
329
  const DEFAULT_USERNAME = 'oauth2'
317
- const DEFAULT_TOKEN_ENV = 'GITLAB_TOKEN'
330
+ /** Prefix of the credential keys the panel allocates; namespaced so an unrelated `GITLAB_TOKEN` in the environment is not picked up. */
331
+ const DEFAULT_TOKEN_ENV = 'GUANXIANG_GITLAB_TOKEN'
318
332
 
319
333
  /** Credential store layers of `@deepseek-ai/dsh-credentials-local` other than its own file. */
320
334
  const TOKEN_SOURCES: Readonly<Record<string, string>> = { 'env': '启动环境变量', 'project-env': '项目 .env', 'user-env': '$DSH_HOME/.env' }
@@ -327,54 +341,84 @@ function tokenPill(token: TokenStatusWire | undefined): JSX.Element {
327
341
  return <span className="gx__pill gx__pill--ok"><span className="gx__dot" />已设置{source === undefined ? '' : `(来自${source})`}</span>
328
342
  }
329
343
 
330
- function TokensTab({ draft, setDraft, state, busy, onSave, onClear }: TabProps & {
344
+ /**
345
+ * First of `GUANXIANG_GITLAB_TOKEN`, `GUANXIANG_GITLAB_TOKEN_2`, … that no
346
+ * saved or drafted entry uses.
347
+ * @param used - keys already in use.
348
+ * @returns a free key.
349
+ */
350
+ function freeTokenEnv(used: ReadonlySet<string>): string {
351
+ if (!used.has(DEFAULT_TOKEN_ENV)) return DEFAULT_TOKEN_ENV
352
+ let index = 2
353
+ while (used.has(`${DEFAULT_TOKEN_ENV}_${index}`)) index += 1
354
+ return `${DEFAULT_TOKEN_ENV}_${index}`
355
+ }
356
+
357
+ function TokensTab({ draft, setDraft, state, busy, onAdd, onSave, onClear }: TabProps & {
331
358
  state: StateWire
332
359
  busy: boolean
360
+ /** Saves the entry, then its token; resolves whether both succeeded. */
361
+ onAdd: (credential: GitCredentialSpec, token: string) => Promise<boolean>
333
362
  onSave: (tokenEnv: string, value: string) => Promise<boolean>
334
- onClear: (tokenEnv: string) => Promise<boolean>
363
+ onClear: (tokenEnv: string) => void
335
364
  }): JSX.Element {
336
365
  const [url, setUrl] = useState('')
337
- const [username, setUsername] = useState(DEFAULT_USERNAME)
338
- const [tokenEnv, setTokenEnv] = useState(DEFAULT_TOKEN_ENV)
366
+ const [alias, setAlias] = useState('')
367
+ const [token, setToken] = useState('')
368
+ const [addError, setAddError] = useState<string | undefined>()
369
+ // Spans both requests of one addition; `busy` drops between them.
370
+ const [adding, setAdding] = useState(false)
339
371
  const [values, setValues] = useState<Record<string, string>>({})
340
- const tokens = useMemo(() => new Map(state.credentials.tokens.map(token => [token.tokenEnv, token])), [state.credentials.tokens])
372
+ const tokens = useMemo(() => new Map(state.credentials.tokens.map(item => [item.tokenEnv, item])), [state.credentials.tokens])
341
373
  const saved = useMemo(() => new Set(state.settings.gitCredentials.map(credential => credential.tokenEnv)), [state.settings.gitCredentials])
374
+ const complete = url.trim() !== '' && alias.trim() !== '' && token.trim() !== ''
342
375
  const add = (): void => {
343
- const trimmed = url.trim()
344
- if (trimmed === '' || draft.gitCredentials.some(credential => credential.url === trimmed)) return
345
- setDraft({ ...draft, gitCredentials: [...draft.gitCredentials, { url: trimmed, username: username.trim() === '' ? DEFAULT_USERNAME : username.trim(), tokenEnv: tokenEnv.trim() === '' ? DEFAULT_TOKEN_ENV : tokenEnv.trim() }] })
346
- setUrl('')
376
+ if (!complete || busy || adding) return
377
+ const address = url.trim()
378
+ if (state.settings.gitCredentials.some(credential => credential.url === address)) {
379
+ setAddError('这个地址已经添加过了,请在上面的列表里更新它的令牌。')
380
+ return
381
+ }
382
+ setAddError(undefined)
383
+ setAdding(true)
384
+ const tokenEnv = freeTokenEnv(new Set([...saved, ...draft.gitCredentials.map(credential => credential.tokenEnv)]))
385
+ void onAdd({ url: address, alias: alias.trim(), username: DEFAULT_USERNAME, tokenEnv }, token.trim()).then((ok) => {
386
+ if (!ok) return
387
+ setUrl('')
388
+ setAlias('')
389
+ setToken('')
390
+ }).finally(() => { setAdding(false) })
347
391
  }
348
392
  const save = (name: string): void => {
349
393
  const value = values[name]?.trim() ?? ''
350
- if (value === '') return
394
+ if (value === '' || busy || adding) return
351
395
  void onSave(name, value).then((ok) => { if (ok) setValues(current => ({ ...current, [name]: '' })) })
352
396
  }
397
+ const submitOnEnter = (event: { key: string }): void => { if (event.key === 'Enter') add() }
353
398
  return (
354
399
  <section className="gx__sec">
355
- <h2 className="gx__secTitle">HTTPS 访问令牌 <Overridden keys={state.overridden} name="gitCredentials" /></h2>
400
+ <h2 className="gx__secTitle">GitLab 访问令牌 <Overridden keys={state.overridden} name="gitCredentials" /></h2>
356
401
  {state.credentials.available ? null : <div className="gx__notice gx__notice--error">当前 DSH 组合没有加载凭据服务(@deepseek-ai/dsh-credentials-local),无法保存或读取令牌。</div>}
357
402
  <div className="gx__list">
358
- {draft.gitCredentials.length === 0 ? <div className="gx__emptyRow">还没有访问令牌。用 https 地址拉取 GitLab 等私有仓库时,添加服务器地址后保存,再填入个人访问令牌。</div> : null}
403
+ {draft.gitCredentials.length === 0 ? <div className="gx__emptyRow">还没有访问令牌。用 https 地址拉取 GitLab 私有仓库时,在下面填入 GitLab 地址、令牌别名和令牌。</div> : null}
359
404
  {draft.gitCredentials.map((credential, index) => {
360
- const token = saved.has(credential.tokenEnv) ? tokens.get(credential.tokenEnv) : undefined
405
+ const status = saved.has(credential.tokenEnv) ? tokens.get(credential.tokenEnv) : undefined
361
406
  const value = values[credential.tokenEnv] ?? ''
362
407
  return (
363
408
  <div key={credential.url} className="gx__row gx__row--top">
364
409
  <div className="gx__rowText">
365
410
  <div className="gx__rowLabel">
366
- <span className="gx__mono">{credential.url}</span>
367
- <span className="gx__tag">用户名 {credential.username}</span>
368
- {tokenPill(token)}
411
+ <span>{credential.alias ?? credential.url}</span>
412
+ {tokenPill(status)}
369
413
  </div>
370
- <div className="gx__rowSummary">令牌名称 <span className="gx__mono">{credential.tokenEnv}</span></div>
371
- {token?.error === undefined ? null : <div className="gx__rowError">{token.error}</div>}
372
- {token !== undefined && token.writable
414
+ {credential.alias === undefined ? null : <div className="gx__rowSummary gx__mono">{credential.url}</div>}
415
+ {status?.error === undefined ? null : <div className="gx__rowError">{status.error}</div>}
416
+ {status !== undefined && status.writable
373
417
  ? (
374
418
  <div className="gx__addRow gx__addRow--flush">
375
- <input className="gx__input gx__mono" type="password" autoComplete="new-password" spellCheck={false} placeholder={token.configured ? '输入新令牌以替换' : '粘贴个人访问令牌,例如 glpat-…'} value={value} onChange={(event) => { setValues({ ...values, [credential.tokenEnv]: event.target.value }) }} onKeyDown={(event) => { if (event.key === 'Enter') save(credential.tokenEnv) }} />
376
- <button type="button" className="gx__btn" disabled={busy || value.trim() === ''} onClick={() => { save(credential.tokenEnv) }}>保存令牌</button>
377
- {token.configured && token.source === 'file' ? <button type="button" className="gx__btn gx__btn--ghost" disabled={busy} onClick={() => { void onClear(credential.tokenEnv) }}>清除</button> : null}
419
+ <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) }} />
420
+ <button type="button" className="gx__btn" disabled={busy || adding || value.trim() === ''} onClick={() => { save(credential.tokenEnv) }}>保存令牌</button>
421
+ {status.configured && status.source === 'file' ? <button type="button" className="gx__btn gx__btn--ghost" disabled={busy} onClick={() => { onClear(credential.tokenEnv) }}>清除</button> : null}
378
422
  </div>
379
423
  )
380
424
  : null}
@@ -385,14 +429,17 @@ function TokensTab({ draft, setDraft, state, busy, onSave, onClear }: TabProps &
385
429
  </div>
386
430
  )
387
431
  })}
388
- <div className="gx__addRow">
389
- <input className="gx__input" placeholder="服务器地址,例如 https://gitlab.example.com" value={url} onChange={(event) => { setUrl(event.target.value) }} onKeyDown={(event) => { if (event.key === 'Enter') add() }} />
390
- <input className="gx__input gx__input--branch" placeholder={`用户名(默认 ${DEFAULT_USERNAME})`} value={username} onChange={(event) => { setUsername(event.target.value) }} onKeyDown={(event) => { if (event.key === 'Enter') add() }} />
391
- <input className="gx__input gx__input--branch gx__mono" placeholder="令牌名称" value={tokenEnv} onChange={(event) => { setTokenEnv(event.target.value) }} onKeyDown={(event) => { if (event.key === 'Enter') add() }} />
392
- <button type="button" className="gx__btn" disabled={url.trim() === ''} onClick={add}>添加</button>
432
+ <div className="gx__addForm">
433
+ <div className="gx__addRow gx__addRow--flush">
434
+ <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>
435
+ <label className="gx__field">令牌别名<input className="gx__input" placeholder="例如 guanxiang" value={alias} onChange={(event) => { setAlias(event.target.value) }} onKeyDown={submitOnEnter} /></label>
436
+ <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>
437
+ <button type="button" className="gx__btn gx__btn--end" disabled={busy || adding || !complete} onClick={add}>添加</button>
438
+ </div>
439
+ {addError === undefined ? null : <div className="gx__rowError">{addError}</div>}
393
440
  </div>
394
441
  </div>
395
- <p className="gx__secFoot">地址在这个前缀下的 https 仓库使用对应令牌拉取(多个匹配时取路径最长的),ssh 地址不受影响。GitLab 个人访问令牌需要 read_repository 权限,用户名填 oauth2 即可。令牌保存在 DSH 凭据库(默认 $DSH_HOME/.credentials.yaml,仅本机用户可读),面板只显示是否已设置;也可以设置与令牌名称同名的环境变量。拉取时令牌只经环境变量交给 git,不写入仓库配置、日志或模型上下文。</p>
442
+ <p className="gx__secFoot">地址在这个前缀下的 https 仓库使用对应令牌(多个匹配时取路径最长的),ssh 地址不受影响。拉取需要 read_repository 权限,开启 SKILL 改进时还需要 write_repository。令牌保存在 DSH 凭据库(默认 $DSH_HOME/.credentials.yaml,仅本机用户可读),面板只显示是否已设置;令牌只经环境变量交给 git,不写入设置文件、仓库配置、日志或模型上下文。</p>
396
443
  </section>
397
444
  )
398
445
  }
@@ -51,6 +51,10 @@ const CSS = `
51
51
  .gx__addRow .gx__input{flex:1 1 160px;width:auto}
52
52
  .gx__addRow .gx__input--branch{flex:0 1 140px}
53
53
  .gx__addRow--flush{padding:6px 0 0}
54
+ .gx__addForm{display:flex;flex-direction:column;gap:6px;padding:10px 14px}
55
+ .gx__field{display:flex;flex-direction:column;gap:2px;flex:1 1 160px;color:var(--gx-fg3);font-size:12px}
56
+ .gx__field .gx__input{flex:none;width:100%}
57
+ .gx__btn--end{align-self:flex-end;height:30px}
54
58
  .gx__emptyRow{padding:11px 14px;color:var(--gx-fg3);font-size:12px}
55
59
  .gx__btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:28px;padding:0 12px;border:1px solid var(--gx-line);border-radius:7px;background:transparent;color:inherit;font:inherit;font-size:13px;cursor:pointer;white-space:nowrap}
56
60
  .gx__btn:hover:not(:disabled){background:var(--gx-hover)}
package/src/config.ts CHANGED
@@ -8,7 +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, TOKEN_ENV_PATTERN } from './repos/credentials.ts'
11
+ import { normalizeCredentialUrl } from './repos/credentials.ts'
12
+ import { TOKEN_ENV_PATTERN } from './settings/contract.ts'
12
13
  import type { EditableSettings, GitCredentialSpec, RepoSpec } from './settings/contract.ts'
13
14
 
14
15
  /** Largest delay Node's `setTimeout` honors (2^31 − 1 ms). */
@@ -61,8 +62,10 @@ const credentialSchema = z.object({
61
62
  url: z.string().trim()
62
63
  .refine(value => normalizeCredentialUrl(value) !== undefined, '令牌地址不合法:填 https://主机[:端口][/路径],不含用户名、查询参数或片段')
63
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),
64
67
  username: z.string().trim().regex(/^\S+$/, '用户名不能为空或包含空白').default('oauth2'),
65
- tokenEnv: z.string().trim().regex(TOKEN_ENV_PATTERN, '令牌名称只能包含字母、数字和下划线,且不能以数字开头'),
68
+ tokenEnv: z.string().trim().regex(TOKEN_ENV_PATTERN, '令牌名称只能包含字母、数字和下划线,且不能以数字开头(它是凭据库中的键名,不是令牌本身)'),
66
69
  }).strict()
67
70
 
68
71
  const hostSchema = z.string().trim().toLowerCase()
@@ -219,7 +222,12 @@ function copyRepos(repos: readonly RepoSpec[]): RepoSpec[] {
219
222
  }
220
223
 
221
224
  function copyCredentials(credentials: readonly GitCredentialSpec[]): GitCredentialSpec[] {
222
- return credentials.map(credential => ({ url: credential.url, username: credential.username, tokenEnv: credential.tokenEnv }))
225
+ return credentials.map(credential => ({
226
+ url: credential.url,
227
+ ...credential.alias === undefined ? {} : { alias: credential.alias },
228
+ username: credential.username,
229
+ tokenEnv: credential.tokenEnv,
230
+ }))
223
231
  }
224
232
 
225
233
  /**
@@ -9,6 +9,8 @@
9
9
 
10
10
  import type { GitCredentialSpec } from '../settings/contract.ts'
11
11
 
12
+ export { TOKEN_ENV_PATTERN } from '../settings/contract.ts'
13
+
12
14
  /** Environment variables the helper reads; set only on git child processes. */
13
15
  const USERNAME_VARIABLE = 'GUANXIANG_GIT_USERNAME'
14
16
  const TOKEN_VARIABLE = 'GUANXIANG_GIT_TOKEN'
@@ -16,9 +18,6 @@ const TOKEN_VARIABLE = 'GUANXIANG_GIT_TOKEN'
16
18
  /** Answers git's `get` request from the two variables and ignores `store` and `erase`. */
17
19
  const HELPER = `!f() { test "$1" = get || exit 0; printf 'username=%s\\npassword=%s\\n' "$${USERNAME_VARIABLE}" "$${TOKEN_VARIABLE}"; }; f`
18
20
 
19
- /** Credential reference grammar of `@deepseek-ai/dsh-credentials` (a POSIX shell identifier). */
20
- export const TOKEN_ENV_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
21
-
22
21
  /** Longest accepted token; personal access tokens are far shorter. */
23
22
  export const MAX_TOKEN_LENGTH = 4096
24
23
 
@@ -22,10 +22,15 @@ export interface RepoSpec {
22
22
  branch?: string
23
23
  }
24
24
 
25
+ /** Credential reference grammar of `@deepseek-ai/dsh-credentials` (a POSIX shell identifier), which {@link GitCredentialSpec.tokenEnv} follows. */
26
+ export const TOKEN_ENV_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
27
+
25
28
  /** HTTPS access token used for the repositories under one address prefix. */
26
29
  export interface GitCredentialSpec {
27
30
  /** `https://host[:port][/path]` (or `http://`) prefix; repository addresses under it use this token. */
28
31
  url: string
32
+ /** Display name the panel shows for this token; omitted shows the address. */
33
+ alias?: string
29
34
  /** HTTP username sent with the token; GitLab personal access tokens accept `oauth2`. */
30
35
  username: string
31
36
  /** Credential reference (environment-variable name) whose value in DSH's credential store is the token. */