dsh-m 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/DESIGN.md CHANGED
@@ -63,12 +63,26 @@
63
63
 
64
64
  底层原语(本机实证):`dsh plugin --profile web add|remove|update`(转发 pnpm,作用于 `$DSH_HOME/profiles/web`)。**profile 的 `package.json` 就是唯一事实源**——不引入任何额外状态文件。
65
65
 
66
- - **安装(npm 源)**:装最新版并以**精确版本锁定**(不用 `^` 范围;用户指定版本必须为精确 semver,经该精确版本 endpoint 查询)。安装前对 profile 的 `package.json` / `pnpm-lock.yaml` / `pnpm-workspace.yaml` 做**字节快照**;安装后核验 importer 依赖为该精确版本(旧版 CLI 写入的 `^v`/`~v` 锚定 range 视为等价放行,2026-09-05 实机实证;精确性由紧随的 lockfile 解析与 integrity 兜底,其余 spec 仍 fail closed),并在 lockfile `packages` 条目中比对与 npm dist 一致的 `resolution.integrity`——缺失或不一致 **fail closed** 并执行 **best-effort dependency rollback**:原子恢复快照字节 + frozen 自愈阶梯(`pnpm install --frozen-lockfile` → 仍报 `CONFIG_MISMATCH` 时把 lockfile 记录的 overrides 对齐回 `package.json#pnpm.overrides` 再复验 → `CONFIG_MISMATCH` / `OUTDATED_LOCKFILE` 顽固失配才降级 `--no-frozen-lockfile` 重建并明确告知「lockfile 已重建」;全阶梯失败才报「可能需要人工修复」)。成功路径若发现安装链丢失了 manifest 顶层未知键(如 `pnpm.overrides`,2026-09-05 升级回滚事故),从快照找回并复验 frozen 一致性,输出带 `[dsh-m 自愈]` 报告。刚发布 ~1 分钟内的 `ERR_PNPM_NO_MATCHING_VERSION` 多为 packument CDN 滞后:退避重试 2 次(5s/15s),每次重试前拉一次完整 packument 预热。不声称 node_modules 与间接依赖已字节级回滚。
67
- - **安装(GitHub 源)**:解析并**锁定 commit SHA**(`github:owner/repo#sha`),skillhub 同款。
66
+ ### 3.1 Profile 变更事务(`src/core/profile-transaction.ts`,2026-09-07 重构)
67
+
68
+ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模块的四个入口**(自升级 = `install-npm` + 自身包名)。编排层(market.ts / host-api.ts / tools.ts / cli.ts)只做「版本/收录条目解析(事务外)→ 开事务 → 包装结果」,互斥锁收编在模块内部(模块级 FIFO,进程内串行)。
69
+
70
+ - **两阶段不变量(失败保证)**:变更失败时,①先把三个关键文件(package.json / pnpm-lock.yaml / pnpm-workspace.yaml)**逐字节还原到变更前快照**,还原后立即重读比对验证(`snapshotRestoreVerified` 报告此历史事实);②再跑 frozen 收敛阶梯处理依赖一致性——阶梯中的 overrides 对齐 / `--no-frozen-lockfile` 重建允许对 manifest/lockfile 做**有记录的**受控改写(`healActions`)。失败终态承诺「**一致**」(`status='rolled-back'`)不必然「等同」;收敛耗尽则 `status='manual-repair'`(还原事实独立如实报告)。不声称 node_modules 字节级回滚。
71
+ - **结果为四分支判别联合**:`committed` / `rejected`(快照失败、前置校验失败、排队期 abort——零写入)/ `rolled-back` / `manual-repair`;`healActions[{code, note}]` + `failure{code, note}`(英文稳定 code,机器可断言);中文散文只在展示层由 `renderFailure` 生成(唯一产地)。消费方拿结构化结果自行渲染;host-api 失败体附 `detail` 白名单投影(不含 raw output,GUI 零改动只读 `error`)。
72
+ - **pnpm 结果只以六类分类穿过接缝**(`dsh-cli.ts` 是分类器唯一产地):ok / retryable-lag(CDN 滞后)/ config-drift(配置漂移)/ unused-patch / needs-builds / hard-fail;**分类发生在任何文案改写之前**,事务与 market 永不 regex pnpm 原始输出、只消费 `PNPM_OUTCOME_CODES` 常量。分类消费矩阵(add/remove/frozen/rebuild × 六类)由参数化测试全枚举钉死。
73
+ - **自愈阶梯**(全部以 healActions 记录):B1 安装链丢 manifest 顶层键 → 快照找回 + frozen 复验(复验失败 fail-closed 进回滚);B2 frozen 收敛:CONFIG_MISMATCH → 把 lockfile 记录的 overrides 对齐回 `package.json#pnpm.overrides` 再复验 → 顽固失配(含 OUTDATED_LOCKFILE specifier 漂移)降级 `--no-frozen-lockfile` 重建;B3 刚发布 `ERR_PNPM_NO_MATCHING_VERSION` 退避重试 2 次(5s/15s,abort-aware),每次重试前预热完整 packument(生产绑定 `makeNpmWarmPackument`,失败吞错);构建脚本被拦 → `dangerouslyAllowAllBuilds` 放行并**必须明确报告**(在途重试在 adapter 的 `makeAddViaLadder` 内耗尽)。
74
+ - **signal 语义**:request 可带 AbortSignal,贯通 runner 四操作、预热与退避 sleep(abort 即醒);mutate 前 abort → `rejected`;mutate 后 abort → 中止在途调用并执行**不可取消的**回滚(不变量优先于取消);mutation 返回与提交前复查 signal——runner 返回 ok 但已取消的事务同样进 ABORTED 回滚,不提交。
75
+ - **互斥边界(已知限制)**:FIFO 锁为**进程内**互斥——Web Host / GUI / Agent tools 与独立 `dshm` CLI 可能运行在不同进程、操作同一 web profile,跨进程并发不在覆盖范围。变更执行期间避免同时从 GUI/Agent/CLI 发起另一次变更;跨进程锁(lockfile + stale 恢复)留作后续演进。
76
+ - **快照/原子写原语**(`npm-integrity.ts`):快照仅吞 ENOENT(其他读取异常 fail closed 零写入);原子写 POSIX 直接 rename(无「先删后改名」窗口),Windows EPERM/EEXIST 走备份协议;**任何失败路径(含 write/sync/close)都清理 tmp**,备份恢复自身失败时报告 backup 路径与双重错误。
77
+
78
+ ### 3.2 各入口语义
79
+
80
+ - **安装(npm 源)**:装最新版并以**精确版本锁定**(不用 `^` 范围;用户指定版本必须为精确 semver,经该精确版本 endpoint 查询;dist integrity 缺失 fail closed 不进事务)。事务内核验 importer 依赖为该精确版本(旧版 CLI 写入的 `^v`/`~v` 锚定 range 视为等价放行并记录 `RANGE_ANCHOR_ACCEPTED`;精确性由紧随的 lockfile 解析与 integrity 兜底,其余 spec 仍 fail closed),并在 lockfile `packages` 条目中比对与 npm dist 一致的 `resolution.integrity`。
81
+ - **安装(GitHub 源)**:解析并**锁定 commit SHA**(`github:owner/repo#sha`)。核验采用**前态比对**:新 spec 必须相对快照是新写入(预置同 spec 旧依赖不误命中),否则 `GITHUB_SPEC_MISMATCH` 回滚。
68
82
  - **已装识别**:读 profile `package.json` dependencies,与 registry 匹配 → 标注「市场安装」;不匹配的也列出,标注「非市场安装 / 来源未知」。卸载/升级对两类都可用。
