@lifenrir/dsh-external-task-context-patch 0.0.0-stage → 0.1.0
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-zh.md +103 -0
- package/README.md +124 -2
- package/install.mjs +543 -0
- package/lib.mjs +274 -0
- package/package.json +33 -4
- package/src/index.ts +189 -0
- package/src/log.ts +235 -0
- package/src/run.ts +650 -0
package/install.mjs
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Install the run-log-capable patched Claude Code subagent provider into an
|
|
4
|
+
* existing DSH profile without touching package.json or running pnpm.
|
|
5
|
+
*
|
|
6
|
+
* The installer downloads the official provider sources for one DSH release
|
|
7
|
+
* tag (or reads them from `--source`), replaces `index.ts`, `run.ts`, and
|
|
8
|
+
* `log.ts` with the patched copies in this package, bundles them with esbuild,
|
|
9
|
+
* backs up the installed `lib/index.js`, and replaces it in place.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-external-task-context-patch/install
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawnSync } from 'node:child_process'
|
|
15
|
+
import {
|
|
16
|
+
chmod,
|
|
17
|
+
copyFile,
|
|
18
|
+
mkdir,
|
|
19
|
+
mkdtemp,
|
|
20
|
+
readFile,
|
|
21
|
+
readdir,
|
|
22
|
+
realpath,
|
|
23
|
+
rm,
|
|
24
|
+
stat,
|
|
25
|
+
writeFile,
|
|
26
|
+
} from 'node:fs/promises'
|
|
27
|
+
import { tmpdir } from 'node:os'
|
|
28
|
+
import { dirname, join, resolve } from 'node:path'
|
|
29
|
+
import { fileURLToPath } from 'node:url'
|
|
30
|
+
import { build } from 'esbuild'
|
|
31
|
+
import {
|
|
32
|
+
backupProfileDir,
|
|
33
|
+
backupsRoot,
|
|
34
|
+
discoverProfiles,
|
|
35
|
+
isMatchingActiveRecord,
|
|
36
|
+
PATCHED_SRC_FILES,
|
|
37
|
+
parseArgs,
|
|
38
|
+
PROVIDER_PACKAGE_NAME,
|
|
39
|
+
PROVIDER_SRC_FILES,
|
|
40
|
+
providerDirFor,
|
|
41
|
+
rawSourceUrl,
|
|
42
|
+
resolveDshHome,
|
|
43
|
+
runLogDirectory,
|
|
44
|
+
tagToVersion,
|
|
45
|
+
timestampDir,
|
|
46
|
+
versionMatchesTag,
|
|
47
|
+
} from './lib.mjs'
|
|
48
|
+
|
|
49
|
+
const PACKAGE_ROOT = dirname(fileURLToPath(import.meta.url))
|
|
50
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000
|
|
51
|
+
const VERIFY_TIMEOUT_MS = 60_000
|
|
52
|
+
const VERIFY_SCRIPT = "const m = await import('./lib/index.js'); if (m.name !== 'subagent-claude-code' || m.Config({}).persistNativeTranscript !== true) throw new Error('patch verification failed')"
|
|
53
|
+
const PROXY_GUIDANCE = [
|
|
54
|
+
'If DSH runs behind a proxy, re-run with NODE_USE_ENV_PROXY=1 and HTTPS_PROXY set,',
|
|
55
|
+
'or pass --source <dir> pointing at a local deepseek-harness checkout.',
|
|
56
|
+
].join('\n')
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Entry point.
|
|
60
|
+
* @param {readonly string[]} argv - arguments without the executable and script.
|
|
61
|
+
* @returns {Promise<void>}
|
|
62
|
+
*/
|
|
63
|
+
async function main(argv) {
|
|
64
|
+
const options = parseArgs(argv)
|
|
65
|
+
if (options.help) {
|
|
66
|
+
printHelp()
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
// Fail early on a malformed tag rather than after touching a profile.
|
|
70
|
+
tagToVersion(options.tag)
|
|
71
|
+
|
|
72
|
+
const dshHome = resolveDshHome(options.dshHome)
|
|
73
|
+
const profiles = await selectProfiles(options, dshHome)
|
|
74
|
+
|
|
75
|
+
if (options.restore) {
|
|
76
|
+
for (const profile of profiles) {
|
|
77
|
+
await restoreProfile({ options, dshHome, profile })
|
|
78
|
+
}
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const targets = []
|
|
83
|
+
for (const profile of profiles) {
|
|
84
|
+
targets.push(await resolveTarget({ dshHome, profile }))
|
|
85
|
+
}
|
|
86
|
+
for (const target of targets) {
|
|
87
|
+
assertVersionMatches(target, options)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const tempRoot = await mkdtemp(join(tmpdir(), 'dsh-ect-patch-'))
|
|
91
|
+
try {
|
|
92
|
+
const sources = await acquireSources(options)
|
|
93
|
+
validateOfficialIndex(sources['index.ts'], options)
|
|
94
|
+
const bundlePath = await buildBundle(sources, tempRoot)
|
|
95
|
+
const bundleSize = await assertBuiltBundle(bundlePath)
|
|
96
|
+
|
|
97
|
+
if (options.dryRun) {
|
|
98
|
+
await syntaxCheck(bundlePath, tempRoot)
|
|
99
|
+
for (const target of targets) {
|
|
100
|
+
await printDryRun({ options, dshHome, target, bundleSize })
|
|
101
|
+
}
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const target of targets) {
|
|
106
|
+
await installTarget({ options, dshHome, target, bundlePath })
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
await rm(tempRoot, { recursive: true, force: true })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Select profiles from explicit flags or discovery.
|
|
115
|
+
* @param {ReturnType<typeof parseArgs>} options - parsed CLI options.
|
|
116
|
+
* @param {string} dshHome - absolute DSH home.
|
|
117
|
+
* @returns {Promise<string[]>} profile names to process.
|
|
118
|
+
*/
|
|
119
|
+
async function selectProfiles(options, dshHome) {
|
|
120
|
+
if (options.profiles.length > 0) {
|
|
121
|
+
return options.profiles
|
|
122
|
+
}
|
|
123
|
+
const profilesRoot = join(dshHome, 'profiles')
|
|
124
|
+
const discovered = await discoverProfiles(dshHome)
|
|
125
|
+
if (discovered.length === 0) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`no profile with ${PROVIDER_PACKAGE_NAME} installed under ${profilesRoot}; `
|
|
128
|
+
+ 'pass --profile <name> or --dsh-home <path>',
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
if (discovered.length > 1 && !options.yes) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`multiple profiles found (${discovered.join(', ')}); pass --profile <name> `
|
|
134
|
+
+ '(repeatable) to select one, or --yes to patch all',
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
return discovered
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve one profile's installed provider package.
|
|
142
|
+
* @param {{ dshHome: string, profile: string }} input - DSH home and profile name.
|
|
143
|
+
* @returns {Promise<{ profile: string, profileDir: string, providerDir: string, version: string }>}
|
|
144
|
+
*/
|
|
145
|
+
async function resolveTarget({ dshHome, profile }) {
|
|
146
|
+
const profileDir = join(dshHome, 'profiles', profile)
|
|
147
|
+
const link = providerDirFor(profileDir)
|
|
148
|
+
let providerDir
|
|
149
|
+
try {
|
|
150
|
+
providerDir = await realpath(link)
|
|
151
|
+
} catch {
|
|
152
|
+
throw new Error(`profile "${profile}": ${PROVIDER_PACKAGE_NAME} is not installed at ${link}`)
|
|
153
|
+
}
|
|
154
|
+
let pkg
|
|
155
|
+
try {
|
|
156
|
+
pkg = JSON.parse(await readFile(join(providerDir, 'package.json'), 'utf8'))
|
|
157
|
+
} catch (error) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`profile "${profile}": cannot read ${join(providerDir, 'package.json')}: `
|
|
160
|
+
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
if (pkg.name !== PROVIDER_PACKAGE_NAME) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`profile "${profile}": ${providerDir} is package ${JSON.stringify(pkg.name)}, `
|
|
166
|
+
+ `expected ${PROVIDER_PACKAGE_NAME}`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
if (typeof pkg.version !== 'string' || pkg.version.length === 0) {
|
|
170
|
+
throw new Error(`profile "${profile}": provider package.json has no version`)
|
|
171
|
+
}
|
|
172
|
+
return { profile, profileDir, providerDir, version: pkg.version }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Reject or warn about a provider-version/tag mismatch.
|
|
177
|
+
* @param {{ profile: string, version: string }} target - resolved profile target.
|
|
178
|
+
* @param {ReturnType<typeof parseArgs>} options - parsed CLI options.
|
|
179
|
+
*/
|
|
180
|
+
function assertVersionMatches(target, options) {
|
|
181
|
+
if (versionMatchesTag(target.version, options.tag)) return
|
|
182
|
+
const message = `profile "${target.profile}": installed provider ${target.version} `
|
|
183
|
+
+ `does not match tag ${options.tag} (tag maps to version ${tagToVersion(options.tag)})`
|
|
184
|
+
if (!options.force) {
|
|
185
|
+
throw new Error(`${message}; pass --force to install anyway`)
|
|
186
|
+
}
|
|
187
|
+
console.warn(`WARNING: ${message}; continuing because --force was passed.`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Read the official provider sources from `--source` or GitHub.
|
|
192
|
+
* @param {ReturnType<typeof parseArgs>} options - parsed CLI options.
|
|
193
|
+
* @returns {Promise<Record<string, string>>} source text keyed by file name.
|
|
194
|
+
*/
|
|
195
|
+
async function acquireSources(options) {
|
|
196
|
+
if (options.source !== undefined) {
|
|
197
|
+
const sourceDir = resolve(options.source)
|
|
198
|
+
const sources = {}
|
|
199
|
+
for (const file of PROVIDER_SRC_FILES) {
|
|
200
|
+
try {
|
|
201
|
+
sources[file] = await readFile(join(sourceDir, file), 'utf8')
|
|
202
|
+
} catch {
|
|
203
|
+
throw new Error(`--source directory ${sourceDir} is missing ${file}`)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return sources
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const sources = {}
|
|
210
|
+
for (const file of PROVIDER_SRC_FILES) {
|
|
211
|
+
const url = rawSourceUrl(options.tag, file)
|
|
212
|
+
try {
|
|
213
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) })
|
|
214
|
+
if (!response.ok) {
|
|
215
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}`)
|
|
216
|
+
}
|
|
217
|
+
sources[file] = await response.text()
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
220
|
+
throw new Error(`failed to download ${url}: ${reason}\n${PROXY_GUIDANCE}`)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return sources
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Confirm the official source really is the Claude Code subagent provider.
|
|
228
|
+
* @param {string} indexSource - downloaded `index.ts`.
|
|
229
|
+
* @param {ReturnType<typeof parseArgs>} options - parsed CLI options.
|
|
230
|
+
*/
|
|
231
|
+
function validateOfficialIndex(indexSource, options) {
|
|
232
|
+
if (!/export\s+const\s+name\s*=\s*['"]subagent-claude-code['"]/.test(indexSource)) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`index.ts for tag ${options.tag} does not export name = 'subagent-claude-code'; `
|
|
235
|
+
+ 'check --tag or --source',
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Assemble patched sources in a temp directory and bundle them with esbuild.
|
|
242
|
+
* @param {Record<string, string>} sources - official source text keyed by file name.
|
|
243
|
+
* @param {string} tempRoot - temporary working directory.
|
|
244
|
+
* @returns {Promise<string>} path to the built `out/index.js`.
|
|
245
|
+
*/
|
|
246
|
+
async function buildBundle(sources, tempRoot) {
|
|
247
|
+
const srcDir = join(tempRoot, 'src')
|
|
248
|
+
const outDir = join(tempRoot, 'out')
|
|
249
|
+
await mkdir(srcDir, { recursive: true })
|
|
250
|
+
await mkdir(outDir, { recursive: true })
|
|
251
|
+
|
|
252
|
+
await writeFile(join(srcDir, 'process.ts'), sources['process.ts'])
|
|
253
|
+
for (const file of PATCHED_SRC_FILES) {
|
|
254
|
+
await copyFile(join(PACKAGE_ROOT, 'src', file), join(srcDir, file))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const outfile = join(outDir, 'index.js')
|
|
258
|
+
try {
|
|
259
|
+
await build({
|
|
260
|
+
entryPoints: [join(srcDir, 'index.ts')],
|
|
261
|
+
outfile,
|
|
262
|
+
bundle: true,
|
|
263
|
+
format: 'esm',
|
|
264
|
+
platform: 'node',
|
|
265
|
+
target: 'node20',
|
|
266
|
+
packages: 'external',
|
|
267
|
+
logLevel: 'silent',
|
|
268
|
+
})
|
|
269
|
+
} catch (error) {
|
|
270
|
+
const details = Array.isArray(error?.errors) && error.errors.length > 0
|
|
271
|
+
? error.errors.map((item) => {
|
|
272
|
+
const location = item.location === undefined || item.location === null
|
|
273
|
+
? ''
|
|
274
|
+
: ` (${item.location.file}:${item.location.line})`
|
|
275
|
+
return `${item.text}${location}`
|
|
276
|
+
}).join('\n')
|
|
277
|
+
: (error instanceof Error ? error.message : String(error))
|
|
278
|
+
throw new Error(`esbuild failed:\n${details}`)
|
|
279
|
+
}
|
|
280
|
+
return outfile
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Cheap structural check before the bundle is allowed anywhere near a profile.
|
|
285
|
+
* @param {string} bundlePath - built bundle path.
|
|
286
|
+
* @returns {Promise<number>} bundle size in bytes.
|
|
287
|
+
*/
|
|
288
|
+
async function assertBuiltBundle(bundlePath) {
|
|
289
|
+
const source = await readFile(bundlePath, 'utf8')
|
|
290
|
+
if (
|
|
291
|
+
!source.includes('subagent-claude-code')
|
|
292
|
+
|| !source.includes('persistNativeTranscript')
|
|
293
|
+
|| !source.includes('export {')
|
|
294
|
+
) {
|
|
295
|
+
throw new Error('built bundle does not look like the patched provider; refusing to install')
|
|
296
|
+
}
|
|
297
|
+
return Buffer.byteLength(source)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Syntax-check the built bundle during `--dry-run`.
|
|
302
|
+
* @param {string} bundlePath - built bundle path.
|
|
303
|
+
* @param {string} tempRoot - temp root that receives an ESM package.json marker.
|
|
304
|
+
* @returns {Promise<void>}
|
|
305
|
+
*/
|
|
306
|
+
async function syntaxCheck(bundlePath, tempRoot) {
|
|
307
|
+
await writeFile(join(tempRoot, 'package.json'), '{"type":"module"}\n')
|
|
308
|
+
const result = spawnSync(process.execPath, ['--check', bundlePath], { encoding: 'utf8' })
|
|
309
|
+
if (result.status !== 0) {
|
|
310
|
+
const detail = (result.stderr || (result.error instanceof Error ? result.error.message : '')).trim()
|
|
311
|
+
throw new Error(`built bundle failed syntax check${detail.length === 0 ? '' : `:\n${detail}`}`)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Print a dry-run plan for one target without writing to the profile.
|
|
317
|
+
* @param {{ options: ReturnType<typeof parseArgs>, dshHome: string, target: { profile: string, providerDir: string, version: string }, bundleSize: number }} input - dry-run facts.
|
|
318
|
+
* @returns {Promise<void>}
|
|
319
|
+
*/
|
|
320
|
+
async function printDryRun({ options, dshHome, target, bundleSize }) {
|
|
321
|
+
const records = await listRecords(backupsRoot(dshHome), target.profile)
|
|
322
|
+
const active = records.find(entry => (
|
|
323
|
+
isMatchingActiveRecord(entry.record, options.tag)
|
|
324
|
+
&& entry.record.providerDir === target.providerDir
|
|
325
|
+
))
|
|
326
|
+
console.log(`[dry-run] Profile: ${target.profile}`)
|
|
327
|
+
console.log(`[dry-run] Provider: ${PROVIDER_PACKAGE_NAME}@${target.version} (tag ${options.tag})`)
|
|
328
|
+
console.log(`[dry-run] Built bundle OK (${bundleSize} bytes); syntax check passed`)
|
|
329
|
+
console.log(`[dry-run] Would replace ${join(target.providerDir, 'lib', 'index.js')}`)
|
|
330
|
+
console.log(`[dry-run] Would back up to ${backupProfileDir(dshHome, '<timestamp>', target.profile)}/index.js`)
|
|
331
|
+
if (active !== undefined) {
|
|
332
|
+
console.log(`[dry-run] Note: tag ${options.tag} already installed (${active.recordPath}); --force would be required`)
|
|
333
|
+
}
|
|
334
|
+
console.log(`[dry-run] Log dir: ${runLogDirectory(dshHome)}`)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Back up and replace one profile's installed provider bundle, then verify it.
|
|
339
|
+
* @param {{ options: ReturnType<typeof parseArgs>, dshHome: string, target: { profile: string, providerDir: string, version: string }, bundlePath: string }} input - install facts.
|
|
340
|
+
* @returns {Promise<void>}
|
|
341
|
+
*/
|
|
342
|
+
async function installTarget({ options, dshHome, target, bundlePath }) {
|
|
343
|
+
const records = await listRecords(backupsRoot(dshHome), target.profile)
|
|
344
|
+
const active = records.find(entry => (
|
|
345
|
+
isMatchingActiveRecord(entry.record, options.tag)
|
|
346
|
+
&& entry.record.providerDir === target.providerDir
|
|
347
|
+
))
|
|
348
|
+
if (active !== undefined && !options.force) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`profile "${target.profile}": tag ${options.tag} is already installed `
|
|
351
|
+
+ `(record ${active.recordPath}); pass --force to reinstall`,
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
if (active !== undefined) {
|
|
355
|
+
console.warn(
|
|
356
|
+
`WARNING: profile "${target.profile}": reinstalling tag ${options.tag} `
|
|
357
|
+
+ 'over an existing patch because --force was passed.',
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const indexPath = join(target.providerDir, 'lib', 'index.js')
|
|
362
|
+
let indexStat
|
|
363
|
+
try {
|
|
364
|
+
indexStat = await stat(indexPath)
|
|
365
|
+
} catch {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`profile "${target.profile}": ${indexPath} does not exist; `
|
|
368
|
+
+ `is ${PROVIDER_PACKAGE_NAME} fully installed?`,
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
if (!indexStat.isFile()) {
|
|
372
|
+
throw new Error(`profile "${target.profile}": ${indexPath} is not a regular file`)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const timestamp = timestampDir()
|
|
376
|
+
const backupDir = backupProfileDir(dshHome, timestamp, target.profile)
|
|
377
|
+
const backupFile = join(backupDir, 'index.js')
|
|
378
|
+
await mkdir(backupDir, { recursive: true })
|
|
379
|
+
await copyFile(indexPath, backupFile)
|
|
380
|
+
|
|
381
|
+
const record = {
|
|
382
|
+
version: target.version,
|
|
383
|
+
tag: options.tag,
|
|
384
|
+
installedAt: new Date().toISOString(),
|
|
385
|
+
providerDir: target.providerDir,
|
|
386
|
+
backupFile,
|
|
387
|
+
}
|
|
388
|
+
const recordPath = join(backupDir, 'install.json')
|
|
389
|
+
await writeFile(recordPath, `${JSON.stringify(record, null, 2)}\n`)
|
|
390
|
+
|
|
391
|
+
const originalMode = indexStat.mode & 0o777
|
|
392
|
+
await chmod(backupFile, originalMode)
|
|
393
|
+
await writeFile(indexPath, await readFile(bundlePath))
|
|
394
|
+
await chmod(indexPath, 0o644)
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
verifyInstalled(target.providerDir)
|
|
398
|
+
} catch (error) {
|
|
399
|
+
await copyFile(backupFile, indexPath)
|
|
400
|
+
await chmod(indexPath, originalMode)
|
|
401
|
+
throw new Error(
|
|
402
|
+
`profile "${target.profile}": ${error instanceof Error ? error.message : String(error)}\n`
|
|
403
|
+
+ `Original lib/index.js restored from ${backupFile}`,
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
console.log(`Profile: ${target.profile}`)
|
|
408
|
+
console.log(`Provider: ${PROVIDER_PACKAGE_NAME}@${target.version} (tag ${options.tag})`)
|
|
409
|
+
console.log(`Patched: ${indexPath}`)
|
|
410
|
+
console.log(`Backup: ${backupFile}`)
|
|
411
|
+
console.log(`Log dir: ${runLogDirectory(dshHome)}`)
|
|
412
|
+
console.log('Restart DSH to load the patched provider.')
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Verify the installed bundle in its real provider directory.
|
|
417
|
+
* @param {string} providerDir - provider package real directory.
|
|
418
|
+
* @returns {void}
|
|
419
|
+
*/
|
|
420
|
+
function verifyInstalled(providerDir) {
|
|
421
|
+
const result = spawnSync(
|
|
422
|
+
process.execPath,
|
|
423
|
+
['--input-type=module', '-e', VERIFY_SCRIPT],
|
|
424
|
+
{ cwd: providerDir, encoding: 'utf8', timeout: VERIFY_TIMEOUT_MS },
|
|
425
|
+
)
|
|
426
|
+
if (result.error !== undefined && result.error !== null) {
|
|
427
|
+
throw new Error(`patch verification could not run: ${result.error.message}`)
|
|
428
|
+
}
|
|
429
|
+
if (result.status !== 0) {
|
|
430
|
+
const detail = (result.stderr || result.stdout || '').trim()
|
|
431
|
+
throw new Error(`patch verification failed${detail.length === 0 ? '' : `:\n${detail}`}`)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Restore the newest backup for one profile.
|
|
437
|
+
* @param {{ options: ReturnType<typeof parseArgs>, dshHome: string, profile: string }} input - restore facts.
|
|
438
|
+
* @returns {Promise<void>}
|
|
439
|
+
*/
|
|
440
|
+
async function restoreProfile({ options, dshHome, profile }) {
|
|
441
|
+
const records = await listRecords(backupsRoot(dshHome), profile)
|
|
442
|
+
if (records.length === 0) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`profile "${profile}": no patch backup records found under ${backupsRoot(dshHome)}`,
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
const newest = records[0]
|
|
448
|
+
const record = newest.record
|
|
449
|
+
if (record.restoredAt !== undefined && record.restoredAt !== null) {
|
|
450
|
+
console.log(`Profile ${profile}: already restored at ${record.restoredAt}; nothing to do.`)
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
const indexPath = join(record.providerDir, 'lib', 'index.js')
|
|
454
|
+
if (options.dryRun) {
|
|
455
|
+
console.log(`[dry-run] Would restore ${record.backupFile} to ${indexPath}`)
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
await stat(record.backupFile)
|
|
460
|
+
} catch {
|
|
461
|
+
throw new Error(
|
|
462
|
+
`profile "${profile}": backup file ${record.backupFile} no longer exists`,
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
await stat(record.providerDir)
|
|
467
|
+
} catch {
|
|
468
|
+
throw new Error(
|
|
469
|
+
`profile "${profile}": provider directory ${record.providerDir} no longer exists; `
|
|
470
|
+
+ `restore the backup manually from ${record.backupFile}`,
|
|
471
|
+
)
|
|
472
|
+
}
|
|
473
|
+
await mkdir(dirname(indexPath), { recursive: true })
|
|
474
|
+
await copyFile(record.backupFile, indexPath)
|
|
475
|
+
await chmod(indexPath, 0o644)
|
|
476
|
+
|
|
477
|
+
record.restoredAt = new Date().toISOString()
|
|
478
|
+
await writeFile(newest.recordPath, `${JSON.stringify(record, null, 2)}\n`)
|
|
479
|
+
|
|
480
|
+
console.log(`Profile: ${profile}`)
|
|
481
|
+
console.log(`Restored: ${indexPath}`)
|
|
482
|
+
console.log(`From: ${record.backupFile}`)
|
|
483
|
+
console.log('Restart DSH to unload the patched provider.')
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* List install records for one profile, newest first.
|
|
488
|
+
* @param {string} root - backup root directory.
|
|
489
|
+
* @param {string} profile - profile name.
|
|
490
|
+
* @returns {Promise<Array<{ timestamp: string, recordPath: string, record: Record<string, any> }>>}
|
|
491
|
+
*/
|
|
492
|
+
async function listRecords(root, profile) {
|
|
493
|
+
let entries
|
|
494
|
+
try {
|
|
495
|
+
entries = await readdir(root, { withFileTypes: true })
|
|
496
|
+
} catch {
|
|
497
|
+
return []
|
|
498
|
+
}
|
|
499
|
+
const records = []
|
|
500
|
+
for (const entry of entries) {
|
|
501
|
+
if (!entry.isDirectory()) continue
|
|
502
|
+
const recordPath = join(root, entry.name, profile, 'install.json')
|
|
503
|
+
try {
|
|
504
|
+
const record = JSON.parse(await readFile(recordPath, 'utf8'))
|
|
505
|
+
records.push({ timestamp: entry.name, recordPath, record })
|
|
506
|
+
} catch {
|
|
507
|
+
// A partial or unrelated timestamp directory carries no usable record.
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
records.sort((left, right) => {
|
|
511
|
+
if (left.timestamp === right.timestamp) return 0
|
|
512
|
+
return left.timestamp < right.timestamp ? 1 : -1
|
|
513
|
+
})
|
|
514
|
+
return records
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Print CLI usage. */
|
|
518
|
+
function printHelp() {
|
|
519
|
+
console.log(`Usage: dsh-external-task-context-patch [options]
|
|
520
|
+
|
|
521
|
+
Options:
|
|
522
|
+
--dsh-home <path> DSH home (default: $DSH_HOME, else ~/.dsh)
|
|
523
|
+
--profile <name> Profile to patch; repeatable. Without it, every profile
|
|
524
|
+
with the provider installed is required to be unique
|
|
525
|
+
unless --yes is passed.
|
|
526
|
+
--tag <git-tag> deepseek-harness release tag (default: dsh-v0.2.2-rc.1)
|
|
527
|
+
--source <dir> Offline provider src directory with index.ts, run.ts,
|
|
528
|
+
and process.ts; skips the GitHub download
|
|
529
|
+
--dry-run Download, build, and verify without touching profiles
|
|
530
|
+
--force Install despite a version/tag mismatch or an existing
|
|
531
|
+
patch record for the same tag
|
|
532
|
+
--restore Restore the newest backup for the selected profile(s)
|
|
533
|
+
--yes Process all discovered profiles non-interactively
|
|
534
|
+
-h, --help Show this help
|
|
535
|
+
`)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
await main(process.argv.slice(2))
|
|
540
|
+
} catch (error) {
|
|
541
|
+
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
|
542
|
+
process.exitCode = 1
|
|
543
|
+
}
|