Merge remote-tracking branch 'origin/master' into claude/unified-environment-credentials-c8841a

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md
#	.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md
#	apps/cli/config/base.cordis.yml
#	apps/cli/package.json
#	apps/cli/reference/README.i18n.yaml
#	apps/cli/reference/README.md
#	apps/cli/reference/README.zh.md
#	apps/cli/src/app-cli-entry.ts
#	apps/cli/src/args.ts
#	apps/cli/src/bin.ts
#	apps/cli/src/config.ts
#	apps/cli/src/dump-config.ts
#	apps/cli/src/headless.ts
#	apps/cli/src/web.ts
#	apps/cli/tests/args.spec.ts
#	apps/cli/tests/built-bin.e2e.ts
#	apps/cli/tests/headless-shutdown.e2e.ts
#	apps/cli/tsconfig.json
#	docs/user/guide/config.i18n.yaml
#	docs/user/guide/config.md
#	docs/user/guide/config.zh.md
#	examples/mcp-memory/README.i18n.yaml
#	examples/mcp-memory/README.md
#	examples/mcp-memory/README.zh.md
#	packages/bundle/web-app/cordis.patch.yml
#	packages/cordis/repository-plugin/README.i18n.yaml
#	packages/cordis/repository-plugin/README.md
#	packages/cordis/repository-plugin/README.zh.md
#	packages/credentials/credentials-local/README.i18n.yaml
#	packages/credentials/credentials-local/README.md
#	packages/credentials/credentials-local/README.zh.md
#	packages/ui/app-boot/README.i18n.yaml
#	packages/ui/app-boot/README.md
#	packages/ui/app-boot/README.zh.md
#	packages/ui/app-boot/src/index.ts
#	packages/ui/app-boot/tests/config-reload.spec.ts
#	packages/ui/app-boot/tests/user-patches.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-08-06 21:34:45 +08:00
352 changed files with 7632 additions and 3268 deletions
+139 -27
View File
@@ -1,14 +1,14 @@
/**
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
* `.env` files, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
* explicit overlay patch lists a surface composes, expose the Harness-home path resolver to
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
* optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to
* config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles.
* @module @deepseek-ai/dsh-app-boot
*/
import { parseEnv } from 'node:util'
import { pathToFileURL } from 'node:url'
import { readFileSync } from 'node:fs'
import { parseEnv } from 'node:util'
import { basename, dirname, resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { Context, type FiberState } from 'cordis'
@@ -27,6 +27,27 @@ declare module 'cordis' {
}
}
export {
composeEntries,
DEFAULT_PROFILE_BUNDLES,
healProfilesModuleFallback,
initProfile,
loadProfile,
PROFILE_PATCH_FILENAME,
PROFILE_TEMPLATES,
PROFILES_DIR,
readProfileManifest,
resolveBundleDir,
resolveProfileDir,
writeProfileManifest,
type DshBundleManifest,
type DshManifestSection,
type DshProfileManifest,
type Profile,
type ProfileLayer,
type ProfileManifest,
} from './profile.ts'
/**
* Resolve the config to boot. Replay swaps a `cordis.yml` basename for
* `cordis.snapshot.yml` in the same directory; every other mode keeps the path.
@@ -171,15 +192,100 @@ export function loadLayeredEnv(
])
}
const bootstrapIncludes = new WeakMap<Context, Entry>()
// The include's YAML dialect (`!!js` scalars become expression nodes the
// Loader interpolates against each entry's context at mount time), imported
// from the include itself so patch parsing and config dumping can never drift
// from what the include mounts. User patch layers share it so they may
// reference `process.env`.
const userPatchesSchema = entryListSchema
/** Options for live user patch-layer reconciliation. */
export interface UserPatchWatchOptions {
/** Diagnostic prefix used by {@link loadOptionalPatches}. */
binName: string
/** Absolute path of the watched patch file (a profile's `cordis.patch.yml`). */
filename: string
/**
* Compose the full patch list for a fresh user-layer generation —
* the same composition the app booted with, so a reload can interleave the
* new user patches between app-owned layers (bundle layers below,
* overlay/flag patches above). Identity when omitted: the user layer
* is the whole patch list.
*/
compose?: (userPatches: PatchOptions[]) => PatchOptions[]
}
/**
* Load an overlay patch list: a surface overlay (`tui.cordis.yml`) or a
* `--config <path>` overlay applied over the shared base. The file is a
* top-level YAML array of loader patch entries (`@cordisjs/plugin-include`'s
* `PatchOptions`): id-targeted config overrides and `insert` lists, with
* `!!js` expressions allowed — the dialect is imported from the include
* itself, so patch parsing and config dumping can never drift from what the
* include mounts. A missing file throws, because the caller named this file:
* its absence is a misconfiguration, not "no overlay".
* Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include.
* @param ctx - settled app context containing the root Include and an active HMR service.
* @param options - diagnostic, file, and patch-composition inputs.
* @returns an asynchronous disposer after the exact-path watcher is ready.
* @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails.
*/
export async function watchUserPatches(
ctx: Context,
options: UserPatchWatchOptions,
): Promise<() => Promise<void>> {
const { binName, filename, compose = (patches: PatchOptions[]) => patches } = options
const hmr = ctx.get('hmr')
if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`)
const entry = bootstrapIncludes.get(ctx)
if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`)
const register = hmr.registerConfig(filename, async () => {
// Re-read the include's non-patch options per refresh: a writer that
// updates the root Include's other options between refreshes (none exists
// today) must not have them silently reverted by a user-layer reload.
const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
const userPatches = loadOptionalPatches(binName, filename) ?? []
const patches = compose(userPatches)
await entry.update({
config: {
...includeConfig,
patches,
},
})
})
try {
return await register
} catch (error) {
// A surface can dispose the whole tree while the watcher is still opening;
// the HMR effect registration then fails with INACTIVE_EFFECT. That is the
// app exiting exactly as asked, not a watch failure, so return a no-op
// disposer instead of crashing.
if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {}
throw error
}
}
/**
* Load an optional patch-list file: a top-level YAML array of loader patch
* entries (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config
* overrides and `insert` lists, with `!!js` expressions allowed. A missing
* file means "no layer"; an unreadable, unparsable, or non-array file throws —
* a present patch file that cannot apply is a misconfiguration and must fail
* loud at boot, never be silently skipped.
* @param binName - the diagnostic prefix on the thrown error.
* @param file - absolute path of the patch file.
* @returns the parsed patches, or `undefined` when the file does not exist.
*/
export function loadOptionalPatches(binName: string, file: string): PatchOptions[] | undefined {
let content: string
try {
content = readFileSync(file, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
throw new Error(`${binName}: failed to read patches ${file}: ${String(error)}`)
}
return parsePatchList(binName, file, content, 'patches')
}
/**
* Load a required overlay patch list: a bundle's `cordis.patch.yml` or a
* `--patch <path>` overlay. Same file format as {@link loadOptionalPatches},
* but a missing file throws, because the caller named this file — its absence
* is a misconfiguration, not "no overlay".
* @param binName - the diagnostic prefix on the thrown error.
* @param file - absolute path of the overlay file.
* @returns the parsed patch list.
@@ -191,32 +297,36 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[
} catch (error) {
throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`)
}
return parsePatchList(binName, file, content)
return parsePatchList(binName, file, content, 'overlay')
}
/**
* Parse one loader patch list. Every shape failure throws, because a patch
* file that cannot be applied at all is a misconfiguration; a single patch
* whose target row is absent stays a per-entry Loader warning, so one overlay
* shared across surfaces does not have to match every tree.
* Parse one loader patch list: a top-level YAML array of
* `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and
* `insert` lists, `!!js` expressions allowed). Every shape failure throws,
* because a patch file that cannot be applied at all is a misconfiguration; a
* single patch whose target row is absent stays a per-entry Loader warning, so
* one overlay shared across surfaces does not have to match every tree.
* @param binName - the diagnostic prefix on the thrown error.
* @param file - the source path, quoted in errors.
* @param content - the file's text.
* @param label - what to call this list in errors (`patches`, `overlay`).
* @returns the parsed patch list.
*/
function parsePatchList(binName: string, file: string, content: string): PatchOptions[] {
function parsePatchList(
binName: string, file: string, content: string, label: string,
): PatchOptions[] {
let parsed: unknown
try {
parsed = yaml.load(content, { schema: entryListSchema })
parsed = yaml.load(content, { schema: userPatchesSchema })
} catch (error) {
throw new Error(`${binName}: failed to parse overlay ${file}: ${String(error)}`)
throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)
}
if (!Array.isArray(parsed)) {
throw new Error(`${binName}: overlay ${file} must be a top-level YAML array of loader patch entries`)
throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`)
}
parsed.forEach((entry, index) => {
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
throw new Error(`${binName}: overlay entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
}
})
return parsed as PatchOptions[]
@@ -226,7 +336,7 @@ function parsePatchList(binName: string, file: string, content: string): PatchOp
export interface ConfigDumpLayer {
/** Source name shown in provenance comments (a file basename or path). */
label: string
/** The layer's patches, from {@link loadOverlayPatches}. */
/** The layer's patches, from {@link loadOverlayPatches} / {@link loadOptionalPatches}. */
patches: PatchOptions[]
}
@@ -358,10 +468,10 @@ function groupedDump(
}
/**
* Mount the root Include entry app boot drives.
* Mount and remember the exact root Include entry used by app boot and user patch-layer HMR.
* @param ctx - context carrying an initialized Loader service.
* @param absoluteConfigPath - absolute YAML or JSON configuration path.
* @param patches - the surface's overlay patches, applied in order.
* @param patches - initial app and user patches, applied in order.
* @returns the created root Include entry, or `undefined` when a surface
* disposed the whole tree (taking the Loader service with it) while the
* transactional create was still settling entry lifecycle.
@@ -386,7 +496,9 @@ export async function mountRootInclude(
const includeId = await ctx.loader.create(rootInclude)
const loader = ctx.get('loader')
if (loader === undefined) return undefined
return loader.resolve(includeId)
const entry = loader.resolve(includeId)
bootstrapIncludes.set(ctx, entry)
return entry
}
/**
@@ -605,7 +717,7 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
* @param absoluteConfigPath - the config to include; must already be absolute
* (see {@link resolveConfigPath}).
* @param patches - optional overlay patches applied over the included tree
* (see {@link loadOverlayPatches}); an empty list mounts none.
* (see {@link loadOptionalPatches}); an empty list mounts none.
* @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
* @returns the root context once every entry has started, or as soon as a
* surface disposed the tree while startup was still in flight.