feat(app-boot): profile machinery — manifest, two-anchor resolution, composition, module fallback

Profiles live at $DSH_HOME/profiles/<name>: a package.json with pnpm-managed
out-of-tree dependencies plus the ordered dsh.plugins bundle list, and a user
cordis.patch.yml layer. Bundles resolve installation-first, then
profile-local; composeEntries applies layers over an empty root through the
include's own applyEntryPatches; healProfilesModuleFallback maintains the flat
profiles/node_modules symlink surface so bare plugin names resolve from any
profile. The personal-overlay machinery ($DSH_HOME/config.yaml) is retargeted
to per-profile patch files: loadPersonalPatches becomes loadOptionalPatches
and watchPersonalPatches takes the exact filename.
This commit is contained in:
Turtle
2026-08-06 04:40:22 +08:00
parent 2365b2c54f
commit 9235d0f90f
7 changed files with 706 additions and 149 deletions

View File

@@ -1,7 +1,7 @@
/**
* Personal-config behavior of `dsh-app-boot`: the Harness home (`~/.dsh`)
* `config.yaml` overlay loader and `boot()` applying the personal overlay over
* a real Loader tree.
* User patch-layer behavior of `dsh-app-boot`: the optional patch-list loader
* (a profile's `cordis.patch.yml`) and `boot()` applying the user layer over
* a real Loader tree, kept live through transactional HMR.
*/
import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs'
@@ -15,8 +15,8 @@ import Loader from '@cordisjs/plugin-loader'
import Timer from '@cordisjs/plugin-timer'
import {
boot,
loadPersonalPatches,
PERSONAL_CONFIG_FILENAME,
loadOptionalPatches,
PROFILE_PATCH_FILENAME,
watchPersonalPatches,
} from '../src/index.ts'
@@ -34,18 +34,18 @@ async function eventually(test: () => boolean, message: string): Promise<void> {
const settleChokidarChangeThrottle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 75))
describe('loadPersonalPatches', () => {
describe('loadOptionalPatches', () => {
afterEach(() => {
delete process.env.DSH_HOME
})
it('returns undefined when no personal patches file exists', () => {
expect(loadPersonalPatches(NAME, tmp())).toBeUndefined()
expect(loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME))).toBeUndefined()
})
it('parses a patch list and preserves !!js expressions as loader expression nodes', () => {
const dir = tmp()
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), [
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), [
'- id: tui-agent',
" name: '@deepseek-ai/dsh-tui-demo'",
' config:',
@@ -55,7 +55,7 @@ describe('loadPersonalPatches', () => {
" name: '@deepseek-ai/dsh-llm-pi-ai'",
'',
].join('\n'))
const patches = loadPersonalPatches(NAME, dir)
const patches = loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))
expect(patches).toHaveLength(2)
expect(patches?.[0]).toMatchObject({
id: 'tui-agent',
@@ -64,38 +64,31 @@ describe('loadPersonalPatches', () => {
expect(patches?.[1]?.insert).toHaveLength(1)
})
it('defaults its directory to the Harness home ($DSH_HOME)', () => {
const dir = tmp()
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: 1\n')
process.env.DSH_HOME = dir
expect(loadPersonalPatches(NAME)).toHaveLength(1)
})
it('fails loud on an unreadable file (a present personal config is never skipped)', () => {
const dir = tmp()
mkdirSync(join(dir, PERSONAL_CONFIG_FILENAME)) // a directory: present, unreadable as a file
expect(() => loadPersonalPatches(NAME, dir))
.toThrow(new RegExp(`^${NAME}: failed to read personal patches `))
mkdirSync(join(dir, PROFILE_PATCH_FILENAME)) // a directory: present, unreadable as a file
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
.toThrow(new RegExp(`^${NAME}: failed to read patches `))
})
it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => {
const dir = tmp()
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'invalid: [unclosed\n')
expect(() => loadPersonalPatches(NAME, dir))
.toThrow(new RegExp(`^${NAME}: failed to parse personal patches `))
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: !!js\n')
expect(() => loadPersonalPatches(NAME, dir))
.toThrow(new RegExp(`^${NAME}: failed to parse personal patches `))
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'invalid: [unclosed\n')
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
.toThrow(new RegExp(`^${NAME}: failed to parse patches `))
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config:\n a: !!js\n')
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
.toThrow(new RegExp(`^${NAME}: failed to parse patches `))
})
it('fails loud when the file is not a top-level array or an entry is not an object', () => {
const dir = tmp()
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'id: not-a-list\n')
expect(() => loadPersonalPatches(NAME, dir))
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'id: not-a-list\n')
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
.toThrow('must be a top-level YAML array of loader patch entries')
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- just-a-string\n')
expect(() => loadPersonalPatches(NAME, dir))
.toThrow(`${NAME}: personal patches entry 1 in`)
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- just-a-string\n')
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
.toThrow(`${NAME}: patches entry 1 in`)
})
})
@@ -119,7 +112,7 @@ describe('boot with personal patches', () => {
it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
const dir = tmp()
const personal = tmp()
writeFileSync(join(personal, PERSONAL_CONFIG_FILENAME), [
writeFileSync(join(personal, PROFILE_PATCH_FILENAME), [
'- id: noop',
' name: ./noop.mjs',
' config:',
@@ -130,7 +123,7 @@ describe('boot with personal patches', () => {
'',
].join('\n'))
process.env['DSH_APP_BOOT_PERSONAL_SPEC'] = 'personal-value'
const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, personal))
const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(personal, PROFILE_PATCH_FILENAME)))
try {
const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop')
// The mounted plugin received the interpolated environment value.
@@ -144,15 +137,15 @@ describe('boot with personal patches', () => {
it('mounts no patch layer for an absent or empty personal overlay', async () => {
const dir = tmp()
const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, tmp()))
const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME)))
try {
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' })
} finally {
await ctx.fiber.dispose()
}
const empty = tmp()
writeFileSync(join(empty, PERSONAL_CONFIG_FILENAME), '[]\n')
const ctxEmpty = await boot(NAME, writeTree(tmp()), loadPersonalPatches(NAME, empty))
writeFileSync(join(empty, PROFILE_PATCH_FILENAME), '[]\n')
const ctxEmpty = await boot(NAME, writeTree(tmp()), loadOptionalPatches(NAME, join(empty, PROFILE_PATCH_FILENAME)))
try {
expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' })
} finally {
@@ -163,7 +156,7 @@ describe('boot with personal patches', () => {
it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => {
const dir = tmp()
const personal = tmp()
const filename = join(personal, PERSONAL_CONFIG_FILENAME)
const filename = join(personal, PROFILE_PATCH_FILENAME)
const basePatches = [{ id: 'noop', config: { value: 'generated' } }]
const ctx = await boot(NAME, writeTree(dir), basePatches)
await ctx.plugin(Timer)
@@ -174,7 +167,7 @@ describe('boot with personal patches', () => {
})
const dispose = await watchPersonalPatches(ctx, {
binName: NAME,
dir: personal,
filename,
compose: personalPatches => [...basePatches, ...personalPatches],
})
try {
@@ -206,7 +199,7 @@ describe('boot with personal patches', () => {
// Default compose: the personal overlay IS the whole patch list, so a
// fresh generation replaces the app-owned layer instead of stacking on it.
await dispose()
const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, dir: personal })
const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, filename })
try {
writeFileSync(filename, '- id: noop\n config:\n value: identity\n')
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose personal patch was not applied')
@@ -222,7 +215,7 @@ describe('boot with personal patches', () => {
it('fails loud when the exact watcher lacks HMR or a root Include', async () => {
const dir = tmp()
const withoutHmr = await boot(NAME, writeTree(dir))
await expect(watchPersonalPatches(withoutHmr, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the Cordis HMR service')
await expect(watchPersonalPatches(withoutHmr, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the Cordis HMR service')
await withoutHmr.fiber.dispose()
const withoutInclude = new Context()
@@ -230,7 +223,7 @@ describe('boot with personal patches', () => {
await withoutInclude.plugin(Loader)
await withoutInclude.plugin(Timer)
await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
await expect(watchPersonalPatches(withoutInclude, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the root Include entry')
await expect(watchPersonalPatches(withoutInclude, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the root Include entry')
await withoutInclude.fiber.dispose()
})
@@ -245,7 +238,7 @@ describe('boot with personal patches', () => {
try {
const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' })
ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) })
const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: tmp() })
const dispose = await watchPersonalPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })
await expect(dispose()).resolves.toBeUndefined()
} finally {
await ctx.fiber.dispose()
@@ -254,14 +247,14 @@ describe('boot with personal patches', () => {
it('propagates registration failures other than mid-teardown', async () => {
const dir = tmp()
const personal = tmp()
const filename = join(tmp(), PROFILE_PATCH_FILENAME)
const ctx = await boot(NAME, writeTree(dir))
try {
await ctx.plugin(Timer)
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: personal })
const dispose = await watchPersonalPatches(ctx, { binName: NAME, filename })
// Same personal path registered twice: HMR refuses; not a teardown race.
await expect(watchPersonalPatches(ctx, { binName: NAME, dir: personal })).rejects.toThrow('already registered')
await expect(watchPersonalPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered')
await dispose()
} finally {
await ctx.fiber.dispose()

View File

@@ -0,0 +1,203 @@
/**
* Profile machinery of `dsh-app-boot`: directory resolution and init,
* manifest round-trips, two-anchor bundle resolution, patch-layer loading,
* empty-root composition, and the installation module-fallback healing.
*/
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
composeEntries,
healProfilesModuleFallback,
initProfile,
loadProfile,
PROFILE_PATCH_FILENAME,
PROFILE_TEMPLATES,
readProfileManifest,
resolveBundleDir,
resolveProfileDir,
writeProfileManifest,
} from '../src/index.ts'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-'))
/** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */
function stageInstallation(bundles: Record<string, { patch?: string; deps?: Record<string, string> }>): string {
const root = tmp()
const appDir = join(root, 'app')
mkdirSync(join(appDir, 'node_modules'), { recursive: true })
const appDeps: Record<string, string> = {}
for (const [name, spec] of Object.entries(bundles)) {
appDeps[name] = '0.0.0'
const dir = join(appDir, 'node_modules', name)
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'package.json'), JSON.stringify({
name,
version: '0.0.0',
dependencies: spec.deps ?? {},
...spec.patch === undefined ? {} : { dsh: { patch: './cordis.patch.yml' } },
}))
if (spec.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), spec.patch)
}
writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'dsh-app', dependencies: appDeps }))
return join(appDir, 'package.json')
}
describe('resolveProfileDir', () => {
it('joins the home and rejects traversal-shaped names', () => {
const home = tmp()
expect(resolveProfileDir('tui', home)).toBe(join(home, 'profiles', 'tui'))
for (const bad of ['', '.', '..', 'a/b', 'a\\b']) {
expect(() => resolveProfileDir(bad, home)).toThrow('invalid profile name')
}
})
})
describe('initProfile', () => {
it('creates manifest, user patch layer, and npmrc once, never overwriting', () => {
const home = tmp()
const dir = resolveProfileDir('tui', home)
initProfile(dir, ['@deepseek-ai/dsh-base'])
const manifest = readProfileManifest('t', dir)
expect(manifest.dsh?.plugins).toEqual(['@deepseek-ai/dsh-base'])
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
expect(readFileSync(join(dir, '.npmrc'), 'utf8')).toContain('node-linker=hoisted')
// Re-init keeps user edits.
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n')
initProfile(dir, ['other'])
expect(readProfileManifest('t', dir).dsh?.plugins).toEqual(['@deepseek-ai/dsh-base'])
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('- id: x')
})
})
describe('manifest round-trip', () => {
it('writes and reads back, and fails loud on a broken manifest', () => {
const dir = tmp()
writeProfileManifest(dir, { name: 'p', dsh: { plugins: ['a'] } })
expect(readProfileManifest('t', dir).dsh?.plugins).toEqual(['a'])
writeFileSync(join(dir, 'package.json'), '[]')
expect(() => readProfileManifest('t', dir)).toThrow('must hold a JSON object')
expect(() => readProfileManifest('t', join(dir, 'nope'))).toThrow('failed to read profile manifest')
})
})
describe('resolveBundleDir', () => {
it('prefers the installation anchor, falls back to the profile, and fails loud', () => {
const anchor = stageInstallation({ 'in-box': { patch: '[]\n' } })
const profileDir = tmp()
mkdirSync(join(profileDir, 'node_modules', 'local-only'), { recursive: true })
writeFileSync(join(profileDir, 'package.json'), '{}')
writeFileSync(join(profileDir, 'node_modules', 'local-only', 'package.json'), JSON.stringify({ name: 'local-only', version: '0.0.0' }))
expect(resolveBundleDir('t', 'in-box', anchor, profileDir)).toContain('in-box')
expect(resolveBundleDir('t', 'local-only', anchor, profileDir)).toContain('local-only')
expect(() => resolveBundleDir('t', 'absent', anchor, profileDir)).toThrow('cannot resolve profile bundle')
})
})
describe('loadProfile', () => {
it('resolves each dsh.plugins bundle to its patch layer in order, plus the user layer', () => {
const anchor = stageInstallation({
'bundle-a': { patch: '- insert:\n - id: a\n name: pkg-a\n' },
'bundle-b': { patch: '- id: a\n config:\n v: 2\n' },
})
const home = tmp()
const dir = resolveProfileDir('demo', home)
initProfile(dir, ['bundle-a', 'bundle-b'])
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: a\n config:\n v: 3\n')
const profile = loadProfile('t', 'demo', anchor, home)
expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a', 'bundle-b'])
expect(profile.patches).toHaveLength(1)
const entries = composeEntries([
...profile.layers.map(layer => layer.patches),
profile.patches,
])
expect(entries).toEqual([{ id: 'a', name: 'pkg-a', config: { v: 3 } }])
// A hand-made profile without the user layer file or dsh section: empty layers, no throw.
rmSync(join(dir, PROFILE_PATCH_FILENAME))
expect(loadProfile('t', 'demo', anchor, home).patches).toEqual([])
writeProfileManifest(dir, { name: 'bare' })
const bare = loadProfile('t', 'demo', anchor, home)
expect(bare.layers).toEqual([])
})
it('auto-initializes only shipped templates and fails loud otherwise', () => {
const anchor = stageInstallation({})
const home = tmp()
expect(() => loadProfile('t', 'custom', anchor, home))
.toThrow('profile "custom" does not exist')
// The web template exists but its bundles are not installed in this fake
// installation: init succeeds, resolution then fails loud on the bundle.
expect(PROFILE_TEMPLATES.web).toContain('@deepseek-ai/dsh-base')
expect(() => loadProfile('t', 'web', anchor, home)).toThrow('cannot resolve profile bundle')
})
it('fails loud when a listed bundle declares no dsh.patch', () => {
const anchor = stageInstallation({ 'not-a-bundle': {} })
const home = tmp()
const dir = resolveProfileDir('demo', home)
initProfile(dir, ['not-a-bundle'])
expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares no dsh.patch')
})
})
describe('composeEntries', () => {
it('applies layers over an empty root and reports skipped patches', () => {
const warnings: string[] = []
const entries = composeEntries([
[{ insert: [{ id: 'x', name: 'pkg-x', config: { a: 1 } }] }],
[{ id: 'x', config: { a: 2 } }, { id: 'missing', config: {} }],
], message => warnings.push(message))
expect(entries).toEqual([{ id: 'x', name: 'pkg-x', config: { a: 2 } }])
expect(warnings.join('\n')).toContain('"missing"')
// Default warn sink: skipped patches are silently dropped (boot repeats them).
expect(composeEntries([[{ id: 'missing', config: {} }]])).toEqual([])
})
})
describe('healProfilesModuleFallback', () => {
it('links the app and bundle dependency surface flat under profiles/node_modules', () => {
const anchor = stageInstallation({
'bundle-a': { patch: '[]\n', deps: { 'dep-of-a': '0.0.0', 'ghost-dep': '0.0.0' } },
'plain-lib': {},
})
// An app dependency that is declared but not installed: skipped, not fatal.
const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record<string, string> }
appManifest.dependencies['never-installed'] = '0.0.0'
writeFileSync(anchor, JSON.stringify(appManifest))
// dep-of-a lives in the installation's node_modules too.
const modules = join(anchor, '..', 'node_modules')
mkdirSync(join(modules, 'dep-of-a'), { recursive: true })
writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' }))
const home = tmp()
healProfilesModuleFallback(anchor, home)
const fallback = join(home, 'profiles', 'node_modules')
// App deps, the bundle's own deps, and the bundle itself are linked; the
// plain library is linked as an app dep (harmless), the app itself too.
for (const name of ['bundle-a', 'plain-lib', 'dep-of-a', 'dsh-app']) {
expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true)
}
// Idempotent, and a moved target is re-pointed.
healProfilesModuleFallback(anchor, home)
const before = readlinkSync(join(fallback, 'dep-of-a'))
expect(before).toContain('dep-of-a')
})
it('throws when a fallback entry is a real directory', () => {
const anchor = stageInstallation({})
const home = tmp()
mkdirSync(join(home, 'profiles', 'node_modules', 'dsh-app'), { recursive: true })
expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow('is not a symlink')
})
it('replaces a wrong symlink', () => {
const anchor = stageInstallation({})
const home = tmp()
const fallback = join(home, 'profiles', 'node_modules')
mkdirSync(fallback, { recursive: true })
symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction')
healProfilesModuleFallback(anchor, home)
expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app')
})
})