69
- - **卸载**:live-disable(先让 client bundle 下线,避免 404)→ 摘除该包在 profile 的补丁条目(`pnpm-workspace.yaml` 顶层 `patchedDependencies` 与 `package.json#pnpm.patchedDependencies`;依赖移除后残留条目会令 pnpm 以 `ERR_PNPM_UNUSED_PATCH` 整单失败,只精确匹配 `pkg` / `pkg@ver`,补丁文件本体保留并计入残留报告)→ `dsh plugin remove`。**不清理插件产生的数据/配置**,但把检测到的疑似残留路径(如 `~/.dsh/<plugin>.json`)列出报告。
70
- - **升级**:**按需检查**(`dshm_outdated` / `dshm_list` 时实时比对本地版本 vs npm latest / GitHub main),半自动——展示升级计划,确认后执行。**不做后台定时器**。
71
- - **自更新**:dsh-m 对自己同样做版本比对 + 提示升级(设置页呈现)。
83
+ - **卸载**(事务定序写死):validate(严格读取:NOT_INSTALLED / NOT_DSH_PLUGIN / PLUGIN_METADATA_UNREADABLE,全部零写入零快照)→ 快照 → live-disable(先让 client bundle 下线,避免 404;回滚时尽力反向)→ 摘除该包补丁条目(`pnpm-workspace.yaml` 顶层 `patchedDependencies` 与 `package.json#pnpm.patchedDependencies`;残留条目会令 pnpm 以 `ERR_PNPM_UNUSED_PATCH` 整单失败,只精确匹配 `pkg` / `pkg@ver`,补丁文件本体保留并计入残留报告)→ remove → verify gone(仍在则回滚)。**不清理插件产生的数据/配置**,但把检测到的疑似残留路径(如 `~/.dsh/<plugin>.json`)列出报告。
84
+ - **升级**:**按需检查**(`dshm_outdated` / `dshm_list` 时实时比对本地版本 vs npm latest / GitHub release,半自动——展示升级计划,确认后执行,即重开一次安装事务)。**不做后台定时器**。
85
+ - **自更新**:dsh-m 对自己同样做版本比对 + 提示升级(设置页呈现);执行即 `install-npm` 事务(integrity 缺失直接拒绝,不进事务)。
72
86
  - **重启**:内置**一键重启**,复用 skillhub 验证过的重启路径(本机 `dsh-web.service` 是转发 shim,不新建 systemd 单元、不监听 3080)。安装/卸载/升级完成后 GUI 弹「需重启生效 [一键重启]」横幅,工具返回重启提示。
