feat(cli): dsh --dump-config / --dump-default-config print the composed tree

dsh --dump-config and dsh web --dump-config compose the shipped base,
the surface overlay, and the --config or personal overlay — exactly the
layers that surface boots — and print the entry list as YAML without
booting; --dump-default-config stops at the surface overlay so the two
outputs diff to precisely the user layer's effect.

The dump shares the mounting code: the vendored include exports its
patch algorithm as applyEntryPatches() and its !!js dialect as
entryListSchema (logged in vendor/README.md), dsh-app-boot's
renderConfigDump() composes and renders through both (and now imports
the dialect instead of duplicating it), and the CLI adds a thin
dump-config mode. !!js expressions print verbatim; unmatched patches
warn on stderr; boot-only flags are rejected alongside the dump flags.

(cherry picked from commit 1fdbebfa8a5dc7df840d53666320064a7e3dae59)
This commit is contained in:
Turtle
2026-07-31 02:00:01 +08:00
parent 9e11f438f3
commit ce0aa90c1e
21 changed files with 756 additions and 106 deletions

View File

@@ -13,7 +13,15 @@ const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
represent: (data) => data['__jsExpr'],
})
const schema = yaml.JSON_SCHEMA.extend(JsExpr)
/**
* The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes
* the Loader evaluates at entry activation. Exported so config tooling
* (`dsh --dump-config`) parses and prints exactly the dialect this include
* mounts.
*/
export const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr)
const schema = entryListSchema
const writable: Record<string, string> = {
'.json': 'application/json',
@@ -23,6 +31,92 @@ const writable: Record<string, string> = {
const supported = new Set(Object.keys(writable))
/**
* Apply patch lists to an entry list — THE patch semantics of this include,
* shared by mounting (`applyPatches`) and offline config tooling
* (`dsh --dump-config`) so a dump can never drift from what boots. The input
* is never mutated: patching shared entry objects would bake earlier patch
* values into the cached parse, so repeated application (config hot-reloads)
* could never revert a removed or changed patch. Inserted entries are indexed
* as they are added, so a later patch in the same list can target a row an
* earlier patch inserted. A patch that matches nothing warns and is skipped.
* @param data - the parsed entry list (JSON-safe plain data).
* @param patches - the patch list to apply, in order.
* @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code).
* @returns a detached entry list with every applicable patch applied.
*/
export function applyEntryPatches(
data: EntryOptions[],
patches: PatchOptions[] | undefined,
warn: (message: string, ...args: any[]) => void,
): EntryOptions[] {
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) {
warn('patch insert: entry %C not found', id)
continue
}
if (!target.group) {
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) {
warn('patch: id is required for non-insert patches')
continue
}
const target = entryMap.get(id)
if (!target) {
warn('patch: entry %C not found', id)
continue
}
if (name && name !== target.name) {
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
}
/** Runtime patch applied to entries loaded from an included config file. */
export interface PatchOptions {
id?: string
@@ -125,79 +219,9 @@ export class Include extends EntryTree {
}
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
return applyEntryPatches(data, patches, (message, ...args) => {
this.ctx.root.logger?.('loader').warn(message, ...args)
})
}
async* [Service.init]() {