feat(cli)!: dsh boots profiles; plugin subcommand manages them via pnpm
dsh --profile <name> replaces the fixed entry modes: --config and -p are removed, --patch adds overlays over the composed profile, a positional task selects one-shot mode (requires the headless-runner row), and dsh web stays as the alias for --profile web carrying the Web flag family as patches. dsh plugin --profile <name> forwards verbatim to pnpm in the profile directory, initializes on first use, and reconciles the dsh.plugins layer list after add/remove (patch-less packages warn and stay plain dependencies). Config dumps and the keyless web e2e scaffold compose the same bundle layers over the same empty root as the boot.
This commit is contained in:
@@ -135,6 +135,13 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
const loader = ctx.get('loader')
|
||||
if (loader === undefined) printUrl()
|
||||
else void loader.await().then(printUrl)
|
||||
else {
|
||||
void loader.await().then(() => {
|
||||
// The tree can be disposed while settlement was in flight (early
|
||||
// SIGTERM); a URL line for a dead server would only mislead, and
|
||||
// reading the torn-down port would turn a clean shutdown into a crash.
|
||||
if (ctx.get('httpServer') !== undefined) printUrl()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,43 @@ describe('web-app runtime glue', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('defers the URL line until Loader settlement and drops it when the server is gone', async () => {
|
||||
stageDist()
|
||||
// Settlement path: the line waits for loader.await() so supervisors can
|
||||
// RPC immediately after observing it.
|
||||
const settled = new Context()
|
||||
settled.provide('httpServer', fakeHttpServer().server)
|
||||
let release: () => void
|
||||
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: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
release!()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
||||
await settled.fiber.dispose()
|
||||
|
||||
// Torn-down path: settlement resolves after the webserver is gone — no
|
||||
// line, no crash.
|
||||
log.mockClear()
|
||||
const torn = new Context()
|
||||
const child = torn.plugin((childCtx: Context) => {
|
||||
childCtx.provide('httpServer', fakeHttpServer().server)
|
||||
})
|
||||
await child
|
||||
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: [] }))
|
||||
await child.dispose() // the httpServer service goes away
|
||||
releaseTorn!()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
await torn.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails loud when the prompt section resolves against a portless webserver', async () => {
|
||||
stageDist()
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
// Empty type import carries the Loader's Fiber#entry merge read below.
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-frontend-static'
|
||||
@@ -16,33 +14,16 @@ export const name = 'frontend-static-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Owned relation: the fallback seat and the owning fiber must stay symmetric —
|
||||
* after the fiber holding the seat unloads, the seat must be claimable again
|
||||
* (a stale fallback would keep serving a disposed plugin's dist). Checked on
|
||||
* every fiber teardown by probing the registerFallback single-owner contract:
|
||||
* when this package's plugin is not mounted, a claim+release cycle must
|
||||
* succeed twice; residue from a leaked disposer makes the second claim throw.
|
||||
* No runtime invariant: the only owned relation is the single fallback seat,
|
||||
* which cannot be probed from the teardown stream — `internal/plugin` fires
|
||||
* before the disposing fiber's effects run, so the legitimate owner still
|
||||
* holds the seat at notification time and any claim probe would
|
||||
* false-positive on every correct disposal (unlike the webserver companion,
|
||||
* whose reserved-path probes never collide with a live registration). The
|
||||
* seat's register/release symmetry is covered by the package's
|
||||
* real-composition HMR-safety test instead.
|
||||
*/
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
// Only audit teardowns of this package's own rows: while a live
|
||||
// frontend-static row legitimately holds the seat, the probe would
|
||||
// false-positive on the legitimate owner.
|
||||
if (fiber.entry?.options.name !== PACKAGE_NAME) return
|
||||
const server = ctx.get('httpServer') as
|
||||
| { registerFallback(handler: () => void): () => void }
|
||||
| undefined
|
||||
if (server === undefined) return // torn down with the webserver itself
|
||||
// The probe handlers are registered and immediately released, never invoked.
|
||||
/* v8 ignore next 4 -- the arrow bodies are dead by design */
|
||||
try {
|
||||
server.registerFallback(() => {})()
|
||||
server.registerFallback(() => {})()
|
||||
} catch {
|
||||
fail('frontend-static fallback disposer left the seat claimed — seat ownership and fiber lifecycle diverged')
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import HttpServer from '@deepseek-ai/dsh-host-webserver'
|
||||
import InvariantService, { type InvariantError } from '@deepseek-ai/dsh-invariants'
|
||||
import * as FrontendStatic from '../src/index.ts'
|
||||
|
||||
let root: string | undefined
|
||||
@@ -126,46 +125,3 @@ describe('real Loader composition', () => {
|
||||
expect(() => server.registerFallback(() => {})).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('invariant companion', () => {
|
||||
const OWN_FIBER = { entry: { options: { name: '@deepseek-ai/dsh-frontend-static' } } }
|
||||
|
||||
// The vitest-wide invariant host (scripts/test-invariants.ts) mounts this
|
||||
// package's companion automatically when the service is plugged.
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('passes on a clean seat release, skips foreign rows, and reports a leaked seat', async () => {
|
||||
const ctx = await setup()
|
||||
let fallback: unknown
|
||||
ctx.provide('httpServer', {
|
||||
registerFallback: (handler: unknown) => {
|
||||
if (fallback !== undefined) throw new Error('webserver: fallback already registered')
|
||||
fallback = handler
|
||||
return () => { fallback = undefined }
|
||||
},
|
||||
} as never)
|
||||
|
||||
// A teardown of this package's own row with the seat released: no violation.
|
||||
expect(() => { ctx.emit('internal/plugin', OWN_FIBER as never) }).not.toThrow()
|
||||
// Foreign-row teardowns are not audited (a live legitimate owner would false-positive).
|
||||
fallback = () => {}
|
||||
expect(() => { ctx.emit('internal/plugin', { entry: { options: { name: 'other-package' } } } as never) }).not.toThrow()
|
||||
// A leaked seat on our own teardown (disposer never ran): the probe cannot claim twice → violation.
|
||||
expect(() => { ctx.emit('internal/plugin', OWN_FIBER as never) })
|
||||
.toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-frontend-static',
|
||||
}))
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('skips the audit when the webserver went down with the row', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('internal/plugin', OWN_FIBER as never) }).not.toThrow()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -159,7 +159,19 @@ function ensureSymlink(link: string, target: string): void {
|
||||
if (readlinkSync(link) === target) return
|
||||
rmSync(link)
|
||||
}
|
||||
symlinkSync(target, link, 'junction')
|
||||
try {
|
||||
symlinkSync(target, link, 'junction')
|
||||
} catch (error) {
|
||||
// Concurrent launches heal the same fallback; losing the race to a
|
||||
// process writing the identical link is success, anything else is not.
|
||||
// The window between the lstat miss above and this write cannot be
|
||||
// staged deterministically from the public surface.
|
||||
/* v8 ignore next 4 */
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST'
|
||||
|| !lstatSync(link).isSymbolicLink() || readlinkSync(link) !== target) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,32 +197,24 @@ export function healProfilesModuleFallback(installAnchor: string, home: string =
|
||||
// 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 appRequire = createRequire(installAnchor)
|
||||
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 ?? {})) {
|
||||
let manifestPath: string
|
||||
try {
|
||||
manifestPath = appRequire.resolve(`${dep}/package.json`)
|
||||
} catch {
|
||||
continue // not resolvable (a bin-less oddity) — nothing to mirror
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest
|
||||
if (manifest.dsh?.patch !== undefined) anchors.push({ anchor: manifestPath, manifest })
|
||||
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) {
|
||||
const requireFrom = createRequire(anchor)
|
||||
/* v8 ignore next -- bundle anchors reach here only with a dependencies map */
|
||||
for (const dep of Object.keys(manifest.dependencies ?? {})) {
|
||||
if (links.has(dep)) continue
|
||||
try {
|
||||
links.set(dep, dirname(requireFrom.resolve(`${dep}/package.json`)))
|
||||
} catch {
|
||||
// A dependency without a resolvable package.json export cannot be a
|
||||
// loader-visible plugin; skip it rather than fail the whole boot.
|
||||
}
|
||||
const dir = packageDirFromAnchor(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).
|
||||
@@ -256,11 +260,35 @@ export function writeProfileManifest(dir: string, manifest: ProfileManifest): vo
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a package's root directory from one anchor without depending on the
|
||||
* package exporting `./package.json`: probe the require resolution paths for
|
||||
* a directory holding the named manifest. This is Node's own lookup order, so
|
||||
* the result matches what the Loader would import from the same anchor.
|
||||
*/
|
||||
function packageDirFromAnchor(anchor: string, packageName: string): string | undefined {
|
||||
const require = createRequire(anchor)
|
||||
// Fast path: the package exports its manifest (every in-box package does).
|
||||
try {
|
||||
return dirname(require.resolve(`${packageName}/package.json`))
|
||||
} catch {
|
||||
// Exports-encapsulated package — fall through to the paths probe.
|
||||
}
|
||||
// resolve.paths returns null only for builtins, which no bundle name is.
|
||||
/* v8 ignore next */
|
||||
for (const searchPath of require.resolve.paths(packageName) ?? []) {
|
||||
const candidate = join(searchPath, packageName)
|
||||
if (existsSync(join(candidate, 'package.json'))) return candidate
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one bundle package's directory: installation anchor first, then the
|
||||
* profile directory. The installation-first order is the contract that
|
||||
* `@deepseek-ai/dsh-base` (and every other in-box bundle) always comes from
|
||||
* the same installation as the running dsh, never from a profile-local copy.
|
||||
* Resolution does not require the package to export `./package.json`.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param packageName - the bundle's package name from `dsh.plugins`.
|
||||
* @param installAnchor - absolute path of a file inside the dsh app package (its package.json).
|
||||
@@ -271,11 +299,8 @@ export function resolveBundleDir(
|
||||
binName: string, packageName: string, installAnchor: string, profileDir: string,
|
||||
): string {
|
||||
for (const anchor of [installAnchor, join(profileDir, 'package.json')]) {
|
||||
try {
|
||||
return dirname(createRequire(anchor).resolve(`${packageName}/package.json`))
|
||||
} catch {
|
||||
// Not resolvable from this anchor — try the next; exhaustion throws below.
|
||||
}
|
||||
const dir = packageDirFromAnchor(anchor, packageName)
|
||||
if (dir !== undefined) return dir
|
||||
}
|
||||
// profileDir always carries at least one segment; String() only satisfies the type.
|
||||
const profileName = String(join(profileDir).split(/[/\\]/).at(-1))
|
||||
|
||||
@@ -94,6 +94,27 @@ describe('resolveBundleDir', () => {
|
||||
expect(resolveBundleDir('t', 'local-only', anchor, profileDir)).toContain('local-only')
|
||||
expect(() => resolveBundleDir('t', 'absent', anchor, profileDir)).toThrow('cannot resolve profile bundle')
|
||||
})
|
||||
|
||||
it('resolves a package whose exports map omits ./package.json', () => {
|
||||
// Common on npm: an exports map without "./package.json" makes
|
||||
// require.resolve('<pkg>/package.json') throw ERR_PACKAGE_PATH_NOT_EXPORTED;
|
||||
// resolution must fall through to the paths probe instead of misreporting
|
||||
// the installed package as missing.
|
||||
const anchor = stageInstallation({})
|
||||
const profileDir = tmp()
|
||||
writeFileSync(join(profileDir, 'package.json'), '{}')
|
||||
const dir = join(profileDir, 'node_modules', 'sealed-bundle')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'sealed-bundle',
|
||||
version: '0.0.0',
|
||||
exports: { '.': './index.js' },
|
||||
dsh: { patch: './cordis.patch.yml' },
|
||||
}))
|
||||
writeFileSync(join(dir, 'index.js'), '')
|
||||
writeFileSync(join(dir, 'cordis.patch.yml'), '[]\n')
|
||||
expect(resolveBundleDir('t', 'sealed-bundle', anchor, profileDir)).toBe(dir)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadProfile', () => {
|
||||
@@ -200,4 +221,18 @@ describe('healProfilesModuleFallback', () => {
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app')
|
||||
})
|
||||
|
||||
it('tolerates losing the concurrent-heal race to an identical link and rejects a different one', () => {
|
||||
// The EEXIST arm: a second process wrote the link between our lstat miss
|
||||
// and symlinkSync. Simulated by pre-creating the correct link and calling
|
||||
// the internal path through a stale-lstat shim is not possible from
|
||||
// outside, so probe the observable contract: healing twice concurrently
|
||||
// is a no-op, and a foreign REAL directory still fails loud.
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
healProfilesModuleFallback(anchor, home) // second healer sees the correct link
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user