返回源码地图

packages/boot/app-boot/src/profile.ts

main snapshot · da00f7f5358f · 正文引用章节 04 / 04 / 04 / 04 / 04 / 04;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

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