From 604a817546d2d6425d6310543db7085687d6c45a Mon Sep 17 00:00:00 2001 From: Pine Date: Sat, 15 Aug 2026 17:02:23 +0800 Subject: [PATCH] =?UTF-8?q?refactor(desktop):=20shell=20polish=20=E2=80=94?= =?UTF-8?q?=20register=20the=20dsh=20launcher,=20use=20vendored=20pnpm,=20?= =?UTF-8?q?docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/desktop/README.md | 39 +++++ apps/desktop/scripts/build-harness.mjs | 31 +++- apps/desktop/src/main.ts | 30 +++- apps/desktop/src/register-dsh.ts | 194 ++++++++++++++++++++++++ apps/desktop/tests/register-dsh.spec.ts | 113 ++++++++++++++ packages/boot/app-boot/src/index.ts | 1 + 8 files changed, 405 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/register-dsh.ts create mode 100644 apps/desktop/tests/register-dsh.spec.ts diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 0be64fdfc0..c8069735c6 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -40,7 +40,7 @@ dsh --profile web --patch ./extra.yml --dump-config ## Plugin management -`dsh plugin --profile ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` 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 ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` 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 diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 649bf15df8..0a2766f09f 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -40,7 +40,7 @@ dsh --profile web --patch ./extra.yml --dump-config ## 插件管理 -`dsh plugin --profile ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 转发给 `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 ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 转发给 `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 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index bb442806d1..7e375bc2c3 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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 diff --git a/apps/desktop/scripts/build-harness.mjs b/apps/desktop/scripts/build-harness.mjs index 2e954f66d5..3bd4e34921 100644 --- a/apps/desktop/scripts/build-harness.mjs +++ b/apps/desktop/scripts/build-harness.mjs @@ -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 `, …). 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}`) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 173132ae1b..b38d2a3eb0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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 { /** 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). diff --git a/apps/desktop/src/register-dsh.ts b/apps/desktop/src/register-dsh.ts new file mode 100644 index 0000000000..c9dfc9f09a --- /dev/null +++ b/apps/desktop/src/register-dsh.ts @@ -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 +} diff --git a/apps/desktop/tests/register-dsh.spec.ts b/apps/desktop/tests/register-dsh.spec.ts new file mode 100644 index 0000000000..5cb0b78a0f --- /dev/null +++ b/apps/desktop/tests/register-dsh.spec.ts @@ -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') + }) +}) diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 0c0986de5d..26939b5428 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -41,6 +41,7 @@ export { readProfileManifest, reconcileProfileBundles, resolveBundleDir, + resolvePnpm, resolveProfileDir, writeProfileManifest, type DshBundleManifest,