@modesto_ma/dsh-guanxiang 0.2.0-beta.3 → 0.2.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -29
- package/lib/client.js +172 -150
- package/lib/client.js.map +1 -1
- package/lib/index.js +425 -794
- package/lib/index.js.map +1 -1
- package/package.json +3 -2
- package/src/client/api.ts +43 -6
- package/src/client/page.tsx +63 -98
- package/src/config.ts +26 -43
- package/src/index.ts +9 -2
- package/src/repos/lookup.ts +92 -0
- package/src/repos/registry.ts +57 -293
- package/src/service.ts +165 -60
- package/src/settings/contract.ts +50 -26
- package/src/settings/routes.ts +19 -1
- package/src/skill-update/change.ts +81 -0
- package/src/skill-update/tool.ts +20 -30
- package/src/repos/credentials.ts +0 -104
- package/src/repos/git.ts +0 -74
- package/src/skill-update/git.ts +0 -84
- package/src/skill-update/repos.ts +0 -51
package/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
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
|
240
|
-
const
|
|
241
|
-
if (
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
*
|
|
249
|
-
*
|
|
250
|
-
* @
|
|
251
|
-
* @
|
|
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
|
-
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
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
|
|
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
|
|
281
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
package/src/settings/contract.ts
CHANGED
|
@@ -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 }`
|
|
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,
|
|
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)
|
|
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
|
-
/**
|
|
35
|
-
export
|
|
36
|
-
|
|
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
|
-
/**
|
|
41
|
-
|
|
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
|
-
/**
|
|
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 = ['
|
|
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
|
|
124
|
-
dir
|
|
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
|
-
/**
|
|
156
|
-
export interface
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
package/src/settings/routes.ts
CHANGED
|
@@ -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
|
+
}
|