73
87
  - **安全基线(5 条)**:
74
88
  1. 所有拉取仅 HTTPS + 响应大小上限 + 超时;
package/lib/cli.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * 独立 CLI 固定 namespace:'cli'(与 Host 的 host namespace cache 互不影响);
6
6
  * registry/search/list/outdated 在清单不可用时打印配置/实际生效地址并 exit 1。
7
7
  */
8
- import { installFromRegistry, listInstalledWithMeta, listMarket, uninstallPlugin, upgradePlugin, withMutationLock, } from './core/market.js';
8
+ import { installFromRegistry, listInstalledWithMeta, listMarket, uninstallPlugin, upgradePlugin, } from './core/market.js';
9
9
  import { loadRegistry } from './core/registry.js';
10
10
  import { scheduleRestart } from './core/restart.js';
11
11
  import { pathToFileURL } from 'node:url';
@@ -92,6 +92,7 @@ const HELP = `dshm — DSH Marketplace(个人自用 DSH 插件市场)
92
92
  dshm upgrade --pkg <包名> --yes
93
93
  dshm uninstall --pkg <包名> --yes
94
94
  dshm restart --yes
95
+ 注意:变更互斥仅在进程内生效——变更执行期间不要同时从 GUI / Agent 工具发起另一次变更。
95
96
 
96
97
  环境变量:DSHM_REGISTRY_URL(registry 源覆盖)、DSHM_TIMEOUT_MS、DSHM_CACHE_TTL_MIN、DSHM_CACHE_DIR
97
98
  `;
@@ -214,7 +215,7 @@ async function runCliDispatch(argv, deps, io) {
214
215
  case 'install': {
215
216
  const id = needFlag(flags, 'id');
216
217
  const version = typeof flags.version === 'string' ? flags.version : undefined;
217
- const res = await withMutationLock(() => d.installFromRegistry(id, cfg, { version, namespace: 'cli' }));
218
+ const res = await d.installFromRegistry(id, cfg, { version, namespace: 'cli' });
218
219
  out(`✅ 已安装 ${res.pkg}(${res.spec})`);
219
220
  if (res.usedAllowAllBuilds)
220
221
  out('⚠️ 该插件执行了构建脚本(已按策略放行)。');
@@ -224,7 +225,7 @@ async function runCliDispatch(argv, deps, io) {
224
225
  case 'upgrade': {
225
226
  const target = needFlag(flags, 'pkg');
226
227
  requireYes(flags, '升级');
227
- const res = await withMutationLock(() => d.upgradePlugin(target, cfg, { namespace: 'cli' }));
228
+ const res = await d.upgradePlugin(target, cfg, { namespace: 'cli' });
228
229
  const from = res.fromVersion ? `v${res.fromVersion} → ` : '';
229
230
  const to = res.version ? `v${res.version}` : res.sha ? res.sha.slice(0, 7) : '最新';
230
231
  out(`✅ 已升级 ${res.pkg}(${from}${to})`);
@@ -236,7 +237,7 @@ async function runCliDispatch(argv, deps, io) {
236
237
  case 'uninstall': {
237
238
  const target = needFlag(flags, 'pkg');
238
239
  requireYes(flags, '卸载');
239
- const res = await withMutationLock(() => d.uninstallPlugin(target, cfg, { namespace: 'cli' }));
240
+ const res = await d.uninstallPlugin(target, cfg, { namespace: 'cli' });
240
241
  out(`✅ 已卸载 ${res.pkg}${res.liveDisabled ? '(运行中的界面已先下线)' : ''}`);
241
242
  if (res.leftovers.length)
242
243
  out(`ℹ️ 疑似残留数据(未删除):${res.leftovers.join('、')}`);