1
/**2
* Profile discovery, initialization, and patch-layer composition for the3
* `dsh --profile` launcher family.4
*5
* A profile is a directory under `$DSH_HOME/profiles/<name>` holding a6
* `package.json` (out-of-tree plugin dependencies plus the profile manifest7
* `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 are9
* npm packages whose manifest declares10
* `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` (one file, or an11
* ordered list of files); the tree is composed by applying each bundle's patch12
* lists in `dsh.profile.bundles` order over an empty entry list, then the13
* profile's own patches, then any launcher layers (`--patch` files and14
* flag-derived patches).15
*16
* Module resolution is two-anchor by construction: a bundle name resolves17
* first from the dsh installation (the launcher's own package), then from the18
* profile directory. Pnpm-managed entries in the profile's `node_modules`19
* resolve first. The runtime resolution supplies packages carried by the20
* installation and selected bundles to Node's ESM and CommonJS resolvers.21
* @module @deepseek-ai/dsh-app-boot/profile22
*/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 { evaluatePluginCompatibility, pluginCompatibilityWarning } from './plugin-compatibility.ts'32
import { readProfileVersionExemptions } from './profile-compatibility.ts'33
import { loadOverlayPatches } from './index.ts'34
import { realModuleDirectory } from './profile-resolution/legacy-links.ts'36
/** Directory under the Harness home holding every profile. */37
export const PROFILES_DIR = 'profiles'39
/** The user patch layer inside a profile directory (hot-reloaded on long-lived surfaces). */40
export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'42
/** Installation-owned defaults used when a shipped profile is first opened. */43
export interface ProfileTemplate {44
/** Ordered bundle layer list. */45
bundles: readonly string[]46
}48
/** Package metadata accepted by the profile reader; local profiles need no published identity. */49
export type ProfileManifest = Partial<DshPackageManifest>51
/**52
* The patch files a bundle declares, as written: one file for a string53
* `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
*/58
export function bundlePatchFiles(bundle: DshBundleManifest): string[] {59
const declared = typeof bundle.patch === 'string' ? [bundle.patch] : bundle.patch60
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 declared64
}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
*/73
export function bundlePatchPaths(packageDir: string, bundle: DshBundleManifest): string[] {74
return bundlePatchFiles(bundle).map(file => join(packageDir, file))75
}77
/** One resolved bundle layer of a profile. */78
export interface ProfileLayer {79
/** The bundle's package name, as listed in `dsh.profile.bundles`. */80
packageName: string81
/** Absolute directory of the resolved bundle package. */82
packageDir: string83
/** 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
}89
/** A loaded profile: resolved bundle layers plus the user's own patch layer. */90
export interface Profile {91
/** The profile name (its directory basename). */92
name: string93
/** Absolute profile directory. */94
dir: string95
/** Bundle layers in `dsh.profile.bundles` order. */96
layers: ProfileLayer[]97
/** Absolute path of the profile's own patch file. */98
patchPath: string99
/** 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
}105
/** A selected bundle the profile could not load, or whose own DSH peers the profile does not exempt. */106
export interface SkippedBundle {107
/** The bundle's package name from `dsh.profile.bundles`. */108
packageName: string109
/** The resolution, manifest, compatibility, or patch-loading failure. */110
reason: string111
}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
*/118
export 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
}124
/** One package the runtime resolution supplies at the interception layer. */125
export interface RuntimeResolutionEntry {126
/** Bare package name. */127
readonly name: string128
/** Package directory selected by the existing dependency traversal. */129
readonly packageDir: string130
/** Selected package version when its manifest declares one. */131
readonly version: string | undefined132
/** Manifest whose dependency edge selected this package. */133
readonly declarer: string134
/** Whether every profile or only the active profile receives this entry. */135
readonly scope: 'installation' | 'profile'136
}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
*/142
export interface LinkedRoot {143
/** Package name of the profile `node_modules` entry, including its scope. */144
readonly name: string145
/** Real directory outside the shared profiles tree and active profile; a package.json is optional. */146
readonly realPath: string147
}149
/** Complete immutable package table for one profile launch. */150
export interface RuntimeResolution {151
/** Directory containing every profile; its node_modules is the interception layer. */152
readonly profilesDir: string153
/** Active profile directory, when profile-scope entries were included. */154
readonly profileDir: string | undefined155
/** 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
}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
*/169
export 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
}178
/** The shipped profile templates auto-initialized on first use, by name. */179
export 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
}197
/** Installation-owned bundle tuples normalized to the shipped template. */198
const INSTALLATION_OWNED_PROFILE_TUPLES: Record<string, readonly string[]> = {199
headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'],200
}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
*/206
const RETIRED_BUNDLES: ReadonlySet<string> = new Set([207
// The Web composition mounts Schedule itself208
// ([upgrade guide](../../../../docs/upgrade-guide/v0.2.0-rc.2/schedule-bundle-retired/guide.md)).209
'@deepseek-ai/dsh-experimental-schedule-bundle',210
])212
/** The bundle list a `dsh plugin` init uses for a name with no shipped template. */213
export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base']215
/**216
* The bundles the dsh installation ships for a person to switch on: each a217
* runtime dependency of the installation that declares `dsh.bundle.patch`,218
* an `icon`, and `./locale/*.json` display metadata, selected by no shipped219
* template, and offered switched off by the plugin manager220
* ([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
*/223
export 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
]230
const 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 config232
# overrides, disables, and insert lists; \`!!js\` expressions allowed).233
[]234
`236
// The hoisted linker gives out-of-tree plugins a flat node_modules whose237
// missing peers (cordis and friends) use the runtime resolution, so every plugin shares the238
// installation's single cordis instance instead of a duplicate. pnpm ≥10239
// reads its settings from pnpm-workspace.yaml, not .npmrc.240
const PROFILE_PNPM_WORKSPACE = `packages:241
- .243
nodeLinker: hoisted244
autoInstallPeers: false245
`247
/**248
* Initialize a profile directory: manifest, empty user patch layer, and the249
* 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
*/254
export 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
}275
/** Directory where the link backend of the dsh 0.1.5 releases projected bundle-carried packages into a profile. */276
const LINK_PROJECTION_DIR = '.dsh-module-fallback'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 inside281
* `<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
*/285
export function removeLinkProjections(dir: string): void {286
const owned = join(dir, LINK_PROJECTION_DIR)287
if (!existsSync(owned)) return288
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
}295
/** Top-level and scoped entries under a node_modules directory that are symlinks or junctions. */296
function symlinksUnder(modules: string): string[] {297
const links: string[] = []298
if (!existsSync(modules)) return links299
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 links310
}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
*/316
function 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: string321
try {322
tree = realModuleDirectory(profilesDir) + sep323
} catch (error) {324
// A profiles tree that is not materialized yet holds no links.325
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error326
tree = resolve(profilesDir) + sep327
}328
const excludedTrees = [tree, realModuleDirectory(profile.dir) + sep]329
const roots: LinkedRoot[] = []330
for (const linkPath of links) {331
let realPath: string332
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 error337
continue338
}339
if (excludedTrees.some(prefix => realPath + sep === prefix || realPath.startsWith(prefix))340
|| !statSync(realPath).isDirectory()) continue341
roots.push({ name: relative(modules, linkPath).split(sep).join('/'), realPath })342
}343
return roots.sort((left, right) => left.name.localeCompare(right.name))344
}346
/** Whether a symlink's target directory is `root` or lies below it. */347
function 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 false357
/* v8 ignore next -- see the host-filesystem exception above */358
throw error359
}360
}362
/** Read one package manifest while traversing a dependency graph. */363
function readPackageManifest(anchor: string): ProfileManifest {364
return JSON.parse(readFileSync(anchor, 'utf8')) as ProfileManifest365
}367
/** Return dependency names that may be imported by a loader-visible plugin. */368
function profileDependencyNames(manifest: ProfileManifest): string[] {369
return [...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})]370
}372
/** Resolve the installation packages that the runtime resolver supplies to every profile. */373
function 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 its390
// 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 link396
// 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 plain401
// 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)) continue405
const dir = packageDirFromAnchor(next.anchor, dep)406
// A declared-but-uninstalled dependency cannot be a loader-visible407
// plugin; skip it rather than fail the whole boot.408
if (dir === undefined) continue409
const manifestPath = join(realModuleDirectory(dir), 'package.json')410
let manifest: ProfileManifest411
try {412
manifest = skippedBundles.has(dep) ? readProfileManifest('dsh', dir) : readPackageManifest(manifestPath)413
} catch (error) {414
if (!skippedBundles.has(dep)) throw error415
continue416
}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
}426
/** Inputs for {@link createRuntimeResolution}. */427
export interface RuntimeResolutionOptions {428
/** Absolute package.json path of the running dsh installation. */429
installAnchor: string430
/** Loaded profile whose selected bundles may carry profile-local plugins. */431
profile?: Profile432
/** Harness home; defaults to {@link resolveDshHome}. */433
home?: string434
}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
*/441
export async function createRuntimeResolution(442
options: RuntimeResolutionOptions,443
): Promise<ProfileRuntimeResolution> {444
const { installAnchor, profile, home = resolveDshHome() } = options445
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 === undefined454
? 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
}476
/** Inputs a {@link ProfileRuntimeResolution} reuses to compute its successor. */477
interface ResolutionSource {478
installAnchor: string479
home: string480
profileDir: string | undefined481
}483
/**484
* A runtime resolution that remembers the inputs it was computed from. Worker environment data carries only its485
* fields; the inputs stay private to the thread that computed it.486
*/487
export class ProfileRuntimeResolution implements RuntimeResolution {488
readonly profilesDir: string489
readonly profileDir: string | undefined490
readonly localPackageNames: readonly string[]491
readonly entries: readonly RuntimeResolutionEntry[]492
readonly linkedRoots: readonly LinkedRoot[]493
readonly #source: ResolutionSource495
/**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 = source501
this.profilesDir = table.profilesDir502
this.profileDir = table.profileDir503
this.localPackageNames = table.localPackageNames504
this.entries = table.entries505
this.linkedRoots = table.linkedRoots506
Object.freeze(this)507
}509
/**510
* Compute the latest generation from the same installation, profile directory, and Harness home, rereading the511
* 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.#source517
return createRuntimeResolution({518
installAnchor, home,519
...profileDir === undefined ? {} : { profile: loadProfileDirectory('dsh', profileDir, installAnchor) },520
})521
}522
}524
/** Synthetic profiles used by direct callers may have no on-disk manifest. */525
function readOptionalProfileManifest(profile: Profile | undefined): ProfileManifest | undefined {526
if (profile === undefined) return undefined527
try {528
return readPackageManifest(join(profile.dir, 'package.json'))529
} catch (error) {530
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined531
throw error532
}533
}535
/** Return installed direct dependencies that Node resolves before profile fallback. */536
function 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
}542
/** Collect the first resolvable package directory for each dependency name. */543
function 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) continue555
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)) continue567
const dir = packageDirFromAnchor(next.anchor, dep)568
// A declared-but-uninstalled dependency cannot be loader-visible.569
if (dir === undefined) continue570
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 links581
}583
/** Collect packages carried by the profile's selected bundles that the installation does not supply. */584
function 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.layers590
.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 bundleLinks595
}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
*/603
export function readProfileManifest(binName: string, dir: string): ProfileManifest {604
const path = join(dir, 'package.json')605
let raw: string606
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 | null613
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 parsed617
}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
*/624
export function writeProfileManifest(dir: string, manifest: ProfileManifest): void {625
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n')626
}628
/** Return whether two bundle lists have the same values in the same order. */629
function sameBundles(left: readonly string[], right: readonly string[]): boolean {630
return left.length === right.length && left.every((value, index) => value === right[index])631
}633
/** Return `manifest` with `dsh.profile.bundles` replaced, preserving all other fields. */634
function 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
}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
*/651
function 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?.bundles655
if (template === undefined || bundles === undefined) return manifest656
const isRetiredTuple = installationOwned !== undefined && sameBundles(bundles, installationOwned)657
if (!isRetiredTuple) return manifest658
const normalized = withBundles(manifest, template.bundles)659
writeProfileManifest(dir, normalized)660
return normalized661
}663
/**664
* Remove {@link RETIRED_BUNDLES} from a profile's bundle list, writing the665
* manifest back only when it listed one.666
*/667
function 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 manifest671
const normalized = withBundles(manifest, kept)672
writeProfileManifest(dir, normalized)673
return normalized674
}676
/**677
* Resolve a package's root directory from one anchor without depending on the678
* package exporting `./package.json` (`require.resolve` would need that):679
* probe the require resolution paths for a directory holding the named680
* manifest. This is Node's own node_modules lookup order, so the result681
* matches what the Loader would import from the same anchor, and682
* `existsSync` follows the symlinks pnpm's isolated layout uses.683
*/684
function 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 candidate690
}691
return undefined692
}694
/**695
* Resolve one bundle package's directory: installation anchor first, then the696
* profile directory. The installation-first order is the contract that697
* `@deepseek-ai/dsh-base` (and every other in-box bundle) always comes from698
* 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
*/706
export 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 dir712
}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
}719
/**720
* Load an already initialized profile directory without resolving it through721
* the shared Harness home. This is used by application-owned profiles whose722
* package project and lifecycle belong to that application.723
* Retired bundles are removed from the stored bundle list first, rewriting the724
* manifest when it listed one. Unreadable bundles, and bundles whose own dsh peers the profile does not exempt, are skipped725
* 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
*/732
export 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?.bundle748
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
}768
/**769
* Load a profile: resolve every `dsh.profile.bundles` entry to its patch770
* layer and parse the profile's own patch file. Unreadable or incompatible bundles771
* 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 a777
* 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
*/781
export 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
}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 flag803
* 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
*/808
export 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 = 0813
warn(message.replace(/%C/g, () => JSON.stringify(args[index++])))814
})815
}