Harden profile replacement against case aliases and Windows rename errors

Reject case variants of shipped profile names, detect a concurrent winner by
the target's state instead of errno, warn instead of failing when staging
cleanup fails after a successful replacement, and cover the restore paths with
deterministic tests. Update the profile Agent Note, reference recovery path,
and upgrade-guide migration steps.
This commit is contained in:
Turtle committed 2026-10-14 12:12:52 +08:00
1 parent 8843c37e37
commit 6bf5668e79
11 files changed
+134 -39

No files matched your search

@@ -9,8 +9,8 @@
en: 504dd5f06475d707
zh: 6557fd5649f48944
/agent-note-profile-plugin-bundles-replace-the-fixed-surface-overlays/decision:
en: 4c58d379cd59d879
zh: a6b6322fe82d7b6a
en: 1077d88affd90a59
zh: 73b07bf6dcb130ba
/agent-note-profile-plugin-bundles-replace-the-fixed-surface-overlays/alternatives-considered:
en: d906d7a421c6b5c0
zh: b230cd58c1b7ac79
@@ -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 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.
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 non-shipped target can use `--from-default-profile <template>` to replace itself with a copy of 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 assembles the copy in a staging directory beside the target and renames it into place, so repeated rescue runs reset the profile; a failed rename restores the previous directory. `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. Bare plugin names in patch rows use the [immutable runtime resolution](2026-09-09-profile-resolution-generations.md), which applies installation-first and ordered-bundle rules in memory and expands dependencies from real package directories.
@@ -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,也不记录继承关系。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 组合约定。
默认 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 在目标旁的暂存目录中组装副本并重命名到目标位置,因此重复执行 rescue 会重置该 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 组合约定。
解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析,因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们。patch 行中的裸插件名称使用[不可变 runtime resolution](2026-09-09-profile-resolution-generations.zh.md),在内存中应用安装优先与有序 bundle 规则,并从包的真实目录展开依赖。
+2 -2
View File
@@ -6,8 +6,8 @@
en: 5a4f43756dae900e
zh: d58c479481cfa5fe
/dsh-cli-behavior-reference/profile-boot:
en: dde77634013ec78a
zh: 111b1ca995eade44
en: b474a6fc3c98837d
zh: da9219f91177b6c8
/dsh-cli-behavior-reference/profile-boot/app-arguments:
en: 60b4e6837299427e
zh: 88eac566a2e63881
+1 -1
View File
@@ -14,7 +14,7 @@ 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 loudly with a hint to run `dsh plugin --profile <name> add <package>`.
`dsh --profile <name> --from-default-profile <template>` replaces the custom target with a fresh copy of one of those five shipped templates before boot. The target name cannot be a shipped profile name. The launcher assembles the new profile in a staging directory beside the target and moves it into place, then deletes any previous target directory, including its installed dependencies and user patch. A failure before the move leaves the previous target unchanged. When several processes replace the same target concurrently, at least one succeeds and the target holds one complete copy. The copy holds the template's current bundle list in 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 replacement adds no launcher output.
`dsh --profile <name> --from-default-profile <template>` replaces the custom target with a fresh copy of one of those five shipped templates before boot. The target name cannot be a shipped profile name in any letter case. The launcher assembles the new profile in a staging directory beside the target and moves it into place, then deletes any previous target directory, including its installed dependencies and user patch. A failed move restores the previous directory. If restoring also fails, or the process stops between the two renames, the previous directory remains at `$DSH_HOME/profiles/.<name>.staging-*/previous` for manual recovery. When several processes replace the same target concurrently, at least one succeeds and the target holds one complete copy. The copy holds the template's current bundle list in 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 replacement adds no launcher output.
Omit the option to boot the profile without resetting it. An unknown template or a shipped target name fails before the target changes. Unknown-template diagnostics name the valid templates. Replacement is committed before bundle resolution and application boot, so a later failure leaves the new profile on disk. All three config-dump modes accept the option and replace the target without booting it.
+1 -1
View File
@@ -16,7 +16,7 @@
`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>` 会在启动前,用上述五个随附模板之一的全新副本替换自定义目标。目标名称不能是随附 profile 名称。launcher 先在目标旁边的暂存目录中组装新 profile 并移动到目标位置,再删除原有的目标目录,包括其中已安装的依赖和用户 patch。移动之前发生的失败不会改动原有目标。多个进程并发替换同一目标时,至少有一个会成功,且目标中只保留一份完整副本。该副本把模板当前的组合包列表写进一份依赖为空、用户 patch 为空的新 manifest。它不会读取 `<template>` 指定的本地同名 profile,不会复制其依赖或 patch,也不会持久化继承字段;模板列表之后的变化不会改写新 profile。复制列表中指名的内置组合包仍从当前 dsh 安装目录解析。替换成功不会增加 launcher 输出。
`dsh --profile <name> --from-default-profile <template>` 会在启动前,用上述五个随附模板之一的全新副本替换自定义目标。目标名称不能是任何大小写形式的随附 profile 名称。launcher 先在目标旁边的暂存目录中组装新 profile 并移动到目标位置,再删除原有的目标目录,包括其中已安装的依赖和用户 patch。移动失败时会恢复原有目录。如果恢复也失败,或进程在两次重命名之间停止,原有目录会保留在 `$DSH_HOME/profiles/.<name>.staging-*/previous`,供手动恢复。多个进程并发替换同一目标时,至少有一个会成功,且目标中只保留一份完整副本。该副本把模板当前的组合包列表写进一份依赖为空、用户 patch 为空的新 manifest。它不会读取 `<template>` 指定的本地同名 profile,不会复制其依赖或 patch,也不会持久化继承字段;模板列表之后的变化不会改写新 profile。复制列表中指名的内置组合包仍从当前 dsh 安装目录解析。替换成功不会增加 launcher 输出。
去掉该选项即可在不重置的情况下启动该 profile。未知模板或随附目标名称会在目标被改动之前失败;未知模板的诊断会列出有效模板。替换在组合包解析和应用启动之前提交,因此后续失败仍会把新 profile 留在磁盘上。三种配置 dump 模式都接受该选项,并在不启动应用的情况下替换目标。
+22 -10
View File
@@ -91,14 +91,19 @@ export const PROFILE_ROOT_FILENAME = 'cordis.yml'
* Replace the named custom profile with a fresh copy of one shipped template.
* This copies only the template's bundle list; local state from the
* same-named shipped profile is not read, and no inheritance metadata is
* persisted. The new profile is assembled in a staging directory under the
* profiles directory and moved into place; only then is any previous target
* directory, including its dependencies and user patch, deleted. Shipped
* profile names are reserved.
* persisted. The new profile is assembled in a `.<name>.staging-*` directory
* under the profiles directory and moved into place; only then is any previous
* target directory, including its dependencies and user patch, deleted. A
* failed move restores the previous directory; when restoring also fails, it
* stays in the staging directory. Shipped profile names, compared without
* case, are reserved. Concurrent replacements leave one complete copy; each
* one either succeeds or reports the concurrent recreation.
* @param name - the profile name to create or replace.
* @param fromDefaultProfile - shipped profile template to copy.
* @param home - Harness home containing the profile directory.
* @throws when the template is unknown, the target name is shipped, or another process recreates the target concurrently.
* @throws when the template is unknown, the target name is shipped, another process recreates the target
* between the two renames, or a file-system operation fails; a restore failure is an `AggregateError`
* that names the staging path holding the previous profile.
*/
export function initializeProfileFromDefault(
name: string,
@@ -115,9 +120,11 @@ export function initializeProfileFromDefault(
`${NAME}: unknown default profile ${JSON.stringify(fromDefaultProfile)}; expected one of ${expected}`,
)
}
if (Object.hasOwn(PROFILE_TEMPLATES, name)) {
// Case-insensitive file systems resolve a case variant such as `Web` to the shipped directory.
const shipped = Object.keys(PROFILE_TEMPLATES).find(shippedName => shippedName.toLowerCase() === name.toLowerCase())
if (shipped !== undefined) {
throw new Error(
`${NAME}: profile ${JSON.stringify(name)} is shipped and cannot be a custom profile target; `
`${NAME}: profile ${JSON.stringify(name)} names shipped profile ${JSON.stringify(shipped)} and cannot be a custom profile target; `
+ 'omit --from-default-profile to use it',
)
}
@@ -138,8 +145,8 @@ export function initializeProfileFromDefault(
try {
renameSync(fresh, dir)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'EEXIST' || code === 'ENOTEMPTY') {
// Windows reports EPERM instead of EEXIST or ENOTEMPTY, so the target's state identifies a concurrent winner.
if (existsSync(dir)) {
// The concurrent replacement supersedes the previous profile.
rmSync(previous, { recursive: true, force: true })
throw new Error(`${NAME}: profile ${JSON.stringify(name)} was recreated concurrently at ${dir}`, { cause: error })
@@ -161,7 +168,12 @@ export function initializeProfileFromDefault(
if (!existsSync(previous)) rmSync(staging, { recursive: true, force: true })
throw error
}
rmSync(staging, { recursive: true, force: true })
try {
rmSync(staging, { recursive: true, force: true })
} catch (error) {
// The replacement is complete; leftover files only occupy space.
process.stderr.write(`${NAME}: replaced profile ${JSON.stringify(name)} but could not remove ${staging}: ${String(error)}\n`)
}
}
/**
+96 -13
View File
@@ -1,6 +1,6 @@
/** Custom-profile replacement from shipped templates. */
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -12,10 +12,32 @@ import {
resolveProfileDir,
writeProfileManifest,
} from '@deepseek-ai/dsh-app-boot'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { execa } from 'execa'
import { initializeProfileFromDefault } from '../src/profile-boot.ts'
/** Replaces one `renameSync` call when it returns true; the real rename runs otherwise. */
const renameHook = vi.hoisted(() => ({ current: undefined as ((from: string, to: string) => boolean) | undefined }))
vi.mock('node:fs', async (importOriginal) => {
const fs = await importOriginal<typeof import('node:fs')>()
return {
...fs,
renameSync(from: string, to: string): void {
if (renameHook.current?.(from, to) !== true) fs.renameSync(from, to)
},
}
})
afterEach(() => {
renameHook.current = undefined
})
/** A rename failure whose code does not identify a concurrent target. */
function renameFailure(): NodeJS.ErrnoException {
return Object.assign(new Error('rename failed'), { code: 'EIO' })
}
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
@@ -117,20 +139,78 @@ describe('initializeProfileFromDefault', () => {
})
})
// Windows ignores POSIX directory write bits.
it.skipIf(process.platform === 'win32')('keeps the previous target when the fresh copy cannot be written', () => {
it('restores the previous target when the fresh copy cannot be moved into place', () => {
withHome((home) => {
const dir = resolveProfileDir('previous', home)
const dir = resolveProfileDir('rescue', home)
initProfile(dir, ['existing-bundle'])
const before = readFileSync(join(dir, 'package.json'))
chmodSync(dirname(dir), 0o500)
try {
expect(() => {
initializeProfileFromDefault('previous', 'web', home)
}).toThrow()
} finally {
chmodSync(dirname(dir), 0o700)
renameHook.current = (from, to) => {
if (to === dir && from.endsWith(join('new', 'rescue'))) throw renameFailure()
return false
}
expect(() => {
initializeProfileFromDefault('rescue', 'web', home)
}).toThrow('rename failed')
expect(readFileSync(join(dir, 'package.json'))).toEqual(before)
expect(readdirSync(dirname(dir))).toEqual(['rescue'])
})
})
it('keeps the previous target in staging when restoring it also fails', () => {
withHome((home) => {
const dir = resolveProfileDir('rescue', home)
initProfile(dir, ['existing-bundle'])
const before = readFileSync(join(dir, 'package.json'))
renameHook.current = (_from, to) => {
if (to === dir) throw renameFailure()
return false
}
let failure: unknown
try {
initializeProfileFromDefault('rescue', 'web', home)
} catch (error) {
failure = error
}
expect(failure).toBeInstanceOf(AggregateError)
const [staging] = readdirSync(dirname(dir))
expect(staging).toMatch(/^\.rescue\.staging-/)
const previous = join(dirname(dir), staging!, 'previous')
expect((failure as AggregateError).message).toContain(previous)
expect(readFileSync(join(previous, 'package.json'))).toEqual(before)
})
})
it('reports a target recreated between the two renames and keeps that copy', () => {
withHome((home) => {
const dir = resolveProfileDir('rescue', home)
initProfile(dir, ['existing-bundle'])
renameHook.current = (_from, to) => {
if (to !== dir) return false
renameHook.current = undefined
initProfile(dir, ['concurrent-bundle'])
// The real rename now fails with the platform's code for an occupied target.
return false
}
expect(() => {
initializeProfileFromDefault('rescue', 'web', home)
}).toThrow('profile "rescue" was recreated concurrently')
expect(readProfileManifest('test', dir).dsh?.profile).toEqual({ bundles: ['concurrent-bundle'] })
expect(readdirSync(dirname(dir))).toEqual(['rescue'])
})
})
it('rejects a case variant of a shipped profile without changing it', () => {
withHome((home) => {
const dir = resolveProfileDir('web', home)
initProfile(dir, ['existing-bundle'])
const before = readFileSync(join(dir, 'package.json'))
expect(() => {
initializeProfileFromDefault('Web', 'headless', home)
}).toThrow('profile "Web" names shipped profile "web"')
expect(readFileSync(join(dir, 'package.json'))).toEqual(before)
})
})
@@ -140,7 +220,7 @@ describe('initializeProfileFromDefault', () => {
expect(() => {
initializeProfileFromDefault(name, 'web', home)
})
.toThrow(`profile ${JSON.stringify(name)} is shipped`)
.toThrow(`names shipped profile ${JSON.stringify(name)}`)
expect(existsSync(resolveProfileDir(name, home))).toBe(false)
})
})
@@ -169,6 +249,9 @@ describe('initializeProfileFromDefault', () => {
writeFileSync(gate, '')
const results = await Promise.all(children)
expect(results.map(result => result.exitCode)).toContain(0)
for (const result of results.filter(result => result.exitCode !== 0)) {
expect(result.stderr).toContain('was recreated concurrently')
}
const dir = resolveProfileDir('rescue', home)
expect(readProfileManifest('test', dir).dsh?.profile).toEqual(PROFILE_TEMPLATES.web)
expect(readdirSync(dirname(dir))).toEqual(['rescue'])
@@ -9,8 +9,8 @@
en: 8290bb3fe4ba89bc
zh: b653e97a607c8897
/from-default-profile-replaces-an-existing-profile/change:
en: 8f6dc291b964bdf4
zh: 803e35637c53397d
en: 0a8e90115d24ce6d
zh: fdc51cffdaf85d53
/from-default-profile-replaces-an-existing-profile/migration:
en: 49eb1ddfa3835acb
zh: c585a602d7591341
en: 34ffdb02d05f4489
zh: 51663f79fe7f94bc
@@ -9,12 +9,12 @@ English | [中文](guide.zh.md)
## Change
`dsh --profile <name> --from-default-profile <template>` used to fail with `profile "<name>" already exists` when the target profile existed, and refused a residual target directory. It now replaces the target directory with a fresh copy of the template every time, then boots it or, with a config-dump flag, prints the composition. The previous directory is deleted, including its `package.json` dependencies, installed `node_modules`, and `cordis.patch.yml`. Shipped profile names are still rejected as targets.
`dsh --profile <name> --from-default-profile <template>` used to fail with `profile "<name>" already exists` when the target profile existed, and refused a residual target directory. It now replaces the target directory with a fresh copy of the template every time, then boots it or, with a config-dump flag, prints the composition. The previous directory is deleted, including its `package.json` dependencies, installed `node_modules`, and `cordis.patch.yml`. Shipped profile names, in any letter case, are still rejected as targets.
This affects anyone who repeats the command, such as `dsh rescue --from-default-profile web`, in scripts or shortcuts and expects later runs to keep the profile's plugins or patch.
## Migration
1. To boot an existing profile without resetting it, drop the option: run `dsh rescue` instead of `dsh rescue --from-default-profile web`.
2. To keep local changes across a reset, copy `$DSH_HOME/profiles/<name>/cordis.patch.yml` before running the command and restore it afterwards.
2. To keep local changes across a reset, copy `$DSH_HOME/profiles/<name>/cordis.patch.yml` before running the command, then reinstall the profile's plugins with `dsh plugin --profile <name> add <package>` and restore the patch.
3. Confirm the migration: after `dsh rescue --from-default-profile web --help`, `$DSH_HOME/profiles/rescue/package.json` lists no dependencies.
@@ -9,12 +9,12 @@ description: "`--from-default-profile` 现在会替换已存在的自定义 prof
## 变更
此前目标 profile 已存在时,`dsh --profile <name> --from-default-profile <template>` 会以 `profile "<name>" already exists` 失败,残留的目标目录也会被拒绝。现在每次执行都会用模板的全新副本替换目标目录,然后启动它;带配置 dump flag 时则打印组合结果。原有目录会被删除,包括 `package.json` 中的依赖、已安装的 `node_modules` 和 `cordis.patch.yml`。随附 profile 名称仍不能作为目标。
此前目标 profile 已存在时,`dsh --profile <name> --from-default-profile <template>` 会以 `profile "<name>" already exists` 失败,残留的目标目录也会被拒绝。现在每次执行都会用模板的全新副本替换目标目录,然后启动它;带配置 dump flag 时则打印组合结果。原有目录会被删除,包括 `package.json` 中的依赖、已安装的 `node_modules` 和 `cordis.patch.yml`。任何大小写形式的随附 profile 名称仍不能作为目标。
受影响的是在脚本或快捷方式中重复执行该命令(例如 `dsh rescue --from-default-profile web`),并期望后续执行保留该 profile 插件或 patch 的用户。
## 迁移
1. 要在不重置的情况下启动已存在的 profile,去掉该选项:运行 `dsh rescue`,而不是 `dsh rescue --from-default-profile web`。
2. 要在重置后保留本地修改,先复制 `$DSH_HOME/profiles/<name>/cordis.patch.yml`,执行命令后再恢复。
2. 要在重置后保留本地修改,先复制 `$DSH_HOME/profiles/<name>/cordis.patch.yml`,执行命令后用 `dsh plugin --profile <name> add <package>` 重新安装该 profile 的插件,再恢复该 patch。
3. 确认迁移结果:执行 `dsh rescue --from-default-profile web --help` 后,`$DSH_HOME/profiles/rescue/package.json` 中没有任何依赖。