Files
deepseek-harness/packages/boot/app-boot/src/profile.ts
T
Chinesezjc 41c29fa832 feat(schedule): ship the Schedule switch as an optional bundle
The shipped Web composition carries `time-context`, `schedule`, and
`ui-schedule` disabled. This adds
`@deepseek-ai/dsh-experimental-schedule-bundle`, whose patch sets
`disabled: false` on those three rows and inserts none, and names it in
`OPTIONAL_BUNDLES` with a runtime dependency of `apps/cli`, so every
installation ships it switched off and the Web Plugins page offers it in
the Official group.

The two Web suites and the built-Web Schedule Remote scenario enable the
rows through that bundle instead of a scenario-local fixture.
2026-09-24 21:03:26 +08:00

739 lines
33 KiB
TypeScript

/**
* Profile discovery, initialization, and patch-layer composition for the
* `dsh --profile` launcher family.
*
* A profile is a directory under `$DSH_HOME/profiles/<name>` holding a
* `package.json` (out-of-tree plugin dependencies plus the profile manifest
* `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml`
* (the user's own patch layer, applied after every bundle layer). Bundles are
* npm packages whose manifest declares
* `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` (one file, or an
* ordered list of files); the tree is composed by applying each bundle's patch
* lists in `dsh.profile.bundles` order over an empty entry list, then the
* profile's own patches, then any launcher layers (`--patch` files and
* flag-derived patches).
*
* Module resolution is two-anchor by construction: a bundle name resolves
* first from the dsh installation (the launcher's own package), then from the
* profile directory. Pnpm-managed entries in the profile's `node_modules`
* resolve first. The runtime resolution supplies packages carried by the
* installation and selected bundles to Node's ESM and CommonJS resolvers.
* @module @deepseek-ai/dsh-app-boot/profile
*/
import { createRequire } from 'node:module'
import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import type { DshBundleManifest, DshPackageManifest } from '@deepseek-ai/dsh-package-manifest'
import { evaluatePluginCompatibility, pluginCompatibilityWarning } from './plugin-compatibility.ts'
import { readProfileVersionExemptions } from './profile-compatibility.ts'
import { loadOverlayPatches } from './index.ts'
import { realModuleDirectory } from './profile-resolution/legacy-links.ts'
/** Directory under the Harness home holding every profile. */
export const PROFILES_DIR = 'profiles'
/** The user patch layer inside a profile directory (hot-reloaded on long-lived surfaces). */
export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
/** Installation-owned defaults used when a shipped profile is first opened. */
export interface ProfileTemplate {
/** Ordered bundle layer list. */
bundles: readonly string[]
}
/** Package metadata accepted by the profile reader; local profiles need no published identity. */
export type ProfileManifest = Partial<DshPackageManifest>
/**
* The patch files a bundle declares, as written: one file for a string
* `patch`, the listed files in order for an array.
* @param bundle - the bundle's `dsh.bundle` declaration, as read from package.json.
* @returns the package-relative patch file paths in application order.
* @throws {Error} when `patch` is neither a string nor a list of strings.
*/
export function bundlePatchFiles(bundle: DshBundleManifest): string[] {
const declared = typeof bundle.patch === 'string' ? [bundle.patch] : bundle.patch
if (!Array.isArray(declared) || !declared.every(file => typeof file === 'string')) {
throw new Error('dsh.bundle.patch must be a file path or a list of file paths')
}
return declared
}
/**
* Resolve a bundle declaration to its ordered absolute patch files.
* @param packageDir - absolute directory of the bundle package.
* @param bundle - the bundle's `dsh.bundle` declaration, as read from package.json.
* @returns the absolute patch file paths in application order.
* @throws {Error} when `patch` is neither a string nor a list of strings.
*/
export function bundlePatchPaths(packageDir: string, bundle: DshBundleManifest): string[] {
return bundlePatchFiles(bundle).map(file => join(packageDir, file))
}
/** One resolved bundle layer of a profile. */
export interface ProfileLayer {
/** The bundle's package name, as listed in `dsh.profile.bundles`. */
packageName: string
/** Absolute directory of the resolved bundle package. */
packageDir: string
/** Absolute paths of the bundle's patch files, in application order. */
patchPaths: readonly string[]
/** The parsed patch lists of every file, concatenated in application order. */
patches: PatchOptions[]
}
/** A loaded profile: resolved bundle layers plus the user's own patch layer. */
export interface Profile {
/** The profile name (its directory basename). */
name: string
/** Absolute profile directory. */
dir: string
/** Bundle layers in `dsh.profile.bundles` order. */
layers: ProfileLayer[]
/** Absolute path of the profile's own patch file. */
patchPath: string
/** The profile's own patches; empty when the file is absent. */
patches: PatchOptions[]
/** Selected bundles that contributed no layer, in `dsh.profile.bundles` order, with why. */
skippedBundles: SkippedBundle[]
}
/** A selected bundle the profile could not load, or whose own DSH peers the profile does not exempt. */
export interface SkippedBundle {
/** The bundle's package name from `dsh.profile.bundles`. */
packageName: string
/** The resolution, manifest, compatibility, or patch-loading failure. */
reason: string
}
/**
* Print each skipped bundle once; loading never prints, so launchers call this once per start.
* @param binName - the diagnostic prefix.
* @param profile - the loaded profile.
*/
export function reportSkippedBundles(binName: string, profile: Pick<Profile, 'skippedBundles'>): void {
for (const { packageName, reason } of profile.skippedBundles) {
process.stderr.write(`${binName}: skipping profile bundle ${JSON.stringify(packageName)}: ${reason}\n`)
}
}
/** One package the runtime resolution supplies at the interception layer. */
export interface RuntimeResolutionEntry {
/** Bare package name. */
readonly name: string
/** Package directory selected by the existing dependency traversal. */
readonly packageDir: string
/** Selected package version when its manifest declares one. */
readonly version: string | undefined
/** Manifest whose dependency edge selected this package. */
readonly declarer: string
/** Whether every profile or only the active profile receives this entry. */
readonly scope: 'installation' | 'profile'
}
/**
* A profile node_modules entry linked to a directory outside the shared profiles tree and the active profile.
* Importers below `realPath` use Node's real ancestor chain, with peer mappings read at each node_modules position.
*/
export interface LinkedRoot {
/** Package name of the profile `node_modules` entry, including its scope. */
readonly name: string
/** Real directory outside the shared profiles tree and active profile; a package.json is optional. */
readonly realPath: string
}
/** Complete immutable package table for one profile launch. */
export interface RuntimeResolution {
/** Directory containing every profile; its node_modules is the interception layer. */
readonly profilesDir: string
/** Active profile directory, when profile-scope entries were included. */
readonly profileDir: string | undefined
/** Profile-declared packages installed in the profile's own node_modules. */
readonly localPackageNames: readonly string[]
/** Installation-scope entries followed by profile-scope entries in precedence order. */
readonly entries: readonly RuntimeResolutionEntry[]
/** Active profile links to external directories, sorted by name. */
readonly linkedRoots: readonly LinkedRoot[]
}
/**
* Resolve a profile's directory under the Harness home.
* @param name - the profile name (`dsh --profile <name>`).
* @param home - the Harness home; defaults to {@link resolveDshHome}.
* @returns the absolute profile directory (which may not exist yet).
*/
export function resolveProfileDir(name: string, home: string = resolveDshHome()): string {
if (name === '' || name.includes('/') || name.includes('\\') || name === '.' || name === '..'
// Node reserves this name for dependency lookup.
|| name === 'node_modules') {
throw new Error(`dsh: invalid profile name ${JSON.stringify(name)}`)
}
return join(home, PROFILES_DIR, name)
}
/** The shipped profile templates auto-initialized on first use, by name. */
export const PROFILE_TEMPLATES: Record<string, ProfileTemplate> = {
acp: {
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app'],
},
web: {
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
},
headless: {
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
},
sdk: {
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'],
},
'sdk-minimal': {
bundles: ['@deepseek-ai/dsh-sdk-minimal'],
},
}
/** Installation-owned bundle tuples normalized to the shipped template. */
const INSTALLATION_OWNED_PROFILE_TUPLES: Record<string, readonly string[]> = {
headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'],
}
/** The bundle list a `dsh plugin` init uses for a name with no shipped template. */
export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base']
/**
* The bundles the dsh installation ships for a person to switch on: each a
* runtime dependency of the installation that declares `dsh.bundle.patch`,
* an `icon`, and `./locale/*.json` display metadata, selected by no shipped
* template, and offered switched off by the plugin manager
* ([rationale](../../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md),
* [admission](../../../../.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md)).
*/
export const OPTIONAL_BUNDLES: readonly string[] = [
'@deepseek-ai/dsh-experimental-agent-team-profile',
'@deepseek-ai/dsh-experimental-voice-input-bundle',
'@deepseek-ai/dsh-experimental-auto-review',
'@deepseek-ai/dsh-experimental-schedule-bundle',
]
const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer:
# a top-level YAML array of loader patch entries (id-targeted config
# overrides, disables, and insert lists; \`!!js\` expressions allowed).
[]
`
// The hoisted linker gives out-of-tree plugins a flat node_modules whose
// missing peers (cordis and friends) use the runtime resolution, so every plugin shares the
// installation's single cordis instance instead of a duplicate. pnpm ≥10
// reads its settings from pnpm-workspace.yaml, not .npmrc.
const PROFILE_PNPM_WORKSPACE = `packages:
- .
nodeLinker: hoisted
autoInstallPeers: false
`
/**
* Initialize a profile directory: manifest, empty user patch layer, and the
* pnpm settings out-of-tree plugins need. Existing files are never touched,
* so re-running is a no-op on an initialized profile.
* @param dir - the profile directory from {@link resolveProfileDir}.
* @param bundles - the initial `dsh.profile.bundles` layer list.
*/
export function initProfile(
dir: string,
bundles: readonly string[],
): void {
mkdirSync(dir, { recursive: true })
const manifestPath = join(dir, 'package.json')
if (!existsSync(manifestPath)) {
const manifest: ProfileManifest & { private: boolean } = {
name: `dsh-profile-${basename(dir)}`,
private: true,
dependencies: {},
dsh: { profile: { bundles: [...bundles] } },
}
writeFileSync(manifestPath, JSON.stringify(manifest, undefined, 2) + '\n')
}
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE)
const workspacePath = join(dir, 'pnpm-workspace.yaml')
if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE)
}
/** Directory where the link backend of the dsh 0.1.5 releases projected bundle-carried packages into a profile. */
const LINK_PROJECTION_DIR = '.dsh-module-fallback'
/**
* Remove the package projections a link-backend launch left in a profile.
* Only symlinks under the profile's `node_modules` whose target lies inside
* `<profile>/.dsh-module-fallback/node_modules` are unlinked, then that directory is removed;
* pnpm-installed packages and every other symlink stay. A profile without the directory is untouched.
* @param dir - the profile directory.
*/
export function removeLinkProjections(dir: string): void {
const owned = join(dir, LINK_PROJECTION_DIR)
if (!existsSync(owned)) return
const ownedModules = join(owned, 'node_modules')
for (const link of symlinksUnder(join(dir, 'node_modules'))) {
if (pointsInto(link, ownedModules)) unlinkSync(link)
}
rmSync(owned, { recursive: true, force: true })
}
/** Top-level and scoped entries under a node_modules directory that are symlinks or junctions. */
function symlinksUnder(modules: string): string[] {
const links: string[] = []
if (!existsSync(modules)) return links
for (const entry of readdirSync(modules, { withFileTypes: true })) {
const path = join(modules, entry.name)
if (entry.isSymbolicLink()) {
links.push(path)
} else if (entry.name.startsWith('@') && entry.isDirectory()) {
for (const child of readdirSync(path, { withFileTypes: true })) {
if (child.isSymbolicLink()) links.push(join(path, child.name))
}
}
}
return links
}
/**
* Active profile `node_modules` entries linked outside the shared profiles tree and the active profile.
* Missing targets and files are not linked roots; invalid link chains retain Node's diagnostic.
*/
function linkedProfileRoots(profile: Profile, profilesDir: string): LinkedRoot[] {
const modules = join(profile.dir, 'node_modules')
const links = symlinksUnder(modules)
if (links.length === 0) return []
let tree: string
try {
tree = realModuleDirectory(profilesDir) + sep
} catch (error) {
// A profiles tree that is not materialized yet holds no links.
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
tree = resolve(profilesDir) + sep
}
const excludedTrees = [tree, realModuleDirectory(profile.dir) + sep]
const roots: LinkedRoot[] = []
for (const linkPath of links) {
let realPath: string
try {
realPath = realModuleDirectory(linkPath)
} catch (error) {
// A dangling link is not a package Node can load from the profile.
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
continue
}
if (excludedTrees.some(prefix => realPath + sep === prefix || realPath.startsWith(prefix))
|| !statSync(realPath).isDirectory()) continue
roots.push({ name: relative(modules, linkPath).split(sep).join('/'), realPath })
}
return roots.sort((left, right) => left.name.localeCompare(right.name))
}
/** Whether a symlink's target directory is `root` or lies below it. */
function pointsInto(link: string, root: string): boolean {
try {
const target = resolve(dirname(link), readlinkSync(link))
const parent = realpathSync.native(dirname(target))
const rootPath = realpathSync.native(root)
return parent === rootPath || parent.startsWith(rootPath + sep)
} catch (error) {
// A target whose parent no longer exists cannot be one of the projections this launch owns.
/* v8 ignore next 2 -- a non-ENOENT realpath failure requires a host filesystem fault */
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
/* v8 ignore next -- see the host-filesystem exception above */
throw error
}
}
/** Read one package manifest while traversing a dependency graph. */
function readPackageManifest(anchor: string): ProfileManifest {
return JSON.parse(readFileSync(anchor, 'utf8')) as ProfileManifest
}
/** Return dependency names that may be imported by a loader-visible plugin. */
function profileDependencyNames(manifest: ProfileManifest): string[] {
return [...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})]
}
/** Resolve the installation packages that the runtime resolver supplies to every profile. */
function collectInstallationScopePackages(
installAnchor: string, skippedBundles: ReadonlySet<string>,
): {
packageNames: ReadonlySet<string>
packageDirs: ReadonlyMap<string, string>
declarers: ReadonlyMap<string, string>
versions: ReadonlyMap<string, string | undefined>
} {
// Real declaring paths keep workspace symlinks under node_modules from disabling tsx path mappings.
const canonicalAnchor = join(realModuleDirectory(dirname(installAnchor)), basename(installAnchor))
const appManifest = readPackageManifest(canonicalAnchor)
const links = new Map<string, string>()
const declarers = new Map<string, string>()
const versions = new Map<string, string | undefined>()
/* v8 ignore next -- a real app manifest always declares its name */
if (appManifest.name !== undefined) {
// The CLI derives the installation anchor from import.meta.url (already real) and the Desktop Host from its
// runtime directory (not a symlink); the directory is kept as given rather than canonicalized here.
links.set(appManifest.name, dirname(installAnchor))
declarers.set(appManifest.name, canonicalAnchor)
versions.set(appManifest.name, appManifest.version)
}
// BFS over the resolvable dependency graph; the visited set is the link
// map itself (first resolution wins, matching Node's own nearest-wins).
const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: canonicalAnchor, manifest: appManifest }]
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
// Peer dependencies participate: Service Definition packages (dsh-subprocess,
// dsh-compaction, ...) are peers of their implementations, never plain
// dependencies, yet out-of-tree plugins import them directly.
/* v8 ignore next -- a real app manifest always declares dependencies */
for (const dep of profileDependencyNames(next.manifest)) {
if (links.has(dep)) continue
const dir = packageDirFromAnchor(next.anchor, dep)
// A declared-but-uninstalled dependency cannot be a loader-visible
// plugin; skip it rather than fail the whole boot.
if (dir === undefined) continue
const manifestPath = join(realModuleDirectory(dir), 'package.json')
let manifest: ProfileManifest
try {
manifest = skippedBundles.has(dep) ? readProfileManifest('dsh', dir) : readPackageManifest(manifestPath)
} catch (error) {
if (!skippedBundles.has(dep)) throw error
continue
}
links.set(dep, dir)
declarers.set(dep, next.anchor)
versions.set(dep, manifest.version)
queue.push({ anchor: manifestPath, manifest })
}
}
return { packageNames: new Set(links.keys()), packageDirs: links, declarers, versions }
}
/** Inputs for {@link createRuntimeResolution}. */
export interface RuntimeResolutionOptions {
/** Absolute package.json path of the running dsh installation. */
installAnchor: string
/** Loaded profile whose selected bundles may carry profile-local plugins. */
profile?: Profile
/** Harness home; defaults to {@link resolveDshHome}. */
home?: string
}
/**
* Compute the runtime resolution without writing module-resolution files.
* @param options - installation anchor, optional loaded profile, and Harness home.
* @returns the complete immutable runtime resolution.
*/
export async function createRuntimeResolution(
options: RuntimeResolutionOptions,
): Promise<RuntimeResolution> {
const { installAnchor, profile, home = resolveDshHome() } = options
const profilesDir = join(home, PROFILES_DIR)
const manifest = readOptionalProfileManifest(profile)
const { packageNames, packageDirs, declarers, versions } = collectInstallationScopePackages(
installAnchor, new Set(profile?.skippedBundles.map(skipped => skipped.packageName)),
)
const profileDeclarers = new Map<string, string>()
const profileVersions = new Map<string, string | undefined>()
const localPackageNames = profile === undefined ? [] : installedProfilePackageNames(profile, manifest)
const profilePackages: ReadonlyMap<string, string> = profile === undefined
? new Map<string, string>()
: collectProfileScopePackages(profile, packageNames, profileDeclarers, profileVersions)
const linkedRoots = profile === undefined ? [] : linkedProfileRoots(profile, profilesDir)
// The Promise return type is the pre-stable API; construction has no asynchronous step.
return await Promise.resolve(Object.freeze({
profilesDir,
profileDir: profile?.dir,
localPackageNames: Object.freeze(localPackageNames),
linkedRoots: Object.freeze(linkedRoots.map(root => Object.freeze(root))),
entries: Object.freeze([
...[...packageDirs].map(([name, packageDir]) => Object.freeze({
name, packageDir, version: versions.get(name),
declarer: declarers.get(name) as string, scope: 'installation' as const,
})),
...[...profilePackages].map(([name, packageDir]) => Object.freeze({
name, packageDir, version: profileVersions.get(name),
declarer: profileDeclarers.get(name) as string, scope: 'profile' as const,
})),
]),
}))
}
/** Synthetic profiles used by direct callers may have no on-disk manifest. */
function readOptionalProfileManifest(profile: Profile | undefined): ProfileManifest | undefined {
if (profile === undefined) return undefined
try {
return readPackageManifest(join(profile.dir, 'package.json'))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
throw error
}
}
/** Return installed direct dependencies that Node resolves before profile fallback. */
function installedProfilePackageNames(profile: Profile, manifest: ProfileManifest | undefined): string[] {
if (manifest === undefined) return []
return profileDependencyNames(manifest)
.filter(name => existsSync(join(profile.dir, 'node_modules', name, 'package.json')))
}
/** Collect the first resolvable package directory for each dependency name. */
function dependencyClosure(
anchors: readonly string[], reserved: ReadonlySet<string>,
declarers?: Map<string, string>,
versions?: Map<string, string | undefined>,
): Map<string, string> {
const links = new Map<string, string>()
const visited = new Set(reserved)
for (const anchor of anchors) {
const canonicalAnchor = join(realModuleDirectory(dirname(anchor)), basename(anchor))
const manifest = readPackageManifest(canonicalAnchor)
/* v8 ignore next -- an installable package manifest always declares its name */
if (manifest.name === undefined) continue
if (!visited.has(manifest.name)) {
visited.add(manifest.name)
links.set(manifest.name, dirname(canonicalAnchor))
declarers?.set(manifest.name, canonicalAnchor)
versions?.set(manifest.name, manifest.version)
}
const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: canonicalAnchor, manifest }]
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
// Service Provider packages commonly expose Service Definitions as peers.
/* v8 ignore next -- an installable package manifest always declares dependencies or peers */
for (const dep of profileDependencyNames(next.manifest)) {
if (visited.has(dep)) continue
const dir = packageDirFromAnchor(next.anchor, dep)
// A declared-but-uninstalled dependency cannot be loader-visible.
if (dir === undefined) continue
visited.add(dep)
links.set(dep, dir)
declarers?.set(dep, next.anchor)
const manifestPath = join(realModuleDirectory(dir), 'package.json')
const dependencyManifest = readPackageManifest(manifestPath)
versions?.set(dep, dependencyManifest.version)
queue.push({ anchor: manifestPath, manifest: dependencyManifest })
}
}
}
return links
}
/** Collect packages carried by the profile's selected bundles that the installation does not supply. */
function collectProfileScopePackages(
profile: Profile, installationPackageNames: ReadonlySet<string>,
declarers?: Map<string, string>,
versions?: Map<string, string | undefined>,
): Map<string, string> {
const bundleAnchors = profile.layers
.filter(layer => !installationPackageNames.has(layer.packageName))
.map(layer => join(layer.packageDir, 'package.json'))
const bundleLinks = dependencyClosure(bundleAnchors, installationPackageNames, declarers, versions)
for (const layer of profile.layers) bundleLinks.delete(layer.packageName)
return bundleLinks
}
/**
* Read a profile's manifest.
* @param binName - the diagnostic prefix on the thrown error.
* @param dir - the profile directory.
* @returns the parsed manifest.
*/
export function readProfileManifest(binName: string, dir: string): ProfileManifest {
const path = join(dir, 'package.json')
let raw: string
try {
raw = readFileSync(path, 'utf8')
} catch (error) {
throw new Error(`${binName}: failed to read profile manifest ${path}: ${String(error)}`)
}
// The field checks below validate the file data before trusting the parse type.
const parsed = JSON.parse(raw) as ProfileManifest | null
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${binName}: profile manifest ${path} must hold a JSON object`)
}
return parsed
}
/**
* Write a profile's manifest back (2-space JSON, trailing newline).
* @param dir - the profile directory.
* @param manifest - the manifest value to persist.
*/
export function writeProfileManifest(dir: string, manifest: ProfileManifest): void {
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n')
}
/** Return whether two bundle lists have the same values in the same order. */
function sameBundles(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
/**
* Normalize an exact installation-owned bundle tuple to its shipped template,
* preserving all other manifest fields. Other bundle lists remain untouched.
*/
function normalizeShippedProfile(name: string, dir: string, manifest: ProfileManifest): ProfileManifest {
const installationOwned = INSTALLATION_OWNED_PROFILE_TUPLES[name]
const template = PROFILE_TEMPLATES[name]
const bundles = manifest.dsh?.profile?.bundles
if (template === undefined || bundles === undefined) return manifest
const isRetiredTuple = installationOwned !== undefined && sameBundles(bundles, installationOwned)
if (!isRetiredTuple) return manifest
const normalized: ProfileManifest = {
...manifest,
dsh: {
...manifest.dsh,
profile: {
...manifest.dsh?.profile,
bundles: [...template.bundles],
},
},
}
writeProfileManifest(dir, normalized)
return normalized
}
/**
* Resolve a package's root directory from one anchor without depending on the
* package exporting `./package.json` (`require.resolve` would need that):
* probe the require resolution paths for a directory holding the named
* manifest. This is Node's own node_modules lookup order, so the result
* matches what the Loader would import from the same anchor, and
* `existsSync` follows the symlinks pnpm's isolated layout uses.
*/
function packageDirFromAnchor(anchor: string, packageName: string): string | undefined {
// resolve.paths returns null only for builtins, which no bundle name is.
/* v8 ignore next */
for (const searchPath of createRequire(anchor).resolve.paths(packageName) ?? []) {
const candidate = join(searchPath, packageName)
if (existsSync(join(candidate, 'package.json'))) return candidate
}
return undefined
}
/**
* Resolve one bundle package's directory: installation anchor first, then the
* profile directory. The installation-first order is the contract that
* `@deepseek-ai/dsh-base` (and every other in-box bundle) always comes from
* the same installation as the running dsh, never from a profile-local copy.
* Resolution does not require the package to export `./package.json`.
* @param binName - the diagnostic prefix on the thrown error.
* @param packageName - the bundle's package name from `dsh.profile.bundles`.
* @param installAnchor - absolute path of a file inside the dsh app package (its package.json).
* @param profileDir - the profile directory (second anchor).
* @returns the bundle package's absolute directory.
*/
export function resolveBundleDir(
binName: string, packageName: string, installAnchor: string, profileDir: string,
): string {
for (const anchor of [installAnchor, join(profileDir, 'package.json')]) {
const dir = packageDirFromAnchor(anchor, packageName)
if (dir !== undefined) return dir
}
throw new Error(
`${binName}: cannot resolve profile bundle ${JSON.stringify(packageName)} from the dsh installation or ${profileDir}; `
+ `run 'dsh plugin --profile ${basename(profileDir)} install' if its dependency is not installed`,
)
}
/**
* Load an already initialized profile directory without resolving it through
* the shared Harness home. This is used by application-owned profiles whose
* package project and lifecycle belong to that application.
* Unreadable bundles, and bundles whose own dsh peers the profile does not exempt, are skipped
* without changing the manifest and listed in `skippedBundles`; nothing is printed.
* @param binName - the diagnostic prefix on thrown errors.
* @param dir - absolute profile package directory.
* @param installAnchor - absolute path of the owning dsh app's package.json.
* @param options - `userLayer: false` skips reading `cordis.patch.yml`.
* @returns the successfully loaded bundle layers and optional user patch layer.
*/
export function loadProfileDirectory(
binName: string,
dir: string,
installAnchor: string,
options: { userLayer?: boolean } = {},
): Profile {
const manifest = readProfileManifest(binName, dir)
const bundles = manifest.dsh?.profile?.bundles ?? []
const layers: ProfileLayer[] = []
const skippedBundles: SkippedBundle[] = []
const exemptions = bundles.length === 0 ? {} : readProfileVersionExemptions(dir)
for (const packageName of bundles) {
try {
const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
const bundleManifest = readProfileManifest(binName, packageDir)
const bundle = bundleManifest.dsh?.bundle
if (bundle === undefined) {
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
}
// A bundle is not a plugin row, so row admission never reads its own peers.
const issue = evaluatePluginCompatibility(bundleManifest, exemptions)
if (issue !== undefined && !issue.exempted) throw new Error(pluginCompatibilityWarning(issue))
const patchPaths = bundlePatchPaths(packageDir, bundle)
const patches = patchPaths.flatMap(patchPath => loadOverlayPatches(binName, patchPath))
layers.push({ packageName, packageDir, patchPaths, patches })
} catch (error) {
skippedBundles.push({ packageName, reason: String(error) })
}
}
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
const patches = options.userLayer !== false && existsSync(patchPath)
? loadOverlayPatches(binName, patchPath)
: []
return { name: basename(dir), dir, layers, patchPath, patches, skippedBundles }
}
/**
* Load a profile: resolve every `dsh.profile.bundles` entry to its patch
* layer and parse the profile's own patch file. Unreadable or incompatible bundles
* are skipped and listed in `skippedBundles`; profile manifest and user patch errors still throw.
* @param binName - the diagnostic prefix on thrown errors.
* @param name - the profile name.
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
* @param home - the Harness home; defaults to {@link resolveDshHome}.
* @param options - `userLayer: false` skips reading `cordis.patch.yml`, so a
* bundles-only consumer (`--dump-default-config`, a recovery diagnostic)
* cannot fail on a broken user layer.
* @returns the loaded profile (empty `patches` when the user layer is skipped).
*/
export function loadProfile(
binName: string, name: string, installAnchor: string, home: string = resolveDshHome(),
options: { userLayer?: boolean } = {},
): Profile {
const dir = resolveProfileDir(name, home)
if (!existsSync(join(dir, 'package.json'))) {
const template = PROFILE_TEMPLATES[name]
if (template === undefined) {
throw new Error(
`${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add <package>'`,
)
}
initProfile(dir, template.bundles)
}
removeLinkProjections(dir)
normalizeShippedProfile(name, dir, readProfileManifest(binName, dir))
return loadProfileDirectory(binName, dir, installAnchor, options)
}
/**
* Compose patch layers into the effective entry list over an empty root —
* the same single `applyEntryPatches` call the boot include makes, so flag
* derivation and config dumps see exactly what mounts.
* @param layers - patch lists in application order.
* @param warn - sink for skipped-patch diagnostics; defaults to silent (boot repeats them).
* @returns the composed entry list.
*/
export function composeEntries(
layers: readonly PatchOptions[][], warn: (message: string) => void = () => {},
): EntryOptions[] {
return applyEntryPatches([], structuredClone(layers.flat()), (message: string, ...args: unknown[]) => {
let index = 0
warn(message.replace(/%C/g, () => JSON.stringify(args[index++])))
})
}