@bruc3van/dsh-doctor 0.1.5 → 0.5.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.en.md +80 -84
- package/README.md +130 -82
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/behavior.md +12 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/config-rules.json +36 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/manifest.json +19 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/packages.json +62 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/services.json +28 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/symbols.json +126 -0
- package/package.json +6 -3
- package/skills/dsh-plugin-upgrade/SKILL.md +98 -0
- package/skills/dsh-plugin-upgrade/evals/evals.json +36 -0
- package/skills/dsh-plugin-upgrade/references/migration-map.md +32 -0
- package/skills/dsh-plugin-upgrade/references/verification.md +27 -0
- package/src/baseline.mjs +69 -0
- package/src/cli.mjs +272 -85
- package/src/config-model.mjs +167 -0
- package/src/doctor.mjs +235 -31
- package/src/i18n.mjs +18 -0
- package/src/migrate-verify.mjs +195 -0
- package/src/migrate.mjs +418 -0
- package/src/migration-catalog.mjs +60 -0
- package/src/recovery.mjs +358 -0
- package/src/redact.mjs +46 -0
- package/src/registry.mjs +61 -0
- package/src/safe-write.mjs +50 -0
package/src/doctor.mjs
CHANGED
|
@@ -6,7 +6,12 @@ import yaml from 'js-yaml'
|
|
|
6
6
|
import semver from 'semver'
|
|
7
7
|
import { parseDocument } from 'yaml'
|
|
8
8
|
import { languageName, localizedFinding } from './i18n.mjs'
|
|
9
|
+
import { buildConfigurationModel } from './config-model.mjs'
|
|
10
|
+
import { buildPluginDiagnoses } from './recovery.mjs'
|
|
9
11
|
|
|
12
|
+
// Mirrors packages/client/web/src/platform.ts in DeepSeek Harness. Installed
|
|
13
|
+
// DSH packages do not expose this source list, so update this baseline when
|
|
14
|
+
// Harness changes PLATFORM_MODULES.
|
|
10
15
|
export const PLATFORM_MODULES = new Set([
|
|
11
16
|
'react',
|
|
12
17
|
'react/jsx-runtime',
|
|
@@ -45,6 +50,13 @@ function finding(severity, code, message, options = {}) {
|
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
function yamlErrorLocation(error) {
|
|
54
|
+
const mark = error?.mark
|
|
55
|
+
return Number.isInteger(mark?.line) && Number.isInteger(mark?.column)
|
|
56
|
+
? `at line ${String(mark.line + 1)}, column ${String(mark.column + 1)}`
|
|
57
|
+
: 'parse error'
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
function readJson(file, subject, findings) {
|
|
49
61
|
let text
|
|
50
62
|
try {
|
|
@@ -168,11 +180,12 @@ function manifestBin(packageDir) {
|
|
|
168
180
|
}
|
|
169
181
|
}
|
|
170
182
|
|
|
171
|
-
function
|
|
183
|
+
function localDshBin(start) {
|
|
172
184
|
let current = resolve(start)
|
|
173
185
|
while (true) {
|
|
174
186
|
const packageDir = join(current, 'node_modules', '@deepseek-ai', 'dsh')
|
|
175
|
-
|
|
187
|
+
const bin = manifestBin(packageDir)
|
|
188
|
+
if (bin !== undefined) return bin
|
|
176
189
|
const parent = dirname(current)
|
|
177
190
|
if (parent === current) return undefined
|
|
178
191
|
current = parent
|
|
@@ -218,10 +231,9 @@ function resolveDshCli(options, harness, home) {
|
|
|
218
231
|
if (bin !== undefined) return { command: [process.execPath, bin.bin], path: bin.bin, source: 'profile', version: bin.version }
|
|
219
232
|
}
|
|
220
233
|
|
|
221
|
-
const
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
return { command: [process.execPath, bin.bin], path: bin.bin, source: 'project', version: bin.version }
|
|
234
|
+
const localBin = localDshBin(cwd)
|
|
235
|
+
if (localBin !== undefined) {
|
|
236
|
+
return { command: [process.execPath, localBin.bin], path: localBin.bin, source: 'project', version: localBin.version }
|
|
225
237
|
}
|
|
226
238
|
|
|
227
239
|
const pathCommand = executableOnPath('dsh', env)
|
|
@@ -422,6 +434,7 @@ function codePositions(source) {
|
|
|
422
434
|
const code = new Uint8Array(source.length)
|
|
423
435
|
let state = 'code'
|
|
424
436
|
let regexClass = false
|
|
437
|
+
const templateExpressions = []
|
|
425
438
|
for (let index = 0; index < source.length; index += 1) {
|
|
426
439
|
const char = source[index]
|
|
427
440
|
const next = source[index + 1]
|
|
@@ -432,11 +445,25 @@ function codePositions(source) {
|
|
|
432
445
|
} else if (char === '/' && next === '*') {
|
|
433
446
|
state = 'block-comment'
|
|
434
447
|
index += 1
|
|
435
|
-
} else if (char === "'" || char === '"'
|
|
448
|
+
} else if (char === "'" || char === '"') {
|
|
436
449
|
state = char
|
|
450
|
+
} else if (char === '`') {
|
|
451
|
+
state = 'template'
|
|
437
452
|
} else if (char === '/' && regexCanStart(source, index)) {
|
|
438
453
|
state = 'regex'
|
|
439
454
|
regexClass = false
|
|
455
|
+
} else if (templateExpressions.length > 0 && char === '{') {
|
|
456
|
+
templateExpressions[templateExpressions.length - 1] += 1
|
|
457
|
+
code[index] = 1
|
|
458
|
+
} else if (templateExpressions.length > 0 && char === '}') {
|
|
459
|
+
const last = templateExpressions.length - 1
|
|
460
|
+
templateExpressions[last] -= 1
|
|
461
|
+
if (templateExpressions[last] === 0) {
|
|
462
|
+
templateExpressions.pop()
|
|
463
|
+
state = 'template'
|
|
464
|
+
} else {
|
|
465
|
+
code[index] = 1
|
|
466
|
+
}
|
|
440
467
|
} else {
|
|
441
468
|
code[index] = 1
|
|
442
469
|
}
|
|
@@ -456,6 +483,14 @@ function codePositions(source) {
|
|
|
456
483
|
else if (char === ']') regexClass = false
|
|
457
484
|
else if (char === '/' && !regexClass) state = 'code'
|
|
458
485
|
else if (char === '\n' || char === '\r') state = 'code'
|
|
486
|
+
} else if (state === 'template') {
|
|
487
|
+
if (char === '\\') index += 1
|
|
488
|
+
else if (char === '`') state = 'code'
|
|
489
|
+
else if (char === '$' && next === '{') {
|
|
490
|
+
templateExpressions.push(1)
|
|
491
|
+
state = 'code'
|
|
492
|
+
index += 1
|
|
493
|
+
}
|
|
459
494
|
} else if (char === '\\') {
|
|
460
495
|
index += 1
|
|
461
496
|
} else if (char === state) {
|
|
@@ -484,6 +519,39 @@ function stringArray(value) {
|
|
|
484
519
|
return Array.isArray(value) && value.every(item => typeof item === 'string') ? value : undefined
|
|
485
520
|
}
|
|
486
521
|
|
|
522
|
+
function packageMainExport(manifest) {
|
|
523
|
+
const exported = manifest?.exports?.['.']
|
|
524
|
+
if (typeof exported === 'string') return exported
|
|
525
|
+
if (objectRecord(exported) !== undefined) {
|
|
526
|
+
for (const key of ['import', 'default', 'require']) {
|
|
527
|
+
if (typeof exported[key] === 'string') return exported[key]
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return typeof manifest?.main === 'string' ? manifest.main : undefined
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function inspectRuntimeServiceProvider(record) {
|
|
534
|
+
const candidates = [...new Set([packageMainExport(record.manifest), clientExport(record.manifest)].filter(value => typeof value === 'string'))]
|
|
535
|
+
const evidence = []
|
|
536
|
+
for (const exported of candidates) {
|
|
537
|
+
const file = safePackageFile(record.directory, exported)
|
|
538
|
+
if (file === undefined || !regularFile(file)) continue
|
|
539
|
+
try {
|
|
540
|
+
if (statSync(file).size > 16 * 1024 * 1024) continue
|
|
541
|
+
const source = readFileSync(file, 'utf8')
|
|
542
|
+
const code = codePositions(source)
|
|
543
|
+
const patterns = [
|
|
544
|
+
/\b(?:ctx|context)\s*\.\s*provide\s*\(/g,
|
|
545
|
+
/\bclass\s+[A-Za-z_$][\w$]*\s+extends\s+Service\b/g,
|
|
546
|
+
]
|
|
547
|
+
if (patterns.some(pattern => [...source.matchAll(pattern)].some(match => code[match.index]))) evidence.push(file)
|
|
548
|
+
} catch {
|
|
549
|
+
// Existing package and client checks own unreadable-artifact findings.
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return evidence.length === 0 ? { status: 'not-detected' } : { status: 'detected', evidence }
|
|
553
|
+
}
|
|
554
|
+
|
|
487
555
|
function inspectClientPackage(record, context) {
|
|
488
556
|
const {
|
|
489
557
|
commandRepair, findings, harnessPackages, harnessPackagesAuthoritative, profile, resolvePackage,
|
|
@@ -663,7 +731,9 @@ function inspectPatchFile(file, subject, findings, packageName) {
|
|
|
663
731
|
} catch (error) {
|
|
664
732
|
findings.push(finding('error', 'INVALID_PATCH_YAML', `${subject} cannot be parsed.`, {
|
|
665
733
|
package: packageName,
|
|
666
|
-
|
|
734
|
+
// js-yaml messages include a source excerpt, which may contain plugin
|
|
735
|
+
// credentials. Preserve only the location needed to repair the syntax.
|
|
736
|
+
evidence: `${file}: ${yamlErrorLocation(error)}`,
|
|
667
737
|
suggestion: 'Repair the YAML syntax before starting this profile.',
|
|
668
738
|
}))
|
|
669
739
|
return undefined
|
|
@@ -1084,7 +1154,8 @@ function inspectCompatibility(record, context) {
|
|
|
1084
1154
|
}))
|
|
1085
1155
|
continue
|
|
1086
1156
|
}
|
|
1087
|
-
const supplier = harnessPackages.get(name)
|
|
1157
|
+
const supplier = harnessPackages.get(name)
|
|
1158
|
+
?? (harnessPackagesAuthoritative ? undefined : resolvePackage(name))
|
|
1088
1159
|
if (supplier === undefined) continue
|
|
1089
1160
|
const version = supplier.manifest?.version
|
|
1090
1161
|
if (typeof version !== 'string' || semver.valid(version) === null) continue
|
|
@@ -1295,6 +1366,11 @@ export function diagnose(options = {}) {
|
|
|
1295
1366
|
operation: { type: 'add-bundle', name },
|
|
1296
1367
|
},
|
|
1297
1368
|
}))
|
|
1369
|
+
findings.push(finding('warning', 'BUNDLE_PROFILE_DECLARATION_CONFLICT', `${name} declares a bundle layer but the profile does not activate it.`, {
|
|
1370
|
+
package: name,
|
|
1371
|
+
evidence: profileManifestFile,
|
|
1372
|
+
suggestion: 'Reconcile the direct dependency and dsh.profile.bundles declaration with the active DSH CLI.',
|
|
1373
|
+
}))
|
|
1298
1374
|
}
|
|
1299
1375
|
}
|
|
1300
1376
|
const patchLayers = []
|
|
@@ -1302,7 +1378,7 @@ export function diagnose(options = {}) {
|
|
|
1302
1378
|
for (const name of bundleNames) {
|
|
1303
1379
|
const layer = inspectBundle(name, bundleRecords.get(name), findings)
|
|
1304
1380
|
if (layer === undefined) patchCompositionValid = false
|
|
1305
|
-
else patchLayers.push(layer)
|
|
1381
|
+
else patchLayers.push({ ...layer, kind: 'bundle' })
|
|
1306
1382
|
}
|
|
1307
1383
|
|
|
1308
1384
|
for (const [file, subject, label] of [
|
|
@@ -1310,10 +1386,87 @@ export function diagnose(options = {}) {
|
|
|
1310
1386
|
[join(home, 'cordis.patch.yml'), 'home patch', 'home patch'],
|
|
1311
1387
|
]) {
|
|
1312
1388
|
const patches = inspectPatchFileIfPresent(file, subject, findings)
|
|
1313
|
-
if (patches !== undefined) patchLayers.push({
|
|
1389
|
+
if (patches !== undefined) patchLayers.push({
|
|
1390
|
+
file,
|
|
1391
|
+
label,
|
|
1392
|
+
patches,
|
|
1393
|
+
kind: label === 'profile patch' ? 'profile' : 'home',
|
|
1394
|
+
})
|
|
1314
1395
|
else if (existsSync(file)) patchCompositionValid = false
|
|
1315
1396
|
}
|
|
1397
|
+
for (const [index, value] of (options.patchFiles ?? []).entries()) {
|
|
1398
|
+
const file = resolve(expandUserPath(value))
|
|
1399
|
+
const patches = inspectPatchFile(file, `CLI overlay ${String(index + 1)}`, findings)
|
|
1400
|
+
if (patches !== undefined) patchLayers.push({ file, label: `CLI overlay ${String(index + 1)}`, patches, kind: 'overlay' })
|
|
1401
|
+
else patchCompositionValid = false
|
|
1402
|
+
}
|
|
1316
1403
|
if (patchCompositionValid) inspectPatchComposition(patchLayers, findings)
|
|
1404
|
+
const configuration = buildConfigurationModel(patchLayers, issue => {
|
|
1405
|
+
const owner = issue.layer?.package ?? issue.details?.origin?.package ?? issue.entry?.name
|
|
1406
|
+
const evidence = issue.layer === undefined
|
|
1407
|
+
? issue.details?.ids?.join(', ')
|
|
1408
|
+
: `${issue.layer.file}: entry ${String((issue.patchIndex ?? 0) + 1)}`
|
|
1409
|
+
const messages = {
|
|
1410
|
+
DUPLICATE_ENTRY_ID: `Entry id ${issue.details.id} is inserted more than once in the effective configuration.`,
|
|
1411
|
+
DUPLICATE_PLUGIN_MOUNT: `${issue.entry.name} is mounted by multiple active entries.`,
|
|
1412
|
+
CORE_ENTRY_DISABLED_BY_HIGHER_LAYER: `A higher configuration layer disables entry ${issue.entry.id}.`,
|
|
1413
|
+
ENTRY_STRUCTURE_OVERRIDDEN: `A higher configuration layer changes the structure of entry ${issue.entry.id}.`,
|
|
1414
|
+
INVALID_GROUP_CONFIG: `Group entry ${issue.entry.id} has a non-array config value.`,
|
|
1415
|
+
GROUP_CONTENT_REPLACED: `A higher configuration layer replaces the complete group content of entry ${issue.entry.id}.`,
|
|
1416
|
+
CONFIG_REPLACED_BY_HIGHER_LAYER: `A higher configuration layer replaces the complete config of entry ${issue.entry.id}.`,
|
|
1417
|
+
}
|
|
1418
|
+
findings.push(finding(['DUPLICATE_ENTRY_ID', 'INVALID_GROUP_CONFIG'].includes(issue.code) ? 'error' : 'warning', issue.code, messages[issue.code], {
|
|
1419
|
+
package: owner,
|
|
1420
|
+
evidence,
|
|
1421
|
+
details: issue.details,
|
|
1422
|
+
suggestion: 'Review the named configuration layer and keep one unambiguous source for this entry.',
|
|
1423
|
+
}))
|
|
1424
|
+
})
|
|
1425
|
+
configuration.profile = profile
|
|
1426
|
+
configuration.layerDetails = patchLayers.map(layer => {
|
|
1427
|
+
const insertedIds = new Set()
|
|
1428
|
+
const collect = entries => entries.forEach(entry => {
|
|
1429
|
+
if (typeof entry.id === 'string') insertedIds.add(entry.id)
|
|
1430
|
+
if (entry.group && Array.isArray(entry.config)) collect(entry.config)
|
|
1431
|
+
})
|
|
1432
|
+
layer.patches.filter(patch => Array.isArray(patch.insert)).forEach(patch => collect(patch.insert))
|
|
1433
|
+
return {
|
|
1434
|
+
kind: layer.kind,
|
|
1435
|
+
file: layer.file,
|
|
1436
|
+
package: layer.package,
|
|
1437
|
+
insertCount: layer.patches.filter(patch => Array.isArray(patch.insert)).length,
|
|
1438
|
+
overrideCount: layer.patches.filter(patch => !Array.isArray(patch.insert) && !insertedIds.has(patch.id)).length,
|
|
1439
|
+
}
|
|
1440
|
+
})
|
|
1441
|
+
configuration.patchReferences = patchLayers.flatMap(layer => layer.patches.flatMap((patch, patchIndex) =>
|
|
1442
|
+
!Array.isArray(patch.insert) && typeof patch.id === 'string'
|
|
1443
|
+
? [{ id: patch.id, kind: layer.kind, file: layer.file, package: layer.package, patchIndex }]
|
|
1444
|
+
: []))
|
|
1445
|
+
if (patchCompositionValid) {
|
|
1446
|
+
const defaultConfiguration = buildConfigurationModel(patchLayers.filter(layer => layer.kind === 'bundle'))
|
|
1447
|
+
const knownIds = new Set(defaultConfiguration.entries.map(entry => entry.id).filter(id => typeof id === 'string' && id.length > 0))
|
|
1448
|
+
const knownGroups = new Set(defaultConfiguration.entries
|
|
1449
|
+
.filter(entry => entry.group === true && typeof entry.id === 'string' && entry.id.length > 0)
|
|
1450
|
+
.map(entry => entry.id))
|
|
1451
|
+
const indexInserted = entries => entries.forEach(entry => {
|
|
1452
|
+
if (typeof entry.id === 'string' && entry.id.length > 0) knownIds.add(entry.id)
|
|
1453
|
+
if (entry.group === true && typeof entry.id === 'string' && entry.id.length > 0) knownGroups.add(entry.id)
|
|
1454
|
+
if (entry.group === true && Array.isArray(entry.config)) indexInserted(entry.config)
|
|
1455
|
+
})
|
|
1456
|
+
for (const layer of patchLayers.filter(item => item.kind !== 'bundle')) {
|
|
1457
|
+
layer.patches.forEach((patch, patchIndex) => {
|
|
1458
|
+
if (Array.isArray(patch.insert)) {
|
|
1459
|
+
if (typeof patch.id !== 'string' || patch.id.length === 0 || knownGroups.has(patch.id)) indexInserted(patch.insert)
|
|
1460
|
+
return
|
|
1461
|
+
}
|
|
1462
|
+
if (typeof patch.id !== 'string' || knownIds.has(patch.id)) return
|
|
1463
|
+
findings.push(finding('warning', 'PATCH_INCOMPATIBLE_WITH_CURRENT_DSH', `${layer.label} still targets entry ${patch.id}, which is absent from the current default DSH tree.`, {
|
|
1464
|
+
evidence: `${layer.file}: entry ${String(patchIndex + 1)}`,
|
|
1465
|
+
suggestion: 'Adjust or remove this patch after comparing it with the current default configuration.',
|
|
1466
|
+
}))
|
|
1467
|
+
})
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1317
1470
|
inspectSettings(join(home, 'settings.yaml'), findings)
|
|
1318
1471
|
inspectCredentials(join(home, '.credentials.yaml'), findings)
|
|
1319
1472
|
|
|
@@ -1346,32 +1499,65 @@ export function diagnose(options = {}) {
|
|
|
1346
1499
|
)
|
|
1347
1500
|
}
|
|
1348
1501
|
|
|
1502
|
+
const activeHarnessVersions = Object.fromEntries([...harness.packages].flatMap(([name, record]) =>
|
|
1503
|
+
typeof record.manifest?.version === 'string' ? [[name, record.manifest.version]] : []))
|
|
1504
|
+
if (typeof harness.version === 'string') activeHarnessVersions['@deepseek-ai/dsh'] ??= harness.version
|
|
1505
|
+
for (const record of thirdPartyRecords) {
|
|
1506
|
+
const peers = objectRecord(record.manifest.peerDependencies)
|
|
1507
|
+
if (peers === undefined) continue
|
|
1508
|
+
for (const name of Object.keys(peers)) {
|
|
1509
|
+
if (name !== 'cordis' && !name.startsWith('@deepseek-ai/')) continue
|
|
1510
|
+
const supplier = harness.packages.get(name)
|
|
1511
|
+
?? (harness.authoritative ? undefined : resolvePackage(name))
|
|
1512
|
+
if (typeof supplier?.manifest?.version === 'string') activeHarnessVersions[name] = supplier.manifest.version
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
const packages = dependencyNames.map(name => {
|
|
1517
|
+
const record = records.get(name)
|
|
1518
|
+
if (record === undefined) return { name, installed: false, compatibility: 'incompatible' }
|
|
1519
|
+
return {
|
|
1520
|
+
name: record.requestedName ?? record.manifest.name,
|
|
1521
|
+
version: record.manifest.version,
|
|
1522
|
+
directory: record.directory,
|
|
1523
|
+
installed: true,
|
|
1524
|
+
client: record.manifest?.dsh?.client !== undefined,
|
|
1525
|
+
clientExternal: stringArray(record.manifest?.dsh?.client?.external) ?? [],
|
|
1526
|
+
clientInject: stringArray(record.manifest?.dsh?.client?.inject) ?? [],
|
|
1527
|
+
runtimeServiceProvider: inspectRuntimeServiceProvider(record),
|
|
1528
|
+
bundle: record.manifest?.dsh?.bundle !== undefined,
|
|
1529
|
+
compatibility: pluginCompatibility(
|
|
1530
|
+
record,
|
|
1531
|
+
findings,
|
|
1532
|
+
compatibilityChecks.get(record.requestedName ?? record.manifest.name),
|
|
1533
|
+
),
|
|
1534
|
+
}
|
|
1535
|
+
})
|
|
1536
|
+
const pluginDiagnoses = buildPluginDiagnoses({
|
|
1537
|
+
packages,
|
|
1538
|
+
findings,
|
|
1539
|
+
configuration,
|
|
1540
|
+
bundleNames,
|
|
1541
|
+
profileManifest,
|
|
1542
|
+
lockfile,
|
|
1543
|
+
dshCli: { available: dshCli !== undefined },
|
|
1544
|
+
})
|
|
1349
1545
|
return finish({
|
|
1350
1546
|
home,
|
|
1351
1547
|
profile,
|
|
1352
1548
|
profileDir,
|
|
1353
|
-
harness: {
|
|
1549
|
+
harness: {
|
|
1550
|
+
root: harness.root,
|
|
1551
|
+
version: harness.version,
|
|
1552
|
+
packages: activeHarnessVersions,
|
|
1553
|
+
},
|
|
1354
1554
|
lockfile,
|
|
1355
1555
|
dshCli: dshCli === undefined
|
|
1356
1556
|
? { available: false, commandRepairNeeded }
|
|
1357
1557
|
: { available: true, commandRepairNeeded, ...dshCli },
|
|
1358
|
-
packages
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
return {
|
|
1362
|
-
name: record.requestedName ?? record.manifest.name,
|
|
1363
|
-
version: record.manifest.version,
|
|
1364
|
-
directory: record.directory,
|
|
1365
|
-
installed: true,
|
|
1366
|
-
client: record.manifest?.dsh?.client !== undefined,
|
|
1367
|
-
bundle: record.manifest?.dsh?.bundle !== undefined,
|
|
1368
|
-
compatibility: pluginCompatibility(
|
|
1369
|
-
record,
|
|
1370
|
-
findings,
|
|
1371
|
-
compatibilityChecks.get(record.requestedName ?? record.manifest.name),
|
|
1372
|
-
),
|
|
1373
|
-
}
|
|
1374
|
-
}),
|
|
1558
|
+
packages,
|
|
1559
|
+
configuration,
|
|
1560
|
+
pluginDiagnoses,
|
|
1375
1561
|
}, findings)
|
|
1376
1562
|
}
|
|
1377
1563
|
|
|
@@ -1392,7 +1578,7 @@ function finish(context, findings) {
|
|
|
1392
1578
|
compatible: context.packages.filter(item => item.compatibility === 'compatible').length,
|
|
1393
1579
|
}
|
|
1394
1580
|
context = { ...context, compatibility }
|
|
1395
|
-
return { version:
|
|
1581
|
+
return { version: 2, ok: summary.errors === 0, context, summary, findings }
|
|
1396
1582
|
}
|
|
1397
1583
|
|
|
1398
1584
|
function appendReportField(lines, label, value, indent = ' ') {
|
|
@@ -1425,7 +1611,7 @@ export function formatReport(report, options = {}) {
|
|
|
1425
1611
|
: zh ? '未找到(命令型修复已禁用)' : 'not found (command-based repairs disabled)'
|
|
1426
1612
|
const lines = [
|
|
1427
1613
|
'DSH Doctor',
|
|
1428
|
-
|
|
1614
|
+
`Profile: ${report.context.profile}`,
|
|
1429
1615
|
`${zh ? 'DSH 主目录' : 'Home'}: ${report.context.home}`,
|
|
1430
1616
|
`${zh ? '当前使用的 DSH' : 'Active DSH'}: ${report.context.harness.version ?? 'unknown'}${report.context.harness.root ? ` (${report.context.harness.root})` : ''}`,
|
|
1431
1617
|
`${zh ? 'DSH CLI' : 'DSH CLI'}: ${cliText}`,
|
|
@@ -1437,6 +1623,7 @@ export function formatReport(report, options = {}) {
|
|
|
1437
1623
|
'',
|
|
1438
1624
|
]
|
|
1439
1625
|
const packageNames = new Set(report.context.packages.map(item => item.name))
|
|
1626
|
+
const diagnoses = new Map((report.context.pluginDiagnoses ?? []).map(item => [item.name, item]))
|
|
1440
1627
|
const pluginFindings = new Map()
|
|
1441
1628
|
const environmentFindings = []
|
|
1442
1629
|
for (const item of report.findings) {
|
|
@@ -1475,6 +1662,14 @@ export function formatReport(report, options = {}) {
|
|
|
1475
1662
|
appendReportField(lines, zh ? '版本' : 'Version', plugin.installed === false
|
|
1476
1663
|
? zh ? '未安装' : 'not installed'
|
|
1477
1664
|
: plugin.version ?? (zh ? '未知' : 'unknown'))
|
|
1665
|
+
const diagnosis = diagnoses.get(plugin.name)
|
|
1666
|
+
if (diagnosis !== undefined) {
|
|
1667
|
+
appendReportField(lines, zh ? '原因类型' : 'Cause', diagnosis.cause.type)
|
|
1668
|
+
appendReportField(lines, zh ? '配置来源' : 'Configuration origin', diagnosis.configuration.originLayer)
|
|
1669
|
+
appendReportField(lines, zh ? '兼容版本' : 'Compatible version', diagnosis.update.status === 'not-checked'
|
|
1670
|
+
? zh ? '未检查 registry' : 'registry not checked'
|
|
1671
|
+
: diagnosis.update.status)
|
|
1672
|
+
}
|
|
1478
1673
|
lines.push(zh
|
|
1479
1674
|
? ` 问题(${String(originals.length)}):`
|
|
1480
1675
|
: ` Problems (${String(originals.length)}):`)
|
|
@@ -1506,6 +1701,15 @@ export function formatReport(report, options = {}) {
|
|
|
1506
1701
|
lines.push(` ${zh ? '可执行命令' : 'Available commands'}:`)
|
|
1507
1702
|
commands.forEach(command => lines.push(` $ ${command}`))
|
|
1508
1703
|
}
|
|
1704
|
+
if (diagnosis !== undefined) {
|
|
1705
|
+
lines.push(` ${zh ? '恢复选择' : 'Recovery options'}:`)
|
|
1706
|
+
for (const option of diagnosis.recovery.options) {
|
|
1707
|
+
lines.push(` - ${option.kind}: ${option.availability}${option.recommended ? (zh ? '(推荐)' : ' (recommended)') : ''}`)
|
|
1708
|
+
}
|
|
1709
|
+
if (diagnosis.recovery.options.some(option => option.kind === 'remove')) {
|
|
1710
|
+
lines.push(` ${zh ? '动态 Service 依赖:未知,静态检查不能证明删除不影响其他功能。' : 'Dynamic Service dependencies: unknown; static checks cannot prove removal leaves every feature working.'}`)
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1509
1713
|
lines.push('')
|
|
1510
1714
|
}
|
|
1511
1715
|
}
|
package/src/i18n.mjs
CHANGED
|
@@ -87,6 +87,15 @@ const ZH_MESSAGES = {
|
|
|
87
87
|
PATCH_TARGET_NOT_FOUND: item => `Patch 指向了不存在的配置行:${captured(item.message, / row ([^.]+)\.$/) ?? '未知'}。`,
|
|
88
88
|
PATCH_TARGET_NOT_GROUP: item => `Patch 尝试向非 group 配置行 ${captured(item.message, / row ([^,]+),/) ?? '未知'} 插入内容。`,
|
|
89
89
|
PATCH_NAME_MISMATCH: () => 'Patch 的 name 断言与目标配置行不一致。',
|
|
90
|
+
PATCH_INCOMPATIBLE_WITH_CURRENT_DSH: item => `配置层仍指向当前 DSH 默认树中不存在的行:${captured(item.message, /targets entry ([^,]+),/) ?? '未知'}。`,
|
|
91
|
+
DUPLICATE_ENTRY_ID: () => '生效配置中存在重复的 entry id。',
|
|
92
|
+
DUPLICATE_PLUGIN_MOUNT: item => `${item.package ?? '插件'}由多个启用状态的 entry 重复挂载。`,
|
|
93
|
+
CORE_ENTRY_DISABLED_BY_HIGHER_LAYER: () => '较高优先级配置层禁用了较低层提供的 entry。',
|
|
94
|
+
ENTRY_STRUCTURE_OVERRIDDEN: () => '较高优先级配置层改变了 entry 的结构。',
|
|
95
|
+
INVALID_GROUP_CONFIG: () => 'group entry 的 config 必须是数组。',
|
|
96
|
+
GROUP_CONTENT_REPLACED: () => '较高优先级配置层替换了 group 的完整内容。',
|
|
97
|
+
CONFIG_REPLACED_BY_HIGHER_LAYER: () => '较高优先级配置层替换了 entry 的完整 config。',
|
|
98
|
+
BUNDLE_PROFILE_DECLARATION_CONFLICT: item => `${item.package} 声明了 bundle 层,但当前 profile 没有启用它。`,
|
|
90
99
|
INVALID_SETTINGS_DOCUMENT: () => 'Harness 设置文件无法解析。',
|
|
91
100
|
INVALID_SETTINGS_ROOT: () => 'Harness 设置文件顶层必须是命名空间映射。',
|
|
92
101
|
INVALID_CREDENTIALS_DOCUMENT: () => 'Harness 凭据文件无法解析。',
|
|
@@ -152,6 +161,15 @@ const ZH_SUGGESTIONS = {
|
|
|
152
161
|
PATCH_TARGET_NOT_FOUND: () => '确认该 overlay 是否适用于当前 profile,并检查 bundle 顺序。',
|
|
153
162
|
PATCH_TARGET_NOT_GROUP: () => '请选择 group 行,或使用根级 insert。',
|
|
154
163
|
PATCH_NAME_MISMATCH: () => '请更新 name 断言,或改为指向正确的配置行。',
|
|
164
|
+
PATCH_INCOMPATIBLE_WITH_CURRENT_DSH: () => '请与当前默认配置对比后调整或移除这个旧 patch。',
|
|
165
|
+
DUPLICATE_ENTRY_ID: () => '请保留一个明确的 entry 来源,并为其他 entry 使用唯一 id。',
|
|
166
|
+
DUPLICATE_PLUGIN_MOUNT: () => '请检查 bundle 与手工 insert,只保留一个需要的插件挂载。',
|
|
167
|
+
CORE_ENTRY_DISABLED_BY_HIGHER_LAYER: () => '请确认该禁用是否仍适用于当前 DSH;需要恢复时撤销较高层覆盖。',
|
|
168
|
+
ENTRY_STRUCTURE_OVERRIDDEN: () => '请检查较高层 patch 是否仍适配当前 entry 结构。',
|
|
169
|
+
INVALID_GROUP_CONFIG: () => '请把 group entry 的 config 修复为 entry 数组。',
|
|
170
|
+
GROUP_CONTENT_REPLACED: () => '请检查被整体替换的 group 内容,避免删除当前 DSH 新增的子项。',
|
|
171
|
+
CONFIG_REPLACED_BY_HIGHER_LAYER: () => '请检查完整 config 替换造成的字段丢失,并按当前默认值调整。',
|
|
172
|
+
BUNDLE_PROFILE_DECLARATION_CONFLICT: () => '请使用当前 DSH CLI 对齐直接依赖与 dsh.profile.bundles。',
|
|
155
173
|
INVALID_SETTINGS_DOCUMENT: () => '修复设置文件语法;Doctor 不会猜测凭据或模型配置值。',
|
|
156
174
|
INVALID_SETTINGS_ROOT: () => '把顶层标量或数组替换为映射。',
|
|
157
175
|
INVALID_CREDENTIALS_DOCUMENT: () => '只修复报告的结构;Doctor 永远不会输出或重写秘密值。',
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { basename, join, resolve } from 'node:path'
|
|
4
|
+
import crossSpawn from 'cross-spawn'
|
|
5
|
+
import { analyzeMigration, publicMigrationReport } from './migrate.mjs'
|
|
6
|
+
import { sha256 } from './safe-write.mjs'
|
|
7
|
+
|
|
8
|
+
function commandResult(command, args, options = {}) {
|
|
9
|
+
const started = Date.now()
|
|
10
|
+
const result = crossSpawn.sync(command, args, {
|
|
11
|
+
cwd: options.cwd,
|
|
12
|
+
env: options.env,
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
timeout: options.timeout ?? 10 * 60 * 1000,
|
|
15
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
16
|
+
})
|
|
17
|
+
return {
|
|
18
|
+
command: [command, ...args],
|
|
19
|
+
status: result.status,
|
|
20
|
+
signal: result.signal,
|
|
21
|
+
durationMs: Date.now() - started,
|
|
22
|
+
stdout: result.stdout ?? '',
|
|
23
|
+
stderr: result.stderr ?? '',
|
|
24
|
+
passed: result.status === 0,
|
|
25
|
+
...(result.error ? { error: result.error.message } : {}),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function commandEvidence(result) {
|
|
30
|
+
const { stdout, stderr, ...evidence } = result
|
|
31
|
+
return {
|
|
32
|
+
...evidence,
|
|
33
|
+
stdoutBytes: Buffer.byteLength(stdout),
|
|
34
|
+
stderrBytes: Buffer.byteLength(stderr),
|
|
35
|
+
stdoutHash: sha256(stdout),
|
|
36
|
+
stderrHash: sha256(stderr),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function packageManager(root) {
|
|
41
|
+
if (existsSync(join(root, 'pnpm-lock.yaml'))) return { command: 'pnpm', run: script => ['run', script], pack: destination => ['pack', '--pack-destination', destination] }
|
|
42
|
+
if (existsSync(join(root, 'yarn.lock'))) return { command: 'yarn', run: script => [script], pack: destination => ['pack', '--out', join(destination, 'plugin.tgz')] }
|
|
43
|
+
return { command: 'npm', run: script => ['run', script], pack: destination => ['pack', '--json', '--pack-destination', destination] }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function discoverDsh(options) {
|
|
47
|
+
if (options.dshCommand) return resolve(options.dshCommand)
|
|
48
|
+
if (options.harnessRoot) {
|
|
49
|
+
const candidate = join(resolve(options.harnessRoot), 'apps', 'cli', 'lib', 'bin.js')
|
|
50
|
+
if (existsSync(candidate)) return candidate
|
|
51
|
+
}
|
|
52
|
+
throw new Error('runtime verification requires --dsh-command or a built --harness-root/apps/cli/lib/bin.js')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function dshInvocation(file, args) {
|
|
56
|
+
return /\.(?:mjs|cjs|js)$/i.test(file) ? [process.execPath, [file, ...args]] : [file, args]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function tarballFromPack(step, destination) {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(step.stdout)
|
|
62
|
+
const filename = Array.isArray(parsed) ? parsed[0]?.filename : parsed?.filename
|
|
63
|
+
if (filename) return join(destination, filename)
|
|
64
|
+
} catch {}
|
|
65
|
+
const match = step.stdout.match(/(?:^|\s)([^\s]+\.tgz)(?:\s|$)/m)
|
|
66
|
+
if (match) return resolve(destination, basename(match[1]))
|
|
67
|
+
const fallback = join(destination, 'plugin.tgz')
|
|
68
|
+
return existsSync(fallback) ? fallback : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function runtimeProfileEvidence(dshHome, pluginName, dumpOutput) {
|
|
72
|
+
const profileDir = join(dshHome, 'profiles', 'web')
|
|
73
|
+
const manifestFile = join(profileDir, 'package.json')
|
|
74
|
+
const installedManifestFile = join(profileDir, 'node_modules', ...pluginName.split('/'), 'package.json')
|
|
75
|
+
try {
|
|
76
|
+
const profile = JSON.parse(readFileSync(manifestFile, 'utf8'))
|
|
77
|
+
const installed = JSON.parse(readFileSync(installedManifestFile, 'utf8'))
|
|
78
|
+
return {
|
|
79
|
+
profileCreated: true,
|
|
80
|
+
dependencyInstalled: profile.dependencies?.[pluginName] !== undefined,
|
|
81
|
+
bundleActivated: Array.isArray(profile.dsh?.profile?.bundles) && profile.dsh.profile.bundles.includes(pluginName),
|
|
82
|
+
installedPackageResolved: installed.name === pluginName,
|
|
83
|
+
effectiveConfigIncludesPlugin: dumpOutput.includes(pluginName),
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return {
|
|
87
|
+
profileCreated: existsSync(manifestFile),
|
|
88
|
+
dependencyInstalled: false,
|
|
89
|
+
bundleActivated: false,
|
|
90
|
+
installedPackageResolved: false,
|
|
91
|
+
effectiveConfigIncludesPlugin: false,
|
|
92
|
+
evidenceError: error instanceof Error ? error.message : String(error),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function verifyMigration(pluginRoot = process.cwd(), options = {}) {
|
|
98
|
+
const level = options.level ?? 'static'
|
|
99
|
+
if (!['static', 'build', 'runtime'].includes(level)) throw new Error(`unsupported verification level ${level}`)
|
|
100
|
+
if (level !== 'static' && options.yes !== true) throw new Error(`migrate verify --level ${level} executes project commands and requires --yes`)
|
|
101
|
+
const analysis = analyzeMigration(pluginRoot, options)
|
|
102
|
+
const result = {
|
|
103
|
+
schemaVersion: 1,
|
|
104
|
+
command: 'migrate verify',
|
|
105
|
+
level,
|
|
106
|
+
plugin: analysis.plugin,
|
|
107
|
+
migration: publicMigrationReport(analysis).migration,
|
|
108
|
+
stages: [{ name: level === 'static' ? 'static' : 'preflight-static', passed: analysis.summary.errors === 0, report: publicMigrationReport(analysis) }],
|
|
109
|
+
status: analysis.summary.errors === 0 ? 'source-migrated' : 'analyzed',
|
|
110
|
+
passed: analysis.summary.errors === 0,
|
|
111
|
+
manualBehaviorVerificationRequired: true,
|
|
112
|
+
}
|
|
113
|
+
if (level === 'static') return result
|
|
114
|
+
|
|
115
|
+
const root = analysis.plugin.root
|
|
116
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
117
|
+
const manager = packageManager(root)
|
|
118
|
+
const scripts = ['typecheck', 'build', 'test', 'pack:check'].filter(name => typeof manifest.scripts?.[name] === 'string')
|
|
119
|
+
const commandRuns = []
|
|
120
|
+
for (const script of scripts) {
|
|
121
|
+
const run = { name: script, ...commandResult(manager.command, manager.run(script), { cwd: root }) }
|
|
122
|
+
commandRuns.push(run)
|
|
123
|
+
if (!run.passed) break
|
|
124
|
+
}
|
|
125
|
+
const commands = commandRuns.map(commandEvidence)
|
|
126
|
+
const artifactProducerDeclared = scripts.includes('build') || scripts.includes('pack:check')
|
|
127
|
+
const commandsPassed = artifactProducerDeclared && commandRuns.length === scripts.length && commandRuns.every(item => item.passed)
|
|
128
|
+
const buildNote = scripts.length === 0
|
|
129
|
+
? 'No recognized verification scripts were declared.'
|
|
130
|
+
: !artifactProducerDeclared ? 'Artifact verification requires a build or pack:check script; typecheck/test alone are insufficient.' : undefined
|
|
131
|
+
result.stages.push({ name: 'build', passed: commandsPassed, packageManager: manager.command, commands, note: buildNote })
|
|
132
|
+
const postBuild = analyzeMigration(root, options)
|
|
133
|
+
const postBuildPassed = postBuild.summary.errors === 0
|
|
134
|
+
result.stages.push({ name: 'static-after-build', passed: postBuildPassed, report: publicMigrationReport(postBuild) })
|
|
135
|
+
const buildPassed = commandsPassed && postBuildPassed
|
|
136
|
+
result.passed = buildPassed
|
|
137
|
+
if (result.passed) result.status = 'artifact-verified'
|
|
138
|
+
if (level === 'build' || !result.passed) return result
|
|
139
|
+
|
|
140
|
+
const dsh = discoverDsh(options)
|
|
141
|
+
const verificationRoot = mkdtempSync(join(tmpdir(), 'dsh-doctor-migrate-'))
|
|
142
|
+
const packDir = join(verificationRoot, 'pack')
|
|
143
|
+
const dshHome = join(verificationRoot, 'dsh-home')
|
|
144
|
+
mkdirSync(packDir, { recursive: true })
|
|
145
|
+
mkdirSync(dshHome, { recursive: true })
|
|
146
|
+
const env = { ...process.env, DSH_HOME: dshHome }
|
|
147
|
+
const [versionCommand, versionArgs] = dshInvocation(dsh, ['--version'])
|
|
148
|
+
const versionRun = commandResult(versionCommand, versionArgs, { cwd: root, env })
|
|
149
|
+
const actualDshVersion = versionRun.stdout.trim()
|
|
150
|
+
const versionPassed = versionRun.passed && actualDshVersion === analysis.migration.to.version
|
|
151
|
+
const pack = commandResult(manager.command, manager.pack(packDir), { cwd: root })
|
|
152
|
+
const tarball = tarballFromPack(pack, packDir)
|
|
153
|
+
const runtimeCommands = []
|
|
154
|
+
if (versionPassed && pack.passed && tarball && existsSync(tarball)) {
|
|
155
|
+
for (const args of [
|
|
156
|
+
['plugin', '--profile', 'web', 'add', tarball],
|
|
157
|
+
['--profile', 'web', '--dump-config'],
|
|
158
|
+
['--profile', 'web', '--help'],
|
|
159
|
+
]) {
|
|
160
|
+
const [command, commandArgs] = dshInvocation(dsh, args)
|
|
161
|
+
const step = commandResult(command, commandArgs, { cwd: root, env })
|
|
162
|
+
runtimeCommands.push(step)
|
|
163
|
+
if (!step.passed) break
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const profileEvidence = runtimeProfileEvidence(dshHome, analysis.plugin.name, runtimeCommands[1]?.stdout ?? '')
|
|
167
|
+
const evidencePassed = Object.entries(profileEvidence).filter(([key]) => key !== 'evidenceError').every(([, value]) => value === true)
|
|
168
|
+
const runtimePassed = versionPassed && pack.passed && tarball !== undefined && runtimeCommands.length === 3 && runtimeCommands.every(item => item.passed) && evidencePassed
|
|
169
|
+
result.stages.push({
|
|
170
|
+
name: 'runtime',
|
|
171
|
+
passed: runtimePassed,
|
|
172
|
+
dshVersion: { expected: analysis.migration.to.version, actual: actualDshVersion, passed: versionPassed, command: commandEvidence(versionRun) },
|
|
173
|
+
pack: commandEvidence(pack),
|
|
174
|
+
tarball,
|
|
175
|
+
commands: runtimeCommands.map(commandEvidence),
|
|
176
|
+
profileEvidence,
|
|
177
|
+
isolatedDshHome: dshHome,
|
|
178
|
+
})
|
|
179
|
+
result.passed = result.passed && runtimePassed
|
|
180
|
+
if (runtimePassed) result.status = 'runtime-verified'
|
|
181
|
+
if (runtimePassed && !options.keepTemp) {
|
|
182
|
+
rmSync(verificationRoot, { recursive: true, force: true })
|
|
183
|
+
result.stages.at(-1).isolatedDshHome = '(removed after successful verification)'
|
|
184
|
+
} else result.retainedTemporaryDirectory = verificationRoot
|
|
185
|
+
return result
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function formatVerification(result, language = 'en') {
|
|
189
|
+
const zh = language === 'zh'
|
|
190
|
+
const lines = [`${zh ? '升级验证' : 'Migration verification'}: ${result.plugin.name}`, `${zh ? '状态' : 'Status'}: ${result.status} (${result.passed ? 'PASS' : 'FAIL'})`]
|
|
191
|
+
for (const stage of result.stages) lines.push(`- ${stage.name}: ${stage.passed ? 'PASS' : 'FAIL'}`)
|
|
192
|
+
if (result.retainedTemporaryDirectory) lines.push(`${zh ? '失败现场保留在' : 'Failure workspace retained at'}: ${result.retainedTemporaryDirectory}`)
|
|
193
|
+
if (result.manualBehaviorVerificationRequired) lines.push(zh ? '仍需人工验证插件的交互、生命周期与业务行为。' : 'Interaction, lifecycle, and business behavior still require manual verification.')
|
|
194
|
+
return `${lines.join('\n')}\n`
|
|
195
|
+
}
|