@bruc3van/dsh-doctor 0.1.6 → 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.
@@ -0,0 +1,358 @@
1
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { isSeq, parseDocument } from 'yaml'
4
+ import yaml from 'js-yaml'
5
+ import semver from 'semver'
6
+ import { atomicWrite, snapshotFile } from './safe-write.mjs'
7
+ import { redactSecrets } from './redact.mjs'
8
+
9
+ const CAUSES = new Map([
10
+ ['HARNESS_PEER_VERSION_MISMATCH', 'plugin-version'],
11
+ ['PROFILE_DEPENDENCY_VERSION_MISMATCH', 'plugin-version'],
12
+ ['LEGACY_HARNESS_PEERS', 'plugin-version'],
13
+ ['LEGACY_HARNESS_DEPENDENCIES', 'plugin-version'],
14
+ ['BUNDLE_PATCH_MISSING', 'plugin-artifact'],
15
+ ['BUNDLE_NOT_INSTALLED', 'plugin-artifact'],
16
+ ['BUNDLE_DECLARATION_MISSING', 'plugin-artifact'],
17
+ ['CLIENT_BUNDLE_MISSING', 'plugin-artifact'],
18
+ ['CLIENT_BUNDLE_UNREADABLE', 'plugin-artifact'],
19
+ ['CLIENT_EXPORT_MISSING', 'client-contract'],
20
+ ['INVALID_CLIENT_DECLARATION', 'client-contract'],
21
+ ['INVALID_CLIENT_PLATFORM', 'client-contract'],
22
+ ['INVALID_CLIENT_IMMEDIATELY', 'client-contract'],
23
+ ['INVALID_CLIENT_EXTERNAL', 'client-contract'],
24
+ ['INVALID_CLIENT_INJECT', 'client-contract'],
25
+ ['CLIENT_EXTERNAL_WITHOUT_SUPPLIER', 'client-contract'],
26
+ ['UNDECLARED_CLIENT_REQUIRE', 'client-contract'],
27
+ ['REDUNDANT_CLIENT_EXTERNAL', 'client-contract'],
28
+ ['REMOVED_CLIENT_INJECT', 'client-contract'],
29
+ ['INVALID_HARNESS_PEER_RANGE', 'plugin-version'],
30
+ ['INVALID_DEPENDENCY_MAP', 'missing-dependency'],
31
+ ['PATCH_TARGET_NOT_FOUND', 'patch-version'],
32
+ ['PATCH_INCOMPATIBLE_WITH_CURRENT_DSH', 'patch-version'],
33
+ ['CORE_ENTRY_DISABLED_BY_HIGHER_LAYER', 'config-override'],
34
+ ['CONFIG_REPLACED_BY_HIGHER_LAYER', 'config-override'],
35
+ ['GROUP_CONTENT_REPLACED', 'config-override'],
36
+ ['BUNDLE_PROFILE_DECLARATION_CONFLICT', 'config-override'],
37
+ ['DUPLICATE_ENTRY_ID', 'duplicate-mount'],
38
+ ['DUPLICATE_PLUGIN_MOUNT', 'duplicate-mount'],
39
+ ['DEPENDENCY_NOT_INSTALLED', 'missing-dependency'],
40
+ ['PLUGIN_NODE_VERSION_MISMATCH', 'runtime-environment'],
41
+ ])
42
+
43
+ function causeFor(findings) {
44
+ const ordered = findings.filter(item => CAUSES.has(item.code))
45
+ const primary = ordered.find(item => item.severity === 'error') ?? ordered[0]
46
+ if (primary === undefined) return { type: 'unknown', confidence: 'unknown', summary: 'No confirmed root cause was identified.' }
47
+ return {
48
+ type: CAUSES.get(primary.code),
49
+ confidence: primary.severity === 'error' || primary.code === 'HARNESS_PEER_VERSION_MISMATCH' ? 'confirmed' : 'likely',
50
+ summary: primary.message,
51
+ findingCodes: [...new Set(ordered.map(item => item.code))],
52
+ }
53
+ }
54
+
55
+ function entriesFor(configuration, packageName) {
56
+ return configuration.entries.filter(entry => entry.name === packageName || entry.origin?.package === packageName)
57
+ }
58
+
59
+ function activeEntriesFor(configuration, packageName) {
60
+ return entriesFor(configuration, packageName).filter(entry => entry.disabled !== true)
61
+ }
62
+
63
+ function quarantineOption(configuration, packageInfo, packages) {
64
+ const packageName = packageInfo.name
65
+ const entries = activeEntriesFor(configuration, packageName)
66
+ const ids = entries.map(entry => entry.id)
67
+ const duplicateIds = configuration.issues.filter(issue => issue.code === 'DUPLICATE_ENTRY_ID').map(issue => issue.details.id)
68
+ const blockers = []
69
+ if (packageName === '@deepseek-ai/dsh-base' || packageName.startsWith('@deepseek-ai/dsh-base-')) {
70
+ blockers.push('package is a template or in-box core bundle')
71
+ }
72
+ if (entries.length === 0) blockers.push('no active entry can be mapped to this package')
73
+ if (ids.some(id => typeof id !== 'string' || id.length === 0)) blockers.push('one or more active entries have no stable id')
74
+ if (ids.some(id => duplicateIds.includes(id))) blockers.push('a target entry id is duplicated')
75
+ const ownedLayers = configuration.layerDetails?.filter(layer => layer.package === packageName) ?? []
76
+ if (ownedLayers.some(layer => layer.overrideCount > 0)) blockers.push('the bundle modifies entries owned by another layer')
77
+ const clientDependents = packages.filter(candidate => candidate.name !== packageName
78
+ && [...candidate.clientExternal ?? [], ...candidate.clientInject ?? []]
79
+ .some(specifier => specifier === packageName || specifier.startsWith(`${packageName}/`)))
80
+ .map(candidate => candidate.name)
81
+ if (clientDependents.length > 0) blockers.push('other plugins declare client dependencies on this package')
82
+ if (packageInfo.runtimeServiceProvider?.status === 'detected') {
83
+ blockers.push('the plugin appears to provide runtime Services whose dependents cannot be proven statically')
84
+ }
85
+ const problematic = packageInfo.compatibility === 'incompatible' || packageInfo.compatibility === 'risk'
86
+ return {
87
+ kind: 'quarantine',
88
+ availability: blockers.length === 0 ? 'available' : 'requires-review',
89
+ risk: 'low',
90
+ recommended: blockers.length === 0 && problematic,
91
+ reason: blockers.length === 0
92
+ ? 'All active entries owned by the plugin can be disabled with name assertions.'
93
+ : 'Safe isolation cannot be proven automatically.',
94
+ impact: {
95
+ entryIds: ids.filter(id => typeof id === 'string'),
96
+ entries: entries.filter(entry => typeof entry.id === 'string').map(entry => ({
97
+ id: entry.id,
98
+ name: entry.name,
99
+ disabledSource: entry.fields?.disabled?.source,
100
+ })),
101
+ clientDependents,
102
+ serviceDependents: {
103
+ status: 'unknown',
104
+ provider: packageInfo.runtimeServiceProvider ?? { status: 'not-checked' },
105
+ },
106
+ blockers,
107
+ },
108
+ }
109
+ }
110
+
111
+ function removalOption(configuration, packageInfo, packages, bundleNames, profileManifest, lockfile, quarantine, dshCli) {
112
+ const packageName = packageInfo.name
113
+ const directDependency = Object.hasOwn(profileManifest.dependencies ?? {}, packageName)
114
+ const bundleLayer = bundleNames.includes(packageName)
115
+ const entries = entriesFor(configuration, packageName)
116
+ const manualMounts = entries.filter(entry => ['profile', 'home', 'overlay'].includes(entry.origin?.kind))
117
+ .map(entry => ({ id: entry.id, file: entry.origin.file, patchIndex: entry.origin.patchIndex }))
118
+ const patchReferences = configuration.patchReferences?.filter(reference => entries.some(entry => entry.id === reference.id)
119
+ && reference.package !== packageName) ?? []
120
+ const core = packageName.startsWith('@deepseek-ai/dsh-base') || (!directDependency && bundleLayer)
121
+ const clientDependents = packages.filter(candidate => candidate.name !== packageName
122
+ && [...candidate.clientExternal ?? [], ...candidate.clientInject ?? []]
123
+ .some(specifier => specifier === packageName || specifier.startsWith(`${packageName}/`)))
124
+ .map(candidate => candidate.name)
125
+ const blockers = []
126
+ if (!directDependency) blockers.push('package is not a direct profile dependency')
127
+ if (core) blockers.push('package is a template or in-box core bundle')
128
+ if (lockfile?.present !== true || lockfile.valid !== true) blockers.push('profile lockfile is missing or unreadable')
129
+ if (!dshCli?.available) blockers.push('no working DSH CLI is available')
130
+ if (manualMounts.length > 0) blockers.push('manual mounts would remain after package removal')
131
+ if (patchReferences.length > 0) blockers.push('higher-layer patches would become dangling references')
132
+ if (clientDependents.length > 0) blockers.push('other plugins declare client dependencies on this package')
133
+ if (quarantine.availability !== 'available') blockers.push('a complete temporary quarantine cannot be generated')
134
+ return {
135
+ kind: 'remove',
136
+ availability: blockers.length === 0 ? 'available' : 'unavailable',
137
+ risk: 'medium',
138
+ recommended: false,
139
+ reason: blockers.length === 0 ? 'Static removal preflight passed; dynamic Service dependencies remain unknown.' : 'Removal preflight found blockers.',
140
+ impact: {
141
+ package: packageName,
142
+ directDependency,
143
+ bundleLayer,
144
+ hostEntries: entries.map(entry => entry.id).filter(Boolean),
145
+ clientBundle: packageInfo.client === true,
146
+ manualMounts,
147
+ patchReferences,
148
+ clientDependents,
149
+ serviceDependents: { status: 'unknown' },
150
+ blockers,
151
+ rollback: packageInfo.version === undefined ? undefined : {
152
+ command: ['plugin', '--profile', configuration.profile, 'add', `${packageName}@${packageInfo.version}`],
153
+ },
154
+ },
155
+ }
156
+ }
157
+
158
+ export function buildPluginDiagnoses({ packages, findings, configuration, bundleNames, profileManifest, lockfile, dshCli }) {
159
+ return packages.map(packageInfo => {
160
+ const related = findings.filter(item => item.package === packageInfo.name)
161
+ const entries = configuration.entries.filter(entry => entry.name === packageInfo.name || entry.origin?.package === packageInfo.name)
162
+ const quarantine = quarantineOption(configuration, packageInfo, packages)
163
+ const remove = removalOption(configuration, packageInfo, packages, bundleNames, profileManifest, lockfile, quarantine, dshCli)
164
+ const options = [
165
+ { kind: 'update', availability: 'unknown', risk: 'medium', recommended: false, reason: 'Registry compatibility has not been checked.', impact: {} },
166
+ ...related.some(item => ['PATCH_TARGET_NOT_FOUND', 'PATCH_INCOMPATIBLE_WITH_CURRENT_DSH'].includes(item.code))
167
+ ? [{ kind: 'adjust-patch', availability: 'requires-review', risk: 'medium', recommended: true, reason: 'A configuration layer targets an entry from an older DSH tree.', impact: {} }]
168
+ : [],
169
+ ...related.some(item => ['CORE_ENTRY_DISABLED_BY_HIGHER_LAYER', 'CONFIG_REPLACED_BY_HIGHER_LAYER', 'GROUP_CONTENT_REPLACED'].includes(item.code))
170
+ ? [{ kind: 'rollback-override', availability: 'requires-review', risk: 'medium', recommended: true, reason: 'A higher configuration layer overrides this plugin or its target.', impact: {} }]
171
+ : [],
172
+ quarantine,
173
+ remove,
174
+ ]
175
+ const recommended = options.find(option => option.recommended)?.kind ?? 'keep'
176
+ return {
177
+ name: packageInfo.name,
178
+ installedVersion: packageInfo.version,
179
+ status: packageInfo.compatibility,
180
+ cause: causeFor(related),
181
+ configuration: {
182
+ originLayer: entries[0]?.origin?.kind ?? 'unknown',
183
+ entries: entries.map(entry => entry.id).filter(Boolean),
184
+ overriddenBy: [...new Set(entries.flatMap(entry => Object.values(entry.fields ?? {}).map(field => field.source?.kind)).filter(kind => kind !== entries[0]?.origin?.kind))],
185
+ issues: related.filter(item => CAUSES.get(item.code) === 'config-override' || CAUSES.get(item.code) === 'duplicate-mount').map(item => item.code),
186
+ },
187
+ update: { status: 'not-checked' },
188
+ recovery: { recommended, options },
189
+ }
190
+ })
191
+ }
192
+
193
+ export function attachUpdateResult(diagnosis, result, dshCli) {
194
+ diagnosis.update = result
195
+ const update = diagnosis.recovery.options.find(option => option.kind === 'update')
196
+ const isNewer = result.status === 'compatible-candidate-found'
197
+ && (semver.valid(diagnosis.installedVersion) === null || semver.gt(result.version, diagnosis.installedVersion))
198
+ if (result.status === 'compatible-candidate-found' && isNewer) {
199
+ Object.assign(update, {
200
+ availability: 'available',
201
+ recommended: diagnosis.status !== 'compatible',
202
+ reason: `Version ${result.version} declares compatibility with the active DSH packages.`,
203
+ command: dshCli?.available ? [...dshCli.command, 'plugin', '--profile', dshCli.profile, 'add', result.spec] : [],
204
+ impact: { candidateVersion: result.version, validation: 'manifest-declared-only' },
205
+ })
206
+ if (diagnosis.status !== 'compatible') diagnosis.recovery.recommended = 'update'
207
+ } else {
208
+ update.availability = result.status === 'registry-unavailable' ? 'unknown' : 'unavailable'
209
+ update.recommended = false
210
+ update.reason = result.status === 'compatible-candidate-found'
211
+ ? `The highest manifest-compatible version (${result.version}) is not newer than the installed version.`
212
+ : result.status
213
+ }
214
+ return diagnosis
215
+ }
216
+
217
+ export function verifyUpdate(report, packageName, candidateVersion) {
218
+ const diagnosis = report.context.pluginDiagnoses?.find(item => item.name === packageName)
219
+ const installedVersionMatches = diagnosis?.installedVersion === candidateVersion
220
+ const compatibilityPassed = diagnosis !== undefined && !['incompatible', 'risk'].includes(diagnosis.status)
221
+ return {
222
+ package: packageName,
223
+ candidateVersion,
224
+ installedVersion: diagnosis?.installedVersion,
225
+ status: diagnosis?.status ?? 'missing',
226
+ installedVersionMatches,
227
+ compatibilityPassed,
228
+ verified: installedVersionMatches && compatibilityPassed && report.summary.errors === 0,
229
+ }
230
+ }
231
+
232
+ export function quarantineDocument(diagnosis) {
233
+ const option = diagnosis.recovery.options.find(item => item.kind === 'quarantine')
234
+ if (option?.availability !== 'available') throw new Error(`quarantine requires review: ${option?.impact?.blockers?.join('; ') ?? 'unknown plugin mapping'}`)
235
+ return option.impact.entries.map(entry => ({ id: entry.id, name: entry.name, disabled: true }))
236
+ }
237
+
238
+ export function verifyQuarantine(report, patches) {
239
+ const entries = report.context.configuration?.entries ?? []
240
+ const targets = patches.map(patch => {
241
+ const matches = entries.filter(entry => entry.id === patch.id && entry.name === patch.name)
242
+ return {
243
+ id: patch.id,
244
+ name: patch.name,
245
+ matches: matches.length,
246
+ disabled: matches.length === 1 && matches[0].disabled === true,
247
+ }
248
+ })
249
+ return {
250
+ allTargetEntriesDisabled: targets.length > 0 && targets.every(target => target.disabled),
251
+ targets,
252
+ }
253
+ }
254
+
255
+ export function writeQuarantineOverlay(diagnosis, output) {
256
+ const file = resolve(output)
257
+ const document = `${yaml.dump(quarantineDocument(diagnosis), { noRefs: true, lineWidth: 120 })}`
258
+ const snapshot = snapshotFile(file)
259
+ const write = atomicWrite(snapshot, document, { backup: snapshot.exists })
260
+ return { ...write, action: 'quarantine', yaml: document }
261
+ }
262
+
263
+ export function persistentQuarantinePlan(diagnosis, profileDir) {
264
+ const quarantine = diagnosis.recovery.options.find(item => item.kind === 'quarantine')
265
+ const higherLayers = [...new Set((quarantine?.impact?.entries ?? [])
266
+ .map(entry => entry.disabledSource?.kind)
267
+ .filter(kind => kind === 'home' || kind === 'overlay'))]
268
+ if (higherLayers.length > 0) {
269
+ throw new Error(`profile quarantine cannot override higher layer(s): ${higherLayers.join(', ')}`)
270
+ }
271
+ const patches = quarantineDocument(diagnosis)
272
+ const file = join(profileDir, 'cordis.patch.yml')
273
+ const snapshot = snapshotFile(file)
274
+ const document = parseDocument(snapshot.text === '' ? '[]\n' : snapshot.text, {
275
+ prettyErrors: true,
276
+ uniqueKeys: true,
277
+ customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: value => value, stringify: item => `!!js ${JSON.stringify(item.value)}` }],
278
+ })
279
+ if (document.errors.length > 0 || !isSeq(document.contents)) throw new Error(`${file} is not a valid patch list`)
280
+ const current = document.toJS() ?? []
281
+ if (!Array.isArray(current)) throw new Error(`${file} is not a patch list`)
282
+ for (const patch of patches) {
283
+ // Append the final override. Editing an earlier occurrence is not enough:
284
+ // a later `disabled: false` would still win under Harness patch ordering.
285
+ document.contents.add(patch)
286
+ }
287
+ const nextText = document.toString({ lineWidth: 120 })
288
+ return { file, snapshot, before: snapshot.text, after: nextText, diff: exactDiff(snapshot.text, nextText), patches }
289
+ }
290
+
291
+ function exactDiff(before, after) {
292
+ if (before === after) return ''
293
+ const left = before.replace(/\n$/, '').split('\n')
294
+ const right = after.replace(/\n$/, '').split('\n')
295
+ let prefix = 0
296
+ while (prefix < left.length && prefix < right.length && left[prefix] === right[prefix]) prefix += 1
297
+ let suffix = 0
298
+ while (suffix < left.length - prefix && suffix < right.length - prefix
299
+ && left[left.length - 1 - suffix] === right[right.length - 1 - suffix]) suffix += 1
300
+ return [
301
+ `@@ line ${String(prefix + 1)} @@`,
302
+ ...left.slice(prefix, left.length - suffix).map(line => `- ${line}`),
303
+ ...right.slice(prefix, right.length - suffix).map(line => `+ ${line}`),
304
+ ].join('\n')
305
+ }
306
+
307
+ export function applyPersistentQuarantine(plan) {
308
+ return { action: 'persist-quarantine', ...atomicWrite(plan.snapshot, plan.after, { recordCreation: true }), patches: plan.patches }
309
+ }
310
+
311
+ export function removalPlan(diagnosis, dshCli, home, profile) {
312
+ const option = diagnosis.recovery.options.find(item => item.kind === 'remove')
313
+ if (option?.availability !== 'available') throw new Error(`remove is unavailable: ${option?.impact?.blockers?.join('; ') ?? 'preflight failed'}`)
314
+ if (!dshCli?.available) throw new Error('no working DSH CLI is available for removal')
315
+ return {
316
+ id: `remove-package:${diagnosis.name}`,
317
+ kind: 'command',
318
+ risk: 'medium',
319
+ description: `Remove ${diagnosis.name} from profile ${profile}.`,
320
+ command: [...dshCli.command, 'plugin', '--profile', profile, 'remove', diagnosis.name],
321
+ env: { DSH_HOME: home },
322
+ impact: option.impact,
323
+ }
324
+ }
325
+
326
+ export function prepareRemovalArtifacts(diagnosis, report) {
327
+ const safeName = diagnosis.name.replace(/[^a-zA-Z0-9._-]+/g, '-')
328
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
329
+ const directory = join(report.context.profileDir, '.dsh-doctor', 'recovery', `${stamp}-${safeName}`)
330
+ const snapshotFileName = join(directory, 'diagnosis.json')
331
+ const snapshot = snapshotFile(snapshotFileName)
332
+ atomicWrite(snapshot, `${JSON.stringify(redactSecrets(report), null, 2)}\n`, { backup: false })
333
+ const overlay = writeQuarantineOverlay(diagnosis, join(directory, 'quarantine.yml'))
334
+ return { directory, diagnosis: snapshotFileName, quarantine: overlay.file }
335
+ }
336
+
337
+ export function restoreBackup(backup, target) {
338
+ const resolvedBackup = resolve(backup)
339
+ const resolvedTarget = resolve(target)
340
+ if (resolvedBackup.startsWith(`${resolvedTarget}.dsh-doctor-`) && resolvedBackup.endsWith('.rollback.json')) {
341
+ const record = JSON.parse(readFileSync(resolvedBackup, 'utf8'))
342
+ if (record?.version !== 1 || record.action !== 'delete-created-file' || resolve(record.target) !== resolvedTarget) {
343
+ throw new Error(`invalid rollback record: ${resolvedBackup}`)
344
+ }
345
+ if (!existsSync(resolvedTarget)) return { file: resolvedTarget, status: 'already-absent', rollbackRecord: resolvedBackup }
346
+ const snapshot = snapshotFile(resolvedTarget)
347
+ if (snapshot.hash !== record.expectedHash) throw new Error(`${resolvedTarget} changed after it was created; diagnose again before rollback`)
348
+ unlinkSync(resolvedTarget)
349
+ return { file: resolvedTarget, status: 'deleted', rollbackRecord: resolvedBackup }
350
+ }
351
+ if (!resolvedBackup.startsWith(`${resolvedTarget}.dsh-doctor-`) || !resolvedBackup.endsWith('.bak')) {
352
+ throw new Error(`backup does not belong to target: ${resolvedBackup}`)
353
+ }
354
+ if (!existsSync(resolvedBackup)) throw new Error(`backup does not exist: ${resolvedBackup}`)
355
+ const backupText = readFileSync(resolvedBackup, 'utf8')
356
+ const snapshot = snapshotFile(target)
357
+ return atomicWrite(snapshot, backupText)
358
+ }
package/src/redact.mjs ADDED
@@ -0,0 +1,46 @@
1
+ const REDACTED = '[REDACTED]'
2
+
3
+ function sensitiveKey(key) {
4
+ const normalized = key.replace(/[^a-zA-Z0-9]/g, '').toLowerCase()
5
+ return normalized === 'auth'
6
+ || normalized === 'authorization'
7
+ || normalized === 'bearer'
8
+ || normalized === 'cookie'
9
+ || normalized === 'credentials'
10
+ || normalized === 'credential'
11
+ || normalized === 'key'
12
+ || normalized === 'password'
13
+ || normalized === 'passwd'
14
+ || normalized === 'pwd'
15
+ || normalized === 'sessionid'
16
+ || normalized.endsWith('password')
17
+ || normalized.endsWith('passwd')
18
+ || normalized.endsWith('secret')
19
+ || normalized.endsWith('token')
20
+ || /^(?:api|access|private|public|client|encryption|signing)key$/.test(normalized)
21
+ }
22
+
23
+ const CONFIG_STRUCTURE_KEYS = new Set([
24
+ 'id', 'name', 'group', 'disabled', 'origin', 'fields', 'source',
25
+ 'replacedSource', 'removedPaths', 'file', 'kind', 'package', 'patchIndex',
26
+ ])
27
+
28
+ function redactConfigValues(value) {
29
+ if (Array.isArray(value)) return value.map(redactConfigValues)
30
+ if (value === null || typeof value !== 'object') return value === null ? null : REDACTED
31
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => {
32
+ if (key === 'config') return [key, redactConfigValues(child)]
33
+ if (CONFIG_STRUCTURE_KEYS.has(key)) return [key, redactSecrets(child)]
34
+ return [key, child !== null && typeof child === 'object' ? redactConfigValues(child) : REDACTED]
35
+ }))
36
+ }
37
+
38
+ export function redactSecrets(value) {
39
+ if (Array.isArray(value)) return value.map(redactSecrets)
40
+ if (value === null || typeof value !== 'object') return value
41
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => {
42
+ if (sensitiveKey(key)) return [key, REDACTED]
43
+ if (key.toLowerCase() === 'config') return [key, redactConfigValues(child)]
44
+ return [key, redactSecrets(child)]
45
+ }))
46
+ }
@@ -0,0 +1,61 @@
1
+ import semver from 'semver'
2
+
3
+ function harnessPeers(manifest) {
4
+ const peers = manifest?.peerDependencies
5
+ if (peers === null || typeof peers !== 'object' || Array.isArray(peers)) return []
6
+ return Object.entries(peers).filter(([name]) => name === 'cordis' || name.startsWith('@deepseek-ai/'))
7
+ }
8
+
9
+ function declaredCompatible(manifest, activeVersions) {
10
+ const peers = harnessPeers(manifest)
11
+ if (peers.length === 0) return { compatible: false, sufficient: false }
12
+ let resolved = 0
13
+ for (const [name, range] of peers) {
14
+ const active = activeVersions[name]
15
+ if (typeof range !== 'string' || semver.validRange(range) === null || typeof active !== 'string' || semver.valid(active) === null) continue
16
+ resolved += 1
17
+ if (!semver.satisfies(active, range, { includePrerelease: true })) return { compatible: false, sufficient: true }
18
+ }
19
+ return { compatible: resolved === peers.length, sufficient: resolved === peers.length }
20
+ }
21
+
22
+ export async function checkCompatibleVersion(packageName, activeVersions, options = {}) {
23
+ const fetcher = options.fetch ?? globalThis.fetch
24
+ if (typeof fetcher !== 'function') return { status: 'registry-unavailable', error: 'fetch is unavailable' }
25
+ const registry = (options.registry ?? 'https://registry.npmjs.org').replace(/\/$/, '')
26
+ const url = `${registry}/${encodeURIComponent(packageName)}`
27
+ let response
28
+ try {
29
+ response = await fetcher(url, {
30
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
31
+ signal: options.signal ?? AbortSignal.timeout(options.timeoutMs ?? 15_000),
32
+ })
33
+ } catch (error) {
34
+ return { status: 'registry-unavailable', error: error instanceof Error ? error.message : String(error) }
35
+ }
36
+ if (!response.ok) return { status: 'registry-unavailable', httpStatus: response.status }
37
+ let packument
38
+ try {
39
+ packument = await response.json()
40
+ } catch (error) {
41
+ return { status: 'metadata-insufficient', error: error instanceof Error ? error.message : String(error) }
42
+ }
43
+ const versions = packument?.versions
44
+ if (versions === null || typeof versions !== 'object' || Array.isArray(versions)) return { status: 'metadata-insufficient' }
45
+ const published = Object.keys(versions).filter(version => semver.valid(version) !== null).sort(semver.rcompare)
46
+ let sawSufficient = false
47
+ for (const version of published) {
48
+ const result = declaredCompatible(versions[version], activeVersions)
49
+ sawSufficient ||= result.sufficient
50
+ if (result.compatible) {
51
+ return {
52
+ status: 'compatible-candidate-found',
53
+ version,
54
+ package: packageName,
55
+ spec: `${packageName}@${version}`,
56
+ basis: 'manifest-declared',
57
+ }
58
+ }
59
+ }
60
+ return { status: sawSufficient ? 'no-declared-compatible-version' : 'metadata-insufficient' }
61
+ }
@@ -0,0 +1,50 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ export function sha256(text) {
6
+ return createHash('sha256').update(text).digest('hex')
7
+ }
8
+
9
+ export function snapshotFile(file) {
10
+ const exists = existsSync(file)
11
+ const text = exists ? readFileSync(file, 'utf8') : ''
12
+ return { file, exists, text, hash: sha256(text) }
13
+ }
14
+
15
+ export function atomicWrite(snapshot, nextText, options = {}) {
16
+ const current = existsSync(snapshot.file) ? readFileSync(snapshot.file, 'utf8') : ''
17
+ if (sha256(current) !== snapshot.hash || existsSync(snapshot.file) !== snapshot.exists) {
18
+ throw new Error(`${snapshot.file} changed after the preview; diagnose again before applying`)
19
+ }
20
+ mkdirSync(dirname(snapshot.file), { recursive: true })
21
+ let backup
22
+ let rollbackRecord
23
+ if (snapshot.exists && options.backup !== false) {
24
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
25
+ backup = `${snapshot.file}.dsh-doctor-${stamp}.bak`
26
+ if (existsSync(backup)) backup = `${snapshot.file}.dsh-doctor-${stamp}-${randomBytes(4).toString('hex')}.bak`
27
+ copyFileSync(snapshot.file, backup)
28
+ } else if (!snapshot.exists && options.recordCreation === true) {
29
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
30
+ const nonce = randomBytes(6).toString('hex')
31
+ rollbackRecord = `${snapshot.file}.dsh-doctor-${stamp}-${nonce}.rollback.json`
32
+ const rollbackTemporary = join(dirname(snapshot.file), `.dsh-doctor-${process.pid}-${nonce}.rollback.tmp`)
33
+ writeFileSync(rollbackTemporary, `${JSON.stringify({
34
+ version: 1,
35
+ action: 'delete-created-file',
36
+ target: snapshot.file,
37
+ expectedHash: sha256(nextText),
38
+ }, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
39
+ renameSync(rollbackTemporary, rollbackRecord)
40
+ }
41
+ const temporary = join(dirname(snapshot.file), `.dsh-doctor-${process.pid}-${randomBytes(6).toString('hex')}.tmp`)
42
+ const mode = snapshot.exists ? statSync(snapshot.file).mode : 0o600
43
+ writeFileSync(temporary, nextText, { mode })
44
+ renameSync(temporary, snapshot.file)
45
+ return {
46
+ file: snapshot.file,
47
+ ...(backup === undefined ? {} : { backup }),
48
+ ...(rollbackRecord === undefined ? {} : { rollbackRecord }),
49
+ }
50
+ }