@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/README.md +2 -1
- package/lib/client.js +140 -91
- package/lib/client.js.map +1 -1
- package/lib/index.js +32 -30
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/page.tsx +83 -36
- package/src/client/styles.ts +4 -0
- package/src/config.ts +11 -3
- package/src/repos/credentials.ts +2 -3
- package/src/settings/contract.ts +5 -0
package/package.json
CHANGED
package/src/client/page.tsx
CHANGED
|
@@ -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 => ({
|
|
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<
|
|
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
|
-
|
|
186
|
+
const next = await action()
|
|
187
|
+
adopt(next, resetDraft)
|
|
182
188
|
setNotice({ kind: 'ok', text: success })
|
|
183
|
-
return
|
|
189
|
+
return next
|
|
184
190
|
} catch (error) {
|
|
185
191
|
setNotice({ kind: 'error', text: messageOf(error) })
|
|
186
|
-
return
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
363
|
+
onClear: (tokenEnv: string) => void
|
|
335
364
|
}): JSX.Element {
|
|
336
365
|
const [url, setUrl] = useState('')
|
|
337
|
-
const [
|
|
338
|
-
const [
|
|
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(
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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">
|
|
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
|
|
403
|
+
{draft.gitCredentials.length === 0 ? <div className="gx__emptyRow">还没有访问令牌。用 https 地址拉取 GitLab 私有仓库时,在下面填入 GitLab 地址、令牌别名和令牌。</div> : null}
|
|
359
404
|
{draft.gitCredentials.map((credential, index) => {
|
|
360
|
-
const
|
|
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
|
|
367
|
-
|
|
368
|
-
{tokenPill(token)}
|
|
411
|
+
<span>{credential.alias ?? credential.url}</span>
|
|
412
|
+
{tokenPill(status)}
|
|
369
413
|
</div>
|
|
370
|
-
<div className="gx__rowSummary
|
|
371
|
-
{
|
|
372
|
-
{
|
|
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={
|
|
376
|
-
<button type="button" className="gx__btn" disabled={busy || value.trim() === ''} onClick={() => { save(credential.tokenEnv) }}>保存令牌</button>
|
|
377
|
-
{
|
|
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="
|
|
389
|
-
<
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
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
|
}
|
package/src/client/styles.ts
CHANGED
|
@@ -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
|
|
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 => ({
|
|
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
|
/**
|
package/src/repos/credentials.ts
CHANGED
|
@@ -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
|
|
package/src/settings/contract.ts
CHANGED
|
@@ -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. */
|