소스 검색

feat(plugin-manager): keep pnpm's colours and name the command on install-log chunks

The installer now decides pnpm's colouring per consumer: the Web host keeps
colours on for the install dialog's terminal, the CLI turns them on only when
stdout is a terminal, and a plain log strips what a colour=always pnpm still
prints. Every chunk carries the argv it came from, so a renderer can label
each pnpm run.
Yichen Jiang 2 주 전
부모
커밋
080e22ee03

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md
-2026-09-04-plugin-manager-over-the-profile-runtime.md: a486806da2a62c4f7265e6e2b2204fff1b274843
-2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 5ca6b436d5e446743b28d610e3957c1618e93818
+2026-09-04-plugin-manager-over-the-profile-runtime.md: 95153138b7d10ea6d13b96e815bd0a52c15d663e
+2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 8f6794568f7aced809b44454b5192415c2ae097c

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md

@@ -14,7 +14,7 @@ Installing a plugin was a terminal-only act: `dsh plugin --profile web add <spec
 
 
 **Enablement is the Loader's transaction.** `enable` puts the bundle in the layer list and calls `profileRuntime.recompose({ reloadBundles: true })`, after `healProfilesModuleFallback` has linked the packages the bundle carries. A rejected recomposition — a `boot`-stage bundle whose row throws — is the Loader rolling back to the tree that was running; the manager restores the list and reports `plugins/enable-failed`. A `runtime`-stage bundle whose row fails is isolated by the contained group and reported per row. Because the boot audit does not run again, the manager calls `recordContainedStates` after a live recomposition, and `ContainedGroup.create` now records a row that resolved in the pending state instead of clearing it — a reload re-creates every row of a group, and a waiting row must keep its record through that. `retry` is disable then enable: the Loader's update leaves an unchanged row alone, so only leaving and returning restarts a failed isolated row.
 **Enablement is the Loader's transaction.** `enable` puts the bundle in the layer list and calls `profileRuntime.recompose({ reloadBundles: true })`, after `healProfilesModuleFallback` has linked the packages the bundle carries. A rejected recomposition — a `boot`-stage bundle whose row throws — is the Loader rolling back to the tree that was running; the manager restores the list and reports `plugins/enable-failed`. A `runtime`-stage bundle whose row fails is isolated by the contained group and reported per row. Because the boot audit does not run again, the manager calls `recordContainedStates` after a live recomposition, and `ContainedGroup.create` now records a row that resolved in the pending state instead of clearing it — a reload re-creates every row of a group, and a waiting row must keep its record through that. `retry` is disable then enable: the Loader's update leaves an unchanged row alone, so only leaving and returning restarts a failed isolated row.
 
 
-**pnpm runs the way the CLI runs it.** Through `node:child_process` with the parent environment and `shell` on Windows, not through the subprocess seam: the seam scrubs secret-shaped variables pnpm needs for registries and proxies and has no shell mode for the `.cmd` shim. Output streams as `plugins/install-log` chunks under a job id; a non-zero exit, a spawn error, or the timeout is `plugins/install-failed` with the log tail. New packages are probed and left disabled unless the caller asked for `enable`.
+**pnpm runs the way the CLI runs it.** Through `node:child_process` with the parent environment and `shell` on Windows, not through the subprocess seam: the seam scrubs secret-shaped variables pnpm needs for registries and proxies and has no shell mode for the `.cmd` shim. Output streams as `plugins/install-log` chunks under a job id, each naming the command line, with pnpm's colours kept for the Web dialog's terminal and stripped for a CLI whose stdout is not a terminal; a non-zero exit, a spawn error, or the timeout is `plugins/install-failed` with the log tail. New packages are probed and left disabled unless the caller asked for `enable`.
 
 
 **A successful `pnpm add` is not an installed plugin.** The manifest is snapshotted before the run and restored when pnpm fails, so a failed add leaves no dependency behind. Each package pnpm added is then judged: one that declares neither a bundle nor a plugin module, or a bundle whose row id a composed layer already owns (`claimLayerIds` over the current layers plus the candidate), is removed again with `pnpm remove` and reported under `removed` with its reason; a package the probe refused stays, because the view can explain it and `retry` can try again. The manager runs one mutation at a time and refuses a second with `plugins/busy` rather than queueing it — every write races on the manifest, the user layers, or `node_modules` otherwise — and `install` and `uninstall` refuse with `plugins/agents-running` while any agent is running, because pnpm rewrites the directory those sessions import from. The three guards follow the community `dshmarket` manager, which learned each of them from a bug.
 **A successful `pnpm add` is not an installed plugin.** The manifest is snapshotted before the run and restored when pnpm fails, so a failed add leaves no dependency behind. Each package pnpm added is then judged: one that declares neither a bundle nor a plugin module, or a bundle whose row id a composed layer already owns (`claimLayerIds` over the current layers plus the candidate), is removed again with `pnpm remove` and reported under `removed` with its reason; a package the probe refused stays, because the view can explain it and `retry` can try again. The manager runs one mutation at a time and refuses a second with `plugins/busy` rather than queueing it — every write races on the manifest, the user layers, or `node_modules` otherwise — and `install` and `uninstall` refuse with `plugins/agents-running` while any agent is running, because pnpm rewrites the directory those sessions import from. The three guards follow the community `dshmarket` manager, which learned each of them from a bug.
 
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 
 **启用就是 Loader 的事务。** `enable` 把组合包放进层列表,在 `healProfilesModuleFallback` 链接好该组合包携带的包之后调用 `profileRuntime.recompose({ reloadBundles: true })`。被拒绝的重新组合——`boot` 阶段而行抛错的组合包——就是 Loader 回滚到原本运行的树;管理器恢复层列表并报告 `plugins/enable-failed`。`runtime` 阶段而行失败的组合包由受控组隔离并逐行报告。由于启动审计不会再跑一次,管理器在在线重新组合之后调用 `recordContainedStates`,而 `ContainedGroup.create` 现在把以 pending 状态完成创建的行记录下来而不是清除——重载会重新创建组里的每一行,等待中的行必须带着记录穿过这一过程。`retry` 是先停用再启用:Loader 的更新不碰未改变的行,只有离开再回来才能重启一条失败的隔离行。
 **启用就是 Loader 的事务。** `enable` 把组合包放进层列表,在 `healProfilesModuleFallback` 链接好该组合包携带的包之后调用 `profileRuntime.recompose({ reloadBundles: true })`。被拒绝的重新组合——`boot` 阶段而行抛错的组合包——就是 Loader 回滚到原本运行的树;管理器恢复层列表并报告 `plugins/enable-failed`。`runtime` 阶段而行失败的组合包由受控组隔离并逐行报告。由于启动审计不会再跑一次,管理器在在线重新组合之后调用 `recordContainedStates`,而 `ContainedGroup.create` 现在把以 pending 状态完成创建的行记录下来而不是清除——重载会重新创建组里的每一行,等待中的行必须带着记录穿过这一过程。`retry` 是先停用再启用:Loader 的更新不碰未改变的行,只有离开再回来才能重启一条失败的隔离行。
 
 
-**pnpm 按 CLI 的方式运行。** 经 `node:child_process`、带父进程环境、Windows 上开 `shell`,而不经 subprocess seam:seam 会清洗 pnpm 访问 registry 与代理所需的形似密钥的变量,也没有解析 `.cmd` shim 的 shell 模式。输出以某个 job id 下的 `plugins/install-log` 分块流式发出;非零退出、spawn 错误或超时即带日志尾部的 `plugins/install-failed`。新包被探测并保持停用,除非调用方要求 `enable`。
+**pnpm 按 CLI 的方式运行。** 经 `node:child_process`、带父进程环境、Windows 上开 `shell`,而不经 subprocess seam:seam 会清洗 pnpm 访问 registry 与代理所需的形似密钥的变量,也没有解析 `.cmd` shim 的 shell 模式。输出以某个 job id 下的 `plugins/install-log` 分块流式发出,每块写明命令行,pnpm 的颜色为 Web 对话框的终端保留、对 stdout 不是终端的 CLI 去掉;非零退出、spawn 错误或超时即带日志尾部的 `plugins/install-failed`。新包被探测并保持停用,除非调用方要求 `enable`。
 
 
 **`pnpm add` 成功不等于装好了插件。** 运行前先给 manifest 拍快照,pnpm 失败时恢复,失败的 add 不会留下依赖。之后逐个裁决 pnpm 加进来的包:既不声明组合包也不声明插件模块的,或者行 id 已被已组合层占有的组合包(对当前各层加候选层跑 `claimLayerIds`),再以 `pnpm remove` 移除并连同原因报在 `removed` 里;探针拒绝的包保留,因为视图能解释它、`retry` 还能再试。管理器一次只跑一个变更,第二个以 `plugins/busy` 拒绝而不是排队——否则每次写入都会在 manifest、用户层或 `node_modules` 上竞争——`install` 与 `uninstall` 在任一 agent 运行时以 `plugins/agents-running` 拒绝,因为 pnpm 会重写那些会话正在 import 的目录。这三道守卫来自社区的 `dshmarket` 管理器,它每一条都是从一个 bug 学来的。
 **`pnpm add` 成功不等于装好了插件。** 运行前先给 manifest 拍快照,pnpm 失败时恢复,失败的 add 不会留下依赖。之后逐个裁决 pnpm 加进来的包:既不声明组合包也不声明插件模块的,或者行 id 已被已组合层占有的组合包(对当前各层加候选层跑 `claimLayerIds`),再以 `pnpm remove` 移除并连同原因报在 `removed` 里;探针拒绝的包保留,因为视图能解释它、`retry` 还能再试。管理器一次只跑一个变更,第二个以 `plugins/busy` 拒绝而不是排队——否则每次写入都会在 manifest、用户层或 `node_modules` 上竞争——`install` 与 `uninstall` 在任一 agent 运行时以 `plugins/agents-running` 拒绝,因为 pnpm 会重写那些会话正在 import 的目录。这三道守卫来自社区的 `dshmarket` 管理器,它每一条都是从一个 bug 学来的。
 
 

+ 2 - 0
apps/cli/src/plugin.ts

@@ -134,6 +134,8 @@ async function runManaged(
     loadProfile: () => loadProfile(NAME, profile, INSTALL_ANCHOR, undefined, { userLayer: false }),
     loadProfile: () => loadProfile(NAME, profile, INSTALL_ANCHOR, undefined, { userLayer: false }),
     config: TOOLING,
     config: TOOLING,
     installLog: (chunk) => { (chunk.stream === 'stdout' ? process.stdout : process.stderr).write(chunk.text) },
     installLog: (chunk) => { (chunk.stream === 'stdout' ? process.stdout : process.stderr).write(chunk.text) },
+    // pnpm's colours reach the terminal the user is looking at, never a redirected file.
+    color: process.stdout.isTTY,
     ...internals,
     ...internals,
   })
   })
   for (const argument of specs) {
   for (const argument of specs) {

+ 23 - 1
apps/cli/tests/plugin.spec.ts

@@ -19,6 +19,8 @@ let home: string
 let previousHome: string | undefined
 let previousHome: string | undefined
 let stderr: string
 let stderr: string
 let stdout: string
 let stdout: string
+/** The FORCE_COLOR each fake pnpm run was spawned with. */
+let spawnEnvs: (string | undefined)[] = []
 
 
 beforeEach(() => {
 beforeEach(() => {
   home = mkdtempSync(join(tmpdir(), 'dsh-plugin-command-'))
   home = mkdtempSync(join(tmpdir(), 'dsh-plugin-command-'))
@@ -26,6 +28,7 @@ beforeEach(() => {
   process.env.DSH_HOME = home
   process.env.DSH_HOME = home
   stderr = ''
   stderr = ''
   stdout = ''
   stdout = ''
+  spawnEnvs = []
   vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { stderr += String(chunk); return true })
   vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { stderr += String(chunk); return true })
   vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { stdout += String(chunk); return true })
   vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { stdout += String(chunk); return true })
 })
 })
@@ -64,6 +67,7 @@ function uninstall(profileDir: string, name: string): void {
 function fakePnpm(calls: string[][], failWith?: { code: number } | { error: NodeJS.ErrnoException }): SpawnLike {
 function fakePnpm(calls: string[][], failWith?: { code: number } | { error: NodeJS.ErrnoException }): SpawnLike {
   return (_command, args, options) => {
   return (_command, args, options) => {
     calls.push([...args])
     calls.push([...args])
+    spawnEnvs.push(options.env?.FORCE_COLOR)
     const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough }
     const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough }
     child.stdout = new PassThrough()
     child.stdout = new PassThrough()
     child.stderr = new PassThrough()
     child.stderr = new PassThrough()
@@ -81,7 +85,8 @@ function fakePnpm(calls: string[][], failWith?: { code: number } | { error: Node
       const profileDir = options.cwd as string
       const profileDir = options.cwd as string
       if (verb === 'add' && target !== undefined) install(profileDir, target, target === 'ext-bundle')
       if (verb === 'add' && target !== undefined) install(profileDir, target, target === 'ext-bundle')
       if (verb === 'remove' && target !== undefined) uninstall(profileDir, target)
       if (verb === 'remove' && target !== undefined) uninstall(profileDir, target)
-      child.stdout.write(`${verb === 'add' ? '+' : '-'} ${String(target)}\n`)
+      // A coloured line, as a pnpm told to colour anyway would print one.
+      child.stdout.write(`\u001b[32m${verb === 'add' ? '+' : '-'}\u001b[39m ${String(target)}\n`)
       child.emit('close', 0)
       child.emit('close', 0)
     }, 5)
     }, 5)
     return child as unknown as ChildProcess
     return child as unknown as ChildProcess
@@ -104,6 +109,20 @@ const fakeProbe: typeof probePackage = (options) => {
 }
 }
 
 
 describe('dsh plugin', () => {
 describe('dsh plugin', () => {
+  it('lets pnpm colour its output when stdout is a terminal', async () => {
+    const calls: string[][] = []
+    const descriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY')
+    Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
+    try {
+      expect(await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm(calls), probe: fakeProbe })).toBe(0)
+    } finally {
+      if (descriptor === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY
+      else Object.defineProperty(process.stdout, 'isTTY', descriptor)
+    }
+    expect(spawnEnvs).toEqual(['1'])
+    expect(stdout).toContain('\u001b[32m+\u001b[39m ext-bundle')
+  })
+
   it('add initializes the profile, installs through the installer, enables the new bundle, and removes a plain library again', async () => {
   it('add initializes the profile, installs through the installer, enables the new bundle, and removes a plain library again', async () => {
     const calls: string[][] = []
     const calls: string[][] = []
 
 
@@ -117,7 +136,10 @@ describe('dsh plugin', () => {
     expect(Object.keys(manifest.dependencies ?? {})).toContain('ext-bundle')
     expect(Object.keys(manifest.dependencies ?? {})).toContain('ext-bundle')
     expect(Object.keys(manifest.dependencies ?? {})).not.toContain('ext-lib')
     expect(Object.keys(manifest.dependencies ?? {})).not.toContain('ext-lib')
     expect(manifest.dsh?.profile?.bundles).toContain('ext-bundle')
     expect(manifest.dsh?.profile?.bundles).toContain('ext-bundle')
+    // The log reaches its readers plain: colours are off for the child and stripped from what it still prints.
+    expect(spawnEnvs).toEqual(['0', '0', '0'])
     expect(stdout).toContain('+ ext-bundle')
     expect(stdout).toContain('+ ext-bundle')
+    expect(stdout).not.toContain('\u001b[')
     expect(stderr).toContain('dsh: removed ext-lib again: declares neither a dsh bundle nor a plugin module')
     expect(stderr).toContain('dsh: removed ext-lib again: declares neither a dsh bundle nor a plugin module')
     expect(existsSync(join(profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(true)
     expect(existsSync(join(profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(true)
   })
   })

+ 2 - 2
packages/boot/plugin-manager/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md
 #   pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md
-README.md: c488373c22b696d3dfe605335cc11d8b942cc75a
-README.zh.md: 4cdfa4c90b6f5fc91c1a29255b09bdda566f588d
+README.md: 0666043f69619fab042228d5f298d63eeeada87b
+README.zh.md: 2c659f51e41b366a793f36ab8865bea2a68e0b65

+ 2 - 2
packages/boot/plugin-manager/README.md

@@ -27,7 +27,7 @@ English | [中文](README.zh.md)
 
 
 ### Installing without booting
 ### Installing without booting
 
 
-Build a `PluginInstaller` from the profile directory, the install anchor (the dsh app's `package.json`), a `loadProfile` that answers the profile's composed layers, the tooling bounds, and a sink for pnpm's output; then `add(spec)` or `remove(name)`:
+Build a `PluginInstaller` from the profile directory, the install anchor (the dsh app's `package.json`), a `loadProfile` that answers the profile's composed layers, the tooling bounds, a sink for pnpm's output, and whether pnpm colours it; then `add(spec)` or `remove(name)`:
 
 
 ```ts
 ```ts
 import { loadProfile } from '@deepseek-ai/dsh-app-boot'
 import { loadProfile } from '@deepseek-ai/dsh-app-boot'
@@ -77,7 +77,7 @@ console.log(await manager.list())
 
 
 `addRow` inserts a row naming one of the package's modules — its main export for a `plugin` package, or a `dsh.plugins` entry — into the profile's global `cordis.patch.yml` (`target: { kind: 'global' }`) or an agent preset's user layer (`{ kind: 'preset', preset }`, through the roster's `overlayPathFor`). The row id derives from the package name and subpath unless given; a taken id fails with `plugins/row-conflict`. `removeRow` removes an inserted row and `setRowDisabled` writes or removes a `disabled: true` for any row — deny-only, so a bundle's own `!!js` gate is restored rather than overridden. The global layer is recomposed live on the spot; a preset's layer reaches its next standing generation. `dependents` says what disabling or removing a package would strand: services its rows provide that rows outside it inject, and user-layer rows naming its modules.
 `addRow` inserts a row naming one of the package's modules — its main export for a `plugin` package, or a `dsh.plugins` entry — into the profile's global `cordis.patch.yml` (`target: { kind: 'global' }`) or an agent preset's user layer (`{ kind: 'preset', preset }`, through the roster's `overlayPathFor`). The row id derives from the package name and subpath unless given; a taken id fails with `plugins/row-conflict`. `removeRow` removes an inserted row and `setRowDisabled` writes or removes a `disabled: true` for any row — deny-only, so a bundle's own `!!js` gate is restored rather than overridden. The global layer is recomposed live on the spot; a preset's layer reaches its next standing generation. `dependents` says what disabling or removing a package would strand: services its rows provide that rows outside it inject, and user-layer rows naming its modules.
 
 
-The manager runs one mutation at a time — a second call while one runs fails with `plugins/busy` naming the operation in flight — and `add` and `uninstall` refuse to change `node_modules` while a session is running, with `plugins/agents-running`. Every change is followed by a `plugins/changed` event on the context, and an install run emits pnpm's output as `plugins/install-log` chunks.
+The manager runs one mutation at a time — a second call while one runs fails with `plugins/busy` naming the operation in flight — and `add` and `uninstall` refuse to change `node_modules` while a session is running, with `plugins/agents-running`. Every change is followed by a `plugins/changed` event on the context, and an install run emits pnpm's output as `plugins/install-log` chunks, each naming the command line it ran and, with colour on, carrying pnpm's SGR escapes.
 
 
 ### Failures
 ### Failures
 
 

+ 2 - 2
packages/boot/plugin-manager/README.zh.md

@@ -27,7 +27,7 @@ kind: "package-reference"
 
 
 ### 不启动也能安装
 ### 不启动也能安装
 
 
-用 profile 目录、安装锚点(dsh 应用的 `package.json`)、一个回答 profile 已组合层列表的 `loadProfile`、工具边界,以及 pnpm 输出的去处构造 `PluginInstaller`,然后 `add(spec)` 或 `remove(name)`:
+用 profile 目录、安装锚点(dsh 应用的 `package.json`)、一个回答 profile 已组合层列表的 `loadProfile`、工具边界、pnpm 输出的去处,以及是否让 pnpm 上色,构造 `PluginInstaller`,然后 `add(spec)` 或 `remove(name)`:
 
 
 ```ts
 ```ts
 import { loadProfile } from '@deepseek-ai/dsh-app-boot'
 import { loadProfile } from '@deepseek-ai/dsh-app-boot'
@@ -77,7 +77,7 @@ console.log(await manager.list())
 
 
 `addRow` 把一条点名该包某个模块的行——`plugin` 包的主导出,或某个 `dsh.plugins` 条目——插入 profile 的全局 `cordis.patch.yml`(`target: { kind: 'global' }`)或某个 agent preset 的用户层(`{ kind: 'preset', preset }`,经 roster 的 `overlayPathFor`)。行 id 未给出时由包名与子路径派生;已被占用的 id 以 `plugins/row-conflict` 失败。`removeRow` 移除一条插入的行,`setRowDisabled` 为任意行写入或移除 `disabled: true`——只写拒绝,因此组合包自己的 `!!js` 门被恢复而不是被覆盖。全局层当场在线重新组合;preset 的层在其下一个常驻代际生效。`dependents` 说明停用或移除一个包会搁浅什么:其行提供而包外的行注入的服务,以及点名其模块的用户层行。
 `addRow` 把一条点名该包某个模块的行——`plugin` 包的主导出,或某个 `dsh.plugins` 条目——插入 profile 的全局 `cordis.patch.yml`(`target: { kind: 'global' }`)或某个 agent preset 的用户层(`{ kind: 'preset', preset }`,经 roster 的 `overlayPathFor`)。行 id 未给出时由包名与子路径派生;已被占用的 id 以 `plugins/row-conflict` 失败。`removeRow` 移除一条插入的行,`setRowDisabled` 为任意行写入或移除 `disabled: true`——只写拒绝,因此组合包自己的 `!!js` 门被恢复而不是被覆盖。全局层当场在线重新组合;preset 的层在其下一个常驻代际生效。`dependents` 说明停用或移除一个包会搁浅什么:其行提供而包外的行注入的服务,以及点名其模块的用户层行。
 
 
-管理器一次只跑一个变更——上一个还在跑时再调用会以 `plugins/busy` 失败并点名正在进行的操作——`add` 与 `uninstall` 在有会话运行时拒绝改动 `node_modules`,报 `plugins/agents-running`。每次变更之后在上下文上发出 `plugins/changed` 事件,安装运行把 pnpm 的输出以 `plugins/install-log` 分块发出。
+管理器一次只跑一个变更——上一个还在跑时再调用会以 `plugins/busy` 失败并点名正在进行的操作——`add` 与 `uninstall` 在有会话运行时拒绝改动 `node_modules`,报 `plugins/agents-running`。每次变更之后在上下文上发出 `plugins/changed` 事件,安装运行把 pnpm 的输出以 `plugins/install-log` 分块发出,每块都写明所跑的命令行,开了颜色时还带着 pnpm 的 SGR 转义
 
 
 ### 失败
 ### 失败
 
 

+ 22 - 7
packages/boot/plugin-manager/src/installer.ts

@@ -32,6 +32,9 @@ import type { PluginInstallLogChunk, PluginInstallRejection, PluginInstallResult
 /** What one install run changed before any newly installed bundle was enabled. */
 /** What one install run changed before any newly installed bundle was enabled. */
 export type PluginInstallOutcome = Omit<PluginInstallResult, 'enabled'>
 export type PluginInstallOutcome = Omit<PluginInstallResult, 'enabled'>
 
 
+/** A terminal escape sequence (CSI): colours, cursor moves, and the rest of what a coloured pnpm prints. */
+const ANSI_SEQUENCE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
+
 /** What {@link PluginInstaller} needs: the profile on disk, the tooling bounds, and where pnpm's output goes. */
 /** What {@link PluginInstaller} needs: the profile on disk, the tooling bounds, and where pnpm's output goes. */
 export interface PluginInstallerOptions {
 export interface PluginInstallerOptions {
   /** The profile directory pnpm runs in and whose manifest records installs. */
   /** The profile directory pnpm runs in and whose manifest records installs. */
@@ -45,6 +48,13 @@ export interface PluginInstallerOptions {
   readonly config: PluginToolingConfig
   readonly config: PluginToolingConfig
   /** Receives every chunk of a pnpm run's output, in order; the last chunk carries the exit code. */
   /** Receives every chunk of a pnpm run's output, in order; the last chunk carries the exit code. */
   readonly installLog: (chunk: PluginInstallLogChunk) => void
   readonly installLog: (chunk: PluginInstallLogChunk) => void
+  /**
+   * Whether pnpm colours its output. On, the chunks carry SGR escapes for a
+   * consumer that draws them (the Web install dialog's terminal); off, they
+   * are plain text, with the escapes a pnpm told to colour anyway prints
+   * (a `color=always` config) dropped.
+   */
+  readonly color: boolean
   /** Test seam: the child spawner; defaults to `node:child_process`. */
   /** Test seam: the child spawner; defaults to `node:child_process`. */
   readonly spawn?: SpawnLike
   readonly spawn?: SpawnLike
   /** Test seam: the package probe; defaults to app-boot's. */
   /** Test seam: the package probe; defaults to app-boot's. */
@@ -240,27 +250,32 @@ export class PluginInstaller {
    * @throws {PluginOperationError} `plugins/install-failed` on a non-zero exit, a signal, or the timeout.
    * @throws {PluginOperationError} `plugins/install-failed` on a non-zero exit, a signal, or the timeout.
    */
    */
   private async runPnpm(args: readonly string[], spec: string): Promise<string> {
   private async runPnpm(args: readonly string[], spec: string): Promise<string> {
-    const { config, profileDir } = this.options
+    const { color, config, profileDir } = this.options
     const jobId = randomUUID()
     const jobId = randomUUID()
+    const argv = [config.pnpmCommand, ...args]
     const tail: string[] = []
     const tail: string[] = []
     let tailBytes = 0
     let tailBytes = 0
-    const record = (stream: 'stdout' | 'stderr', text: string): void => {
+    const record = (stream: 'stdout' | 'stderr', chunk: string): void => {
+      const text = color ? chunk : chunk.replace(ANSI_SEQUENCE, '')
       tail.push(text)
       tail.push(text)
       tailBytes += Buffer.byteLength(text)
       tailBytes += Buffer.byteLength(text)
       while (tailBytes > config.installLogTailBytes && tail.length > 1) {
       while (tailBytes > config.installLogTailBytes && tail.length > 1) {
         tailBytes -= Buffer.byteLength(tail.shift() as string)
         tailBytes -= Buffer.byteLength(tail.shift() as string)
       }
       }
-      this.options.installLog({ jobId, spec, stream, text })
+      this.options.installLog({ jobId, argv, spec, stream, text })
     }
     }
     // Windows resolves pnpm through its .cmd shim, which spawn() refuses
     // Windows resolves pnpm through its .cmd shim, which spawn() refuses
     // without a shell since the CVE-2024-27980 hardening. The parent
     // without a shell since the CVE-2024-27980 hardening. The parent
     // environment is passed whole, as the `dsh plugin` command does: pnpm
     // environment is passed whole, as the `dsh plugin` command does: pnpm
-    // needs the user's registry, proxy, and auth settings.
+    // needs the user's registry, proxy, and auth settings. pnpm writes to a
+    // pipe and would decide against colour on its own, so `FORCE_COLOR`
+    // decides for it either way: a parent forcing colours for its own
+    // terminal cannot leak escapes into a plain log.
     const child = this.spawn(config.pnpmCommand, args, {
     const child = this.spawn(config.pnpmCommand, args, {
       cwd: profileDir,
       cwd: profileDir,
       stdio: ['ignore', 'pipe', 'pipe'],
       stdio: ['ignore', 'pipe', 'pipe'],
       shell: process.platform === 'win32',
       shell: process.platform === 'win32',
-      env: process.env,
+      env: { ...process.env, FORCE_COLOR: color ? '1' : '0' },
     })
     })
     child.stdout?.setEncoding('utf8')
     child.stdout?.setEncoding('utf8')
     child.stderr?.setEncoding('utf8')
     child.stderr?.setEncoding('utf8')
@@ -272,10 +287,10 @@ export class PluginInstaller {
     ).catch((error: unknown) => {
     ).catch((error: unknown) => {
       const message = messageOf(error)
       const message = messageOf(error)
       record('stderr', `${message}\n`)
       record('stderr', `${message}\n`)
-      this.options.installLog({ jobId, spec, stream: 'stderr', text: '', exitCode: null })
+      this.options.installLog({ jobId, argv, spec, stream: 'stderr', text: '', exitCode: null })
       throw new PluginOperationError('plugins/install-failed', `${NAME}: ${message}`, { spec, exitCode: null, log: tail.join('') }, { cause: error })
       throw new PluginOperationError('plugins/install-failed', `${NAME}: ${message}`, { spec, exitCode: null, log: tail.join('') }, { cause: error })
     })
     })
-    this.options.installLog({ jobId, spec, stream: 'stdout', text: '', exitCode })
+    this.options.installLog({ jobId, argv, spec, stream: 'stdout', text: '', exitCode })
     if (exitCode !== 0) {
     if (exitCode !== 0) {
       throw new PluginOperationError(
       throw new PluginOperationError(
         'plugins/install-failed',
         'plugins/install-failed',

+ 2 - 0
packages/boot/plugin-manager/src/manager.ts

@@ -154,6 +154,8 @@ export class PluginManager {
       loadProfile: () => runtime.current,
       loadProfile: () => runtime.current,
       config: this.options.config,
       config: this.options.config,
       installLog: (chunk) => { this.ctx.emit('plugins/install-log', chunk) },
       installLog: (chunk) => { this.ctx.emit('plugins/install-log', chunk) },
+      // The chunks feed the Web install dialog's terminal, which draws SGR colour.
+      color: true,
       ...this.options.spawn === undefined ? {} : { spawn: this.options.spawn },
       ...this.options.spawn === undefined ? {} : { spawn: this.options.spawn },
       ...this.options.probe === undefined ? {} : { probe: this.options.probe },
       ...this.options.probe === undefined ? {} : { probe: this.options.probe },
     })
     })

+ 4 - 1
packages/boot/plugin-manager/src/types.ts

@@ -188,11 +188,14 @@ export type PluginChangeReason = 'install' | 'uninstall' | 'enable' | 'disable'
 export interface PluginInstallLogChunk {
 export interface PluginInstallLogChunk {
   /** The run the chunk belongs to. */
   /** The run the chunk belongs to. */
   readonly jobId: string
   readonly jobId: string
+  /** The command line the run executes: pnpm's command name, then its arguments. */
+  readonly argv: readonly string[]
   /** The package spec the run installs or removes. */
   /** The package spec the run installs or removes. */
   readonly spec: string
   readonly spec: string
   readonly stream: 'stdout' | 'stderr'
   readonly stream: 'stdout' | 'stderr'
+  /** The output as pnpm wrote it; with the installer's colours on, its SGR escapes included. */
   readonly text: string
   readonly text: string
-  /** Present on the run's last chunk, with pnpm's exit code (null for a signal). */
+  /** Present on the run's last chunk, with pnpm's exit code (null when it ended by a signal or never started). */
   readonly exitCode?: number | null
   readonly exitCode?: number | null
 }
 }
 
 

+ 22 - 3
packages/boot/plugin-manager/tests/plugin-manager.spec.ts

@@ -575,13 +575,32 @@ describe('PluginManager', () => {
       // The fake pnpm records the spec itself as the dependency name, which
       // The fake pnpm records the spec itself as the dependency name, which
       // resolves to nothing: a plain dependency whose probe cannot run.
       // resolves to nothing: a plain dependency whose probe cannot run.
       expect(result).toEqual({ installed: ['github:acme/ext-new'], removed: [], enabled: [], installedOnly: [], plain: ['github:acme/ext-new'], jobId: expect.any(String) as string })
       expect(result).toEqual({ installed: ['github:acme/ext-new'], removed: [], enabled: [], installedOnly: [], plain: ['github:acme/ext-new'], jobId: expect.any(String) as string })
-      expect(log.map(chunk => [chunk.stream, chunk.text, chunk.exitCode])).toEqual([
-        ['stdout', '+ github:acme/ext-new 1.0.0\n', undefined],
-        ['stdout', '', 0],
+      expect(log.map(chunk => [chunk.argv, chunk.stream, chunk.text, chunk.exitCode])).toEqual([
+        [['pnpm', 'add', 'github:acme/ext-new'], 'stdout', '+ github:acme/ext-new 1.0.0\n', undefined],
+        [['pnpm', 'add', 'github:acme/ext-new'], 'stdout', '', 0],
       ])
       ])
       expect(changes).toEqual([{ reason: 'install' }])
       expect(changes).toEqual([{ reason: 'install' }])
     })
     })
 
 
+    it('has pnpm colour its output and streams the escapes as they come', async () => {
+      const staged = await stageHome()
+      stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
+      const colours: (string | undefined)[] = []
+      const pnpm = fakePnpm(staged.profileDir, (args) => {
+        addDependency(staged.profileDir, args[1] as string)
+        return { code: 0, stdout: '\u001b[32m+\u001b[39m ext-new \u001b[90m1.0.0\u001b[39m\n' }
+      })
+      const { manager, log } = await bootProfile(staged, {
+        spawn: (command, args, options) => {
+          colours.push(options.env?.FORCE_COLOR)
+          return pnpm(command, args, options)
+        },
+      })
+      await manager.add('ext-new')
+      expect(colours).toEqual(['1'])
+      expect(log[0]?.text).toBe('\u001b[32m+\u001b[39m ext-new \u001b[90m1.0.0\u001b[39m\n')
+    })
+
     it('reconciles by the installed name, and enables the new bundle when asked', async () => {
     it('reconciles by the installed name, and enables the new bundle when asked', async () => {
       const staged = await stageHome()
       const staged = await stageHome()
       stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
       stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })