@deepseek-ai/dsh-app-boot 0.2.2-rc.1 → 0.2.2-rc.2

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.
Files changed (73) hide show
  1. package/README.i18n.yaml +4 -4
  2. package/README.md +4 -2
  3. package/README.zh.md +4 -2
  4. package/lib/index.js +37 -17
  5. package/lib/types/bundle-platform.d.ts +4 -2
  6. package/lib/types/bundle-platform.d.ts.map +1 -0
  7. package/lib/types/bundle-platform.js.map +1 -0
  8. package/lib/types/compatibility-preflight.d.ts.map +1 -0
  9. package/lib/types/compatibility-preflight.js.map +1 -0
  10. package/lib/types/config-schema/collect.d.ts.map +1 -0
  11. package/lib/types/config-schema/collect.js.map +1 -0
  12. package/lib/types/config-schema/document.d.ts.map +1 -0
  13. package/lib/types/config-schema/document.js.map +1 -0
  14. package/lib/types/config-schema/index.d.ts.map +1 -0
  15. package/lib/types/config-schema/index.js.map +1 -0
  16. package/lib/types/config-schema/native.d.ts.map +1 -0
  17. package/lib/types/config-schema/native.js.map +1 -0
  18. package/lib/types/config-schema/pattern.d.ts.map +1 -0
  19. package/lib/types/config-schema/pattern.js.map +1 -0
  20. package/lib/types/config-schema/projector.d.ts.map +1 -0
  21. package/lib/types/config-schema/projector.js.map +1 -0
  22. package/lib/types/config-schema/types.d.ts.map +1 -0
  23. package/lib/types/config-schema/types.js.map +1 -0
  24. package/lib/types/index.d.ts +1 -1
  25. package/lib/types/index.d.ts.map +1 -0
  26. package/lib/types/index.js.map +1 -0
  27. package/lib/types/package-meta.d.ts.map +1 -0
  28. package/lib/types/package-meta.js.map +1 -0
  29. package/lib/types/plugin-compatibility.d.ts.map +1 -0
  30. package/lib/types/plugin-compatibility.js.map +1 -0
  31. package/lib/types/profile-compatibility.d.ts.map +1 -0
  32. package/lib/types/profile-compatibility.js.map +1 -0
  33. package/lib/types/profile-context.d.ts.map +1 -0
  34. package/lib/types/profile-context.js.map +1 -0
  35. package/lib/types/profile-plugins.d.ts.map +1 -0
  36. package/lib/types/profile-plugins.js.map +1 -0
  37. package/lib/types/profile-resolution/legacy-links.d.ts.map +1 -0
  38. package/lib/types/profile-resolution/legacy-links.js.map +1 -0
  39. package/lib/types/profile-resolution/resolver.d.ts.map +1 -0
  40. package/lib/types/profile-resolution/resolver.js.map +1 -0
  41. package/lib/types/profile-resolution/service.d.ts.map +1 -0
  42. package/lib/types/profile-resolution/service.js.map +1 -0
  43. package/lib/types/profile-resolution/worker-bootstrap.d.ts.map +1 -0
  44. package/lib/types/profile-resolution/worker-bootstrap.js.map +1 -0
  45. package/lib/types/profile-sanitize.d.ts.map +1 -0
  46. package/lib/types/profile-sanitize.js.map +1 -0
  47. package/lib/types/profile.d.ts.map +1 -0
  48. package/lib/types/profile.js.map +1 -0
  49. package/lib/types/startup-diagnostics.d.ts.map +1 -0
  50. package/lib/types/startup-diagnostics.js.map +1 -0
  51. package/package.json +15 -11
  52. package/src/bundle-platform.ts +35 -0
  53. package/src/compatibility-preflight.ts +187 -0
  54. package/src/config-schema/collect.ts +249 -0
  55. package/src/config-schema/document.ts +169 -0
  56. package/src/config-schema/index.ts +36 -0
  57. package/src/config-schema/native.ts +16 -0
  58. package/src/config-schema/pattern.ts +62 -0
  59. package/src/config-schema/projector.ts +455 -0
  60. package/src/config-schema/types.ts +95 -0
  61. package/src/index.ts +1286 -0
  62. package/src/package-meta.ts +200 -0
  63. package/src/plugin-compatibility.ts +117 -0
  64. package/src/profile-compatibility.ts +141 -0
  65. package/src/profile-context.ts +85 -0
  66. package/src/profile-plugins.ts +133 -0
  67. package/src/profile-resolution/legacy-links.ts +33 -0
  68. package/src/profile-resolution/resolver.ts +1072 -0
  69. package/src/profile-resolution/service.ts +148 -0
  70. package/src/profile-resolution/worker-bootstrap.ts +12 -0
  71. package/src/profile-sanitize.ts +34 -0
  72. package/src/profile.ts +762 -0
  73. package/src/startup-diagnostics.ts +104 -0
