Merge remote-tracking branch 'origin/master' into claude/unified-environment-credentials-c8841a
# Conflicts: # .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml # .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md # .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md # apps/cli/config/base.cordis.yml # apps/cli/package.json # apps/cli/reference/README.i18n.yaml # apps/cli/reference/README.md # apps/cli/reference/README.zh.md # apps/cli/src/app-cli-entry.ts # apps/cli/src/args.ts # apps/cli/src/bin.ts # apps/cli/src/config.ts # apps/cli/src/dump-config.ts # apps/cli/src/headless.ts # apps/cli/src/web.ts # apps/cli/tests/args.spec.ts # apps/cli/tests/built-bin.e2e.ts # apps/cli/tests/headless-shutdown.e2e.ts # apps/cli/tsconfig.json # docs/user/guide/config.i18n.yaml # docs/user/guide/config.md # docs/user/guide/config.zh.md # examples/mcp-memory/README.i18n.yaml # examples/mcp-memory/README.md # examples/mcp-memory/README.zh.md # packages/bundle/web-app/cordis.patch.yml # packages/cordis/repository-plugin/README.i18n.yaml # packages/cordis/repository-plugin/README.md # packages/cordis/repository-plugin/README.zh.md # packages/credentials/credentials-local/README.i18n.yaml # packages/credentials/credentials-local/README.md # packages/credentials/credentials-local/README.zh.md # packages/ui/app-boot/README.i18n.yaml # packages/ui/app-boot/README.md # packages/ui/app-boot/README.zh.md # packages/ui/app-boot/src/index.ts # packages/ui/app-boot/tests/config-reload.spec.ts # packages/ui/app-boot/tests/user-patches.spec.ts # pnpm-lock.yaml
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
|
||||
* `.env` files, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
|
||||
* explicit overlay patch lists a surface composes, expose the Harness-home path resolver to
|
||||
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
|
||||
* optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to
|
||||
* config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles.
|
||||
* @module @deepseek-ai/dsh-app-boot
|
||||
*/
|
||||
|
||||
import { parseEnv } from 'node:util'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parseEnv } from 'node:util'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { Context, type FiberState } from 'cordis'
|
||||
@@ -27,6 +27,27 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
composeEntries,
|
||||
DEFAULT_PROFILE_BUNDLES,
|
||||
healProfilesModuleFallback,
|
||||
initProfile,
|
||||
loadProfile,
|
||||
PROFILE_PATCH_FILENAME,
|
||||
PROFILE_TEMPLATES,
|
||||
PROFILES_DIR,
|
||||
readProfileManifest,
|
||||
resolveBundleDir,
|
||||
resolveProfileDir,
|
||||
writeProfileManifest,
|
||||
type DshBundleManifest,
|
||||
type DshManifestSection,
|
||||
type DshProfileManifest,
|
||||
type Profile,
|
||||
type ProfileLayer,
|
||||
type ProfileManifest,
|
||||
} from './profile.ts'
|
||||
|
||||
/**
|
||||
* Resolve the config to boot. Replay swaps a `cordis.yml` basename for
|
||||
* `cordis.snapshot.yml` in the same directory; every other mode keeps the path.
|
||||
@@ -171,15 +192,100 @@ export function loadLayeredEnv(
|
||||
])
|
||||
}
|
||||
|
||||
const bootstrapIncludes = new WeakMap<Context, Entry>()
|
||||
|
||||
// The include's YAML dialect (`!!js` scalars become expression nodes the
|
||||
// Loader interpolates against each entry's context at mount time), imported
|
||||
// from the include itself so patch parsing and config dumping can never drift
|
||||
// from what the include mounts. User patch layers share it so they may
|
||||
// reference `process.env`.
|
||||
const userPatchesSchema = entryListSchema
|
||||
|
||||
/** Options for live user patch-layer reconciliation. */
|
||||
export interface UserPatchWatchOptions {
|
||||
/** Diagnostic prefix used by {@link loadOptionalPatches}. */
|
||||
binName: string
|
||||
/** Absolute path of the watched patch file (a profile's `cordis.patch.yml`). */
|
||||
filename: string
|
||||
/**
|
||||
* Compose the full patch list for a fresh user-layer generation —
|
||||
* the same composition the app booted with, so a reload can interleave the
|
||||
* new user patches between app-owned layers (bundle layers below,
|
||||
* overlay/flag patches above). Identity when omitted: the user layer
|
||||
* is the whole patch list.
|
||||
*/
|
||||
compose?: (userPatches: PatchOptions[]) => PatchOptions[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an overlay patch list: a surface overlay (`tui.cordis.yml`) or a
|
||||
* `--config <path>` overlay applied over the shared base. The file is a
|
||||
* top-level YAML array of loader patch entries (`@cordisjs/plugin-include`'s
|
||||
* `PatchOptions`): id-targeted config overrides and `insert` lists, with
|
||||
* `!!js` expressions allowed — the dialect is imported from the include
|
||||
* itself, so patch parsing and config dumping can never drift from what the
|
||||
* include mounts. A missing file throws, because the caller named this file:
|
||||
* its absence is a misconfiguration, not "no overlay".
|
||||
* Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include.
|
||||
* @param ctx - settled app context containing the root Include and an active HMR service.
|
||||
* @param options - diagnostic, file, and patch-composition inputs.
|
||||
* @returns an asynchronous disposer after the exact-path watcher is ready.
|
||||
* @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails.
|
||||
*/
|
||||
export async function watchUserPatches(
|
||||
ctx: Context,
|
||||
options: UserPatchWatchOptions,
|
||||
): Promise<() => Promise<void>> {
|
||||
const { binName, filename, compose = (patches: PatchOptions[]) => patches } = options
|
||||
const hmr = ctx.get('hmr')
|
||||
if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`)
|
||||
const entry = bootstrapIncludes.get(ctx)
|
||||
if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`)
|
||||
const register = hmr.registerConfig(filename, async () => {
|
||||
// Re-read the include's non-patch options per refresh: a writer that
|
||||
// updates the root Include's other options between refreshes (none exists
|
||||
// today) must not have them silently reverted by a user-layer reload.
|
||||
const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
|
||||
const userPatches = loadOptionalPatches(binName, filename) ?? []
|
||||
const patches = compose(userPatches)
|
||||
await entry.update({
|
||||
config: {
|
||||
...includeConfig,
|
||||
patches,
|
||||
},
|
||||
})
|
||||
})
|
||||
try {
|
||||
return await register
|
||||
} catch (error) {
|
||||
// A surface can dispose the whole tree while the watcher is still opening;
|
||||
// the HMR effect registration then fails with INACTIVE_EFFECT. That is the
|
||||
// app exiting exactly as asked, not a watch failure, so return a no-op
|
||||
// disposer instead of crashing.
|
||||
if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an optional patch-list file: a top-level YAML array of loader patch
|
||||
* entries (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config
|
||||
* overrides and `insert` lists, with `!!js` expressions allowed. A missing
|
||||
* file means "no layer"; an unreadable, unparsable, or non-array file throws —
|
||||
* a present patch file that cannot apply is a misconfiguration and must fail
|
||||
* loud at boot, never be silently skipped.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param file - absolute path of the patch file.
|
||||
* @returns the parsed patches, or `undefined` when the file does not exist.
|
||||
*/
|
||||
export function loadOptionalPatches(binName: string, file: string): PatchOptions[] | undefined {
|
||||
let content: string
|
||||
try {
|
||||
content = readFileSync(file, 'utf8')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
|
||||
throw new Error(`${binName}: failed to read patches ${file}: ${String(error)}`)
|
||||
}
|
||||
return parsePatchList(binName, file, content, 'patches')
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a required overlay patch list: a bundle's `cordis.patch.yml` or a
|
||||
* `--patch <path>` overlay. Same file format as {@link loadOptionalPatches},
|
||||
* but a missing file throws, because the caller named this file — its absence
|
||||
* is a misconfiguration, not "no overlay".
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param file - absolute path of the overlay file.
|
||||
* @returns the parsed patch list.
|
||||
@@ -191,32 +297,36 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[
|
||||
} catch (error) {
|
||||
throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`)
|
||||
}
|
||||
return parsePatchList(binName, file, content)
|
||||
return parsePatchList(binName, file, content, 'overlay')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one loader patch list. Every shape failure throws, because a patch
|
||||
* file that cannot be applied at all is a misconfiguration; a single patch
|
||||
* whose target row is absent stays a per-entry Loader warning, so one overlay
|
||||
* shared across surfaces does not have to match every tree.
|
||||
* Parse one loader patch list: a top-level YAML array of
|
||||
* `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and
|
||||
* `insert` lists, `!!js` expressions allowed). Every shape failure throws,
|
||||
* because a patch file that cannot be applied at all is a misconfiguration; a
|
||||
* single patch whose target row is absent stays a per-entry Loader warning, so
|
||||
* one overlay shared across surfaces does not have to match every tree.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param file - the source path, quoted in errors.
|
||||
* @param content - the file's text.
|
||||
* @param label - what to call this list in errors (`patches`, `overlay`).
|
||||
* @returns the parsed patch list.
|
||||
*/
|
||||
function parsePatchList(binName: string, file: string, content: string): PatchOptions[] {
|
||||
function parsePatchList(
|
||||
binName: string, file: string, content: string, label: string,
|
||||
): PatchOptions[] {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = yaml.load(content, { schema: entryListSchema })
|
||||
parsed = yaml.load(content, { schema: userPatchesSchema })
|
||||
} catch (error) {
|
||||
throw new Error(`${binName}: failed to parse overlay ${file}: ${String(error)}`)
|
||||
throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`${binName}: overlay ${file} must be a top-level YAML array of loader patch entries`)
|
||||
throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`)
|
||||
}
|
||||
parsed.forEach((entry, index) => {
|
||||
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
||||
throw new Error(`${binName}: overlay entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
|
||||
throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
|
||||
}
|
||||
})
|
||||
return parsed as PatchOptions[]
|
||||
@@ -226,7 +336,7 @@ function parsePatchList(binName: string, file: string, content: string): PatchOp
|
||||
export interface ConfigDumpLayer {
|
||||
/** Source name shown in provenance comments (a file basename or path). */
|
||||
label: string
|
||||
/** The layer's patches, from {@link loadOverlayPatches}. */
|
||||
/** The layer's patches, from {@link loadOverlayPatches} / {@link loadOptionalPatches}. */
|
||||
patches: PatchOptions[]
|
||||
}
|
||||
|
||||
@@ -358,10 +468,10 @@ function groupedDump(
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the root Include entry app boot drives.
|
||||
* Mount and remember the exact root Include entry used by app boot and user patch-layer HMR.
|
||||
* @param ctx - context carrying an initialized Loader service.
|
||||
* @param absoluteConfigPath - absolute YAML or JSON configuration path.
|
||||
* @param patches - the surface's overlay patches, applied in order.
|
||||
* @param patches - initial app and user patches, applied in order.
|
||||
* @returns the created root Include entry, or `undefined` when a surface
|
||||
* disposed the whole tree (taking the Loader service with it) while the
|
||||
* transactional create was still settling entry lifecycle.
|
||||
@@ -386,7 +496,9 @@ export async function mountRootInclude(
|
||||
const includeId = await ctx.loader.create(rootInclude)
|
||||
const loader = ctx.get('loader')
|
||||
if (loader === undefined) return undefined
|
||||
return loader.resolve(includeId)
|
||||
const entry = loader.resolve(includeId)
|
||||
bootstrapIncludes.set(ctx, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -605,7 +717,7 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
|
||||
* @param absoluteConfigPath - the config to include; must already be absolute
|
||||
* (see {@link resolveConfigPath}).
|
||||
* @param patches - optional overlay patches applied over the included tree
|
||||
* (see {@link loadOverlayPatches}); an empty list mounts none.
|
||||
* (see {@link loadOptionalPatches}); an empty list mounts none.
|
||||
* @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
|
||||
* @returns the root context once every entry has started, or as soon as a
|
||||
* surface disposed the tree while startup was still in flight.
|
||||
|
||||
388
packages/ui/app-boot/src/profile.ts
Normal file
388
packages/ui/app-boot/src/profile.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* 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" } }`; the tree is
|
||||
* composed by applying each bundle's patch list 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. The Loader's `baseUrl` is the profile directory, whose
|
||||
* `node_modules` pnpm manages for out-of-tree plugins, while the maintained
|
||||
* flat fallback directory `$DSH_HOME/profiles/node_modules` (one symlink per
|
||||
* package the installation's app and bundles depend on) makes every in-box
|
||||
* plugin Node-resolvable from any profile through the ordinary parent-walk.
|
||||
* @module @deepseek-ai/dsh-app-boot/profile
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import {
|
||||
existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import type { EntryOptions } from '@cordisjs/plugin-loader'
|
||||
import { applyEntryPatches, type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { loadOverlayPatches } from './index.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'
|
||||
|
||||
/** The bundle half of the `dsh` manifest section: what a bundle package exports. */
|
||||
export interface DshBundleManifest {
|
||||
/** The patch layer this bundle exports, relative to its package root. */
|
||||
patch: string
|
||||
}
|
||||
|
||||
/** The profile half of the `dsh` manifest section: what a profile directory composes. */
|
||||
export interface DshProfileManifest {
|
||||
/** Ordered bundle layer list (package names). */
|
||||
bundles?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The `dsh`-owned manifest section of a package.json. The nested key names
|
||||
* the manifest kind: a bundle package declares `bundle`, a profile directory
|
||||
* declares `profile`; nothing declares both.
|
||||
*/
|
||||
export interface DshManifestSection {
|
||||
/** Present on bundle packages only. */
|
||||
bundle?: DshBundleManifest
|
||||
/** Present on profile manifests only. */
|
||||
profile?: DshProfileManifest
|
||||
}
|
||||
|
||||
/** The slice of package.json both profiles and bundles use. */
|
||||
export interface ProfileManifest {
|
||||
name?: string
|
||||
dependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
dsh?: DshManifestSection
|
||||
}
|
||||
|
||||
/** 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 path of the bundle's patch file. */
|
||||
patchPath: string
|
||||
/** The parsed patch list. */
|
||||
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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 === '..'
|
||||
// The launcher-maintained flat module fallback lives at this sibling path.
|
||||
|| 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, readonly string[]> = {
|
||||
web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
|
||||
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']
|
||||
|
||||
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) fall through to the healed
|
||||
// profiles/node_modules installation fallback, 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)
|
||||
}
|
||||
|
||||
/** Ensure `link` is a symlink to `target`, replacing a wrong or dangling link; a real directory throws. */
|
||||
function ensureSymlink(link: string, target: string): void {
|
||||
let stat
|
||||
try {
|
||||
stat = lstatSync(link)
|
||||
} catch {
|
||||
// Missing link (first run) — created below. Any other lstat failure on a
|
||||
// path we just created the parent of would resurface on symlinkSync.
|
||||
stat = undefined
|
||||
}
|
||||
if (stat !== undefined) {
|
||||
if (!stat.isSymbolicLink()) {
|
||||
throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`)
|
||||
}
|
||||
if (readlinkSync(link) === target) return
|
||||
rmSync(link)
|
||||
}
|
||||
try {
|
||||
symlinkSync(target, link, 'junction')
|
||||
} catch (error) {
|
||||
// Concurrent launches heal the same fallback; losing the race to a
|
||||
// process writing the identical link is success, anything else is not.
|
||||
// The window between the lstat miss above and this write cannot be
|
||||
// staged deterministically from the public surface.
|
||||
/* v8 ignore next 4 */
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST'
|
||||
|| !lstatSync(link).isSymbolicLink() || readlinkSync(link) !== target) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one
|
||||
* symlink per package in the dsh app's resolvable dependency CLOSURE (BFS
|
||||
* over `dependencies` from the app manifest), each resolved from its own
|
||||
* real location. Node's parent-directory walk from any profile finds this
|
||||
* directory after the profile's own `node_modules`, so every in-box plugin
|
||||
* resolves without pnpm ever managing it — the exact "bundles come from the
|
||||
* installation" contract. The closure (not just direct dependencies) is
|
||||
* required for out-of-tree plugins: their peer dependencies name seam
|
||||
* packages (`dsh-compact`, `dsh-invariants`, ...) that the app reaches only
|
||||
* through its implementation packages. Symlinked packages resolve their own
|
||||
* dependencies from their real directories (Node's default
|
||||
* symlink-following), so each package needs only its one flat link.
|
||||
* Idempotent: correct links are kept and moved installations are
|
||||
* re-pointed; a stale link to a vanished package stays until its name is
|
||||
* reused (dangling links are invisible to resolution).
|
||||
* @param installAnchor - absolute path of the dsh app's package.json.
|
||||
* @param home - the Harness home; defaults to {@link resolveDshHome}.
|
||||
*/
|
||||
export function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): void {
|
||||
const profilesDir = join(home, PROFILES_DIR)
|
||||
const modulesDir = join(profilesDir, 'node_modules')
|
||||
mkdirSync(modulesDir, { recursive: true })
|
||||
const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest
|
||||
const links = new Map<string, string>()
|
||||
/* v8 ignore next -- a real app manifest always declares its name */
|
||||
if (appManifest.name !== undefined) links.set(appManifest.name, dirname(installAnchor))
|
||||
// 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: installAnchor, manifest: appManifest }]
|
||||
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
||||
// Peer dependencies participate: seam packages (dsh-subprocess,
|
||||
// dsh-compact, ...) 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 [...Object.keys(next.manifest.dependencies ?? {}), ...Object.keys(next.manifest.peerDependencies ?? {})]) {
|
||||
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
|
||||
links.set(dep, dir)
|
||||
const manifestPath = join(dir, 'package.json')
|
||||
queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest })
|
||||
}
|
||||
}
|
||||
for (const [packageName, target] of links) {
|
||||
const link = join(modulesDir, packageName)
|
||||
mkdirSync(dirname(link), { recursive: true })
|
||||
ensureSymlink(link, target)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)}`)
|
||||
}
|
||||
// File boundary: the shape check below validates what the parse type asserts.
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 a profile: resolve every `dsh.profile.bundles` entry to its patch
|
||||
* layer and parse the profile's own patch file. A listed bundle without a
|
||||
* `dsh.bundle` manifest fails loud — naming a bundle-less package as a layer
|
||||
* is a misconfiguration, not "no patches".
|
||||
* @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)
|
||||
}
|
||||
const manifest = readProfileManifest(binName, dir)
|
||||
// A hand-written profile manifest may omit the dsh section entirely.
|
||||
const bundles = manifest.dsh?.profile?.bundles ?? []
|
||||
const layers = bundles.map((packageName): ProfileLayer => {
|
||||
const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
|
||||
const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest
|
||||
const declared = bundleManifest.dsh?.bundle?.patch
|
||||
if (declared === undefined) {
|
||||
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
|
||||
}
|
||||
const patchPath = join(packageDir, declared)
|
||||
return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) }
|
||||
})
|
||||
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
|
||||
const patches = options.userLayer !== false && existsSync(patchPath)
|
||||
? loadOverlayPatches(binName, patchPath)
|
||||
: []
|
||||
return { name, dir, layers, patchPath, patches }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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++])))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user