fix(cli): make profile template creation exclusive
This commit is contained in:
1 parent
dd25e1e6b3
commit
7a7b7d5a53
18 files changed
+167
-36
No files matched your search
@@ -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 .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md
|
||||
2026-08-05-profile-plugin-bundles.md: ad374c6ca1e67aac66e4c6b5996e39550e6e4342
|
||||
2026-08-05-profile-plugin-bundles.zh.md: cd37d5f0cbf1e2a6cdace4eae57d3d7df29d0b48
|
||||
2026-08-05-profile-plugin-bundles.md: 7e51345e7eba8a58db63807e31d4a11481e3ffea
|
||||
2026-08-05-profile-plugin-bundles.zh.md: b2631603737ea9412eb97029ff01d751d8084cec
|
||||
@@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y
|
||||
|
||||
Everything becomes a **profile**: a directory `$DSH_HOME/profiles/<name>` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](../../archived/architecture/2026-08-06-app-owned-command-line.md).
|
||||
|
||||
The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. A missing target can use `--from-default-profile <template>` to copy one default template's bundle list and patch-reload policy before boot or config dump. This creates an independent profile with empty dependencies and an empty user patch: it neither reads a local profile named by the template nor records an inheritance relationship, and an existing target fails without modification. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes a base-backed profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
|
||||
The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. A new, non-shipped target can use `--from-default-profile <template>` to copy one default template's bundle list and patch-reload policy before boot or config dump. This creates an independent profile with empty dependencies and an empty user patch: it neither reads a local profile named by the template nor records an inheritance relationship. The launcher claims the complete target directory exclusively, so existing state and concurrent creators fail without modification. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes a base-backed profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
|
||||
|
||||
Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch).
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
一切都变成 **profile**:即目录 `$DSH_HOME/profiles/<name>`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](../../archived/architecture/2026-08-06-app-owned-command-line.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。
|
||||
|
||||
默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。缺失的目标可以使用 `--from-default-profile <template>`,在启动或配置 dump 之前复制一个默认模板的 bundle 列表与 patch 重载策略。这会创建依赖为空、用户 patch 为空的独立 profile:它既不读取与模板同名的本地 profile,也不记录继承关系;目标已经存在时则失败且不作修改。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化一个以 base 为基础的 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
|
||||
默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。新的非内置目标可以使用 `--from-default-profile <template>`,在启动或配置 dump 之前复制一个默认模板的 bundle 列表与 patch 重载策略。这会创建依赖为空、用户 patch 为空的独立 profile:它既不读取与模板同名的本地 profile,也不记录继承关系。launcher 会以独占方式领取完整的目标目录,因此既有状态和并发创建者都会在不作修改的情况下失败。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化一个以 base 为基础的 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
|
||||
|
||||
解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。
|
||||
|
||||
|
||||
@@ -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 apps/cli/README.md
|
||||
README.md: a4297d28633296a3ef52201da76cd1fcd9000d38
|
||||
README.zh.md: 89e6259a0636e581959bbf7d4c7214da9e1fec96
|
||||
README.md: 2860da092be8e6122f0d62838c5304710f47d100
|
||||
README.zh.md: 94c5bfedc113147a46496202a578eb9436656988
|
||||
+2
-2
@@ -9,7 +9,7 @@ The `dsh` command is the sole supported Node application launcher: profiles are
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `dsh --profile <name>` | Boot the named profile under `$DSH_HOME/profiles/<name>`. |
|
||||
| `dsh --profile <name> --from-default-profile <template>` | Create a missing profile from a shipped template, then boot it. |
|
||||
| `dsh --profile <name> --from-default-profile <template>` | Create a new custom profile from a shipped template, then boot it. |
|
||||
| `dsh --profile acp` | Serve automation clients over ACP stdio until disconnect. |
|
||||
| `dsh --profile headless "job"` | Run one fresh persisted session, print the final answer, and exit. |
|
||||
| `dsh --profile sdk` | Serve SDK clients over JSON-RPC stdio until shutdown or disconnect. |
|
||||
@@ -17,7 +17,7 @@ The `dsh` command is the sole supported Node application launcher: profiles are
|
||||
| `dsh web` | Alias of `--profile web`. |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | Manage a profile's plugins by forwarding to pnpm in the profile directory. |
|
||||
|
||||
The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates. Create another profile from one of those templates with `--from-default-profile`, or initialize a base-backed profile through `dsh plugin`.
|
||||
The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates. Create another profile at an unused, non-shipped name with `--from-default-profile`, or initialize a base-backed profile through `dsh plugin`.
|
||||
|
||||
## App arguments
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 |
|
||||
| `dsh --profile <name> --from-default-profile <template>` | 从随附模板创建缺失的 profile,然后启动它。 |
|
||||
| `dsh --profile <name> --from-default-profile <template>` | 从随附模板创建新的自定义 profile,然后启动它。 |
|
||||
| `dsh --profile acp` | 通过 ACP stdio 为自动化 client 提供服务,直至断开连接。 |
|
||||
| `dsh --profile headless "job"` | 运行一个全新的持久化会话,打印最终答案并退出。 |
|
||||
| `dsh --profile sdk` | 通过 JSON-RPC stdio 为 SDK client 提供服务,直至关闭或断开连接。 |
|
||||
@@ -17,7 +17,7 @@
|
||||
| `dsh web` | `--profile web` 的别名。 |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 |
|
||||
|
||||
运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化。使用 `--from-default-profile` 可以基于这些模板之一创建其他 profile;通过 `dsh plugin` 则可以初始化一个以 base 为基础的 profile。
|
||||
运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化。使用 `--from-default-profile` 可以基于这些模板之一,在尚未使用的非内置名称处创建其他 profile;通过 `dsh plugin` 则可以初始化一个以 base 为基础的 profile。
|
||||
|
||||
## 应用参数
|
||||
|
||||
|
||||
@@ -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 apps/cli/reference/README.md
|
||||
README.md: fed6e86baa44dbc29342c91090cf0ec74e58273f
|
||||
README.zh.md: c97796276de4d2e1a47593164e6c76e6b4557a58
|
||||
README.md: 42fb2855e97465417ca284944102d3c6a610416d
|
||||
README.zh.md: 786774dae50a2dfcadbf3fd260b4680277c0d39e
|
||||
@@ -12,9 +12,9 @@ Bundle names resolve from the dsh installation first, then from the profile dire
|
||||
|
||||
The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `sdk-minimal`: its standalone bundle with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
|
||||
|
||||
`dsh --profile <name> --from-default-profile <template>` initializes a missing target from one of those five shipped templates before boot. It copies the template's current bundle list and `patchReload` value into a new manifest with empty dependencies and an empty user patch. It does not read the local profile named by `<template>`, copy its dependencies or patch, or persist an inheritance field; later template-list changes do not rewrite the new profile. The in-box bundles named by that copied list still resolve from the current dsh installation. A successful initialization adds no launcher output.
|
||||
`dsh --profile <name> --from-default-profile <template>` initializes a new custom target from one of those five shipped templates before boot. The target name cannot be a shipped profile name, and its complete profile directory must not exist. The launcher claims that directory exclusively, so residual files and another concurrent creator are rejected without modification. It copies the template's current bundle list and `patchReload` value into a new manifest with empty dependencies and an empty user patch. It does not read the local profile named by `<template>`, copy its dependencies or patch, or persist an inheritance field; later template-list changes do not rewrite the new profile. The in-box bundles named by that copied list still resolve from the current dsh installation. A successful initialization adds no launcher output.
|
||||
|
||||
An existing target rejects `--from-default-profile` without changing or booting the profile; omit the option to boot it. An unknown template fails before creating the target and names the valid templates. Initialization is committed before bundle resolution and application boot, so a later failure leaves the new profile on disk and the retry omits the creation option. `--dump-config` and `--dump-default-config` accept the option, initialize the target, print the requested tree, and do not boot it.
|
||||
An existing profile rejects `--from-default-profile` without changing or booting it; omit the option to use it. A residual target directory is also preserved and requires a different profile name. An unknown template or a shipped target name fails before creating the target. Unknown-template diagnostics name the valid templates. Initialization is committed before bundle resolution and application boot, so a later failure leaves the new profile on disk and the retry omits the creation option. `--dump-config` and `--dump-default-config` accept the option, initialize the target, print the requested tree, and do not boot it.
|
||||
|
||||
```sh
|
||||
dsh --profile rescue --from-default-profile web
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
|
||||
`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`sdk-minimal`:独立组合包,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
|
||||
|
||||
`dsh --profile <name> --from-default-profile <template>` 会在启动前,从上述五个随附模板之一初始化缺失的目标。它把模板当前的 bundle 列表和 `patchReload` 值复制进一份依赖为空、用户 patch 为空的新 manifest。它不会读取 `<template>` 指定的本地同名 profile,不会复制其依赖或 patch,也不会持久化继承字段;模板列表之后的变化不会改写新 profile。复制列表中指名的内置 bundle 仍从当前 dsh 安装目录解析。初始化成功不会增加 launcher 输出。
|
||||
`dsh --profile <name> --from-default-profile <template>` 会在启动前,从上述五个随附模板之一初始化新的自定义目标。目标名称不能是随附 profile 名称,并且完整的目标 profile 目录必须不存在。launcher 会以独占方式领取该目录,因此残留文件和另一个并发创建者都会在不作修改的情况下被拒绝。它把模板当前的 bundle 列表和 `patchReload` 值复制进一份依赖为空、用户 patch 为空的新 manifest。它不会读取 `<template>` 指定的本地同名 profile,不会复制其依赖或 patch,也不会持久化继承字段;模板列表之后的变化不会改写新 profile。复制列表中指名的内置 bundle 仍从当前 dsh 安装目录解析。初始化成功不会增加 launcher 输出。
|
||||
|
||||
目标已经存在时,`--from-default-profile` 会被拒绝,且不会修改或启动该 profile;去掉该选项即可启动它。未知模板会在创建目标之前失败,并列出有效模板。初始化在 bundle 解析和应用启动之前提交,因此后续失败仍会把新 profile 留在磁盘上,重试时需要去掉创建选项。`--dump-config` 和 `--dump-default-config` 接受该选项:它们初始化目标并打印所请求的配置树,但不启动应用。
|
||||
profile 已经存在时,`--from-default-profile` 会被拒绝,且不会修改或启动它;去掉该选项即可使用它。残留的目标目录同样会被原样保留,此时必须改用另一个 profile 名称。未知模板或随附目标名称会在创建目标之前失败;未知模板的诊断会列出有效模板。初始化在 bundle 解析和应用启动之前提交,因此后续失败仍会把新 profile 留在磁盘上,重试时需要去掉创建选项。`--dump-config` 和 `--dump-default-config` 接受该选项:它们初始化目标并打印所请求的配置树,但不启动应用。
|
||||
|
||||
```sh
|
||||
dsh --profile rescue --from-default-profile web
|
||||
|
||||
@@ -140,7 +140,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
|
||||
.enablePositionalOptions()
|
||||
.argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile <name> --help)')
|
||||
.option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot')
|
||||
.option('--from-default-profile <name>', 'initialize a missing profile from a shipped profile template')
|
||||
.option('--from-default-profile <name>', 'initialize a new custom profile from a shipped profile template')
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
.option('--dump-config', 'print the composed profile tree and exit')
|
||||
.option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit')
|
||||
|
||||
+1
-3
@@ -34,9 +34,7 @@ export async function runCli(): Promise<void> {
|
||||
await runProfile({
|
||||
environment: loadLayeredEnv('dsh'),
|
||||
profile: invocation.profile,
|
||||
...invocation.fromDefaultProfile === undefined
|
||||
? {}
|
||||
: { fromDefaultProfile: invocation.fromDefaultProfile },
|
||||
fromDefaultProfile: invocation.fromDefaultProfile,
|
||||
patchFiles: invocation.patches,
|
||||
args: invocation.args,
|
||||
})
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* @module @deepseek-ai/dsh/profile-boot
|
||||
*/
|
||||
|
||||
import { existsSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { FiberState, type Context } from '@deepseek-ai/cordis'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
@@ -94,11 +94,12 @@ export const PROFILE_ROOT_FILENAME = 'cordis.yml'
|
||||
* Initialize a missing profile from one shipped template. This copies only
|
||||
* the template's bundle list and patch-reload policy; local state from the
|
||||
* same-named shipped profile is not read, and no inheritance metadata is
|
||||
* persisted. An existing manifest is never changed.
|
||||
* persisted. Shipped profile names are reserved, and the target directory is
|
||||
* claimed exclusively so existing or concurrent state is never reused.
|
||||
* @param name - the new profile name.
|
||||
* @param fromDefaultProfile - shipped profile template to copy.
|
||||
* @param home - Harness home containing the profile directory.
|
||||
* @throws when the template is unknown or the target profile already exists.
|
||||
* @throws when the template is unknown, the target name is shipped, or the target directory exists.
|
||||
*/
|
||||
export function initializeProfileFromDefault(
|
||||
name: string,
|
||||
@@ -115,14 +116,41 @@ export function initializeProfileFromDefault(
|
||||
`${NAME}: unknown default profile ${JSON.stringify(fromDefaultProfile)}; expected one of ${expected}`,
|
||||
)
|
||||
}
|
||||
const manifestPath = join(dir, 'package.json')
|
||||
if (existsSync(manifestPath)) {
|
||||
if (Object.hasOwn(PROFILE_TEMPLATES, name)) {
|
||||
throw new Error(
|
||||
`${NAME}: profile ${JSON.stringify(name)} already exists at ${manifestPath}; `
|
||||
+ 'omit --from-default-profile to boot it',
|
||||
`${NAME}: profile ${JSON.stringify(name)} is shipped and cannot be a custom profile target; `
|
||||
+ 'omit --from-default-profile to use it',
|
||||
)
|
||||
}
|
||||
initProfile(dir, template.bundles, template.patchReload)
|
||||
mkdirSync(dirname(dir), { recursive: true })
|
||||
try {
|
||||
mkdirSync(dir)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
||||
const manifestPath = join(dir, 'package.json')
|
||||
if (existsSync(manifestPath)) {
|
||||
throw new Error(
|
||||
`${NAME}: profile ${JSON.stringify(name)} already exists at ${manifestPath}; `
|
||||
+ 'omit --from-default-profile to use it',
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`${NAME}: profile directory ${dir} already exists; choose an unused profile name`,
|
||||
)
|
||||
}
|
||||
try {
|
||||
initProfile(dir, template.bundles, template.patchReload)
|
||||
} catch (error) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[error, cleanupError],
|
||||
`${NAME}: profile initialization failed and ${dir} could not be removed`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,7 +250,7 @@ export interface RunProfileOptions {
|
||||
/** The profile name to boot. */
|
||||
profile: string
|
||||
/** Shipped template used once to initialize a missing profile. */
|
||||
fromDefaultProfile?: string
|
||||
fromDefaultProfile?: string | undefined
|
||||
/** `--patch` overlay paths, in argv order. */
|
||||
patchFiles: readonly string[]
|
||||
/** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
|
||||
|
||||
@@ -679,7 +679,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(repeated.code).toBe(1)
|
||||
expect(repeated.stdout).toBe('')
|
||||
expect(repeated.stderr).toContain('profile "rescue" already exists')
|
||||
expect(repeated.stderr).toContain('omit --from-default-profile to boot it')
|
||||
expect(repeated.stderr).toContain('omit --from-default-profile to use it')
|
||||
|
||||
const reopened = await runBuiltBin(
|
||||
['--profile', 'rescue', '--help'],
|
||||
@@ -693,6 +693,29 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, SPAWN_TIMEOUT_MS * 3 + 30_000)
|
||||
|
||||
it('keeps a newly created profile when application boot rejects its arguments', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-from-default-profile-failed-boot-'))
|
||||
try {
|
||||
const failed = await runBuiltBin(
|
||||
['--profile', 'rescue', '--from-default-profile', 'web', '--port', 'not-a-number'],
|
||||
{ DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
|
||||
)
|
||||
expect(failed.code).toBe(1)
|
||||
expect(failed.stderr).toContain('--port must be a number')
|
||||
expect(existsSync(join(home, 'profiles', 'rescue', 'package.json'))).toBe(true)
|
||||
|
||||
const retried = await runBuiltBin(
|
||||
['--profile', 'rescue', '--help'],
|
||||
{ DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
|
||||
)
|
||||
expect(retried.code).toBe(0)
|
||||
expect(retried.stderr).toBe('')
|
||||
expect(retried.stdout).toContain('Usage: dsh --profile web')
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, SPAWN_TIMEOUT_MS * 2 + 30_000)
|
||||
|
||||
it('uses the launching endpoint and managed credential through the published entry', async () => {
|
||||
const apiKey = 'built-home-layer-key'
|
||||
const server = await startMockLlmServer({
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Synchronize competing source-process profile initialization for the race acceptance test. */
|
||||
|
||||
import { existsSync, writeFileSync } from 'node:fs'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { initializeProfileFromDefault } from '../../src/profile-boot.ts'
|
||||
|
||||
const [home, name, source, ready, gate] = process.argv.slice(2)
|
||||
if (home === undefined || name === undefined || source === undefined || ready === undefined || gate === undefined) {
|
||||
throw new Error('profile initialization fixture requires home, name, source, ready, and gate')
|
||||
}
|
||||
|
||||
writeFileSync(ready, '')
|
||||
const deadline = Date.now() + 30_000
|
||||
while (!existsSync(gate)) {
|
||||
if (Date.now() >= deadline) throw new Error(`profile initialization fixture timed out waiting for ${gate}`)
|
||||
await delay(20)
|
||||
}
|
||||
initializeProfileFromDefault(name, source, home)
|
||||
@@ -1,8 +1,9 @@
|
||||
/** One-time custom-profile initialization from shipped templates. */
|
||||
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
initProfile,
|
||||
PROFILE_PATCH_FILENAME,
|
||||
@@ -12,8 +13,22 @@ import {
|
||||
writeProfileManifest,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { execa } from 'execa'
|
||||
import { initializeProfileFromDefault } from '../src/profile-boot.ts'
|
||||
|
||||
const childEntry = fileURLToPath(new URL('./fixtures/initialize-profile-from-default.ts', import.meta.url))
|
||||
const tsxLoader = import.meta.resolve('tsx/esm')
|
||||
const CHILD_TIMEOUT_MS = 30_000
|
||||
|
||||
/** Wait until a child has reached the shared creation barrier. */
|
||||
async function waitForFile(file: string): Promise<void> {
|
||||
const deadline = Date.now() + CHILD_TIMEOUT_MS
|
||||
while (!existsSync(file)) {
|
||||
if (Date.now() >= deadline) throw new Error(`profile initialization marker did not appear: ${file}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one assertion against a private Harness home and remove it afterwards. */
|
||||
function withHome(assertion: (home: string) => void): void {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-profile-from-default-'))
|
||||
@@ -82,6 +97,33 @@ describe('initializeProfileFromDefault', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a residual target directory without changing its contents', () => {
|
||||
withHome((home) => {
|
||||
const dir = resolveProfileDir('rescue', home)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const residual = join(dir, PROFILE_PATCH_FILENAME)
|
||||
writeFileSync(residual, '- id: residual\n disabled: true\n')
|
||||
const before = readFileSync(residual)
|
||||
|
||||
expect(() => {
|
||||
initializeProfileFromDefault('rescue', 'web', home)
|
||||
})
|
||||
.toThrow('profile directory')
|
||||
expect(readFileSync(residual)).toEqual(before)
|
||||
expect(existsSync(join(dir, 'package.json'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(Object.keys(PROFILE_TEMPLATES))('rejects shipped target name %s without creating it', (name) => {
|
||||
withHome((home) => {
|
||||
expect(() => {
|
||||
initializeProfileFromDefault(name, 'web', home)
|
||||
})
|
||||
.toThrow(`profile ${JSON.stringify(name)} is shipped`)
|
||||
expect(existsSync(resolveProfileDir(name, home))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['unknown', 'toString'])('rejects unknown template %s without creating the target', (source) => {
|
||||
withHome((home) => {
|
||||
expect(() => {
|
||||
@@ -91,4 +133,26 @@ describe('initializeProfileFromDefault', () => {
|
||||
expect(existsSync(resolveProfileDir('rescue', home))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('allows only one of two synchronized processes to create the target', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-profile-from-default-race-'))
|
||||
const gate = join(home, 'start')
|
||||
const ready = [join(home, 'ready-1'), join(home, 'ready-2')]
|
||||
const children = ready.map(marker => execa(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, childEntry, home, 'rescue', 'web', marker, gate],
|
||||
{ reject: false, timeout: CHILD_TIMEOUT_MS },
|
||||
))
|
||||
try {
|
||||
await Promise.all(ready.map(waitForFile))
|
||||
writeFileSync(gate, '')
|
||||
const results = await Promise.all(children)
|
||||
expect(results.map(result => result.exitCode).sort()).toEqual([0, 1])
|
||||
expect(readProfileManifest('test', resolveProfileDir('rescue', home)).dsh?.profile)
|
||||
.toEqual(PROFILE_TEMPLATES.web)
|
||||
} finally {
|
||||
for (const child of children) child.kill('SIGKILL')
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, CHILD_TIMEOUT_MS + 10_000)
|
||||
})
|
||||
@@ -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/boot/app-boot/README.md
|
||||
README.md: b13fd2d241476ca9d49492a0bebc3e9fea6605d4
|
||||
README.zh.md: 41f5c1ffbe4cde30943f60ae061f230f5be7eeea
|
||||
README.md: 220444aca339f557b1064eebade756b1c7df62f7
|
||||
README.zh.md: d0cbe33b0d0c11996c9f5a1a2693833448389d4f
|
||||
@@ -47,7 +47,7 @@ With that entry point, success looks like a running app with every plugin active
|
||||
|
||||
Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot owns profile loading, JSON validation, and resolved runtime data.
|
||||
|
||||
A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. A missing bundle or one without a patch declaration fails startup loudly.
|
||||
A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. A missing bundle or one without a patch declaration fails startup loudly.
|
||||
|
||||
Your machine-local preferences also live in the Harness home:
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
|
||||
|
||||
Profile 与 bundle 的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.zh.md) 导入。App-boot 负责 profile 加载、JSON 校验和解析后的运行时数据。
|
||||
|
||||
profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装 bundle、自身 `cordis.patch.yml` 与 `patchReload: live | startup` 组成;自定义 profile 省略 reload 策略时保留历史 `live` 默认值。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立 bundle,其他模板保留 base 加模式 bundle 的栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板创建自定义 profile,`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的 bundle。缺失 bundle 或未声明 patch 的 bundle 会让启动明确失败。
|
||||
profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装 bundle、自身 `cordis.patch.yml` 与 `patchReload: live | startup` 组成;自定义 profile 省略 reload 策略时保留历史 `live` 默认值。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立 bundle,其他模板保留 base 加模式 bundle 的栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的 bundle。缺失 bundle 或未声明 patch 的 bundle 会让启动明确失败。
|
||||
|
||||
你的机器本地偏好同样位于 harness home 中:
|
||||
|
||||
|
||||
Reference in new issue
Block a user