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.
This commit is contained in:
1 parent
43ba51d585
commit
080e22ee03
12 files changed
+85
-22
No files matched your search
+2
-2
@@ -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-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
@@ -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.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
+1
-1
@@ -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 的更新不碰未改变的行,只有离开再回来才能重启一条失败的隔离行。
|
||||
|
||||
**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 学来的。
|
||||
|
||||
|
||||
@@ -134,6 +134,8 @@ async function runManaged(
|
||||
loadProfile: () => loadProfile(NAME, profile, INSTALL_ANCHOR, undefined, { userLayer: false }),
|
||||
config: TOOLING,
|
||||
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,
|
||||
})
|
||||
for (const argument of specs) {
|
||||
|
||||
@@ -19,6 +19,8 @@ let home: string
|
||||
let previousHome: string | undefined
|
||||
let stderr: string
|
||||
let stdout: string
|
||||
/** The FORCE_COLOR each fake pnpm run was spawned with. */
|
||||
let spawnEnvs: (string | undefined)[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'dsh-plugin-command-'))
|
||||
@@ -26,6 +28,7 @@ beforeEach(() => {
|
||||
process.env.DSH_HOME = home
|
||||
stderr = ''
|
||||
stdout = ''
|
||||
spawnEnvs = []
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { stderr += 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 {
|
||||
return (_command, args, options) => {
|
||||
calls.push([...args])
|
||||
spawnEnvs.push(options.env?.FORCE_COLOR)
|
||||
const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough }
|
||||
child.stdout = 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
|
||||
if (verb === 'add' && target !== undefined) install(profileDir, target, target === 'ext-bundle')
|
||||
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)
|
||||
}, 5)
|
||||
return child as unknown as ChildProcess
|
||||
@@ -104,6 +109,20 @@ const fakeProbe: typeof probePackage = (options) => {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const calls: string[][] = []
|
||||
|
||||
@@ -117,7 +136,10 @@ describe('dsh plugin', () => {
|
||||
expect(Object.keys(manifest.dependencies ?? {})).toContain('ext-bundle')
|
||||
expect(Object.keys(manifest.dependencies ?? {})).not.toContain('ext-lib')
|
||||
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).not.toContain('\u001b[')
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -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/plugin-manager/README.md
|
||||
README.md: c488373c22b696d3dfe605335cc11d8b942cc75a
|
||||
README.zh.md: 4cdfa4c90b6f5fc91c1a29255b09bdda566f588d
|
||||
README.md: 0666043f69619fab042228d5f298d63eeeada87b
|
||||
README.zh.md: 2c659f51e41b366a793f36ab8865bea2a68e0b65
|
||||
@@ -27,7 +27,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
### 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
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
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` 说明停用或移除一个包会搁浅什么:其行提供而包外的行注入的服务,以及点名其模块的用户层行。
|
||||
|
||||
管理器一次只跑一个变更——上一个还在跑时再调用会以 `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 转义。
|
||||
|
||||
### 失败
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ import type { PluginInstallLogChunk, PluginInstallRejection, PluginInstallResult
|
||||
/** What one install run changed before any newly installed bundle was 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. */
|
||||
export interface PluginInstallerOptions {
|
||||
/** The profile directory pnpm runs in and whose manifest records installs. */
|
||||
@@ -45,6 +48,13 @@ export interface PluginInstallerOptions {
|
||||
readonly config: PluginToolingConfig
|
||||
/** Receives every chunk of a pnpm run's output, in order; the last chunk carries the exit code. */
|
||||
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`. */
|
||||
readonly spawn?: SpawnLike
|
||||
/** 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.
|
||||
*/
|
||||
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 argv = [config.pnpmCommand, ...args]
|
||||
const tail: string[] = []
|
||||
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)
|
||||
tailBytes += Buffer.byteLength(text)
|
||||
while (tailBytes > config.installLogTailBytes && tail.length > 1) {
|
||||
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
|
||||
// without a shell since the CVE-2024-27980 hardening. The parent
|
||||
// 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, {
|
||||
cwd: profileDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32',
|
||||
env: process.env,
|
||||
env: { ...process.env, FORCE_COLOR: color ? '1' : '0' },
|
||||
})
|
||||
child.stdout?.setEncoding('utf8')
|
||||
child.stderr?.setEncoding('utf8')
|
||||
@@ -272,10 +287,10 @@ export class PluginInstaller {
|
||||
).catch((error: unknown) => {
|
||||
const message = messageOf(error)
|
||||
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 })
|
||||
})
|
||||
this.options.installLog({ jobId, spec, stream: 'stdout', text: '', exitCode })
|
||||
this.options.installLog({ jobId, argv, spec, stream: 'stdout', text: '', exitCode })
|
||||
if (exitCode !== 0) {
|
||||
throw new PluginOperationError(
|
||||
'plugins/install-failed',
|
||||
|
||||
@@ -154,6 +154,8 @@ export class PluginManager {
|
||||
loadProfile: () => runtime.current,
|
||||
config: this.options.config,
|
||||
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.probe === undefined ? {} : { probe: this.options.probe },
|
||||
})
|
||||
|
||||
@@ -188,11 +188,14 @@ export type PluginChangeReason = 'install' | 'uninstall' | 'enable' | 'disable'
|
||||
export interface PluginInstallLogChunk {
|
||||
/** The run the chunk belongs to. */
|
||||
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. */
|
||||
readonly spec: string
|
||||
readonly stream: 'stdout' | 'stderr'
|
||||
/** The output as pnpm wrote it; with the installer's colours on, its SGR escapes included. */
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -575,13 +575,32 @@ describe('PluginManager', () => {
|
||||
// The fake pnpm records the spec itself as the dependency name, which
|
||||
// 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(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' }])
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const staged = await stageHome()
|
||||
stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
|
||||
|
||||
Reference in new issue
Block a user