@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/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
+ }