package/src/profile.ts ADDED
@@ -0,0 +1,762 @@
1
+ /**
2
+ * Profile discovery, initialization, and patch-layer composition for the
3
+ * `dsh --profile` launcher family.
4
+ *
5
+ * A profile is a directory under `$DSH_HOME/profiles/<name>` holding a
6
+ * `package.json` (out-of-tree plugin dependencies plus the profile manifest
7
+ * `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml`
8
+ * (the user's own patch layer, applied after every bundle layer). Bundles are
9
+ * npm packages whose manifest declares
10
+ * `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` (one file, or an
11
+ * ordered list of files); the tree is composed by applying each bundle's patch
12
+ * lists in `dsh.profile.bundles` order over an empty entry list, then the
13
+ * profile's own patches, then any launcher layers (`--patch` files and
14
+ * flag-derived patches).
15
+ *
16
+ * Module resolution is two-anchor by construction: a bundle name resolves
17
+ * first from the dsh installation (the launcher's own package), then from the
18
+ * profile directory. Pnpm-managed entries in the profile's `node_modules`
19
+ * resolve first. The runtime resolution supplies packages carried by the
20
+ * installation and selected bundles to Node's ESM and CommonJS resolvers.
21
+ * @module @deepseek-ai/dsh-app-boot/profile
22
+ */
23
+
24
+ import { createRequire } from 'node:module'
25
+ import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
26
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
27
+ import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
28
+ import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
29
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
30
+ import type { DshBundleManifest, DshPackageManifest } from '@deepseek-ai/dsh-package-manifest'
31
+ import { INSTALLATION_OWNED_PROFILE_TUPLES, PROFILE_TEMPLATES, RETIRED_BUNDLES } from '@deepseek-ai/dsh-official-bundles'
32
+ import { assertBundlePlatform } from './bundle-platform.ts'
33
+ import { evaluatePluginCompatibility, pluginCompatibilityWarning } from './plugin-compatibility.ts'
34
+ import { readProfileVersionExemptions } from './profile-compatibility.ts'
35
+ import { loadOverlayPatches } from './index.ts'
36
+ import { realModuleDirectory } from './profile-resolution/legacy-links.ts'
37
+
38
+ /** Directory under the Harness home holding every profile. */
39
+ export const PROFILES_DIR = 'profiles'
40
+
41
+ /** The user patch layer inside a profile directory (hot-reloaded on long-lived surfaces). */
42
+ export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
43
+
44
+ /** Package metadata accepted by the profile reader; local profiles need no published identity. */
45
+ export type ProfileManifest = Partial<DshPackageManifest>
46
+
47
+ /**
48
+ * The patch files a bundle declares, as written: one file for a string
49
+ * `patch`, the listed files in order for an array.
50
+ * @param bundle - the bundle's `dsh.bundle` declaration, as read from package.json.
51
+ * @returns the package-relative patch file paths in application order.
52
+ * @throws {Error} when `patch` is neither a string nor a list of strings.
53
+ */
54
+ export function bundlePatchFiles(bundle: DshBundleManifest): string[] {
55
+ const declared = typeof bundle.patch === 'string' ? [bundle.patch] : bundle.patch
56
+ if (!Array.isArray(declared) || !declared.every(file => typeof file === 'string')) {
57
+ throw new Error('dsh.bundle.patch must be a file path or a list of file paths')
58
+ }
59
+ return declared
60
+ }
61
+
62
+ /**
63
+ * Resolve a bundle declaration to its ordered absolute patch files.
64
+ * @param packageDir - absolute directory of the bundle package.
65
+ * @param bundle - the bundle's `dsh.bundle` declaration, as read from package.json.
66
+ * @returns the absolute patch file paths in application order.
67
+ * @throws {Error} when `patch` is neither a string nor a list of strings.
68
+ */
69
+ export function bundlePatchPaths(packageDir: string, bundle: DshBundleManifest): string[] {
70
+ return bundlePatchFiles(bundle).map(file => join(packageDir, file))
71
+ }
72
+
73
+ /** One resolved bundle layer of a profile. */
74
+ export interface ProfileLayer {
75
+ /** The bundle's package name, as listed in `dsh.profile.bundles`. */
76
+ packageName: string
77
+ /** Absolute directory of the resolved bundle package. */
78
+ packageDir: string
79
+ /** Absolute paths of the bundle's patch files, in application order. */
80
+ patchPaths: readonly string[]
81
+ /** The parsed patch lists of every file, concatenated in application order. */
82
+ patches: PatchOptions[]
83
+ }
84
+
85
+ /** A loaded profile: resolved bundle layers plus the user's own patch layer. */
86
+ export interface Profile {
87
+ /** The profile name (its directory basename). */
88
+ name: string
89
+ /** Absolute profile directory. */
90
+ dir: string
91
+ /** Bundle layers in `dsh.profile.bundles` order. */
92
+ layers: ProfileLayer[]
93
+ /** Absolute path of the profile's own patch file. */
94
+ patchPath: string
95
+ /** The profile's own patches; empty when the file is absent. */
96
+ patches: PatchOptions[]
97
+ /** Selected bundles that contributed no layer, in `dsh.profile.bundles` order, with why. */
98
+ skippedBundles: SkippedBundle[]
99
+ }
100
+
101
+ /** A selected bundle the profile could not load, or whose own DSH requirements the profile does not exempt. */
102
+ export interface SkippedBundle {
103
+ /** The bundle's package name from `dsh.profile.bundles`. */
104
+ packageName: string
105
+ /** The resolution, manifest, compatibility, or patch-loading failure. */
106
+ reason: string
107
+ }
108
+
109
+ /**
110
+ * Print each skipped bundle once; loading never prints, so launchers call this once per start.
111
+ * @param binName - the diagnostic prefix.
112
+ * @param profile - the loaded profile.
113
+ */
114
+ export function reportSkippedBundles(binName: string, profile: Pick<Profile, 'skippedBundles'>): void {
115
+ for (const { packageName, reason } of profile.skippedBundles) {
116
+ process.stderr.write(`${binName}: skipping profile bundle ${JSON.stringify(packageName)}: ${reason}\n`)
117
+ }
118
+ }
119
+
120
+ /** One package the runtime resolution supplies at the interception layer. */
121
+ export interface RuntimeResolutionEntry {
122
+ /** Bare package name. */
123
+ readonly name: string
124
+ /** Package directory selected by the existing dependency traversal. */
125
+ readonly packageDir: string
126
+ /** Selected package version when its manifest declares one. */
127
+ readonly version: string | undefined
128
+ /** Manifest whose dependency edge selected this package. */
129
+ readonly declarer: string
130
+ /** Whether every profile or only the active profile receives this entry. */
131
+ readonly scope: 'installation' | 'profile'
132
+ }
133
+
134
+ /**
135
+ * A profile node_modules entry linked to a directory outside the shared profiles tree and the active profile.
136
+ * Importers below `realPath` use Node's real ancestor chain, with peer mappings read at each node_modules position.
137
+ */
138
+ export interface LinkedRoot {
139
+ /** Package name of the profile `node_modules` entry, including its scope. */
140
+ readonly name: string
141
+ /** Real directory outside the shared profiles tree and active profile; a package.json is optional. */
142
+ readonly realPath: string
143
+ }
144
+
145
+ /** Complete immutable package table for one profile launch. */
146
+ export interface RuntimeResolution {
147
+ /** Directory containing every profile; its node_modules is the interception layer. */
148
+ readonly profilesDir: string
149
+ /** Active profile directory, when profile-scope entries were included. */
150
+ readonly profileDir: string | undefined
151
+ /** Profile-declared packages installed in the profile's own node_modules. */
152
+ readonly localPackageNames: readonly string[]
153
+ /** Installation-scope entries followed by profile-scope entries in precedence order. */
154
+ readonly entries: readonly RuntimeResolutionEntry[]
155
+ /** Active profile links to external directories, sorted by name. */
156
+ readonly linkedRoots: readonly LinkedRoot[]
157
+ }
158
+
159
+ /**
160
+ * Resolve a profile's directory under the Harness home.
161
+ * @param name - the profile name (`dsh --profile <name>`).
162
+ * @param home - the Harness home; defaults to {@link resolveDshHome}.
163
+ * @returns the absolute profile directory (which may not exist yet).
164
+ */
165
+ export function resolveProfileDir(name: string, home: string = resolveDshHome()): string {
166
+ if (name === '' || name.includes('/') || name.includes('\\') || name === '.' || name === '..'
167
+ // Node reserves this name for dependency lookup.
168
+ || name === 'node_modules') {
169
+ throw new Error(`dsh: invalid profile name ${JSON.stringify(name)}`)
170
+ }
171
+ return join(home, PROFILES_DIR, name)
172
+ }
173
+
174
+ const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer:
175
+ # a top-level YAML array of loader patch entries (id-targeted config
176
+ # overrides, disables, and insert lists; \`!!js\` expressions allowed).
177
+ []
178
+ `
179
+
180
+ // The hoisted linker gives out-of-tree plugins a flat node_modules whose
181
+ // missing peers (cordis and friends) use the runtime resolution, so every plugin shares the
182
+ // installation's single cordis instance instead of a duplicate. pnpm ≥10
183
+ // reads its settings from pnpm-workspace.yaml, not .npmrc.
184
+ const PROFILE_PNPM_WORKSPACE = `packages:
185
+ - .
186
+
187
+ nodeLinker: hoisted
188
+ autoInstallPeers: false
189
+ `
190
+
191
+ /**
192
+ * Initialize a profile directory: manifest, empty user patch layer, and the
193
+ * pnpm settings out-of-tree plugins need. Existing files are never touched,
194
+ * so re-running is a no-op on an initialized profile.
195
+ * @param dir - the profile directory from {@link resolveProfileDir}.
196
+ * @param bundles - the initial `dsh.profile.bundles` layer list.
197
+ */
198
+ export function initProfile(
199
+ dir: string,
200
+ bundles: readonly string[],
201
+ ): void {
202
+ mkdirSync(dir, { recursive: true })
203
+ const manifestPath = join(dir, 'package.json')
204
+ if (!existsSync(manifestPath)) {
205
+ const manifest: ProfileManifest & { private: boolean } = {
206
+ name: `dsh-profile-${basename(dir)}`,
207
+ private: true,
208
+ dependencies: {},
209
+ dsh: { profile: { bundles: [...bundles] } },
210
+ }
211
+ writeFileSync(manifestPath, JSON.stringify(manifest, undefined, 2) + '\n')
212
+ }
213
+ const patchPath = join(dir, PROFILE_PATCH_FILENAME)
214
+ if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE)
215
+ const workspacePath = join(dir, 'pnpm-workspace.yaml')
216
+ if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE)
217
+ }
218
+
219
+ /** Directory where the link backend of the dsh 0.1.5 releases projected bundle-carried packages into a profile. */
220
+ const LINK_PROJECTION_DIR = '.dsh-module-fallback'
221
+
222
+ /**
223
+ * Remove the package projections a link-backend launch left in a profile.
224
+ * Only symlinks under the profile's `node_modules` whose target lies inside
225
+ * `<profile>/.dsh-module-fallback/node_modules` are unlinked, then that directory is removed;
226
+ * pnpm-installed packages and every other symlink stay. A profile without the directory is untouched.
227
+ * @param dir - the profile directory.
228
+ */
229
+ export function removeLinkProjections(dir: string): void {
230
+ const owned = join(dir, LINK_PROJECTION_DIR)
231
+ if (!existsSync(owned)) return
232
+ const ownedModules = join(owned, 'node_modules')
233
+ for (const link of symlinksUnder(join(dir, 'node_modules'))) {
234
+ if (pointsInto(link, ownedModules)) unlinkSync(link)
235
+ }
236
+ rmSync(owned, { recursive: true, force: true })
237
+ }
238
+
239
+ /** Top-level and scoped entries under a node_modules directory that are symlinks or junctions. */
240
+ function symlinksUnder(modules: string): string[] {
241
+ const links: string[] = []
242
+ if (!existsSync(modules)) return links
243
+ for (const entry of readdirSync(modules, { withFileTypes: true })) {
244
+ const path = join(modules, entry.name)
245
+ if (entry.isSymbolicLink()) {
246
+ links.push(path)
247
+ } else if (entry.name.startsWith('@') && entry.isDirectory()) {
248
+ for (const child of readdirSync(path, { withFileTypes: true })) {
249
+ if (child.isSymbolicLink()) links.push(join(path, child.name))
250
+ }
251
+ }
252
+ }
253
+ return links
254
+ }
255
+
256
+ /**
257
+ * Active profile `node_modules` entries linked outside the shared profiles tree and the active profile.
258
+ * Missing targets and files are not linked roots; invalid link chains retain Node's diagnostic.
259
+ */
260
+ function linkedProfileRoots(profile: Profile, profilesDir: string): LinkedRoot[] {
261
+ const modules = join(profile.dir, 'node_modules')
262
+ const links = symlinksUnder(modules)
263
+ if (links.length === 0) return []
264
+ let tree: string
265
+ try {
266
+ tree = realModuleDirectory(profilesDir) + sep
267
+ } catch (error) {
268
+ // A profiles tree that is not materialized yet holds no links.
269
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
270
+ tree = resolve(profilesDir) + sep
271
+ }
272
+ const excludedTrees = [tree, realModuleDirectory(profile.dir) + sep]
273
+ const roots: LinkedRoot[] = []
274
+ for (const linkPath of links) {
275
+ let realPath: string
276
+ try {
277
+ realPath = realModuleDirectory(linkPath)
278
+ } catch (error) {
279
+ // A dangling link is not a package Node can load from the profile.
280
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
281
+ continue
282
+ }
283
+ if (excludedTrees.some(prefix => realPath + sep === prefix || realPath.startsWith(prefix))
284
+ || !statSync(realPath).isDirectory()) continue
285
+ roots.push({ name: relative(modules, linkPath).split(sep).join('/'), realPath })
286
+ }
287
+ return roots.sort((left, right) => left.name.localeCompare(right.name))
288
+ }
289
+
290
+ /** Whether a symlink's target directory is `root` or lies below it. */
291
+ function pointsInto(link: string, root: string): boolean {
292
+ try {
293
+ const target = resolve(dirname(link), readlinkSync(link))
294
+ const parent = realpathSync.native(dirname(target))
295
+ const rootPath = realpathSync.native(root)
296
+ return parent === rootPath || parent.startsWith(rootPath + sep)
297
+ } catch (error) {
298
+ // A target whose parent no longer exists cannot be one of the projections this launch owns.
299
+ /* v8 ignore next 2 -- a non-ENOENT realpath failure requires a host filesystem fault */
300
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
301
+ /* v8 ignore next -- see the host-filesystem exception above */
302
+ throw error
303
+ }
304
+ }
305
+
306
+ /** Read one package manifest while traversing a dependency graph. */
307
+ function readPackageManifest(anchor: string): ProfileManifest {
308
+ return JSON.parse(readFileSync(anchor, 'utf8')) as ProfileManifest
309
+ }
310
+
311
+ /** Return dependency names that may be imported by a loader-visible plugin. */
312
+ function profileDependencyNames(manifest: ProfileManifest): string[] {
313
+ return [...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {}),
314
+ ...Object.keys(manifest.optionalDependencies ?? {})]
315
+ }
316
+
317
+ /** Resolve the installation packages that the runtime resolver supplies to every profile. */
318
+ function collectInstallationScopePackages(
319
+ installAnchor: string, skippedBundles: ReadonlySet<string>,
320
+ ): {
321
+ packageNames: ReadonlySet<string>
322
+ packageDirs: ReadonlyMap<string, string>
323
+ declarers: ReadonlyMap<string, string>
324
+ versions: ReadonlyMap<string, string | undefined>
325
+ } {
326
+ // Real declaring paths keep workspace symlinks under node_modules from disabling tsx path mappings.
327
+ const canonicalAnchor = join(realModuleDirectory(dirname(installAnchor)), basename(installAnchor))
328
+ const appManifest = readPackageManifest(canonicalAnchor)
329
+ const links = new Map<string, string>()
330
+ const declarers = new Map<string, string>()
331
+ const versions = new Map<string, string | undefined>()
332
+ /* v8 ignore next -- a real app manifest always declares its name */
333
+ if (appManifest.name !== undefined) {
334
+ // The CLI derives the installation anchor from import.meta.url (already real) and the Desktop Host from its
335
+ // runtime directory (not a symlink); the directory is kept as given rather than canonicalized here.
336
+ links.set(appManifest.name, dirname(installAnchor))
337
+ declarers.set(appManifest.name, canonicalAnchor)
338
+ versions.set(appManifest.name, appManifest.version)
339
+ }
340
+ // BFS over the resolvable dependency graph; the visited set is the link
341
+ // map itself (first resolution wins, matching Node's own nearest-wins).
342
+ const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: canonicalAnchor, manifest: appManifest }]
343
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
344
+ // Peer dependencies participate: Service Definition packages (dsh-subprocess,
345
+ // dsh-compaction, ...) are peers of their implementations, never plain
346
+ // dependencies, yet out-of-tree plugins import them directly.
347
+ /* v8 ignore next -- a real app manifest always declares dependencies */
348
+ for (const dep of profileDependencyNames(next.manifest)) {
349
+ if (links.has(dep)) continue
350
+ const dir = packageDirFromAnchor(next.anchor, dep)
351
+ // A declared-but-uninstalled dependency cannot be a loader-visible
352
+ // plugin; skip it rather than fail the whole boot.
353
+ if (dir === undefined) continue
354
+ const manifestPath = join(realModuleDirectory(dir), 'package.json')
355
+ let manifest: ProfileManifest
356
+ try {
357
+ manifest = skippedBundles.has(dep) ? readProfileManifest('dsh', dir) : readPackageManifest(manifestPath)
358
+ } catch (error) {
359
+ if (!skippedBundles.has(dep)) throw error
360
+ continue
361
+ }
362
+ links.set(dep, dir)
363
+ declarers.set(dep, next.anchor)
364
+ versions.set(dep, manifest.version)
365
+ queue.push({ anchor: manifestPath, manifest })
366
+ }
367
+ }
368
+ return { packageNames: new Set(links.keys()), packageDirs: links, declarers, versions }
369
+ }
370
+
371
+ /** Inputs for {@link createRuntimeResolution}. */
372
+ export interface RuntimeResolutionOptions {
373
+ /** Absolute package.json path of the running dsh installation. */
374
+ installAnchor: string
375
+ /** Loaded profile whose selected bundles may carry profile-local plugins. */
376
+ profile?: Profile
377
+ /** Harness home; defaults to {@link resolveDshHome}. */
378
+ home?: string
379
+ }
380
+
381
+ /**
382
+ * Compute the runtime resolution without writing module-resolution files.
383
+ * @param options - installation anchor, optional loaded profile, and Harness home.
384
+ * @returns the complete immutable runtime resolution.
385
+ */
386
+ export async function createRuntimeResolution(
387
+ options: RuntimeResolutionOptions,
388
+ ): Promise<ProfileRuntimeResolution> {
389
+ const { installAnchor, profile, home = resolveDshHome() } = options
390
+ const profilesDir = join(home, PROFILES_DIR)
391
+ const manifest = readOptionalProfileManifest(profile)
392
+ const { packageNames, packageDirs, declarers, versions } = collectInstallationScopePackages(
393
+ installAnchor, new Set(profile?.skippedBundles.map(skipped => skipped.packageName)),
394
+ )
395
+ const profileDeclarers = new Map<string, string>()
396
+ const profileVersions = new Map<string, string | undefined>()
397
+ const localPackageNames = profile === undefined ? [] : installedProfilePackageNames(profile, manifest)
398
+ const profilePackages: ReadonlyMap<string, string> = profile === undefined
399
+ ? new Map<string, string>()
400
+ : collectProfileScopePackages(profile, packageNames, profileDeclarers, profileVersions)
401
+ const linkedRoots = profile === undefined ? [] : linkedProfileRoots(profile, profilesDir)
402
+ // The Promise return type is the pre-stable API; construction has no asynchronous step.
403
+ return await Promise.resolve(new ProfileRuntimeResolution({ installAnchor, profileDir: profile?.dir, home }, {
404
+ profilesDir,
405
+ profileDir: profile?.dir,
406
+ localPackageNames: Object.freeze(localPackageNames),
407
+ linkedRoots: Object.freeze(linkedRoots.map(root => Object.freeze(root))),
408
+ entries: Object.freeze([
409
+ ...[...packageDirs].map(([name, packageDir]) => Object.freeze({
410
+ name, packageDir, version: versions.get(name),
411
+ declarer: declarers.get(name) as string, scope: 'installation' as const,
412
+ })),
413
+ ...[...profilePackages].map(([name, packageDir]) => Object.freeze({
414
+ name, packageDir, version: profileVersions.get(name),
415
+ declarer: profileDeclarers.get(name) as string, scope: 'profile' as const,
416
+ })),
417
+ ]),
418
+ }))
419
+ }
420
+
421
+ /** Inputs a {@link ProfileRuntimeResolution} reuses to compute its successor. */
422
+ interface ResolutionSource {
423
+ installAnchor: string
424
+ home: string
425
+ profileDir: string | undefined
426
+ }
427
+
428
+ /**
429
+ * A runtime resolution that remembers the inputs it was computed from. Worker environment data carries only its
430
+ * fields; the inputs stay private to the thread that computed it.
431
+ */
432
+ export class ProfileRuntimeResolution implements RuntimeResolution {
433
+ readonly profilesDir: string
434
+ readonly profileDir: string | undefined
435
+ readonly localPackageNames: readonly string[]
436
+ readonly entries: readonly RuntimeResolutionEntry[]
437
+ readonly linkedRoots: readonly LinkedRoot[]
438
+ readonly #source: ResolutionSource
439
+
440
+ /**
441
+ * @param source - inputs of {@link createRuntimeResolution}, reused by {@link computeLatestResolution}.
442
+ * @param table - the computed package table.
443
+ */
444
+ constructor(source: ResolutionSource, table: RuntimeResolution) {
445
+ this.#source = source
446
+ this.profilesDir = table.profilesDir
447
+ this.profileDir = table.profileDir
448
+ this.localPackageNames = table.localPackageNames
449
+ this.entries = table.entries
450
+ this.linkedRoots = table.linkedRoots
451
+ Object.freeze(this)
452
+ }
453
+
454
+ /**
455
+ * Compute the latest generation from the same installation, profile directory, and Harness home, rereading the
456
+ * profile's manifest, bundle selection, and installed packages from disk, without retaining synthetic layers.
457
+ * With no profile directory, only installation packages are recomputed. This instance is unchanged.
458
+ * @returns a new resolution for the latest generation.
459
+ */
460
+ computeLatestResolution(): Promise<ProfileRuntimeResolution> {
461
+ const { installAnchor, home, profileDir } = this.#source
462
+ return createRuntimeResolution({
463
+ installAnchor, home,
464
+ ...profileDir === undefined ? {} : { profile: loadProfileDirectory('dsh', profileDir, installAnchor) },
465
+ })
466
+ }
467
+ }
468
+
469
+ /** Synthetic profiles used by direct callers may have no on-disk manifest. */
470
+ function readOptionalProfileManifest(profile: Profile | undefined): ProfileManifest | undefined {
471
+ if (profile === undefined) return undefined
472
+ try {
473
+ return readPackageManifest(join(profile.dir, 'package.json'))
474
+ } catch (error) {
475
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
476
+ throw error
477
+ }
478
+ }
479
+
480
+ /** Return installed direct dependencies that Node resolves before profile fallback. */
481
+ function installedProfilePackageNames(profile: Profile, manifest: ProfileManifest | undefined): string[] {
482
+ if (manifest === undefined) return []
483
+ return profileDependencyNames(manifest)
484
+ .filter(name => existsSync(join(profile.dir, 'node_modules', name, 'package.json')))
485
+ }
486
+
487
+ /** Collect the first resolvable package directory for each dependency name. */
488
+ function dependencyClosure(
489
+ anchors: readonly string[], reserved: ReadonlySet<string>,
490
+ declarers?: Map<string, string>,
491
+ versions?: Map<string, string | undefined>,
492
+ ): Map<string, string> {
493
+ const links = new Map<string, string>()
494
+ const visited = new Set(reserved)
495
+ for (const anchor of anchors) {
496
+ const canonicalAnchor = join(realModuleDirectory(dirname(anchor)), basename(anchor))
497
+ const manifest = readPackageManifest(canonicalAnchor)
498
+ /* v8 ignore next -- an installable package manifest always declares its name */
499
+ if (manifest.name === undefined) continue
500
+ if (!visited.has(manifest.name)) {
501
+ visited.add(manifest.name)
502
+ links.set(manifest.name, dirname(canonicalAnchor))
503
+ declarers?.set(manifest.name, canonicalAnchor)
504
+ versions?.set(manifest.name, manifest.version)
505
+ }
506
+ const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: canonicalAnchor, manifest }]
507
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
508
+ // Service Provider packages commonly expose Service Definitions as peers.
509
+ /* v8 ignore next -- an installable package manifest always declares dependencies or peers */
510
+ for (const dep of profileDependencyNames(next.manifest)) {
511
+ if (visited.has(dep)) continue
512
+ const dir = packageDirFromAnchor(next.anchor, dep)
513
+ // A declared-but-uninstalled dependency cannot be loader-visible.
514
+ if (dir === undefined) continue
515
+ visited.add(dep)
516
+ links.set(dep, dir)
517
+ declarers?.set(dep, next.anchor)
518
+ const manifestPath = join(realModuleDirectory(dir), 'package.json')
519
+ const dependencyManifest = readPackageManifest(manifestPath)
520
+ versions?.set(dep, dependencyManifest.version)
521
+ queue.push({ anchor: manifestPath, manifest: dependencyManifest })
522
+ }
523
+ }
524
+ }
525
+ return links
526
+ }
527
+
528
+ /** Collect packages carried by the profile's selected bundles that the installation does not supply. */
529
+ function collectProfileScopePackages(
530
+ profile: Profile, installationPackageNames: ReadonlySet<string>,
531
+ declarers?: Map<string, string>,
532
+ versions?: Map<string, string | undefined>,
533
+ ): Map<string, string> {
534
+ const bundleAnchors = profile.layers
535
+ .filter(layer => !installationPackageNames.has(layer.packageName))
536
+ .map(layer => join(layer.packageDir, 'package.json'))
537
+ const bundleLinks = dependencyClosure(bundleAnchors, installationPackageNames, declarers, versions)
538
+ for (const layer of profile.layers) bundleLinks.delete(layer.packageName)
539
+ return bundleLinks
540
+ }
541
+
542
+ /**
543
+ * Read a profile's manifest.
544
+ * @param binName - the diagnostic prefix on the thrown error.
545
+ * @param dir - the profile directory.
546
+ * @returns the parsed manifest.
547
+ */
548
+ export function readProfileManifest(binName: string, dir: string): ProfileManifest {
549
+ const path = join(dir, 'package.json')
550
+ let raw: string
551
+ try {
552
+ raw = readFileSync(path, 'utf8')
553
+ } catch (error) {
554
+ throw new Error(`${binName}: failed to read profile manifest ${path}: ${String(error)}`)
555
+ }
556
+ // The field checks below validate the file data before trusting the parse type.
557
+ const parsed = JSON.parse(raw) as ProfileManifest | null
558
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
559
+ throw new Error(`${binName}: profile manifest ${path} must hold a JSON object`)
560
+ }
561
+ return parsed
562
+ }
563
+
564
+ /**
565
+ * Write a profile's manifest back (2-space JSON, trailing newline).
566
+ * @param dir - the profile directory.
567
+ * @param manifest - the manifest value to persist.
568
+ */
569
+ export function writeProfileManifest(dir: string, manifest: ProfileManifest): void {
570
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n')
571
+ }
572
+
573
+ /** Return whether two bundle lists have the same values in the same order. */
574
+ function sameBundles(left: readonly string[], right: readonly string[]): boolean {
575
+ return left.length === right.length && left.every((value, index) => value === right[index])
576
+ }
577
+
578
+ /** Return `manifest` with `dsh.profile.bundles` replaced, preserving all other fields. */
579
+ function withBundles(manifest: ProfileManifest, bundles: readonly string[]): ProfileManifest {
580
+ return {
581
+ ...manifest,
582
+ dsh: {
583
+ ...manifest.dsh,
584
+ profile: {
585
+ ...manifest.dsh?.profile,
586
+ bundles: [...bundles],
587
+ },
588
+ },
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Normalize an exact installation-owned bundle tuple to its shipped template,
594
+ * preserving all other manifest fields. Other bundle lists remain untouched.
595
+ */
596
+ function normalizeShippedProfile(name: string, dir: string, manifest: ProfileManifest): ProfileManifest {
597
+ const installationOwned = INSTALLATION_OWNED_PROFILE_TUPLES[name]
598
+ const template = PROFILE_TEMPLATES[name]
599
+ const bundles = manifest.dsh?.profile?.bundles
600
+ if (template === undefined || bundles === undefined) return manifest
601
+ const isRetiredTuple = installationOwned !== undefined && sameBundles(bundles, installationOwned)
602
+ if (!isRetiredTuple) return manifest
603
+ const normalized = withBundles(manifest, template.bundles)
604
+ writeProfileManifest(dir, normalized)
605
+ return normalized
606
+ }
607
+
608
+ /**
609
+ * Remove {@link RETIRED_BUNDLES} from a profile's bundle list, writing the
610
+ * manifest back only when it listed one.
611
+ */
612
+ function dropRetiredBundles(dir: string, manifest: ProfileManifest): ProfileManifest {
613
+ const bundles = manifest.dsh?.profile?.bundles ?? []
614
+ const kept = bundles.filter(bundle => !RETIRED_BUNDLES.has(bundle))
615
+ if (kept.length === bundles.length) return manifest
616
+ const normalized = withBundles(manifest, kept)
617
+ writeProfileManifest(dir, normalized)
618
+ return normalized
619
+ }
620
+
621
+ /**
622
+ * Resolve a package's root directory from one anchor without depending on the
623
+ * package exporting `./package.json` (`require.resolve` would need that):
624
+ * probe the require resolution paths for a directory holding the named
625
+ * manifest. This is Node's own node_modules lookup order, so the result
626
+ * matches what the Loader would import from the same anchor, and
627
+ * `existsSync` follows the symlinks pnpm's isolated layout uses.
628
+ */
629
+ function packageDirFromAnchor(anchor: string, packageName: string): string | undefined {
630
+ // resolve.paths returns null only for builtins, which no bundle name is.
631
+ /* v8 ignore next */
632
+ for (const searchPath of createRequire(anchor).resolve.paths(packageName) ?? []) {
633
+ const candidate = join(searchPath, packageName)
634
+ if (existsSync(join(candidate, 'package.json'))) return candidate
635
+ }
636
+ return undefined
637
+ }
638
+
639
+ /**
640
+ * Resolve one bundle package's directory: installation anchor first, then the
641
+ * profile directory. The installation-first order is the contract that
642
+ * `@deepseek-ai/dsh-base` (and every other in-box bundle) always comes from
643
+ * the same installation as the running dsh, never from a profile-local copy.
644
+ * Resolution does not require the package to export `./package.json`.
645
+ * @param binName - the diagnostic prefix on the thrown error.
646
+ * @param packageName - the bundle's package name from `dsh.profile.bundles`.
647
+ * @param installAnchor - absolute path of a file inside the dsh app package (its package.json).
648
+ * @param profileDir - the profile directory (second anchor).
649
+ * @returns the bundle package's absolute directory.
650
+ */
651
+ export function resolveBundleDir(
652
+ binName: string, packageName: string, installAnchor: string, profileDir: string,
653
+ ): string {
654
+ for (const anchor of [installAnchor, join(profileDir, 'package.json')]) {
655
+ const dir = packageDirFromAnchor(anchor, packageName)
656
+ if (dir !== undefined) return dir
657
+ }
658
+ throw new Error(
659
+ `${binName}: cannot resolve profile bundle ${JSON.stringify(packageName)} from the dsh installation or ${profileDir}; `
660
+ + `run 'dsh plugin --profile ${basename(profileDir)} install' if its dependency is not installed`,
661
+ )
662
+ }
663
+
664
+ /**
665
+ * Load an already initialized profile directory without resolving it through
666
+ * the shared Harness home. This is used by application-owned profiles whose
667
+ * package project and lifecycle belong to that application.
668
+ * Retired bundles are removed from the stored bundle list first, rewriting the
669
+ * manifest when it listed one. Unreadable bundles, unsupported Host platforms, and bundles whose own dsh requirements
670
+ * the profile does not exempt are skipped
671
+ * without changing the manifest and listed in `skippedBundles`; nothing is printed.
672
+ * @param binName - the diagnostic prefix on thrown errors.
673
+ * @param dir - absolute profile package directory.
674
+ * @param installAnchor - absolute path of the owning dsh app's package.json.
675
+ * @param options - `userLayer: false` skips reading `cordis.patch.yml`.
676
+ * @returns the successfully loaded bundle layers and optional user patch layer.
677
+ */
678
+ export function loadProfileDirectory(
679
+ binName: string,
680
+ dir: string,
681
+ installAnchor: string,
682
+ options: { userLayer?: boolean } = {},
683
+ ): Profile {
684
+ const manifest = dropRetiredBundles(dir, readProfileManifest(binName, dir))
685
+ const bundles = manifest.dsh?.profile?.bundles ?? []
686
+ const layers: ProfileLayer[] = []
687
+ const skippedBundles: SkippedBundle[] = []
688
+ const exemptions = bundles.length === 0 ? {} : readProfileVersionExemptions(dir)
689
+ for (const packageName of bundles) {
690
+ try {
691
+ const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
692
+ const bundleManifest = readProfileManifest(binName, packageDir)
693
+ const bundle = bundleManifest.dsh?.bundle
694
+ if (bundle === undefined) {
695
+ throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
696
+ }
697
+ assertBundlePlatform(bundle.platforms, process.platform)
698
+ // A bundle is not a plugin row, so row admission never reads its own dsh requirements.
699
+ const issue = evaluatePluginCompatibility(bundleManifest, exemptions)
700
+ if (issue !== undefined && !issue.exempted) throw new Error(pluginCompatibilityWarning(issue))
701
+ const patchPaths = bundlePatchPaths(packageDir, bundle)
702
+ const patches = patchPaths.flatMap(patchPath => loadOverlayPatches(binName, patchPath))
703
+ layers.push({ packageName, packageDir, patchPaths, patches })
704
+ } catch (error) {
705
+ skippedBundles.push({ packageName, reason: String(error) })
706
+ }
707
+ }
708
+ const patchPath = join(dir, PROFILE_PATCH_FILENAME)
709
+ const patches = options.userLayer !== false && existsSync(patchPath)
710
+ ? loadOverlayPatches(binName, patchPath)
711
+ : []
712
+ return { name: basename(dir), dir, layers, patchPath, patches, skippedBundles }
713
+ }
714
+
715
+ /**
716
+ * Load a profile: resolve every `dsh.profile.bundles` entry to its patch
717
+ * layer and parse the profile's own patch file. Unreadable bundles, unsupported Host platforms,
718
+ * and incompatible bundles are skipped and listed in `skippedBundles`; profile manifest and user patch errors still throw.
719
+ * @param binName - the diagnostic prefix on thrown errors.
720
+ * @param name - the profile name.
721
+ * @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
722
+ * @param home - the Harness home; defaults to {@link resolveDshHome}.
723
+ * @param options - `userLayer: false` skips reading `cordis.patch.yml`, so a
724
+ * bundles-only consumer (`--dump-default-config`, a recovery diagnostic)
725
+ * cannot fail on a broken user layer.
726
+ * @returns the loaded profile (empty `patches` when the user layer is skipped).
727
+ */
728
+ export function loadProfile(
729
+ binName: string, name: string, installAnchor: string, home: string = resolveDshHome(),
730
+ options: { userLayer?: boolean } = {},
731
+ ): Profile {
732
+ const dir = resolveProfileDir(name, home)
733
+ if (!existsSync(join(dir, 'package.json'))) {
734
+ const template = PROFILE_TEMPLATES[name]
735
+ if (template === undefined) {
736
+ throw new Error(
737
+ `${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add <package>'`,
738
+ )
739
+ }
740
+ initProfile(dir, template.bundles)
741
+ }
742
+ removeLinkProjections(dir)
743
+ normalizeShippedProfile(name, dir, readProfileManifest(binName, dir))
744
+ return loadProfileDirectory(binName, dir, installAnchor, options)
745
+ }
746
+
747
+ /**
748
+ * Compose patch layers into the effective entry list over an empty root —
749
+ * the same single `applyEntryPatches` call the boot include makes, so flag
750
+ * derivation and config dumps see exactly what mounts.
751
+ * @param layers - patch lists in application order.
752
+ * @param warn - sink for skipped-patch diagnostics; defaults to silent (boot repeats them).
753
+ * @returns the composed entry list.
754
+ */
755
+ export function composeEntries(
756
+ layers: readonly PatchOptions[][], warn: (message: string) => void = () => {},
757
+ ): EntryOptions[] {
758
+ return applyEntryPatches([], structuredClone(layers.flat()), (message: string, ...args: unknown[]) => {
759
+ let index = 0
760
+ warn(message.replace(/%C/g, () => JSON.stringify(args[index++])))
761
+ })
762
+ }