refactor(desktop): shell polish — register the dsh launcher, use vendored pnpm, docs

Register the dsh CLI launcher from the desktop shell, prefer the vendored
pnpm in the CLI plugin command, and update the desktop and CLI reference docs.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-15 17:02:23 +08:00
parent a53769955b
commit 604a817546
8 changed files with 405 additions and 7 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ dsh --profile web --patch ./extra.yml --dump-config
## Plugin management
`dsh plugin --profile <name> <args...>` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `<args...>` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack.
`dsh plugin --profile <name> <args...>` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `<args...>` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged. In a packaged install the pnpm vendored into the harness is used automatically (deterministic, needing no pnpm on the machine); in a development checkout, where no vendored pnpm exists, pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack.
```sh
dsh plugin --profile tui add github:deepseek-harness/turtle-ui
+1 -1
View File
@@ -40,7 +40,7 @@ dsh --profile web --patch ./extra.yml --dump-config
## 插件管理
`dsh plugin --profile <name> <args...>` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `<args...>` 转发给 `pnpm``add``remove``why``update` 及其他所有 pnpm 子命令都照常可用pnpm 必须在 PATH 上。相对路径 spec(`.``../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,系统都会根据当前安装状态更新 `dsh.profile.bundles`:如果某项依赖解析到的包在 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,该依赖就会加入配置层栈;如果某项依赖在 `update` 后获得该声明,也会随即激活。没有组合包声明的依赖仍作为普通依赖保留,并显示一次性警告;已移除的依赖则从配置层栈中删除。
`dsh plugin --profile <name> <args...>` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `<args...>` 转发给 `pnpm``add``remove``why``update` 及其他所有 pnpm 子命令都照常可用。在打包安装中会自动使用内置的 vendored pnpm(行为确定,无需目标机器安装 pnpm);在开发 checkout 中不存在 vendored pnpm,此时 pnpm 必须在 PATH 上。相对路径 spec(`.``../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,系统都会根据当前安装状态更新 `dsh.profile.bundles`:如果某项依赖解析到的包在 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,该依赖就会加入配置层栈;如果某项依赖在 `update` 后获得该声明,也会随即激活。没有组合包声明的依赖仍作为普通依赖保留,并显示一次性警告;已移除的依赖则从配置层栈中删除。
```sh
dsh plugin --profile tui add github:deepseek-harness/turtle-ui
+39
View File
@@ -46,6 +46,45 @@ box.
- **Self-contained harness** — the packaged app ships the full harness runtime
under `Contents/Resources/harness` (see below), so it runs on any target
machine with no external Node or repository install.
- **External `dsh` CLI** — the bundled harness ships a `dsh` launcher, so you
can install plugins of any source form from a terminal outside the app
(see below). It resolves to the bundled Node, CLI entry, and vendored pnpm,
so no Node or pnpm install is needed on the machine.
## Installing plugins from a terminal
The in-app Settings → Plugins install box covers npm names, tarballs, and
GitHub URLs. For anything the box can't express — a local directory, a tarball
you haven't placed somewhere — or for full control, run the bundled `dsh` CLI
directly. Any `pnpm add` specifier works, because `dsh plugin add` is a pnpm
forwarder that automatically uses the app's vendored pnpm.
The app **registers the `dsh` command on your PATH automatically on first
launch** (macOS: a symlink into `/usr/local/bin`; Windows: a `dsh.cmd` shim plus
a user-PATH entry), so in a new terminal you can just run:
```sh
dsh plugin --profile web add ./plugin # local dir
# … or dsh-better-sidebar, github:user/repo, ./plugin.tgz,
# https://github.com/user/repo/archive/refs/heads/main.tar.gz
```
Notes:
- The CLI and the in-app install share the same `~/.dsh/profiles/web`, so an
install from either surface is picked up by the other. But a CLI install while
the app is running does **not** hot-reload the running tree — restart the app
(or re-open the plugin page) to see it.
- Build-script consent is the terminal's own: pnpm prints the packages it will
run and, if you allow them, writes them under `allowBuilds` in the profile's
`pnpm-workspace.yaml` — the same per-package, user-consented posture the
in-app modal follows.
- Registration is non-destructive: an existing `dsh` that is not ours is left
untouched, and a PATH that cannot be written (e.g. an unwritable
`/usr/local/bin`) only logs a warning. Open a new shell to pick up a fresh
PATH.
- In a development checkout (no packaged harness launcher) registration is
skipped; use `pnpm dsh` or a PATH `dsh`/`pnpm` instead.
## Run from a checkout
+27 -4
View File
@@ -13,7 +13,7 @@
* Run from the repository root before `desktop:pack`.
*/
import { cpSync, chmodSync, existsSync, mkdirSync, rmSync } from 'node:fs'
import { cpSync, chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execFileSync } from 'node:child_process'
@@ -37,11 +37,14 @@ for (const [src, dst] of extraDirs) {
cpSync(join(root, src), join(out, dst), { recursive: true })
}
// Bundle the platform Node binary for the harness child.
// Bundle the platform Node binary for the harness child. Windows needs a
// `.exe` extension so both the child spawn and the `dsh.cmd` shim can execute
// it; POSIX uses a bare `node`.
const nodeBin = execFileSync('node', ['-e', 'process.stdout.write(process.execPath)']).toString()
if (!existsSync(nodeBin)) throw new Error(`node executable not found: ${nodeBin}`)
cpSync(nodeBin, join(out, 'bin/node'))
chmodSync(join(out, 'bin/node'), 0o755)
const nodeFile = process.platform === 'win32' ? 'node.exe' : 'node'
cpSync(nodeBin, join(out, 'bin', nodeFile))
chmodSync(join(out, 'bin', nodeFile), 0o755)
// Vendor pnpm so the packaged app can install third-party plugins without pnpm
// on the target machine. npm ships with Node, so use it on the build machine
@@ -54,4 +57,24 @@ try {
console.warn('could not vendor pnpm; registry plugin install will be unavailable in the packaged app')
}
// A `dsh` launcher so a user can run the bundled CLI from a terminal outside
// the app (`dsh plugin --profile web add <spec>`, …). It execs the bundled node
// against the CLI entry, so the vendored pnpm is picked up automatically and no
// Node/pnpm install is needed on the target. POSIX ships an executable `dsh`
// shell script; Windows ships a `dsh.cmd` shim (the desktop registers the
// harness directory on the user PATH on first launch).
if (process.platform === 'win32') {
writeFileSync(
join(out, 'dsh.cmd'),
'@echo off\r\n"%~dp0bin\\node.exe" "%~dp0apps\\cli\\lib\\bin.js" %*\r\n',
)
} else {
const dshLauncher = join(out, 'dsh')
writeFileSync(
dshLauncher,
'#!/bin/sh\nexec "$(dirname "$0")/bin/node" "$(dirname "$0")/apps/cli/lib/bin.js" "$@"\n',
)
chmodSync(dshLauncher, 0o755)
}
console.log(`assembled self-contained harness at ${out}`)
+29 -1
View File
@@ -20,6 +20,7 @@ import { fileURLToPath } from 'node:url'
import { app, dialog, BrowserWindow, ipcMain, Menu, shell, type MenuItemConstructorOptions } from 'electron'
import { LOOPBACK_HOST, parseReadyPort } from './ready-port.ts'
import { registerDshLauncher } from './register-dsh.ts'
import type { UpdateCheckResult } from './preload.ts'
const require = createRequire(import.meta.url)
@@ -201,7 +202,8 @@ async function checkForUpdate(): Promise<UpdateCheckResult> {
/** Spawn's node executable: a bundled one when the app is packaged, else PATH. */
function nodeExecutable(): string {
const harness = harnessRoot()
if (harness !== undefined) return join(harness, 'bin/node')
// Windows ships the bundled node as `node.exe`; POSIX as a bare `node`.
if (harness !== undefined) return join(harness, 'bin', process.platform === 'win32' ? 'node.exe' : 'node')
if (process.env.DSH_NODE) return resolve(process.env.DSH_NODE)
return 'node'
}
@@ -298,6 +300,32 @@ function startSession(): void {
app.whenReady().then(() => {
installAppMenu()
// Expose the bundled `dsh` CLI on the user's terminal (macOS: symlink into
// /usr/local/bin or a user-writable fallback on PATH; Windows: a `dsh.cmd` +
// user PATH registration). Best-effort and non-destructive: an existing `dsh`
// that is not ours is left untouched. A new registration or a failure is
// surfaced to the user so it is never silently absent.
if (app.isPackaged && (process.platform === 'darwin' || process.platform === 'win32')) {
const harness = harnessRoot()
if (harness !== undefined) {
const registration = registerDshLauncher(harness)
if (registration.status === 'error') {
void dialog.showMessageBox({
type: 'warning',
message: 'dsh 命令注册失败',
detail: registration.detail,
buttons: ['知道了'],
})
} else if (registration.status === 'registered' && registration.created) {
void dialog.showMessageBox({
type: 'info',
message: '已将 dsh 命令注册到 PATH',
detail: `命令已链接到 ${registration.path}。请打开一个新的终端窗口,即可直接使用 dsh 命令,例如:dsh plugin --profile web add <插件>`,
buttons: ['知道了'],
})
}
}
}
// The SPA's About "check for updates" button asks the main process.
ipcMain.handle(UPDATE_CHANNEL, () => checkForUpdate())
// The SPA's About reads this build's version from the main process (no network).
+194
View File
@@ -0,0 +1,194 @@
/**
* Register the bundled `dsh` CLI on the system PATH so the user can run
* `dsh plugin ...` from any terminal after the app is installed.
*
* The harness ships a `dsh` launcher (see scripts/build-harness.mjs) that execs
* the bundled Node against the CLI entry, so a `dsh` on PATH needs no external
* Node or pnpm. Registration is platform-specific and best-effort:
*
* - **macOS**: symlink the harness `dsh` launcher into `/usr/local/bin` (on PATH
* by default). `/usr/local/bin` is root-owned on modern macOS and commonly
* not user-writable, so an unwritable primary falls back to the user-writable
* `~/.local/bin` and idempotently puts that directory on PATH via the user's
* shell profile (`~/.zshrc`, `~/.bash_profile`, `~/.profile`).
* - **Windows**: write a `dsh.cmd` shim into the harness and register the harness
* directory in the user PATH (`HKCU\Environment`).
*
* An existing `dsh` that is not ours is always left untouched. The pure helpers
* (`buildDshCmd`, `appendToUserPath`, `linkDsh`) are testable on any host; the
* registry write and shell-profile edit run only on their platform.
* @module @deepseek-ai/dsh-desktop/register-dsh
*/
import { spawnSync } from 'node:child_process'
import {
appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync,
} from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
/** Where the `dsh` command should live on macOS (on PATH by default). */
export const MACOS_BIN_DIR = '/usr/local/bin'
/** The user-writable fallback directory on macOS. */
export const MACOS_USER_BIN_DIR = join(homedir(), '.local', 'bin')
/** Outcome of a launcher registration attempt. */
export type DshRegistration =
| { readonly status: 'registered'; readonly path: string; readonly created: boolean }
| { readonly status: 'already-present'; readonly path: string }
| { readonly status: 'skipped' }
| { readonly status: 'error'; readonly detail: string }
/**
* Register the `dsh` launcher on PATH for the current platform. No-op on
* unsupported platforms.
* @param harnessDir - the packaged harness root (`process.resourcesPath/harness`).
* @param binDir - the primary macOS bin dir; overridable for tests.
* @param fallbackDir - the user-writable macOS fallback; overridable for tests.
* @param ensurePath - put the fallback dir on PATH via the shell profile (macOS only).
* @returns the registration outcome.
*/
export function registerDshLauncher(
harnessDir: string,
binDir: string = MACOS_BIN_DIR,
fallbackDir: string = MACOS_USER_BIN_DIR,
ensurePath = true,
): DshRegistration {
if (process.platform === 'win32') return registerWindowsDsh(harnessDir)
if (process.platform === 'darwin') return registerDarwinDsh(harnessDir, binDir, fallbackDir, ensurePath)
return { status: 'skipped' }
}
/** macOS: symlink into the primary dir, falling back to a user-writable dir. */
function registerDarwinDsh(harnessDir: string, binDir: string, fallbackDir: string, ensurePath: boolean): DshRegistration {
const launcher = join(harnessDir, 'dsh')
const preferred = linkDsh(launcher, binDir)
if (preferred.status !== 'error') return preferred
// The primary is unwritable (root-owned /usr/local/bin on modern macOS):
// register into a user-writable dir and put that dir on PATH via the shell
// profile, so `dsh` resolves in a new terminal without admin rights.
const fallback = linkDsh(launcher, fallbackDir)
if (fallback.status === 'error') {
return { status: 'error', detail: `${preferred.detail} and ${fallback.detail}` }
}
if (ensurePath) ensureUserDirOnPath(fallbackDir)
return fallback
}
/**
* Symlink `launcher` into `binDir`. Idempotent: a link already pointing at our
* launcher is confirmed (`created: false`); an existing entry that is not ours
* is left untouched; a created link reports `created: true`.
*/
export function linkDsh(launcher: string, binDir: string): DshRegistration {
const link = join(binDir, 'dsh')
let stat
try {
stat = lstatSync(link)
} catch {
stat = undefined
}
if (stat !== undefined) {
if (stat.isSymbolicLink()) {
return readlinkSync(link) === launcher
? { status: 'registered', path: link, created: false }
: { status: 'already-present', path: link }
}
return { status: 'already-present', path: link }
}
if (!existsSync(launcher)) return { status: 'skipped' }
try {
mkdirSync(binDir, { recursive: true })
symlinkSync(launcher, link)
return { status: 'registered', path: link, created: true }
} catch (error) {
return { status: 'error', detail: error instanceof Error ? error.message : String(error) }
}
}
/** Ensure `dir` is exported on PATH in the user's shell profiles (idempotent). */
function ensureUserDirOnPath(dir: string): void {
const line = `export PATH="${dir}:$PATH" # dsh (DeepSeek Harness)`
for (const rc of ['.zshrc', '.zprofile', '.bash_profile', '.profile']) {
const path = join(homedir(), rc)
let content: string
try {
content = readFileSync(path, 'utf8')
} catch {
continue
}
if (content.includes('# dsh (DeepSeek Harness)')) continue
try {
appendFileSync(path, `\n${line}\n`)
} catch {
// A read-only or otherwise unopenable profile is not fatal.
}
}
}
/**
* Windows: write a `dsh.cmd` shim into the harness and add the harness
* directory to the user PATH so `dsh` resolves in a new shell.
*/
function registerWindowsDsh(harnessDir: string): DshRegistration {
const nodeExe = join(harnessDir, 'bin', 'node.exe')
if (!existsSync(nodeExe)) {
// The harness was built without a Windows Node (`bin/node.exe`); nothing
// to shim against.
return { status: 'skipped' }
}
try {
writeFileSync(join(harnessDir, 'dsh.cmd'), buildDshCmd(), 'utf8')
} catch (error) {
return { status: 'error', detail: error instanceof Error ? error.message : String(error) }
}
const current = readUserPath()
if (current === null) return { status: 'error', detail: 'could not read the user PATH' }
const merged = appendToUserPath(current, harnessDir)
if (merged === current) return { status: 'registered', path: join(harnessDir, 'dsh.cmd'), created: false }
return writeUserPath(merged) ? { status: 'registered', path: join(harnessDir, 'dsh.cmd'), created: true } : { status: 'error', detail: 'could not write the user PATH' }
}
/**
* The Windows `dsh.cmd` shim: run the bundled Node against the CLI entry with
* the caller's arguments. `%~dp0` resolves to the harness directory.
*/
export function buildDshCmd(): string {
return '@echo off\r\n"%~dp0bin\\node.exe" "%~dp0apps\\cli\\lib\\bin.js" %*\r\n'
}
/**
* Merge a directory into a `Path` value, appending it when absent
* (case-insensitive). Returns the input unchanged when already present.
* @param path - the existing PATH value.
* @param dir - the directory to ensure is on PATH.
* @returns the merged PATH value.
*/
export function appendToUserPath(path: string, dir: string): string {
const lower = dir.toLocaleLowerCase()
if (path.split(';').some(part => part.toLocaleLowerCase() === lower)) return path
return path.endsWith(';') ? `${path}${dir}` : `${path};${dir}`
}
/** Read the current user PATH value (`HKCU\Environment` → `Path`), or null. */
function readUserPath(): string | null {
const result = spawnSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'], { encoding: 'utf8' })
if (result.status !== 0) return null
const lines = (result.stdout ?? '').split(/\r?\n/)
for (let index = lines.length - 1; index >= 0; index -= 1) {
const match = /^\s*Path\s+REG_(?:EXPAND_)?SZ\s+(.*)$/.exec(lines[index] ?? '')
if (match !== null) return match[1] ?? null
}
return null
}
/** Overwrite the user PATH value (`HKCU\Environment` → `Path`), preserving expansion. */
function writeUserPath(value: string): boolean {
const result = spawnSync(
'reg',
['add', 'HKCU\\Environment', '/v', 'Path', '/t', 'REG_EXPAND_SZ', '/d', value, '/f'],
{ encoding: 'utf8' },
)
return result.status === 0
}
+113
View File
@@ -0,0 +1,113 @@
import { lstatSync, mkdirSync, mkdtempSync, readlinkSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { appendToUserPath, buildDshCmd, registerDshLauncher } from '../src/register-dsh.ts'
const dirs: string[] = []
/** A fake harness dir (with or without a `dsh` launcher) and a bin dir to register into. */
function fixtures(): { harnessDir: string; binDir: string } {
const root = mkdtempSync(join(tmpdir(), 'dsh-register-'))
dirs.push(root)
return { harnessDir: join(root, 'harness'), binDir: join(root, 'bin') }
}
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
describe('registerDshLauncher', () => {
it('symlinks the harness launcher into the bin dir when absent', () => {
const { harnessDir, binDir } = fixtures()
mkdirSync(join(harnessDir, 'bin'), { recursive: true })
writeFileSync(join(harnessDir, 'dsh'), '#!/bin/sh\n')
expect(registerDshLauncher(harnessDir, binDir)).toEqual({
status: 'registered', path: join(binDir, 'dsh'), created: true,
})
expect(readlinkSync(join(binDir, 'dsh'))).toBe(join(harnessDir, 'dsh'))
})
it('is idempotent: confirms an existing link that already points at the launcher', () => {
const { harnessDir, binDir } = fixtures()
mkdirSync(harnessDir, { recursive: true })
mkdirSync(binDir, { recursive: true })
symlinkSync(join(harnessDir, 'dsh'), join(binDir, 'dsh'))
expect(registerDshLauncher(harnessDir, binDir)).toEqual({
status: 'registered', path: join(binDir, 'dsh'), created: false,
})
})
it('falls back to a user-writable dir when the primary is unwritable', () => {
const { harnessDir, binDir } = fixtures()
mkdirSync(harnessDir, { recursive: true })
writeFileSync(join(harnessDir, 'dsh'), '#!/bin/sh\n')
// A file occupies the primary's parent path, so creating the link there
// fails (ENOTDIR) and the fallback dir is used instead — mirroring a
// root-owned, non-writable /usr/local/bin.
mkdirSync(binDir, { recursive: true })
const fileBlocker = join(binDir, 'blocker')
writeFileSync(fileBlocker, '')
const result = registerDshLauncher(harnessDir, join(fileBlocker, 'nested'), join(binDir, 'fallback'), false)
if (result.status !== 'registered') {
throw new Error(`expected a registered link, got ${JSON.stringify(result)}`)
}
expect(result.created).toBe(true)
expect(readlinkSync(join(binDir, 'fallback', 'dsh'))).toBe(join(harnessDir, 'dsh'))
})
it('leaves an existing non-dsh entry untouched', () => {
const { harnessDir, binDir } = fixtures()
mkdirSync(harnessDir, { recursive: true })
mkdirSync(binDir, { recursive: true })
writeFileSync(join(binDir, 'dsh'), 'a real file, not ours\n')
expect(registerDshLauncher(harnessDir, binDir)).toEqual({
status: 'already-present', path: join(binDir, 'dsh'),
})
// The file is not replaced by a symlink and its content is untouched.
expect(lstatSync(join(binDir, 'dsh')).isSymbolicLink()).toBe(false)
expect(readFileSync(join(binDir, 'dsh'), 'utf8')).toBe('a real file, not ours\n')
})
it('leaves a symlink pointing elsewhere untouched', () => {
const { harnessDir, binDir } = fixtures()
mkdirSync(harnessDir, { recursive: true })
mkdirSync(binDir, { recursive: true })
symlinkSync('/elsewhere/dsh', join(binDir, 'dsh'))
expect(registerDshLauncher(harnessDir, binDir)).toEqual({
status: 'already-present', path: join(binDir, 'dsh'),
})
expect(readlinkSync(join(binDir, 'dsh'))).toBe('/elsewhere/dsh')
})
it('skips when the harness has no launcher (a development checkout)', () => {
const { harnessDir, binDir } = fixtures()
mkdirSync(harnessDir, { recursive: true })
expect(registerDshLauncher(harnessDir, binDir)).toEqual({ status: 'skipped' })
})
})
describe('buildDshCmd', () => {
it('runs the bundled node against the CLI entry with the caller arguments', () => {
expect(buildDshCmd()).toBe('@echo off\r\n"%~dp0bin\\node.exe" "%~dp0apps\\cli\\lib\\bin.js" %*\r\n')
})
})
describe('appendToUserPath', () => {
it('appends a missing directory', () => {
expect(appendToUserPath('C:\\a;C:\\b', 'C:\\harness'))
.toBe('C:\\a;C:\\b;C:\\harness')
})
it('is case-insensitive and idempotent for a present directory', () => {
expect(appendToUserPath('C:\\a;C:\\Harness', 'c:\\harness')).toBe('C:\\a;C:\\Harness')
})
it('appends to a trailing semicolon without doubling it', () => {
expect(appendToUserPath('C:\\a;', 'C:\\harness')).toBe('C:\\a;C:\\harness')
})
it('appends to an empty PATH', () => {
expect(appendToUserPath('', 'C:\\harness')).toBe(';C:\\harness')
})
})
+1
View File
@@ -41,6 +41,7 @@ export {
readProfileManifest,
reconcileProfileBundles,
resolveBundleDir,
resolvePnpm,
resolveProfileDir,
writeProfileManifest,
type DshBundleManifest,