refactor(cli)!: namespace the profile and bundle manifests under dsh.profile and dsh.bundle
A profile manifest and a bundle manifest are different kinds and shared one flat `dsh` section: `dsh.plugins` listed bundles (not plugins) and `dsh.patch` declared a bundle's layer. Each kind now names its role — a bundle declares `dsh.bundle.patch`, a profile declares `dsh.profile.bundles` — so a package.json states which role it plays and the list name matches its contents. `DEFAULT_PROFILE_PLUGINS` becomes `DEFAULT_PROFILE_BUNDLES`, and `DshManifestSection` splits into `DshBundleManifest`/`DshProfileManifest`. Pre-release: no compatibility shim; turtle-ui moved with it (bd5ff10).
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
|
||||
README.md: 49ad8270ffc62974023cdeba17f3f1356aaf27ae
|
||||
README.zh.md: 8d01d850d467eb6e21789201fbdef6d79fcc68f0
|
||||
README.md: cdd78047b6ad71148c6ebeba598b63b4ae4cfa7b
|
||||
README.zh.md: ee2b07884e68510e2b59b9f2c27053c263d15f1a
|
||||
|
||||
@@ -15,8 +15,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
| `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape; a missing file also throws, because the caller named it |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by user patch-layer HMR |
|
||||
| `watchPersonalPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer |
|
||||
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_PLUGINS` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) |
|
||||
| `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer |
|
||||
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
@@ -32,14 +32,14 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
|
||||
|
||||
## Profiles
|
||||
|
||||
A profile is a directory under `$DSH_HOME/profiles/<name>` (the Harness home resolves through [`resolveDshHome`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the ordered `dsh.plugins` bundle-layer list — and the user's own `cordis.patch.yml`. A bundle is an npm package whose manifest declares `"dsh": { "patch": "./cordis.patch.yml" }`; `loadProfile` resolves each `dsh.plugins` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a patch declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps can never drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm ever managing in-box packages. `PROFILE_TEMPLATES` (`web`, `headless`) auto-initialize on first use; other names fail loud until `initProfile` creates them (the `dsh plugin` path).
|
||||
A profile is a directory under `$DSH_HOME/profiles/<name>` (the Harness home resolves through [`resolveDshHome`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list — and the user's own `cordis.patch.yml`. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps can never drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm ever managing in-box packages. `PROFILE_TEMPLATES` (`web`, `headless`) auto-initialize on first use; other names fail loud until `initProfile` creates them (the `dsh plugin` path).
|
||||
|
||||
User-level machine-local preferences also live in the Harness home:
|
||||
|
||||
- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the Web settings page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone.
|
||||
- **`cordis.patch.yml`** (home level) and **`profiles/<name>/cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`.
|
||||
|
||||
Long-lived surfaces keep `cordis.patch.yml` live through `watchPersonalPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
| `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
| `loadOverlayPatches(binName, file)` | 解析一份形状相同的必需 patch 列表文件;文件缺失同样抛出异常,因为该文件是调用方指名的 |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 |
|
||||
| `watchPersonalPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步 disposer |
|
||||
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_PLUGINS` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) |
|
||||
| `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步 disposer |
|
||||
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
@@ -32,14 +32,14 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面
|
||||
|
||||
## Profile
|
||||
|
||||
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上有序的 `dsh.plugins` 组合包层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "patch": "./cordis.patch.yml" }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.plugins` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有 patch 声明则大声失败。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而 pnpm 从不管理随安装内置的包。`PROFILE_TEMPLATES`(`web`、`headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会大声失败(即 `dsh plugin` 路径)。
|
||||
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则大声失败。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而 pnpm 从不管理随安装内置的包。`PROFILE_TEMPLATES`(`web`、`headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会大声失败(即 `dsh plugin` 路径)。
|
||||
|
||||
用户级的机器本地偏好同样位于 Harness home 中:
|
||||
|
||||
- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 Web 设置页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。
|
||||
- **`cordis.patch.yml`**(home 级)与 **`profiles/<name>/cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。
|
||||
|
||||
长期运行的 surface 会持续应用 `cordis.patch.yml` 的变更,具体由 `watchPersonalPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
长期运行的 surface 会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
|
||||
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
|
||||
* optional personal overlay patches from the Harness home (`~/.dsh`), expose its path resolver to
|
||||
* 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
|
||||
*/
|
||||
@@ -27,7 +27,7 @@ declare module 'cordis' {
|
||||
|
||||
export {
|
||||
composeEntries,
|
||||
DEFAULT_PROFILE_PLUGINS,
|
||||
DEFAULT_PROFILE_BUNDLES,
|
||||
healProfilesModuleFallback,
|
||||
initProfile,
|
||||
loadProfile,
|
||||
@@ -38,7 +38,9 @@ export {
|
||||
resolveBundleDir,
|
||||
resolveProfileDir,
|
||||
writeProfileManifest,
|
||||
type DshBundleManifest,
|
||||
type DshManifestSection,
|
||||
type DshProfileManifest,
|
||||
type Profile,
|
||||
type ProfileLayer,
|
||||
type ProfileManifest,
|
||||
@@ -89,12 +91,12 @@ 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. Personal patches share it so they may
|
||||
// from what the include mounts. User patch layers share it so they may
|
||||
// reference `process.env`.
|
||||
const personalPatchesSchema = entryListSchema
|
||||
const userPatchesSchema = entryListSchema
|
||||
|
||||
/** Options for live user patch-layer reconciliation. */
|
||||
export interface PersonalPatchWatchOptions {
|
||||
export interface UserPatchWatchOptions {
|
||||
/** Diagnostic prefix used by {@link loadOptionalPatches}. */
|
||||
binName: string
|
||||
/** Absolute path of the watched patch file (a profile's `cordis.patch.yml`). */
|
||||
@@ -106,7 +108,7 @@ export interface PersonalPatchWatchOptions {
|
||||
* overlay/flag patches above). Identity when omitted: the user layer
|
||||
* is the whole patch list.
|
||||
*/
|
||||
compose?: (personalPatches: PatchOptions[]) => PatchOptions[]
|
||||
compose?: (userPatches: PatchOptions[]) => PatchOptions[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,22 +118,22 @@ export interface PersonalPatchWatchOptions {
|
||||
* @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 watchPersonalPatches(
|
||||
export async function watchUserPatches(
|
||||
ctx: Context,
|
||||
options: PersonalPatchWatchOptions,
|
||||
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}: personal config watching requires the Cordis HMR service`)
|
||||
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}: personal config watching requires the root Include entry`)
|
||||
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 personal reload.
|
||||
// today) must not have them silently reverted by a user-layer reload.
|
||||
const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
|
||||
const personalPatches = loadOptionalPatches(binName, filename) ?? []
|
||||
const patches = compose(personalPatches)
|
||||
const userPatches = loadOptionalPatches(binName, filename) ?? []
|
||||
const patches = compose(userPatches)
|
||||
await entry.update({
|
||||
config: {
|
||||
...includeConfig,
|
||||
@@ -201,7 +203,7 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[
|
||||
* @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 (`personal patches`, `overlay`).
|
||||
* @param label - what to call this list in errors (`patches`, `overlay`).
|
||||
* @returns the parsed patch list.
|
||||
*/
|
||||
function parsePatchList(
|
||||
@@ -209,7 +211,7 @@ function parsePatchList(
|
||||
): PatchOptions[] {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = yaml.load(content, { schema: personalPatchesSchema })
|
||||
parsed = yaml.load(content, { schema: userPatchesSchema })
|
||||
} catch (error) {
|
||||
throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)
|
||||
}
|
||||
@@ -360,10 +362,10 @@ function groupedDump(
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount and remember the exact root Include entry used by app boot and personal-config HMR.
|
||||
* 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 - initial app and personal 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.
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
* `dsh --profile` launcher family.
|
||||
*
|
||||
* A profile is a directory under `$DSH_HOME/profiles/<name>` holding a
|
||||
* `package.json` (out-of-tree plugin dependencies plus the ordered
|
||||
* `dsh.plugins` bundle list) and a `cordis.patch.yml` (the user's own patch
|
||||
* layer, applied after every bundle layer). Bundles are npm packages whose
|
||||
* manifest declares `"dsh": { "patch": "./cordis.patch.yml" }`; the tree is
|
||||
* composed by applying each bundle's patch list in `dsh.plugins` order over
|
||||
* `package.json` (out-of-tree plugin dependencies plus the profile manifest
|
||||
* `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml`
|
||||
* (the user's own patch layer, applied after every bundle layer). Bundles are
|
||||
* npm packages whose manifest declares
|
||||
* `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the tree is
|
||||
* composed by applying each bundle's patch list in `dsh.profile.bundles` order over
|
||||
* an empty entry list, then the profile's own patches, then any launcher
|
||||
* layers (`--patch` files and flag-derived patches).
|
||||
*
|
||||
@@ -37,12 +38,28 @@ export const PROFILES_DIR = 'profiles'
|
||||
/** The user patch layer inside a profile directory (hot-reloaded on long-lived surfaces). */
|
||||
export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
|
||||
|
||||
/** The `dsh`-owned manifest section of a profile's or bundle's package.json. */
|
||||
/** The bundle half of the `dsh` manifest section: what a bundle package exports. */
|
||||
export interface DshBundleManifest {
|
||||
/** The patch layer this bundle exports, relative to its package root. */
|
||||
patch: string
|
||||
}
|
||||
|
||||
/** The profile half of the `dsh` manifest section: what a profile directory composes. */
|
||||
export interface DshProfileManifest {
|
||||
/** Ordered bundle layer list (package names). */
|
||||
bundles?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The `dsh`-owned manifest section of a package.json. The nested key names
|
||||
* the manifest kind: a bundle package declares `bundle`, a profile directory
|
||||
* declares `profile`; nothing declares both.
|
||||
*/
|
||||
export interface DshManifestSection {
|
||||
/** Bundle manifest: profile patch this package exports, relative to its root. */
|
||||
patch?: string
|
||||
/** Profile manifest: ordered bundle layer list (package names). */
|
||||
plugins?: string[]
|
||||
/** Present on bundle packages only. */
|
||||
bundle?: DshBundleManifest
|
||||
/** Present on profile manifests only. */
|
||||
profile?: DshProfileManifest
|
||||
}
|
||||
|
||||
/** The slice of package.json both profiles and bundles use. */
|
||||
@@ -55,7 +72,7 @@ export interface ProfileManifest {
|
||||
|
||||
/** One resolved bundle layer of a profile. */
|
||||
export interface ProfileLayer {
|
||||
/** The bundle's package name, as listed in `dsh.plugins`. */
|
||||
/** The bundle's package name, as listed in `dsh.profile.bundles`. */
|
||||
packageName: string
|
||||
/** Absolute directory of the resolved bundle package. */
|
||||
packageDir: string
|
||||
@@ -71,7 +88,7 @@ export interface Profile {
|
||||
name: string
|
||||
/** Absolute profile directory. */
|
||||
dir: string
|
||||
/** Bundle layers in `dsh.plugins` order. */
|
||||
/** Bundle layers in `dsh.profile.bundles` order. */
|
||||
layers: ProfileLayer[]
|
||||
/** Absolute path of the profile's own patch file. */
|
||||
patchPath: string
|
||||
@@ -101,7 +118,7 @@ export const PROFILE_TEMPLATES: Record<string, readonly string[]> = {
|
||||
}
|
||||
|
||||
/** The bundle list a `dsh plugin` init uses for a name with no shipped template. */
|
||||
export const DEFAULT_PROFILE_PLUGINS: readonly string[] = ['@deepseek-ai/dsh-base']
|
||||
export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base']
|
||||
|
||||
const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer:
|
||||
# a top-level YAML array of loader patch entries (id-targeted config
|
||||
@@ -126,9 +143,9 @@ autoInstallPeers: false
|
||||
* pnpm settings out-of-tree plugins need. Existing files are never touched,
|
||||
* so re-running is a no-op on an initialized profile.
|
||||
* @param dir - the profile directory from {@link resolveProfileDir}.
|
||||
* @param plugins - the initial `dsh.plugins` bundle list.
|
||||
* @param bundles - the initial `dsh.profile.bundles` layer list.
|
||||
*/
|
||||
export function initProfile(dir: string, plugins: readonly string[]): void {
|
||||
export function initProfile(dir: string, bundles: readonly string[]): void {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const manifestPath = join(dir, 'package.json')
|
||||
if (!existsSync(manifestPath)) {
|
||||
@@ -136,7 +153,7 @@ export function initProfile(dir: string, plugins: readonly string[]): void {
|
||||
name: `dsh-profile-${basename(dir)}`,
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { plugins: [...plugins] },
|
||||
dsh: { profile: { bundles: [...bundles] } },
|
||||
}
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest, undefined, 2) + '\n')
|
||||
}
|
||||
@@ -287,7 +304,7 @@ function packageDirFromAnchor(anchor: string, packageName: string): string | und
|
||||
* 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 packageName - the bundle's package name from `dsh.profile.bundles`.
|
||||
* @param installAnchor - absolute path of a file inside the dsh app package (its package.json).
|
||||
* @param profileDir - the profile directory (second anchor).
|
||||
* @returns the bundle package's absolute directory.
|
||||
@@ -306,10 +323,10 @@ export function resolveBundleDir(
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a profile: resolve every `dsh.plugins` bundle to its patch layer and
|
||||
* parse the profile's own patch file. A listed bundle without a `dsh.patch`
|
||||
* manifest field fails loud — naming a patch-less package as a layer is a
|
||||
* misconfiguration, not "no patches".
|
||||
* Load a profile: resolve every `dsh.profile.bundles` entry to its patch
|
||||
* layer and parse the profile's own patch file. A listed bundle without a
|
||||
* `dsh.bundle` manifest fails loud — naming a bundle-less package as a layer
|
||||
* is a misconfiguration, not "no patches".
|
||||
* @param binName - the diagnostic prefix on thrown errors.
|
||||
* @param name - the profile name.
|
||||
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
|
||||
@@ -335,13 +352,13 @@ export function loadProfile(
|
||||
}
|
||||
const manifest = readProfileManifest(binName, dir)
|
||||
// A hand-written profile manifest may omit the dsh section entirely.
|
||||
const plugins = manifest.dsh?.plugins ?? []
|
||||
const layers = plugins.map((packageName): ProfileLayer => {
|
||||
const bundles = manifest.dsh?.profile?.bundles ?? []
|
||||
const layers = bundles.map((packageName): ProfileLayer => {
|
||||
const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
|
||||
const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest
|
||||
const declared = bundleManifest.dsh?.patch
|
||||
const declared = bundleManifest.dsh?.bundle?.patch
|
||||
if (declared === undefined) {
|
||||
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.patch in its package.json`)
|
||||
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
|
||||
}
|
||||
const patchPath = join(packageDir, declared)
|
||||
return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) }
|
||||
|
||||
@@ -37,7 +37,7 @@ function stageInstallation(bundles: Record<string, { patch?: string; deps?: Reco
|
||||
name,
|
||||
version: '0.0.0',
|
||||
dependencies: spec.deps ?? {},
|
||||
...spec.patch === undefined ? {} : { dsh: { patch: './cordis.patch.yml' } },
|
||||
...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml' } } },
|
||||
}))
|
||||
if (spec.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), spec.patch)
|
||||
}
|
||||
@@ -61,13 +61,13 @@ describe('initProfile', () => {
|
||||
const dir = resolveProfileDir('tui', home)
|
||||
initProfile(dir, ['@deepseek-ai/dsh-base'])
|
||||
const manifest = readProfileManifest('t', dir)
|
||||
expect(manifest.dsh?.plugins).toEqual(['@deepseek-ai/dsh-base'])
|
||||
expect(manifest.dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
|
||||
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
|
||||
expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
|
||||
// Re-init keeps user edits.
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n')
|
||||
initProfile(dir, ['other'])
|
||||
expect(readProfileManifest('t', dir).dsh?.plugins).toEqual(['@deepseek-ai/dsh-base'])
|
||||
expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
|
||||
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('- id: x')
|
||||
})
|
||||
})
|
||||
@@ -75,8 +75,8 @@ describe('initProfile', () => {
|
||||
describe('manifest round-trip', () => {
|
||||
it('writes and reads back, and fails loud on a broken manifest', () => {
|
||||
const dir = tmp()
|
||||
writeProfileManifest(dir, { name: 'p', dsh: { plugins: ['a'] } })
|
||||
expect(readProfileManifest('t', dir).dsh?.plugins).toEqual(['a'])
|
||||
writeProfileManifest(dir, { name: 'p', dsh: { profile: { bundles: ['a'] } } })
|
||||
expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['a'])
|
||||
writeFileSync(join(dir, 'package.json'), '[]')
|
||||
expect(() => readProfileManifest('t', dir)).toThrow('must hold a JSON object')
|
||||
expect(() => readProfileManifest('t', join(dir, 'nope'))).toThrow('failed to read profile manifest')
|
||||
@@ -109,7 +109,7 @@ describe('resolveBundleDir', () => {
|
||||
name: 'sealed-bundle',
|
||||
version: '0.0.0',
|
||||
exports: { '.': './index.js' },
|
||||
dsh: { patch: './cordis.patch.yml' },
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
}))
|
||||
writeFileSync(join(dir, 'index.js'), '')
|
||||
writeFileSync(join(dir, 'cordis.patch.yml'), '[]\n')
|
||||
@@ -118,7 +118,7 @@ describe('resolveBundleDir', () => {
|
||||
})
|
||||
|
||||
describe('loadProfile', () => {
|
||||
it('resolves each dsh.plugins bundle to its patch layer in order, plus the user layer', () => {
|
||||
it('resolves each dsh.profile.bundles entry to its patch layer in order, plus the user layer', () => {
|
||||
const anchor = stageInstallation({
|
||||
'bundle-a': { patch: '- insert:\n - id: a\n name: pkg-a\n' },
|
||||
'bundle-b': { patch: '- id: a\n config:\n v: 2\n' },
|
||||
@@ -157,16 +157,16 @@ describe('loadProfile', () => {
|
||||
} catch {
|
||||
// Resolution failure is the plain-Node outcome for this empty anchor.
|
||||
}
|
||||
expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.plugins)
|
||||
expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles)
|
||||
.toEqual([...PROFILE_TEMPLATES.web ?? []])
|
||||
})
|
||||
|
||||
it('fails loud when a listed bundle declares no dsh.patch', () => {
|
||||
it('fails loud when a listed bundle declares no dsh.bundle', () => {
|
||||
const anchor = stageInstallation({ 'not-a-bundle': {} })
|
||||
const home = tmp()
|
||||
const dir = resolveProfileDir('demo', home)
|
||||
initProfile(dir, ['not-a-bundle'])
|
||||
expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares no dsh.patch')
|
||||
expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares no dsh.bundle')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user