`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a twenty-key pass-through Config. Neither file was what its location claimed — apps/cli hardcoded the "example" as the product default and the "demo" bundle was the application — and every capability change had to be made twice. - apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and web.cordis.yml are patch lists stating only what differs per surface - overlays apply as SIBLING patch lists at one include level, because include patches never cross an include boundary. Precedence: base < surface < (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches - `--config` now applies an overlay INSTEAD OF the personal one, so a demo or test tree never inherits the user's route; new `--config-replace` boots a file as the entire tree (the old `--config` behaviour). Both survive /resume - vendor/include: index each `insert`ed row as it is added so a later patch can configure or disable it. Upstream built the id index once before the patch loop, leaving every surface-only row — the whole TUI front door — silently unpatchable from user config. Logged as local modification 8 - session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY; dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it) - delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo; TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests, examples/code-mode survives as an overlay leaf - `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay Three latent defects surfaced and are fixed here: the TUI captured the optional sessionQuery service once at construction and could permanently disable /resume when it won the mount race; the session-store root silently reverted to a project-local ./.sessions; --config-replace was dropped by the resume handoff. Verified by booting each tree through the real Loader (TUI 55 entries, web 75, zero unsettled) rather than reading YAML. All eight terminal snapshots replay byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene and lint clean.
271 lines
8.8 KiB
TypeScript
271 lines
8.8 KiB
TypeScript
import { EntryTree, isJsExpr, type EntryOptions } from '@cordisjs/plugin-loader'
|
|
import { Context, Service } from 'cordis'
|
|
import { extname } from 'node:path'
|
|
import { access, constants, readFile, rename, writeFile } from 'node:fs/promises'
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
import * as yaml from 'js-yaml'
|
|
|
|
const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
|
|
kind: 'scalar',
|
|
resolve: (data) => typeof data === 'string',
|
|
construct: (data) => ({ __jsExpr: data }),
|
|
predicate: isJsExpr,
|
|
represent: (data) => data['__jsExpr'],
|
|
})
|
|
|
|
const schema = yaml.JSON_SCHEMA.extend(JsExpr)
|
|
|
|
const writable: Record<string, string> = {
|
|
'.json': 'application/json',
|
|
'.yaml': 'application/yaml',
|
|
'.yml': 'application/yaml',
|
|
}
|
|
|
|
const supported = new Set(Object.keys(writable))
|
|
|
|
/** Runtime patch applied to entries loaded from an included config file. */
|
|
export interface PatchOptions {
|
|
id?: string
|
|
insert?: EntryOptions[]
|
|
name?: string
|
|
config?: any
|
|
group?: boolean | null
|
|
disabled?: boolean | null
|
|
inject?: any
|
|
intercept?: any
|
|
isolate?: any
|
|
[key: string]: any
|
|
}
|
|
|
|
/** Config namespace for the file-backed include loader. */
|
|
export namespace Include {
|
|
/** Config for a file-backed loader subtree. */
|
|
export interface Config {
|
|
/** YAML or JSON path resolved from `ctx.baseUrl`. */
|
|
path: string
|
|
/** Entry list written when the file does not already exist. */
|
|
initial?: any[]
|
|
/** Runtime patches applied after reading the file. */
|
|
patches?: PatchOptions[]
|
|
/** Enables loader apply/reload/unload logs for this subtree. */
|
|
enableLogs?: boolean
|
|
}
|
|
}
|
|
|
|
/** Loader entry tree backed by a YAML or JSON file. */
|
|
export class Include extends EntryTree {
|
|
static inject = ['loader']
|
|
|
|
public filename: string
|
|
private type?: string
|
|
private readonly: boolean
|
|
private content?: string
|
|
private data?: EntryOptions[]
|
|
private writeTask?: NodeJS.Timeout
|
|
|
|
constructor(ctx: Context, public config: Include.Config) {
|
|
super(ctx)
|
|
this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false
|
|
this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl))
|
|
const ext = extname(this.filename)
|
|
if (!supported.has(ext)) {
|
|
throw new Error(`extension "${ext}" not supported`)
|
|
}
|
|
this.type = writable[ext]
|
|
this.readonly = !this.type
|
|
this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href
|
|
|
|
ctx.on('internal/update', (config, _, next) => {
|
|
if (config.path !== this.config.path) return next()
|
|
// Veto the fiber restart (children update in place), but persist the new
|
|
// config ourselves — `Fiber.update` only assigns `this.config` behind
|
|
// `next()`, and a stale `this.config.patches` would make the next
|
|
// `refresh()` re-apply the old overlay.
|
|
this.config = config
|
|
this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => {
|
|
this.ctx.logger.warn('config update at %C failed', this.filename)
|
|
this.ctx.logger.warn(error)
|
|
})
|
|
})
|
|
}
|
|
|
|
private async checkAccess() {
|
|
if (!this.type) return
|
|
try {
|
|
await access(this.filename, constants.W_OK)
|
|
} catch {
|
|
this.readonly = true
|
|
}
|
|
}
|
|
|
|
private async read(forced = false) {
|
|
const content = await readFile(this.filename, 'utf8')
|
|
if (!forced && this.content === content) return false
|
|
let data: any
|
|
if (this.type === 'application/yaml') {
|
|
data = yaml.load(content, { schema })
|
|
} else if (this.type === 'application/json') {
|
|
data = JSON.parse(content)
|
|
} else {
|
|
const module = await import(/* @vite-ignore */ this.filename)
|
|
data = module.default || module
|
|
}
|
|
// An empty or truncated file (common mid-edit: editors and `sed -i` write
|
|
// through temp states) parses to `undefined`, not an error; reject every
|
|
// non-array shape here so callers see one "invalid file" signal. Content
|
|
// and data commit only on success, so an edit that is later reverted to
|
|
// the exact last good content correctly reads as "unchanged".
|
|
if (!Array.isArray(data)) {
|
|
throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`)
|
|
}
|
|
this.content = content
|
|
this.data = data
|
|
await this.checkAccess()
|
|
return true
|
|
}
|
|
|
|
private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] {
|
|
// Always detach from the cached parse: patching shared entry objects would
|
|
// bake earlier patch values into `this.data`, so repeated application
|
|
// (config hot-reloads) could never revert a removed or changed patch. The
|
|
// supported extensions guarantee JSON-safe plain data, so `structuredClone`
|
|
// cannot throw here.
|
|
if (!patches?.length) return [...data]
|
|
data = structuredClone(data)
|
|
|
|
const entryMap = new Map<string, EntryOptions>()
|
|
const buildMap = (entries: EntryOptions[]) => {
|
|
for (const entry of entries) {
|
|
if (entry.id) entryMap.set(entry.id, entry)
|
|
if (entry.group && Array.isArray(entry.config)) {
|
|
buildMap(entry.config)
|
|
}
|
|
}
|
|
}
|
|
buildMap(data)
|
|
|
|
for (const patch of patches) {
|
|
const { id, insert, name, ...overrides } = patch
|
|
|
|
if (insert) {
|
|
if (id) {
|
|
const target = entryMap.get(id)
|
|
if (!target) {
|
|
this.ctx.root.logger?.('loader').warn('patch insert: entry %C not found', id)
|
|
continue
|
|
}
|
|
if (!target.group) {
|
|
this.ctx.root.logger?.('loader').warn('patch insert: entry %C is not a group', id)
|
|
continue
|
|
}
|
|
if (!Array.isArray(target.config)) target.config = []
|
|
target.config.push(...insert)
|
|
} else {
|
|
data.push(...insert)
|
|
}
|
|
// Index what this patch added so a LATER patch in the same list can
|
|
// target it. Patch lists compose one layer per source (surface overlay,
|
|
// then `--config`, then the user's), and a layer must be able to
|
|
// configure or disable a row an earlier layer inserted; without this,
|
|
// inserted rows were silently unpatchable.
|
|
buildMap(insert)
|
|
continue
|
|
}
|
|
|
|
if (!id) {
|
|
this.ctx.root.logger?.('loader').warn('patch: id is required for non-insert patches')
|
|
continue
|
|
}
|
|
|
|
const target = entryMap.get(id)
|
|
if (!target) {
|
|
this.ctx.root.logger?.('loader').warn('patch: entry %C not found', id)
|
|
continue
|
|
}
|
|
|
|
if (name && name !== target.name) {
|
|
this.ctx.root.logger?.('loader').warn(
|
|
'patch: name mismatch for %C (expected %C, got %C), skipping',
|
|
id, target.name, name,
|
|
)
|
|
continue
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(overrides)) {
|
|
if (key === 'id') continue
|
|
target[key] = value
|
|
}
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
async* [Service.init]() {
|
|
try {
|
|
await this.read()
|
|
} catch (error) {
|
|
// Only a missing file falls back to `initial` (or the not-found error):
|
|
// an existing-but-invalid file must fail loud with its real parse error,
|
|
// never be mislabelled as absent or silently overwritten.
|
|
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error
|
|
if (this.config.initial) {
|
|
this.writeFile(this.config.initial as any)
|
|
await this.read()
|
|
} else {
|
|
throw new Error(`config file not found: ${this.filename}`)
|
|
}
|
|
}
|
|
|
|
yield () => this.stop()
|
|
await this.root.update(this.applyPatches(this.data!))
|
|
}
|
|
|
|
stop() {
|
|
this.root.stop()
|
|
}
|
|
|
|
/**
|
|
* Re-read the file and refresh child entries when content changed. An
|
|
* unreadable or unparsable file logs a warning and keeps the last good
|
|
* tree: a hot-reload of a live app must never take the process down.
|
|
*/
|
|
async refresh() {
|
|
try {
|
|
if (!await this.read()) return
|
|
await this.root.update(this.applyPatches(this.data!))
|
|
} catch (error) {
|
|
this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename)
|
|
this.ctx.logger.warn(error)
|
|
}
|
|
}
|
|
|
|
private async _writeFile(config: EntryOptions[]) {
|
|
if (this.readonly) {
|
|
throw new Error(`cannot overwrite readonly config`)
|
|
}
|
|
if (this.type === 'application/yaml') {
|
|
this.content = yaml.dump(config, { schema })
|
|
} else if (this.type === 'application/json') {
|
|
this.content = JSON.stringify(config, null, 2)
|
|
}
|
|
await writeFile(this.filename + '.tmp', this.content!)
|
|
await rename(this.filename + '.tmp', this.filename)
|
|
}
|
|
|
|
private writeFile(config: EntryOptions[]) {
|
|
clearTimeout(this.writeTask)
|
|
this.writeTask = setTimeout(() => {
|
|
this.writeTask = undefined
|
|
this._writeFile(config)
|
|
}, 0)
|
|
}
|
|
|
|
/** Schedule a write of the current root entry data. */
|
|
write() {
|
|
this.context.emit('loader/config-update')
|
|
return this.writeFile(this.root.data)
|
|
}
|
|
}
|
|
|
|
export default Include
|