fix: address ds-review-bot round — insert-aliasing clones, settlement gates, closure module fallback
- Clone patch lists per generation (boot + composeLive): the include pushes insert rows by reference and mutates them in place, so a reused object baked user overrides into bundle rows and removal could not revert; the built-bin hot-reload e2e now asserts an override AND its removal reverting. - The headless runner awaits Loader settlement before prompting (its inject gate covers only apiProxy/httpServer) and abandons cleanly when the tree died during the wait. - healProfilesModuleFallback walks the app's full dependency+peer closure: out-of-tree plugins import seam packages (dsh-compact, dsh-subprocess, ...) that only implementations reach, and peers are how seams are declared. - Profile init writes pnpm-workspace.yaml (nodeLinker: hoisted), not .npmrc — pnpm >=10 reads settings from the workspace manifest. - Web dumps reject boot-only flags instead of printing a tree that differs from the same invocation's boot; --port validates at the flag; --dump-default-config no longer parses the (possibly broken) user layer; trustedHosts flag derivation merges over the composed value instead of replacing it; web-runtime gains surfaceContext (headless disables the GUI prompt/bash-vars the old -p never mounted); 'node_modules' is a reserved profile name; plugin-warning names the recovery step; client AGENTS.md registration surfaces point at the web-app bundle. - Ship session-reference/tmux-context/tool-ask-user as app dependencies for terminal front-door patch layers (turtle-ui), same stance as mcp-client.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# The dsh-headless bundle patch: one-shot task mode over dsh-base +
|
||||
# dsh-web-app. The web composition stays mounted (the session is observable
|
||||
# in a browser while it runs); this layer silences the URL line, moves the
|
||||
# in a browser while it runs); this layer silences the URL line and the
|
||||
# GUI-orientation surface context (this user is not in the GUI), moves the
|
||||
# webserver to an OS-assigned port so parallel headless runs never collide,
|
||||
# and mounts the one-shot runner. The launcher patches the runner's `task`.
|
||||
|
||||
@@ -13,6 +14,7 @@
|
||||
config:
|
||||
mode: production
|
||||
printUrl: false
|
||||
surfaceContext: false
|
||||
|
||||
- insert:
|
||||
- id: headless-runner
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
|
||||
@@ -17,6 +17,8 @@ import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apipro
|
||||
// Empty type imports carry the httpServer and agent/status Context merges used below.
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
// Empty type import carries the loader Context merge for the settlement await.
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -136,13 +138,20 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// Fire-and-forget by design: the run outlives plugin activation, and every
|
||||
// failure path inside ends in io.exit, not a rejection.
|
||||
void (async () => {
|
||||
// The Loader mounts sibling rows concurrently and this plugin's inject
|
||||
// gate covers only apiProxy/httpServer; prompting before the agent loop,
|
||||
// adapters, and tools settle would fail the turn on a half-mounted tree.
|
||||
// The old launcher ran strictly after settled boot — preserve that.
|
||||
// A tree disposed mid-settlement (early SIGTERM) has nothing to run.
|
||||
await ctx.get('loader')?.await()
|
||||
if (ctx.get('httpServer') === undefined) return
|
||||
// The headless session is web-observable while it runs (same composition).
|
||||
io.stderr.write(`dsh: observing at http://127.0.0.1:${String(ctx.httpServer.port)}\n`)
|
||||
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
|
||||
const created = await unwrap(await api.sessions.create({}), io)
|
||||
// Open the stream before prompting so no frame is lost — kept in this
|
||||
// order even though in-process delivery has no race, so the code survives
|
||||
// a move to a remote HTTP carrier unchanged.
|
||||
// Open the stream before prompting so no frame is lost. The quiescence
|
||||
// anchor below is an in-process ctx subscription, so a remote-carrier
|
||||
// port of this runner must replace it with a wire-visible idle signal.
|
||||
const abort = new AbortController()
|
||||
const frames = api.events.mux({}, abort.signal)
|
||||
const idle = new Promise<void>((resolve) => {
|
||||
|
||||
@@ -174,6 +174,36 @@ describe('headless runner', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('waits for Loader settlement and abandons the run when the tree died during it', async () => {
|
||||
const ctx = new Context()
|
||||
let err = ''
|
||||
let exited = false
|
||||
ctx.provide('headlessIo', {
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: () => { exited = true },
|
||||
} satisfies HeadlessIo)
|
||||
ctx.provide('apiProxy', scriptedApi([]) as never)
|
||||
// The webserver is provided by a child fiber whose disposal (early
|
||||
// SIGTERM during the boot window) removes the service; settlement
|
||||
// resolves only afterwards, and the runner must abandon rather than
|
||||
// crash on the torn-down port read.
|
||||
const webserverFiber = ctx.plugin((childCtx: Context) => {
|
||||
childCtx.provide('httpServer', { port: 1 } as never)
|
||||
})
|
||||
await webserverFiber
|
||||
let release: () => void
|
||||
const settlement = new Promise<void>((resolve) => { release = resolve })
|
||||
ctx.provide('loader', { await: () => settlement } as never)
|
||||
apply(ctx, { task: 't' })
|
||||
await webserverFiber.dispose()
|
||||
release!()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(err).toBe('')
|
||||
expect(exited).toBe(false)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails loud without the launcher-owned headlessIo seam', () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('apiProxy', scriptedApi([]) as never)
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
|
||||
@@ -34,6 +34,13 @@ export interface Config {
|
||||
mode: WebMode
|
||||
/** Print the URL line on activation; a headless layer over this bundle turns it off. */
|
||||
printUrl: boolean
|
||||
/**
|
||||
* Register the model-visible surface context (the `app:web-surface` prompt
|
||||
* section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot
|
||||
* layer turns it off: its user is not interacting through the GUI, so the
|
||||
* orientation text would be false.
|
||||
*/
|
||||
surfaceContext: boolean
|
||||
/**
|
||||
* LAN IPv4 addresses sampled once by the launcher when the effective bind
|
||||
* is all-interfaces — the exact snapshot the /api trust fence was
|
||||
@@ -46,6 +53,7 @@ export interface Config {
|
||||
export const Config: z<Config> = z.object({
|
||||
mode: z.union([z.const('production'), z.const('development')]).default('production'),
|
||||
printUrl: z.boolean().default(true),
|
||||
surfaceContext: z.boolean().default(true),
|
||||
lanAddresses: z.array(String).default([]),
|
||||
})
|
||||
|
||||
@@ -104,23 +112,25 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
|
||||
ctx.inject(['systemPrompt'], (promptCtx) => {
|
||||
promptCtx.systemPrompt.section({
|
||||
name: 'app:web-surface',
|
||||
order: -98,
|
||||
text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode),
|
||||
if (config.surfaceContext) {
|
||||
ctx.inject(['systemPrompt'], (promptCtx) => {
|
||||
promptCtx.systemPrompt.section({
|
||||
name: 'app:web-surface',
|
||||
order: -98,
|
||||
text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode),
|
||||
})
|
||||
})
|
||||
})
|
||||
ctx.inject(['bashEnv'], (runtimeCtx) => {
|
||||
runtimeCtx.bashEnv.register({
|
||||
name: 'web-runtime',
|
||||
variables: {
|
||||
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
|
||||
[DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' },
|
||||
},
|
||||
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: config.mode }),
|
||||
ctx.inject(['bashEnv'], (runtimeCtx) => {
|
||||
runtimeCtx.bashEnv.register({
|
||||
name: 'web-runtime',
|
||||
variables: {
|
||||
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
|
||||
[DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' },
|
||||
},
|
||||
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: config.mode }),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
if (config.printUrl) {
|
||||
// The URL line is a readiness signal: supervisors (and the keyless CLI
|
||||
// smoke) RPC as soon as they observe it, so it must not print while
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('web-app runtime glue', () => {
|
||||
},
|
||||
} as never)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
apply(ctx, new Config({ mode: 'development', printUrl: true, lanAddresses: ['192.168.1.5'] }))
|
||||
apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
// Settle the injected registrations.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
@@ -90,7 +90,7 @@ describe('web-app runtime glue', () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer().server)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: false, lanAddresses: [] }))
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
@@ -100,12 +100,32 @@ describe('web-app runtime glue', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('skips the surface context when disabled (the one-shot layer): no prompt section, no bash variables', async () => {
|
||||
stageDist()
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer().server)
|
||||
const contributions: BashContribution[] = []
|
||||
ctx.provide('bashEnv', {
|
||||
register: (contribution: BashContribution) => {
|
||||
contributions.push(contribution)
|
||||
return () => {}
|
||||
},
|
||||
} as never)
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false)
|
||||
expect(contributions).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('prints the loopback-only URL line when no LAN snapshot exists', async () => {
|
||||
stageDist()
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer().server)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: true, lanAddresses: [] }))
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
||||
await ctx.fiber.dispose()
|
||||
@@ -121,7 +141,7 @@ describe('web-app runtime glue', () => {
|
||||
const settlement = new Promise<void>((resolve) => { release = resolve })
|
||||
settled.provide('loader', { await: () => settlement } as never)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
apply(settled, new Config({ mode: 'production', printUrl: true, lanAddresses: [] }))
|
||||
apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
release!()
|
||||
@@ -140,7 +160,7 @@ describe('web-app runtime glue', () => {
|
||||
let releaseTorn: () => void
|
||||
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
|
||||
torn.provide('loader', { await: () => tornSettlement } as never)
|
||||
apply(torn, new Config({ mode: 'production', printUrl: true, lanAddresses: [] }))
|
||||
apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await child.dispose() // the httpServer service goes away
|
||||
releaseTorn!()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
@@ -156,7 +176,7 @@ describe('web-app runtime glue', () => {
|
||||
const { server } = fakeHttpServer()
|
||||
Object.defineProperty(server, 'port', { get: () => undefined })
|
||||
ctx.provide('httpServer', server)
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: false, lanAddresses: [] }))
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')
|
||||
|
||||
@@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
|
||||
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
|
||||
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
|
||||
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface DshManifestSection {
|
||||
export interface ProfileManifest {
|
||||
name?: string
|
||||
dependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
dsh?: DshManifestSection
|
||||
}
|
||||
|
||||
@@ -85,7 +86,9 @@ export interface Profile {
|
||||
* @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 === '..') {
|
||||
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)
|
||||
@@ -109,9 +112,13 @@ const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied
|
||||
// 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.
|
||||
const PROFILE_NPMRC = `node-linker=hoisted
|
||||
auto-install-peers=false
|
||||
// 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
|
||||
`
|
||||
|
||||
/**
|
||||
@@ -138,8 +145,8 @@ export function initProfile(dir: string, plugins: readonly string[]): void {
|
||||
}
|
||||
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
|
||||
if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE)
|
||||
const npmrcPath = join(dir, '.npmrc')
|
||||
if (!existsSync(npmrcPath)) writeFileSync(npmrcPath, PROFILE_NPMRC)
|
||||
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. */
|
||||
@@ -176,17 +183,20 @@ function ensureSymlink(link: string, target: string): void {
|
||||
|
||||
/**
|
||||
* Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one
|
||||
* symlink per package that the dsh app and each of its in-box bundle
|
||||
* dependencies declare, resolved from their own real locations. Node's
|
||||
* parent-directory walk from any profile finds this directory after the
|
||||
* profile's own `node_modules`, so every in-box plugin (and its host-shared
|
||||
* peers like cordis) resolves without pnpm ever managing it — the exact
|
||||
* "bundles come from the installation" contract. Symlinked packages resolve
|
||||
* their own dependencies from their real directories (Node's default
|
||||
* symlink-following), so only this first hop needs maintaining. 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).
|
||||
* 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}.
|
||||
*/
|
||||
@@ -194,32 +204,27 @@ export function healProfilesModuleFallback(installAnchor: string, home: string =
|
||||
const profilesDir = join(home, PROFILES_DIR)
|
||||
const modulesDir = join(profilesDir, 'node_modules')
|
||||
mkdirSync(modulesDir, { recursive: true })
|
||||
// The app manifest plus every resolvable direct dependency's manifest that
|
||||
// itself declares a dsh patch (a bundle): their dependency names form the
|
||||
// fallback surface.
|
||||
const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest
|
||||
const anchors: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }]
|
||||
/* v8 ignore next -- a real app manifest always declares dependencies */
|
||||
for (const dep of Object.keys(appManifest.dependencies ?? {})) {
|
||||
const dir = packageDirFromAnchor(installAnchor, dep)
|
||||
if (dir === undefined) continue // declared but not installed — nothing to mirror
|
||||
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as ProfileManifest
|
||||
if (manifest.dsh?.patch !== undefined) anchors.push({ anchor: join(dir, 'package.json'), manifest })
|
||||
}
|
||||
const links = new Map<string, string>()
|
||||
for (const { anchor, manifest } of anchors) {
|
||||
/* v8 ignore next -- bundle anchors reach here only with a dependencies map */
|
||||
for (const dep of Object.keys(manifest.dependencies ?? {})) {
|
||||
/* 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(anchor, dep)
|
||||
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) links.set(dep, dir)
|
||||
}
|
||||
// The anchor package itself is part of the surface (a profile may list it
|
||||
// in dsh.plugins or a row may name it).
|
||||
if (manifest.name !== undefined && !links.has(manifest.name)) {
|
||||
links.set(manifest.name, dirname(anchor))
|
||||
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) {
|
||||
@@ -319,10 +324,14 @@ export function resolveBundleDir(
|
||||
* @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}.
|
||||
* @returns the loaded profile.
|
||||
* @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'))) {
|
||||
@@ -348,7 +357,9 @@ export function loadProfile(
|
||||
return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) }
|
||||
})
|
||||
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
|
||||
const patches = existsSync(patchPath) ? loadOverlayPatches(binName, patchPath) : []
|
||||
const patches = options.userLayer !== false && existsSync(patchPath)
|
||||
? loadOverlayPatches(binName, patchPath)
|
||||
: []
|
||||
return { name, dir, layers, patchPath, patches }
|
||||
}
|
||||
|
||||
|
||||
@@ -56,14 +56,14 @@ describe('resolveProfileDir', () => {
|
||||
})
|
||||
|
||||
describe('initProfile', () => {
|
||||
it('creates manifest, user patch layer, and npmrc once, never overwriting', () => {
|
||||
it('creates manifest, user patch layer, and pnpm workspace 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')
|
||||
expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
|
||||
// Re-init keeps user edits.
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n')
|
||||
initProfile(dir, ['other'])
|
||||
|
||||
Reference in New Issue
Block a user