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:
@@ -24,6 +24,9 @@
|
|||||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||||
"@deepseek-ai/dsh-pty": "workspace:^",
|
"@deepseek-ai/dsh-pty": "workspace:^",
|
||||||
"@deepseek-ai/dsh-pty-local": "workspace:^",
|
"@deepseek-ai/dsh-pty-local": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-tmux-context": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
|
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
|
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
|
||||||
"@deepseek-ai/dsh-web-app": "workspace:^",
|
"@deepseek-ai/dsh-web-app": "workspace:^",
|
||||||
|
|||||||
@@ -168,9 +168,19 @@ Examples:
|
|||||||
if (defaultOnly && patches.length > 0) {
|
if (defaultOnly && patches.length > 0) {
|
||||||
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
|
||||||
}
|
}
|
||||||
|
// The dump is boot-free and does not derive flag patches; silently
|
||||||
|
// dropping them would print a tree that differs from the same
|
||||||
|
// invocation's boot.
|
||||||
|
if (options.host !== undefined || options.port !== undefined || options.dev === true
|
||||||
|
|| options.workspaceRoot !== undefined || options.trustedHost !== undefined) {
|
||||||
|
program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--trusted-host)')
|
||||||
|
}
|
||||||
resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches }
|
resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
|
||||||
|
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
|
||||||
|
}
|
||||||
resolved = {
|
resolved = {
|
||||||
mode: 'web',
|
mode: 'web',
|
||||||
patches,
|
patches,
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ const NAME = 'dsh'
|
|||||||
*/
|
*/
|
||||||
export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void {
|
export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void {
|
||||||
healProfilesModuleFallback(INSTALL_ANCHOR)
|
healProfilesModuleFallback(INSTALL_ANCHOR)
|
||||||
const loaded = loadProfile(NAME, profile, INSTALL_ANCHOR)
|
// The default dump never reads the user layer: it doubles as the recovery
|
||||||
|
// diagnostic for a broken cordis.patch.yml, so parsing that file here would
|
||||||
|
// defeat its purpose.
|
||||||
|
const loaded = loadProfile(NAME, profile, INSTALL_ANCHOR, undefined, { userLayer: !defaultOnly })
|
||||||
const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({
|
const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({
|
||||||
label: layer.packageName,
|
label: layer.packageName,
|
||||||
patches: layer.patches,
|
patches: layer.patches,
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
|
|||||||
for (const packageName of afterDeps) {
|
for (const packageName of afterDeps) {
|
||||||
if (beforeDeps.has(packageName) || plugins.includes(packageName)) continue
|
if (beforeDeps.has(packageName) || plugins.includes(packageName)) continue
|
||||||
if (!exportsPatch(packageName, profileDir)) {
|
if (!exportsPatch(packageName, profileDir)) {
|
||||||
process.stderr.write(`${NAME}: warning: ${packageName} declares no dsh.patch — installed as a plain dependency, not a profile layer\n`)
|
process.stderr.write(
|
||||||
|
`${NAME}: warning: ${packageName} declares no dsh.patch — installed as a plain dependency, not a profile layer `
|
||||||
|
+ '(if it gains one later, add it to dsh.plugins in the profile\'s package.json)\n',
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
plugins.push(packageName)
|
plugins.push(packageName)
|
||||||
|
|||||||
@@ -186,15 +186,22 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
|||||||
composed.profile.layers.reduce((n, layer) => n + layer.patches.length, 0)
|
composed.profile.layers.reduce((n, layer) => n + layer.patches.length, 0)
|
||||||
+ composed.profile.patches.length,
|
+ composed.profile.patches.length,
|
||||||
)
|
)
|
||||||
const composeLive = (profilePatches: PatchOptions[]): PatchOptions[] => [
|
// Fresh clones per generation: the include pushes `insert` rows into the
|
||||||
|
// mounted tree BY REFERENCE and later id-targeted patches mutate those
|
||||||
|
// objects in place. Reusing one parsed patch object across applications
|
||||||
|
// would bake a user override into the bundle's in-memory insert row, so
|
||||||
|
// removing the override could never revert the row to the bundle default.
|
||||||
|
const composeLive = (profilePatches: PatchOptions[]): PatchOptions[] => structuredClone([
|
||||||
...composed.profile.layers.flatMap(layer => layer.patches),
|
...composed.profile.layers.flatMap(layer => layer.patches),
|
||||||
...profilePatches,
|
...profilePatches,
|
||||||
...overlayAndFlags,
|
...overlayAndFlags,
|
||||||
]
|
])
|
||||||
// One-shot runs exit through the runner; watching would only hold the
|
// One-shot runs exit through the runner; watching would only hold the
|
||||||
// process open after its exit request.
|
// process open after its exit request.
|
||||||
const watchProfilePatch = options.task === undefined
|
const watchProfilePatch = options.task === undefined
|
||||||
const ctx = await boot(NAME, rootConfig, composed.patches, async (hostCtx) => {
|
// Cloned for the same insert-aliasing reason as composeLive: the boot
|
||||||
|
// application must not mutate the objects later reloads recompose from.
|
||||||
|
const ctx = await boot(NAME, rootConfig, structuredClone(composed.patches), async (hostCtx) => {
|
||||||
app.current = hostCtx
|
app.current = hostCtx
|
||||||
if (options.task !== undefined) {
|
if (options.task !== undefined) {
|
||||||
const io: HeadlessIo = {
|
const io: HeadlessIo = {
|
||||||
|
|||||||
+14
-4
@@ -85,7 +85,15 @@ function deriveWebFlagPatches(
|
|||||||
if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot)
|
if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot)
|
||||||
const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
|
const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
|
||||||
const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? [])
|
const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? [])
|
||||||
if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts)
|
if (trustedHosts.length > 0) {
|
||||||
|
// Additive over the composed value: a cordis.patch.yml-configured fence
|
||||||
|
// authority must survive the derived LAN literals and flag extras — a
|
||||||
|
// silent drop of security-relevant fence configuration.
|
||||||
|
const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? []
|
||||||
|
put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts])
|
||||||
|
}
|
||||||
|
// mode and lanAddresses are launcher-derived on every boot (--dev also
|
||||||
|
// inserts the client-hmr row), never pass-throughs of composed values.
|
||||||
put('web-runtime', 'mode', flags.dev ? 'development' : 'production')
|
put('web-runtime', 'mode', flags.dev ? 'development' : 'production')
|
||||||
put('web-runtime', 'lanAddresses', lanAddresses)
|
put('web-runtime', 'lanAddresses', lanAddresses)
|
||||||
const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => {
|
const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => {
|
||||||
@@ -98,9 +106,11 @@ function deriveWebFlagPatches(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serve the browser UI from the web profile. Flags are passed through only
|
* Serve the browser UI from the web profile. Host/port/workspace-root flags
|
||||||
* when given; absent, the composed profile values stand. The URL line is
|
* are passed through only when given (absent, the composed profile values
|
||||||
* printed by the web-app bundle's runtime row after Loader settlement.
|
* stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on
|
||||||
|
* every boot. The URL line is printed by the web-app bundle's runtime row
|
||||||
|
* after Loader settlement.
|
||||||
* @param flags - the parsed `dsh web` flag family.
|
* @param flags - the parsed `dsh web` flag family.
|
||||||
*/
|
*/
|
||||||
export async function runWeb(flags: WebFlags): Promise<void> {
|
export async function runWeb(flags: WebFlags): Promise<void> {
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ describe('parseDshArgs', () => {
|
|||||||
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
|
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
|
||||||
expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1)
|
expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1)
|
||||||
expect(exitCode(['web', '--patch='])).toBe(1)
|
expect(exitCode(['web', '--patch='])).toBe(1)
|
||||||
|
// Boot-free dumps derive no flag patches; silently dropping the flags
|
||||||
|
// would print a tree that differs from the same invocation's boot.
|
||||||
|
expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1)
|
||||||
|
expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1)
|
||||||
|
// A non-numeric port fails at the flag, not deep in the webserver schema.
|
||||||
|
expect(exitCode(['web', '--port', 'abc'])).toBe(1)
|
||||||
expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required
|
expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required
|
||||||
expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward
|
expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward
|
||||||
expect(exitCode(['plugin', '--profile', ''])).toBe(1)
|
expect(exitCode(['plugin', '--profile', ''])).toBe(1)
|
||||||
|
|||||||
@@ -55,11 +55,15 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
|||||||
mkdirSync(bundleDir, { recursive: true })
|
mkdirSync(bundleDir, { recursive: true })
|
||||||
writeFileSync(join(bundleDir, 'plugin.mjs'), [
|
writeFileSync(join(bundleDir, 'plugin.mjs'), [
|
||||||
"import { writeFileSync } from 'node:fs'",
|
"import { writeFileSync } from 'node:fs'",
|
||||||
|
"import { join } from 'node:path'",
|
||||||
"export const name = 'profile-lifecycle-fixture'",
|
"export const name = 'profile-lifecycle-fixture'",
|
||||||
'export function apply(ctx) {',
|
'export function apply(ctx, config = {}) {',
|
||||||
' let active = true',
|
' let active = true',
|
||||||
' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
|
' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
|
||||||
' const heartbeat = setInterval(() => {}, 1000)',
|
' const heartbeat = setInterval(() => {}, 1000)',
|
||||||
|
' // Echo the mounted generation so the hot-reload e2e can assert both an',
|
||||||
|
' // applied override and its removal reverting to this bundle default.',
|
||||||
|
" writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
|
||||||
" writeFileSync(process.env.RAW_READY_FILE, 'ready')",
|
" writeFileSync(process.env.RAW_READY_FILE, 'ready')",
|
||||||
' void ctx.loader.await().then(() => {',
|
' void ctx.loader.await().then(() => {',
|
||||||
" if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')",
|
" if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')",
|
||||||
@@ -166,24 +170,36 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
|||||||
}
|
}
|
||||||
}, 30_000)
|
}, 30_000)
|
||||||
|
|
||||||
it('fully settles a custom profile, hot-reloads its patch layer, and disposes on a signal', async () => {
|
it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => {
|
||||||
const fixture = createProfileLifecycleFixture()
|
const fixture = createProfileLifecycleFixture()
|
||||||
const child = startProfileLifecycle(fixture)
|
const child = startProfileLifecycle(fixture)
|
||||||
|
const profilePatch = join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml')
|
||||||
|
const configFile = join(fixture.home, 'config-echo')
|
||||||
try {
|
try {
|
||||||
await waitForFile(fixture.settled)
|
await waitForFile(fixture.settled)
|
||||||
// The live profile layer: even without an hmr row in the composition,
|
// The live profile layer: even without an hmr row in the composition,
|
||||||
// the launcher mounts a config-only watcher, so an edited
|
// the launcher mounts a config-only watcher, so an edited
|
||||||
// cordis.patch.yml lands in the running tree (the reload disposes the
|
// cordis.patch.yml lands in the running tree (the reload disposes the
|
||||||
// patched row's old fiber — observable as the disposed marker — and
|
// patched row's old fiber — observable as the disposed marker — and
|
||||||
// mounts the new config, which re-writes the ready marker).
|
// mounts the new config, which echoes its generation and re-writes the
|
||||||
|
// ready marker).
|
||||||
rmSync(fixture.ready)
|
rmSync(fixture.ready)
|
||||||
writeFileSync(join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml'), [
|
writeFileSync(profilePatch, [
|
||||||
'- id: profile-lifecycle-fixture',
|
'- id: profile-lifecycle-fixture',
|
||||||
' config:',
|
' config:',
|
||||||
' generation: 2',
|
' generation: 2',
|
||||||
'',
|
'',
|
||||||
].join('\n'))
|
].join('\n'))
|
||||||
await waitForFile(fixture.ready)
|
await waitForFile(fixture.ready)
|
||||||
|
expect(readFileSync(configFile, 'utf8')).toBe('2')
|
||||||
|
// Removal reverts: the bundle's inserted row must return to its own
|
||||||
|
// default config, not keep the removed override — the insert-aliasing
|
||||||
|
// regression (a shared patch object mutated in place by a former
|
||||||
|
// generation would make this impossible).
|
||||||
|
rmSync(fixture.ready)
|
||||||
|
writeFileSync(profilePatch, '[]\n')
|
||||||
|
await waitForFile(fixture.ready)
|
||||||
|
expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
|
||||||
child.kill('SIGTERM')
|
child.kill('SIGTERM')
|
||||||
const result = await child
|
const result = await child
|
||||||
expect(result.exitCode).toBe(0)
|
expect(result.exitCode).toBe(0)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Shared scaffold for the keyless browser e2e lane (Agent Note:
|
// Shared scaffold for the keyless browser e2e lane (Agent Note:
|
||||||
// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
|
// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
|
||||||
// Boots the REAL web composition — the shipped base plus web overlay through
|
// Boots the REAL web composition — the dsh-base and dsh-web-app bundle
|
||||||
// the vendored Loader (the same include boot AppCLIEntry drives), patched the
|
// patches over the empty profile root through the vendored Loader (the same
|
||||||
|
// layer stack the profile boot composes), patched the
|
||||||
// snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket
|
// snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket
|
||||||
// downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
|
// downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
|
||||||
// replay (default, keyless: normally disables the llm-deepseek row and
|
// replay (default, keyless: normally disables the llm-deepseek row and
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# The dsh-headless bundle patch: one-shot task mode over dsh-base +
|
# The dsh-headless bundle patch: one-shot task mode over dsh-base +
|
||||||
# dsh-web-app. The web composition stays mounted (the session is observable
|
# 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,
|
# 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`.
|
# and mounts the one-shot runner. The launcher patches the runner's `task`.
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
config:
|
config:
|
||||||
mode: production
|
mode: production
|
||||||
printUrl: false
|
printUrl: false
|
||||||
|
surfaceContext: false
|
||||||
|
|
||||||
- insert:
|
- insert:
|
||||||
- id: headless-runner
|
- id: headless-runner
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"cordis": "^4.0.0-rc.7"
|
"cordis": "^4.0.0-rc.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@cordisjs/plugin-loader": "workspace:^",
|
||||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||||
"@deepseek-ai/dsh-host-webserver": "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.
|
// 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-host-webserver'
|
||||||
import type {} from '@deepseek-ai/dsh-agent'
|
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 { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||||
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
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
|
// Fire-and-forget by design: the run outlives plugin activation, and every
|
||||||
// failure path inside ends in io.exit, not a rejection.
|
// failure path inside ends in io.exit, not a rejection.
|
||||||
void (async () => {
|
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).
|
// 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`)
|
io.stderr.write(`dsh: observing at http://127.0.0.1:${String(ctx.httpServer.port)}\n`)
|
||||||
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
|
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
|
||||||
const created = await unwrap(await api.sessions.create({}), io)
|
const created = await unwrap(await api.sessions.create({}), io)
|
||||||
// Open the stream before prompting so no frame is lost — kept in this
|
// Open the stream before prompting so no frame is lost. The quiescence
|
||||||
// order even though in-process delivery has no race, so the code survives
|
// anchor below is an in-process ctx subscription, so a remote-carrier
|
||||||
// a move to a remote HTTP carrier unchanged.
|
// port of this runner must replace it with a wire-visible idle signal.
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const frames = api.events.mux({}, abort.signal)
|
const frames = api.events.mux({}, abort.signal)
|
||||||
const idle = new Promise<void>((resolve) => {
|
const idle = new Promise<void>((resolve) => {
|
||||||
|
|||||||
@@ -174,6 +174,36 @@ describe('headless runner', () => {
|
|||||||
await ctx.fiber.dispose()
|
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', () => {
|
it('fails loud without the launcher-owned headlessIo seam', () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
ctx.provide('apiProxy', scriptedApi([]) as never)
|
ctx.provide('apiProxy', scriptedApi([]) as never)
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
{
|
{
|
||||||
"path": "../../../vendor/cordis"
|
"path": "../../../vendor/cordis"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "../../../vendor/loader"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "../../../vendor/schemastery"
|
"path": "../../../vendor/schemastery"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ export interface Config {
|
|||||||
mode: WebMode
|
mode: WebMode
|
||||||
/** Print the URL line on activation; a headless layer over this bundle turns it off. */
|
/** Print the URL line on activation; a headless layer over this bundle turns it off. */
|
||||||
printUrl: boolean
|
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
|
* LAN IPv4 addresses sampled once by the launcher when the effective bind
|
||||||
* is all-interfaces — the exact snapshot the /api trust fence was
|
* 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({
|
export const Config: z<Config> = z.object({
|
||||||
mode: z.union([z.const('production'), z.const('development')]).default('production'),
|
mode: z.union([z.const('production'), z.const('development')]).default('production'),
|
||||||
printUrl: z.boolean().default(true),
|
printUrl: z.boolean().default(true),
|
||||||
|
surfaceContext: z.boolean().default(true),
|
||||||
lanAddresses: z.array(String).default([]),
|
lanAddresses: z.array(String).default([]),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -104,23 +112,25 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex
|
|||||||
*/
|
*/
|
||||||
export function apply(ctx: Context, config: Config): void {
|
export function apply(ctx: Context, config: Config): void {
|
||||||
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
|
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
|
||||||
ctx.inject(['systemPrompt'], (promptCtx) => {
|
if (config.surfaceContext) {
|
||||||
promptCtx.systemPrompt.section({
|
ctx.inject(['systemPrompt'], (promptCtx) => {
|
||||||
name: 'app:web-surface',
|
promptCtx.systemPrompt.section({
|
||||||
order: -98,
|
name: 'app:web-surface',
|
||||||
text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode),
|
order: -98,
|
||||||
|
text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
ctx.inject(['bashEnv'], (runtimeCtx) => {
|
||||||
ctx.inject(['bashEnv'], (runtimeCtx) => {
|
runtimeCtx.bashEnv.register({
|
||||||
runtimeCtx.bashEnv.register({
|
name: 'web-runtime',
|
||||||
name: 'web-runtime',
|
variables: {
|
||||||
variables: {
|
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
|
||||||
[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.' },
|
||||||
[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 }),
|
||||||
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: config.mode }),
|
})
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
if (config.printUrl) {
|
if (config.printUrl) {
|
||||||
// The URL line is a readiness signal: supervisors (and the keyless CLI
|
// 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
|
// 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)
|
} as never)
|
||||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
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: '' })
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||||
// Settle the injected registrations.
|
// Settle the injected registrations.
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
@@ -90,7 +90,7 @@ describe('web-app runtime glue', () => {
|
|||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
ctx.provide('httpServer', fakeHttpServer().server)
|
ctx.provide('httpServer', fakeHttpServer().server)
|
||||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
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 ctx.plugin(SystemPrompt, { persona: '' })
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
expect(log).not.toHaveBeenCalled()
|
expect(log).not.toHaveBeenCalled()
|
||||||
@@ -100,12 +100,32 @@ describe('web-app runtime glue', () => {
|
|||||||
await ctx.fiber.dispose()
|
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 () => {
|
it('prints the loopback-only URL line when no LAN snapshot exists', async () => {
|
||||||
stageDist()
|
stageDist()
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
ctx.provide('httpServer', fakeHttpServer().server)
|
ctx.provide('httpServer', fakeHttpServer().server)
|
||||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
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))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
@@ -121,7 +141,7 @@ describe('web-app runtime glue', () => {
|
|||||||
const settlement = new Promise<void>((resolve) => { release = resolve })
|
const settlement = new Promise<void>((resolve) => { release = resolve })
|
||||||
settled.provide('loader', { await: () => settlement } as never)
|
settled.provide('loader', { await: () => settlement } as never)
|
||||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
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))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
expect(log).not.toHaveBeenCalled()
|
expect(log).not.toHaveBeenCalled()
|
||||||
release!()
|
release!()
|
||||||
@@ -140,7 +160,7 @@ describe('web-app runtime glue', () => {
|
|||||||
let releaseTorn: () => void
|
let releaseTorn: () => void
|
||||||
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
|
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
|
||||||
torn.provide('loader', { await: () => tornSettlement } as never)
|
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
|
await child.dispose() // the httpServer service goes away
|
||||||
releaseTorn!()
|
releaseTorn!()
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
@@ -156,7 +176,7 @@ describe('web-app runtime glue', () => {
|
|||||||
const { server } = fakeHttpServer()
|
const { server } = fakeHttpServer()
|
||||||
Object.defineProperty(server, 'port', { get: () => undefined })
|
Object.defineProperty(server, 'port', { get: () => undefined })
|
||||||
ctx.provide('httpServer', server)
|
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 ctx.plugin(SystemPrompt, { persona: '' })
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')
|
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):
|
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.
|
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.
|
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).
|
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.
|
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 {
|
export interface ProfileManifest {
|
||||||
name?: string
|
name?: string
|
||||||
dependencies?: Record<string, string>
|
dependencies?: Record<string, string>
|
||||||
|
peerDependencies?: Record<string, string>
|
||||||
dsh?: DshManifestSection
|
dsh?: DshManifestSection
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +86,9 @@ export interface Profile {
|
|||||||
* @returns the absolute profile directory (which may not exist yet).
|
* @returns the absolute profile directory (which may not exist yet).
|
||||||
*/
|
*/
|
||||||
export function resolveProfileDir(name: string, home: string = resolveDshHome()): string {
|
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)}`)
|
throw new Error(`dsh: invalid profile name ${JSON.stringify(name)}`)
|
||||||
}
|
}
|
||||||
return join(home, PROFILES_DIR, 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
|
// The hoisted linker gives out-of-tree plugins a flat node_modules whose
|
||||||
// missing peers (cordis and friends) fall through to the healed
|
// missing peers (cordis and friends) fall through to the healed
|
||||||
// profiles/node_modules installation fallback, so every plugin shares the
|
// profiles/node_modules installation fallback, so every plugin shares the
|
||||||
// installation's single cordis instance instead of a duplicate.
|
// installation's single cordis instance instead of a duplicate. pnpm ≥10
|
||||||
const PROFILE_NPMRC = `node-linker=hoisted
|
// reads its settings from pnpm-workspace.yaml, not .npmrc.
|
||||||
auto-install-peers=false
|
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)
|
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
|
||||||
if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE)
|
if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE)
|
||||||
const npmrcPath = join(dir, '.npmrc')
|
const workspacePath = join(dir, 'pnpm-workspace.yaml')
|
||||||
if (!existsSync(npmrcPath)) writeFileSync(npmrcPath, PROFILE_NPMRC)
|
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. */
|
/** 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
|
* 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
|
* symlink per package in the dsh app's resolvable dependency CLOSURE (BFS
|
||||||
* dependencies declare, resolved from their own real locations. Node's
|
* over `dependencies` from the app manifest), each resolved from its own
|
||||||
* parent-directory walk from any profile finds this directory after the
|
* real location. Node's parent-directory walk from any profile finds this
|
||||||
* profile's own `node_modules`, so every in-box plugin (and its host-shared
|
* directory after the profile's own `node_modules`, so every in-box plugin
|
||||||
* peers like cordis) resolves without pnpm ever managing it — the exact
|
* resolves without pnpm ever managing it — the exact "bundles come from the
|
||||||
* "bundles come from the installation" contract. Symlinked packages resolve
|
* installation" contract. The closure (not just direct dependencies) is
|
||||||
* their own dependencies from their real directories (Node's default
|
* required for out-of-tree plugins: their peer dependencies name seam
|
||||||
* symlink-following), so only this first hop needs maintaining. Idempotent:
|
* packages (`dsh-compact`, `dsh-invariants`, ...) that the app reaches only
|
||||||
* correct links are kept and moved installations are re-pointed; a stale
|
* through its implementation packages. Symlinked packages resolve their own
|
||||||
* link to a vanished package stays until its name is reused (dangling links
|
* dependencies from their real directories (Node's default
|
||||||
* are invisible to resolution).
|
* 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 installAnchor - absolute path of the dsh app's package.json.
|
||||||
* @param home - the Harness home; defaults to {@link resolveDshHome}.
|
* @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 profilesDir = join(home, PROFILES_DIR)
|
||||||
const modulesDir = join(profilesDir, 'node_modules')
|
const modulesDir = join(profilesDir, 'node_modules')
|
||||||
mkdirSync(modulesDir, { recursive: true })
|
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 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>()
|
const links = new Map<string, string>()
|
||||||
for (const { anchor, manifest } of anchors) {
|
/* v8 ignore next -- a real app manifest always declares its name */
|
||||||
/* v8 ignore next -- bundle anchors reach here only with a dependencies map */
|
if (appManifest.name !== undefined) links.set(appManifest.name, dirname(installAnchor))
|
||||||
for (const dep of Object.keys(manifest.dependencies ?? {})) {
|
// 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
|
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
|
// A declared-but-uninstalled dependency cannot be a loader-visible
|
||||||
// plugin; skip it rather than fail the whole boot.
|
// plugin; skip it rather than fail the whole boot.
|
||||||
if (dir !== undefined) links.set(dep, dir)
|
if (dir === undefined) continue
|
||||||
}
|
links.set(dep, dir)
|
||||||
// The anchor package itself is part of the surface (a profile may list it
|
const manifestPath = join(dir, 'package.json')
|
||||||
// in dsh.plugins or a row may name it).
|
queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest })
|
||||||
if (manifest.name !== undefined && !links.has(manifest.name)) {
|
|
||||||
links.set(manifest.name, dirname(anchor))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [packageName, target] of links) {
|
for (const [packageName, target] of links) {
|
||||||
@@ -319,10 +324,14 @@ export function resolveBundleDir(
|
|||||||
* @param name - the profile name.
|
* @param name - the profile name.
|
||||||
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
|
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
|
||||||
* @param home - the Harness home; defaults to {@link resolveDshHome}.
|
* @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(
|
export function loadProfile(
|
||||||
binName: string, name: string, installAnchor: string, home: string = resolveDshHome(),
|
binName: string, name: string, installAnchor: string, home: string = resolveDshHome(),
|
||||||
|
options: { userLayer?: boolean } = {},
|
||||||
): Profile {
|
): Profile {
|
||||||
const dir = resolveProfileDir(name, home)
|
const dir = resolveProfileDir(name, home)
|
||||||
if (!existsSync(join(dir, 'package.json'))) {
|
if (!existsSync(join(dir, 'package.json'))) {
|
||||||
@@ -348,7 +357,9 @@ export function loadProfile(
|
|||||||
return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) }
|
return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) }
|
||||||
})
|
})
|
||||||
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
|
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 }
|
return { name, dir, layers, patchPath, patches }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,14 +56,14 @@ describe('resolveProfileDir', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('initProfile', () => {
|
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 home = tmp()
|
||||||
const dir = resolveProfileDir('tui', home)
|
const dir = resolveProfileDir('tui', home)
|
||||||
initProfile(dir, ['@deepseek-ai/dsh-base'])
|
initProfile(dir, ['@deepseek-ai/dsh-base'])
|
||||||
const manifest = readProfileManifest('t', dir)
|
const manifest = readProfileManifest('t', dir)
|
||||||
expect(manifest.dsh?.plugins).toEqual(['@deepseek-ai/dsh-base'])
|
expect(manifest.dsh?.plugins).toEqual(['@deepseek-ai/dsh-base'])
|
||||||
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
|
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.
|
// Re-init keeps user edits.
|
||||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n')
|
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n')
|
||||||
initProfile(dir, ['other'])
|
initProfile(dir, ['other'])
|
||||||
|
|||||||
Generated
+12
@@ -161,6 +161,15 @@ importers:
|
|||||||
'@deepseek-ai/dsh-pty-local':
|
'@deepseek-ai/dsh-pty-local':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../packages/pty/pty-local
|
version: link:../../packages/pty/pty-local
|
||||||
|
'@deepseek-ai/dsh-session-reference':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../packages/context/session-reference
|
||||||
|
'@deepseek-ai/dsh-tmux-context':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../packages/context/tmux-context
|
||||||
|
'@deepseek-ai/dsh-tool-ask-user':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../packages/ui/tool-ask-user
|
||||||
'@deepseek-ai/dsh-tool-bash-persistent':
|
'@deepseek-ai/dsh-tool-bash-persistent':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../packages/pty/tool-bash-persistent
|
version: link:../../packages/pty/tool-bash-persistent
|
||||||
@@ -1053,6 +1062,9 @@ importers:
|
|||||||
specifier: ^3.18.0
|
specifier: ^3.18.0
|
||||||
version: link:../../../vendor/schemastery
|
version: link:../../../vendor/schemastery
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@cordisjs/plugin-loader':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../../vendor/loader
|
||||||
'@deepseek-ai/dsh-agent':
|
'@deepseek-ai/dsh-agent':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../core/agent
|
version: link:../../core/agent
|
||||||
|
|||||||
Reference in New Issue
Block a user