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:
Vendored
+1
@@ -39,6 +39,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
|
||||
7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
|
||||
8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). `applyPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`.
|
||||
9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions.
|
||||
10. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm.
|
||||
|
||||
## Sync procedure
|
||||
|
||||
|
||||
Vendored
+98
-74
@@ -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]() {
|
||||
|
||||
Reference in New Issue
Block a user