@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/README.md +25 -35
- package/lib/client.js +173 -151
- package/lib/client.js.map +1 -1
- package/lib/index.js +493 -891
- package/lib/index.js.map +1 -1
- package/package.json +3 -1
- package/src/client/api.ts +43 -6
- package/src/client/page.tsx +64 -99
- package/src/config.ts +28 -45
- package/src/index.ts +11 -4
- package/src/repos/lookup.ts +92 -0
- package/src/repos/registry.ts +57 -295
- package/src/service.ts +166 -59
- 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 +74 -49
- package/src/repos/credentials.ts +0 -104
- package/src/repos/git.ts +0 -74
- package/src/skill-update/git.ts +0 -157
package/src/service.ts
CHANGED
|
@@ -5,20 +5,19 @@
|
|
|
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'
|
|
@@ -27,14 +26,24 @@ import { SkillUpdater } from './skill-update/tool.ts'
|
|
|
27
26
|
/** A settings write the panel reports as a field error. */
|
|
28
27
|
export class SettingsError extends Error {}
|
|
29
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} 个字符或包含换行等控制字符`)
|
|
30
34
|
const tokenClearSchema = z.object({ tokenEnv: z.string() }).strict()
|
|
31
|
-
const tokenSetSchema = tokenClearSchema.extend({
|
|
32
|
-
|
|
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(),
|
|
33
43
|
}).strict()
|
|
34
44
|
|
|
35
45
|
/** Optional seams for tests. */
|
|
36
46
|
export interface ServiceOptions {
|
|
37
|
-
readonly git?: GitRunner
|
|
38
47
|
readonly fetch?: typeof fetch
|
|
39
48
|
}
|
|
40
49
|
|
|
@@ -51,6 +60,7 @@ export class GuanxiangService {
|
|
|
51
60
|
private active = true
|
|
52
61
|
private readonly refreshWaitMs: number
|
|
53
62
|
private readonly analyzeTokenRef: string
|
|
63
|
+
private readonly legacyCredentials: readonly LegacyCredential[]
|
|
54
64
|
private readonly booted = Promise.withResolvers<void>()
|
|
55
65
|
|
|
56
66
|
/**
|
|
@@ -68,21 +78,18 @@ export class GuanxiangService {
|
|
|
68
78
|
this.defaults = defaultsOf(config)
|
|
69
79
|
this.refreshWaitMs = config.refreshWaitMs
|
|
70
80
|
this.analyzeTokenRef = config.analyzeTokenRef
|
|
81
|
+
this.legacyCredentials = config.gitCredentials
|
|
71
82
|
this.current = this.defaults
|
|
72
|
-
const git = options.git ?? runGit
|
|
73
83
|
this.registry = new RepoRegistry({
|
|
74
|
-
|
|
75
|
-
gitTimeoutMs: config.gitTimeoutMs,
|
|
76
|
-
auth: url => this.gitAuth(url),
|
|
84
|
+
repos: () => ctx.get?.(DSH_REPOS_SERVICE),
|
|
77
85
|
onChange: () => { this.onRegistryChange() },
|
|
78
86
|
})
|
|
79
87
|
this.updater = new SkillUpdater({
|
|
80
88
|
registry: this.registry,
|
|
81
89
|
settings: () => this.current,
|
|
82
|
-
git,
|
|
83
|
-
gitTimeoutMs: config.gitTimeoutMs,
|
|
84
90
|
...config.skillUpdateAuthor === undefined ? {} : { author: config.skillUpdateAuthor },
|
|
85
91
|
approval: () => ctx.get?.('approval') as HostApproval | undefined,
|
|
92
|
+
repos: () => ctx.get?.(DSH_REPOS_SERVICE),
|
|
86
93
|
})
|
|
87
94
|
this.dispatcher = new Dispatcher({
|
|
88
95
|
registry: this.registry,
|
|
@@ -99,7 +106,10 @@ export class GuanxiangService {
|
|
|
99
106
|
maxAppsPerRequest: () => this.current.maxAppsPerRequest,
|
|
100
107
|
skillUpdates: () => this.current.skillUpdates.enabled,
|
|
101
108
|
autoInvoke: config.autoInvoke,
|
|
102
|
-
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
|
+
},
|
|
103
113
|
})
|
|
104
114
|
}
|
|
105
115
|
|
|
@@ -156,7 +166,8 @@ export class GuanxiangService {
|
|
|
156
166
|
}
|
|
157
167
|
|
|
158
168
|
/**
|
|
159
|
-
* 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.
|
|
160
171
|
* @returns when the first sync round settled.
|
|
161
172
|
*/
|
|
162
173
|
async boot(): Promise<void> {
|
|
@@ -176,10 +187,54 @@ export class GuanxiangService {
|
|
|
176
187
|
this.ctx.logger?.warn?.('guanxiang: 忽略面板设置:%s', read.error)
|
|
177
188
|
}
|
|
178
189
|
this.current = applyOverlay(this.defaults, this.overlay)
|
|
179
|
-
this.registry.configure(this.current.
|
|
190
|
+
this.registry.configure(this.current.repos)
|
|
191
|
+
await this.migrateCredentials()
|
|
180
192
|
await this.registry.syncAll()
|
|
181
193
|
}
|
|
182
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
|
+
|
|
183
238
|
/** Stop registering tools; in-flight syncs finish without side effects on the host. */
|
|
184
239
|
shutdown(): void {
|
|
185
240
|
this.active = false
|
|
@@ -199,7 +254,8 @@ export class GuanxiangService {
|
|
|
199
254
|
overridden: overriddenKeys(this.overlay),
|
|
200
255
|
...this.overlayError === undefined ? {} : { overlayError: this.overlayError },
|
|
201
256
|
repos: this.registry.status(),
|
|
202
|
-
|
|
257
|
+
hosts: await this.hostsState(),
|
|
258
|
+
credentialsAvailable: this.credentialStore() !== undefined,
|
|
203
259
|
analyze: await this.analyzeState(),
|
|
204
260
|
}
|
|
205
261
|
}
|
|
@@ -234,39 +290,100 @@ export class GuanxiangService {
|
|
|
234
290
|
return this.ctx.get?.('credentials') as HostCredentials | undefined
|
|
235
291
|
}
|
|
236
292
|
|
|
237
|
-
private async
|
|
238
|
-
const
|
|
239
|
-
if (
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
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
|
+
}
|
|
243
313
|
}
|
|
244
314
|
|
|
245
315
|
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* @
|
|
249
|
-
* @
|
|
250
|
-
* @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.
|
|
251
320
|
*/
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
if (
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
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()
|
|
261
340
|
}
|
|
262
341
|
|
|
263
342
|
/**
|
|
264
|
-
* Store
|
|
265
|
-
*
|
|
266
|
-
*
|
|
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.
|
|
267
384
|
* @param raw - request body `{ tokenEnv, value }`.
|
|
268
385
|
* @returns the new panel state.
|
|
269
|
-
* @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.
|
|
270
387
|
*/
|
|
271
388
|
async setToken(raw: unknown): Promise<StateWire> {
|
|
272
389
|
const parsed = tokenSetSchema.safeParse(raw)
|
|
@@ -275,8 +392,8 @@ export class GuanxiangService {
|
|
|
275
392
|
}
|
|
276
393
|
|
|
277
394
|
/**
|
|
278
|
-
* Remove the stored token
|
|
279
|
-
*
|
|
395
|
+
* Remove the stored analyze token; an environment variable of the same name
|
|
396
|
+
* applies again afterwards.
|
|
280
397
|
* @param raw - request body `{ tokenEnv }`.
|
|
281
398
|
* @returns the new panel state.
|
|
282
399
|
* @throws SettingsError on the same conditions as {@link setToken}.
|
|
@@ -288,18 +405,17 @@ export class GuanxiangService {
|
|
|
288
405
|
}
|
|
289
406
|
|
|
290
407
|
private async writeToken(tokenEnv: string, value: string | undefined): Promise<StateWire> {
|
|
291
|
-
if (tokenEnv !== this.analyzeTokenRef
|
|
408
|
+
if (tokenEnv !== this.analyzeTokenRef) throw new SettingsError(`${tokenEnv} 不是分析接口令牌的名称`)
|
|
292
409
|
const store = this.credentialStore()
|
|
293
410
|
if (store === undefined) throw new SettingsError('当前 DSH 组合没有凭据服务(@deepseek-ai/dsh-credentials-local),无法保存令牌')
|
|
294
411
|
if (value === undefined) await store.unset(tokenEnv)
|
|
295
412
|
else await store.set(tokenEnv, value)
|
|
296
|
-
void this.registry.resyncUrls(this.current.repos.map(repo => repo.url).filter(url => credentialFor(url, this.current.gitCredentials)?.tokenEnv === tokenEnv))
|
|
297
413
|
return await this.state()
|
|
298
414
|
}
|
|
299
415
|
|
|
300
416
|
/**
|
|
301
417
|
* Validate and persist a complete settings payload from the panel, then
|
|
302
|
-
* sync repositories whose address
|
|
418
|
+
* sync repositories whose address or branch changed.
|
|
303
419
|
* @param raw - request body.
|
|
304
420
|
* @returns the new panel state.
|
|
305
421
|
* @throws SettingsError when the payload is invalid.
|
|
@@ -333,21 +449,12 @@ export class GuanxiangService {
|
|
|
333
449
|
void (url === undefined ? this.registry.syncAll() : this.registry.syncUrls([url]))
|
|
334
450
|
}
|
|
335
451
|
|
|
336
|
-
/**
|
|
337
|
-
* Make an overlay effective and sync the repositories whose address, branch,
|
|
338
|
-
* directory, or matching access-token configuration changed.
|
|
339
|
-
*/
|
|
452
|
+
/** Make an overlay effective and sync the repositories whose address or branch changed. */
|
|
340
453
|
private apply(overlay: SettingsOverlay): void {
|
|
341
|
-
const previous = this.current.gitCredentials
|
|
342
454
|
this.overlay = overlay
|
|
343
455
|
this.overlayError = undefined
|
|
344
456
|
this.current = applyOverlay(this.defaults, overlay)
|
|
345
|
-
|
|
346
|
-
const next = this.current.gitCredentials
|
|
347
|
-
const reauthorized = this.current.repos.map(repo => repo.url)
|
|
348
|
-
.filter(url => JSON.stringify(credentialFor(url, previous)) !== JSON.stringify(credentialFor(url, next)))
|
|
349
|
-
void this.registry.syncUrls(changed)
|
|
350
|
-
void this.registry.resyncUrls(reauthorized.filter(url => !changed.includes(url)))
|
|
457
|
+
void this.registry.syncUrls(this.registry.configure(this.current.repos))
|
|
351
458
|
}
|
|
352
459
|
|
|
353
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
|
+
}
|