feat: enhance desktop packaging process with self-contained harness verification

- Updated README and README.zh to clarify packaging commands and added verification step.
- Improved packaging scripts to automatically build the self-contained runtime and verify its integrity.
- Introduced new scripts for verifying harness self-containment and booting the web profile.
- Added comprehensive tests for relinking harness symlinks and ensuring self-containment.
- Updated package.json scripts to reflect new build and verification processes.
This commit is contained in:
2026-08-16 00:31:19 +08:00
parent 82814ad7d3
commit 8e79ec4942
12 changed files with 892 additions and 18 deletions
+54 -10
View File
@@ -101,11 +101,17 @@ override the spawned Node or `dsh` entry with `DSH_NODE` / `DSH_ENTRY`.
## Packaging
```sh
pnpm --filter @deepseek-ai/dsh-desktop run build:harness # (re)assemble the bundled runtime
pnpm desktop:pack # electron-builder: mac .dmg / win .nsis
pnpm desktop:pack # build lib+web, assemble the self-contained harness, then electron-builder: mac .dmg / win .nsis
pnpm desktop:verify # prove the packaged app's harness is self-contained and boots
```
Artifacts land in `apps/desktop/dist`.
`desktop:pack` runs `build:harness` (assembling `build/harness`) automatically,
so a stale or missing runtime can never be shipped. Artifacts land in
`apps/desktop/dist`. `desktop:verify` rebuilds an unpacked app
(`electron-builder --dir`) and checks that every symlink inside the packaged
`resources/harness` resolves inside the bundle, then boots the web profile from
it — the check that the installer is genuinely detached from the development
machine.
### The self-contained harness (`build/harness`)
@@ -145,7 +151,9 @@ Each target platform needs its own harness: the bundled `bin/node` and the
native addons are OS/arch-specific. Regenerate `build/harness` on each target
platform (or per-target in CI) before packaging that platform. The current
configuration targets **macOS arm64** (`mac.target: dmg`); `win.target: nsis`
is declared but needs a Windows-built harness.
is declared but needs a Windows-built harness. On Windows the harness build
additionally needs Developer Mode enabled or an admin shell so workspace links
can be rewritten to relative in-tree symlinks (see *Link correctness*).
### Signing
@@ -161,6 +169,38 @@ The bundled harness is multi-GB uncompressed (≈2 GB), dominated by
~1 GB. This is the inherent footprint of shipping the full harness runtime
standalone, and is the accepted trade-off for a zero-external-dependency app.
### Link correctness
The harness is held together by symlinks inside `node_modules` (pnpm workspace
links). On **Windows**, pnpm often records those as **junctions — absolute-path
reparse points pointing at the build machine's dev tree**. A plain directory
copy keeps them verbatim, so a packaged app built that way reads the
*development* tree when run on the build machine and dangles on any other
machine.
`scripts/build-harness.mjs` therefore runs a relink pass
(`scripts/harness-relink.mjs`) after copying, re-running it until no link changes
(rewriting one link can expose another, so a single pass may not converge):
every symlink/junction is rewritten to a **relative target inside
`build/harness`** (mirroring the dev layout), any genuinely external target is
dereferenced (copied in as real files), and a link to a workspace package the
harness does not ship (e.g. the desktop shell itself, or a dependency-resolution
member like `python/sdk-runtime`) is **dropped** — it cannot be mirrored and is
not part of the runtime closure. The pass is followed by a self-containment
check that **fails the build if any link still escapes the bundle**, and by a
`dsh --version` smoke test.
To build you must be able to create directory symlinks: **Windows — enable
Developer Mode or use an admin shell** (macOS/Linux need nothing). Without that
capability `build:harness` refuses, because the only fallback (dereferencing the
whole pnpm store) would balloon the ≈2 GB runtime to many times its size. A
locked-down machine can opt into that anyway with `--dereference-ok`.
`pnpm desktop:verify` is the end-to-end check: it packs an unpacked app
(`electron-builder --dir`) and re-runs the same link check against the packaged
`resources/harness`, proving electron-builder's own `extraResources` copy kept
the relative links intact, then boots the web profile straight from the bundle.
## Release process
The app is **not code-signed**, so updates are a manual-download flow rather
@@ -192,14 +232,18 @@ bump it too if you keep them in sync, but only the desktop one is user-visible.
### 2. Build and package
```sh
pnpm --filter @deepseek-ai/dsh-desktop run build:harness # assemble the self-contained runtime
pnpm desktop:pack # electron-builder: .dmg (mac) / .nsis .exe (win)
pnpm desktop:pack # build lib+web, assemble the self-contained harness, then electron-builder: .dmg (mac) / .nsis .exe (win)
pnpm desktop:verify # check the packaged harness is self-contained and boots
```
Artifacts land in `apps/desktop/dist/`. Each target OS/arch needs its own
harness (`build/harness` bundles a platform Node + native addons), so regenerate
it on the target platform before packing that platform. Building the Windows
`.exe` on macOS needs wine (or build on a Windows host).
Artifacts land in `apps/desktop/dist/`. `desktop:pack` assembles `build/harness`
first (`build:harness`), so a stale or missing runtime cannot be shipped. Each
target OS/arch needs its own harness (`build/harness` bundles a platform Node +
native addons), so regenerate it on the target platform before packing that
platform. Building the Windows `.exe` on macOS needs wine (or build on a Windows
host). Run `desktop:verify` on each platform you package: it rebuilds an
unpacked app and proves the packaged `resources/harness` has no link escaping
the bundle and the web profile boots from it.
### 3. Generate the update manifest
+3
View File
@@ -16,6 +16,9 @@
"build": "tsc -b tsconfig.json && pnpm run build:preload",
"build:preload": "esbuild src/preload.ts --bundle --platform=node --format=cjs --external:electron --outfile=lib/types/preload.cjs",
"build:harness": "node scripts/build-harness.mjs",
"verify:harness": "node scripts/verify-harness.mjs",
"pack:dir": "electron-builder --dir",
"verify:packed": "node scripts/verify-harness.mjs --app dist",
"generate-release-json": "node scripts/generate-release-json.mjs",
"stage-release": "node scripts/stage-release.mjs",
"typecheck": "tsc -b tsconfig.json",
+66 -3
View File
@@ -10,13 +10,19 @@
* native, apps/cli, apps/web — plus the platform Node binary, into
* `build/harness`, which electron-builder ships as `extraResources`.
*
* Run from the repository root before `desktop:pack`.
* Run from the repository root (`pnpm --filter @deepseek-ai/dsh-desktop run
* build:harness`; the root `desktop:pack` script runs it automatically). The
* copy is followed by a relink pass (harness-relink.mjs) that rewrites every
* Windows junction / absolute link into a RELATIVE in-tree symlink, so the
* packaged harness no longer references the build machine's dev tree, and by a
* self-containment check plus a `dsh --version` smoke test.
*/
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'
import { assertSelfContained, probeSymlinkSupport, relinkHarness } from './harness-relink.mjs'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const out = join(root, 'apps/desktop/build/harness')
@@ -25,13 +31,41 @@ const out = join(root, 'apps/desktop/build/harness')
const harnessDirs = ['node_modules', 'vendor', 'packages', 'native']
const extraDirs = [['apps/cli', 'apps/cli'], ['apps/web', 'apps/web']]
// A compact, portable harness depends on rewriting Windows junctions / absolute
// links into RELATIVE in-tree symlinks (see harness-relink.mjs). If this host
// cannot create directory symlinks, the only fallback is dereferencing the whole
// pnpm store — a multi-GB bloat — so refuse unless --dereference-ok opts in.
const dereferenceOk = process.argv.includes('--dereference-ok')
if (!probeSymlinkSupport() && !dereferenceOk) {
throw new Error(
'cannot assemble a self-contained harness: this host cannot create directory symlinks.\n' +
'Enable Windows Developer Mode or run as an administrator (macOS and Linux need no setup),\n' +
'or pass --dereference-ok to accept a much larger dereferenced harness.',
)
}
// A missing critical dir silently shipped a broken (link-less) harness before.
// Refuse loudly instead: each one must exist, and lib/web must already be built.
for (const d of [...harnessDirs, ...extraDirs.map(([src]) => src)]) {
if (!existsSync(join(root, d))) {
throw new Error(`cannot build the harness: ${d}/ is missing — run \`pnpm install\` first`)
}
}
for (const [label, path] of [
['the dsh CLI (apps/cli/lib/bin.js)', join(root, 'apps/cli/lib/bin.js')],
['the web frontend dist (apps/web/dist)', join(root, 'apps/web/dist')],
]) {
if (!existsSync(path)) {
throw new Error(`cannot build the harness: ${label} is missing — run \`pnpm desktop:build\` first`)
}
}
rmSync(out, { recursive: true, force: true })
mkdirSync(join(out, 'apps'), { recursive: true })
mkdirSync(join(out, 'bin'), { recursive: true })
for (const d of harnessDirs) {
const src = join(root, d)
if (existsSync(src)) cpSync(src, join(out, d), { recursive: true })
cpSync(join(root, d), join(out, d), { recursive: true })
}
for (const [src, dst] of extraDirs) {
cpSync(join(root, src), join(out, dst), { recursive: true })
@@ -78,3 +112,32 @@ if (process.platform === 'win32') {
}
console.log(`assembled self-contained harness at ${out}`)
// Normalize every link to a relative in-tree target, then prove the tree no
// longer references the build machine. This is the guarantee that the packaged
// app runs detached from the development environment. Re-run until a pass makes
// no further changes: rewriting one link can expose another (e.g. a package the
// copy dereferenced into a real dir whose internal links only become reachable
// on the next walk), so a single pass may not converge.
let report
for (let pass = 1; pass <= 5; pass += 1) {
report = relinkHarness(out, root)
console.log(
`relink pass ${pass}: ${report.normalized} rewritten, ${report.unchanged} in-tree, ` +
`${report.dereferenced} dereferenced, ${report.removed} dropped`,
)
if (report.errors.length > 0) {
throw new Error(`harness relink failed:\n${report.errors.join('\n')}`)
}
if (report.normalized === 0 && report.removed === 0) break
}
assertSelfContained(out)
console.log('harness links: self-contained (no link escapes the bundle)')
// Boot smoke: the bundled CLI must run straight from the bundle, proving the
// relinked node_modules resolves without the repository present.
const version = execFileSync(join(out, 'bin', nodeFile), [join(out, 'apps/cli/lib/bin.js'), '--version'], {
cwd: out,
encoding: 'utf8',
})
console.log(`bundled dsh: ${version.trim()}`)
+14
View File
@@ -0,0 +1,14 @@
/** Type declarations for the pure relink/verify helpers in harness-relink.mjs. */
export interface RelinkReport {
normalized: number
dereferenced: number
unchanged: number
removed: number
errors: string[]
}
export function isWithin(root: string, p: string): boolean
export function relinkHarness(out: string, repoRoot: string): RelinkReport
export function findEscapingLinks(out: string): string[]
export function assertSelfContained(out: string): void
export function probeSymlinkSupport(): boolean
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env node
/**
* Normalize every symlink/junction in a copied harness tree so the tree is
* self-contained: no link may resolve to a path outside the tree.
*
* A pnpm workspace is held together by links inside node_modules. On Windows
* those are often **junctions** (absolute-path reparse points) because real
* symlinks need Developer Mode or an admin shell; even plain symlinks can be
* absolute. `build-harness.mjs` copies the working tree with
* `cpSync(..., { recursive: true })`, which — with the default
* `dereference: false` — recreates every link verbatim, so the packaged harness
* keeps pointing at the BUILD machine's dev tree: it reads dev resources on the
* same machine and dangles on any other. This module rewrites each such link to
* a RELATIVE target inside the harness (mirroring the dev layout), and copies
* the real content of any genuinely external target in so nothing escapes.
*
* Pure Node, no Electron and no workspace TS program: imported by
* `scripts/build-harness.mjs` / `scripts/verify-harness.mjs` and unit-tested
* from `tests/harness-relink.spec.ts`.
* @module dsh-desktop/harness-relink
*/
import {
cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readlinkSync, rmSync, symlinkSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
/** True when `p` resolves inside `root` (or is `root` itself). */
export function isWithin(root, p) {
const rel = relative(resolve(root), resolve(p))
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
}
/** The link's own kind so Windows creates the right reparse point ('dir'/'file'). */
function linkKind(target) {
try {
return lstatSync(target).isDirectory() ? 'dir' : 'file'
} catch {
return 'dir'
}
}
/** Remove a link and copy the target's real content into its place. */
function dereferenceInto(link, target) {
rmSync(link, { force: true })
cpSync(target, link, { recursive: true, dereference: true })
}
/** Rewrite `link` as a relative symlink to `mirrored`; dereference on failure. */
function relinkTo(link, mirrored, report) {
const rel = relative(dirname(link), mirrored)
try {
rmSync(link, { force: true })
symlinkSync(rel, link, linkKind(mirrored))
report.normalized += 1
} catch (error) {
// Symlink creation failed (e.g. no Developer Mode on Windows): a compact
// portable link is impossible, so copy the target content in instead.
try {
dereferenceInto(link, mirrored)
report.dereferenced += 1
} catch (copyError) {
report.errors.push(`${link} -> ${mirrored} (${String(copyError)})`)
}
}
}
/**
* Walk `out` and normalize every symlink/junction to a relative in-tree link.
* Idempotent: a link already pointing inside the tree is left alone.
* @param out - the harness root.
* @param repoRoot - the repository root whose layout `out` mirrors.
* @returns per-kind counts plus any unrecoverable errors.
*/
export function relinkHarness(out, repoRoot) {
const report = { normalized: 0, dereferenced: 0, unchanged: 0, removed: 0, errors: [] }
forEachLink(out, (link) => {
const target = readlinkSync(link)
const abs = resolve(dirname(link), target)
if (isWithin(out, abs)) {
if (!existsSync(abs)) {
// A link inside the tree whose target is absent points at a workspace
// package the harness does not ship (python/sdk-runtime, examples, …).
// It cannot resolve and is not part of the runtime: drop it.
rmSync(link, { force: true })
report.removed += 1
} else if (relative(dirname(link), abs) === target) {
// Already inside the tree: make sure the link is a relative in-tree path.
report.unchanged += 1
} else {
relinkTo(link, abs, report)
}
} else if (isWithin(repoRoot, abs)) {
// Points at the dev tree: mirror the target into the harness and relink.
const mirrored = join(out, relative(repoRoot, abs))
if (existsSync(mirrored)) {
relinkTo(link, mirrored, report)
} else if (isWithin(abs, out)) {
// The target contains the harness itself (e.g. the desktop shell
// package, which the harness deliberately does not ship). Copying it in
// would recurse into the bundle and it is not part of the runtime
// closure, so the link cannot be made self-contained — drop it.
rmSync(link, { force: true })
report.removed += 1
} else if (existsSync(abs)) {
try {
dereferenceInto(link, abs)
report.dereferenced += 1
} catch (error) {
report.errors.push(`${link} -> ${abs} (${String(error)})`)
}
} else {
report.errors.push(`${link} -> ${abs} (dangling)`)
}
} else if (existsSync(abs)) {
// Genuinely external target (e.g. a global store): copy content in.
try {
dereferenceInto(link, abs)
report.dereferenced += 1
} catch (error) {
report.errors.push(`${link} -> ${abs} (${String(error)})`)
}
} else {
report.errors.push(`${link} -> ${abs} (dangling)`)
}
})
return report
}
/**
* Every symlink/junction whose target escapes `out` or no longer exists. A
* non-empty result means the harness is NOT detached from its build machine.
*/
export function findEscapingLinks(out) {
const escaping = []
forEachLink(out, (link) => {
const abs = resolve(dirname(link), readlinkSync(link))
if (!isWithin(out, abs) || !existsSync(abs)) escaping.push(link)
})
return escaping
}
/** Throw unless the tree is self-contained (no escaping or dangling links). */
export function assertSelfContained(out) {
const escaping = findEscapingLinks(out)
if (escaping.length === 0) return
const listed = escaping.slice(0, 20).join('\n ')
throw new Error(
`harness is NOT self-contained: ${escaping.length} link(s) resolve outside it or are dangling:\n ${listed}`,
)
}
/**
* Whether the current host can create (relative) directory symlinks. Windows
* without Developer Mode (and without an admin shell) cannot; without this
* capability the packaged harness could not be relinked compactly.
*/
export function probeSymlinkSupport() {
const base = mkdtempSync(join(tmpdir(), 'dsh-symlink-probe-'))
const target = join(base, 'target')
const link = join(base, 'link')
try {
// The target must exist: Windows refuses a directory link to a missing
// target, and on POSIX existsSync() would report a dangling link as absent.
mkdirSync(target, { recursive: true })
symlinkSync(target, link, 'dir')
return existsSync(link)
} catch {
return false
} finally {
rmSync(base, { recursive: true, force: true })
}
}
/**
* Visit every symlink/junction under `root` without following into any link.
* Junctions are reported as symlinks by `lstat` on Windows; because the walk
* never recurses through a link, it cannot loop or escape the tree.
*/
function forEachLink(root, fn) {
const stack = [resolve(root)]
const seen = new Set()
while (stack.length > 0) {
const dir = stack.pop()
if (dir === undefined || seen.has(dir)) continue
seen.add(dir)
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const path = join(dir, entry.name)
let stat
try {
stat = lstatSync(path)
} catch {
continue
}
if (stat.isSymbolicLink()) {
fn(path)
} else if (stat.isDirectory()) {
stack.push(path)
}
}
}
}
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env node
/**
* Verify a harness tree is self-contained and actually boots from the bundle.
*
* Default: check the assembled `build/harness`. With `--app <dir>`, locate the
* `resources/harness` inside a packed/unpacked app instead — this additionally
* proves electron-builder's `extraResources` copy preserved the relative
* in-tree links. `--boot` also spawns the web profile straight from the bundle
* (temp DSH_HOME, telemetry off) and waits for its readiness line, proving the
* relinked node_modules resolves without the repository present.
*
* Usage:
* node scripts/verify-harness.mjs # build/harness
* node scripts/verify-harness.mjs --boot # + web boot smoke
* node scripts/verify-harness.mjs --app dist # a packed app's harness
* node scripts/verify-harness.mjs --app dist --boot
*/
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { assertSelfContained } from './harness-relink.mjs'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const desktopDir = join(root, 'apps/desktop')
const defaultHarness = join(desktopDir, 'build/harness')
const args = process.argv.slice(2)
const boot = args.includes('--boot')
const appIndex = args.indexOf('--app')
const appValue = args.find((a) => a.startsWith('--app='))?.slice('--app='.length) ?? (appIndex !== -1 ? args[appIndex + 1] : undefined)
const appRoot = appValue === undefined
? undefined
: isAbsolute(appValue)
? resolve(appValue)
: resolve(desktopDir, appValue)
const timeoutMs = 60_000
const READY_RE = /http:\/\/127\.0\.0\.1:(\d+)/
function fail(message) {
console.error(`verify-harness: ${message}`)
process.exit(1)
}
/** Find every `resources/harness` under an unpacked electron-builder app. */
function findHarnessUnder(appDir) {
const found = []
// electron-builder unpacked layout:
// win: <root>/win-unpacked/resources/harness
// mac: <root>/mac(-arch)/<Name>.app/Contents/Resources/harness
const visit = (dir, depth) => {
if (depth > 5) return
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const p = join(dir, entry.name)
if (basename(p) === 'harness' && basename(dirname(p)) === 'resources') found.push(p)
if (entry.isDirectory()) visit(p, depth + 1)
}
}
visit(appDir, 0)
return found
}
function nodeBinary(harness) {
return join(harness, 'bin', process.platform === 'win32' ? 'node.exe' : 'node')
}
function cliEntry(harness) {
return join(harness, 'apps/cli/lib/bin.js')
}
/** Run the bundled CLI's `--version` straight from the bundle. */
function checkVersion(harness) {
const result = spawnSync(nodeBinary(harness), [cliEntry(harness), '--version'], {
cwd: harness,
encoding: 'utf8',
timeout: timeoutMs,
})
if (result.error !== undefined || result.status !== 0) {
fail(`the bundled CLI failed to run: ${result.error?.message ?? result.stderr?.trim() ?? `exit ${String(result.status)}`}`)
}
console.log(` dsh --version: ${result.stdout?.trim()}`)
}
/**
* Spawn the web profile from the bundle and resolve once it prints its
* readiness URL. Hermetic: a temp DSH_HOME and telemetry off, so the check
* neither touches the real profile nor the repository.
*/
function bootWeb(harness) {
const home = mkdtempSync(join(tmpdir(), 'dsh-verify-home-'))
const child = spawn(
nodeBinary(harness),
[cliEntry(harness), '--profile', 'web', '--host', '127.0.0.1', '--port', '0'],
{
cwd: harness,
stdio: ['ignore', 'pipe', 'inherit'],
env: { ...process.env, DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DSH_ALLOW_PLUGIN_INSTALL: '1' },
},
)
return new Promise((resolvePromise, reject) => {
let settled = false
let buffered = ''
const cleanup = () => {
try {
rmSync(home, { recursive: true, force: true })
} catch {
// Best effort; a locked file on Windows is not fatal for a verify tool.
}
}
const timer = setTimeout(() => {
if (settled) return
settled = true
child.kill()
cleanup()
reject(new Error(`web boot timed out after ${timeoutMs}ms`))
}, timeoutMs)
child.stdout?.setEncoding('utf8')
child.stdout?.on('data', (chunk) => {
if (settled) return
buffered += chunk
if (READY_RE.test(buffered)) {
settled = true
clearTimeout(timer)
child.kill()
cleanup()
resolvePromise()
}
})
child.on('error', (error) => {
if (settled) return
settled = true
clearTimeout(timer)
cleanup()
reject(error)
})
child.on('exit', (code) => {
if (settled) return
settled = true
clearTimeout(timer)
cleanup()
reject(new Error(`harness exited before serving (code ${String(code)})`))
})
})
}
async function main() {
const harness = (() => {
if (appRoot !== undefined) {
const found = findHarnessUnder(appRoot)
if (found.length === 0) fail(`no resources/harness found under ${appRoot}`)
if (found.length > 1) {
console.warn(`verify-harness: ${found.length} harness dirs found; checking the first:\n ${found.join('\n ')}`)
}
return found[0]
}
if (!existsSync(defaultHarness)) fail(`build/harness not found at ${defaultHarness} — run build:harness first`)
return defaultHarness
})()
console.log(`verify-harness: ${harness}`)
if (!existsSync(nodeBinary(harness))) fail(`bundled node not found at ${nodeBinary(harness)}`)
try {
assertSelfContained(harness)
} catch (error) {
fail(error instanceof Error ? error.message : String(error))
}
console.log(' links: self-contained (no link escapes the bundle)')
checkVersion(harness)
if (boot) {
try {
await bootWeb(harness)
} catch (error) {
fail(error instanceof Error ? error.message : String(error))
}
console.log(' web boot: ok')
}
console.log('verify-harness: ok')
}
main().catch((error) => fail(error instanceof Error ? error.message : String(error)))
+165
View File
@@ -0,0 +1,165 @@
import {
existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
assertSelfContained, findEscapingLinks, isWithin, probeSymlinkSupport, relinkHarness,
} from '../scripts/harness-relink.mjs'
const dirs: string[] = []
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
/**
* A repo whose layout mirrors the real one: `out` is the harness copy of the
* dev tree at `<repo>/apps/desktop/build/harness`, and the repo has a package
* the harness mirrors.
*/
function makeTree() {
const root = mkdtempSync(join(tmpdir(), 'dsh-relink-'))
dirs.push(root)
const repo = join(root, 'repo')
const out = join(repo, 'apps/desktop/build/harness')
mkdirSync(join(repo, 'packages/core/dsh-core'), { recursive: true })
writeFileSync(join(repo, 'packages/core/dsh-core/index.js'), 'export const x = 1\n')
mkdirSync(join(out, 'packages/core/dsh-core'), { recursive: true })
writeFileSync(join(out, 'packages/core/dsh-core/index.js'), 'export const x = 1\n')
mkdirSync(join(out, 'node_modules/@deepseek-ai'), { recursive: true })
return { root, repo, out }
}
/** The dir-symlink type to use when pre-creating a fixture link on this host. */
const dirLinkType = () => (process.platform === 'win32' ? 'junction' : 'dir')
/** Whether tests that create symlinks can run on this host. */
const canSymlink = probeSymlinkSupport()
describe('isWithin', () => {
it('accepts a path inside the root and rejects an escape', () => {
expect(isWithin('C:\\a\\b', 'C:\\a\\b\\c')).toBe(true)
expect(isWithin('C:\\a\\b', 'C:\\a\\other')).toBe(false)
expect(isWithin('C:\\a\\b', 'C:\\a\\b')).toBe(true)
})
it('rejects an absolute path on another drive', () => {
expect(isWithin('C:\\a\\b', 'D:\\x')).toBe(false)
})
})
describe.skipIf(!canSymlink)('relinkHarness', () => {
it('leaves a relative in-tree link alone', () => {
const { out } = makeTree()
const link = join(out, 'node_modules/@deepseek-ai/dsh-core')
symlinkSync('../../packages/core/dsh-core', link, 'dir')
const report = relinkHarness(out, dirname(out))
expect(report.unchanged).toBeGreaterThanOrEqual(1)
expect(report.errors).toEqual([])
expect(findEscapingLinks(out)).toEqual([])
})
it('rewrites an absolute link to the dev tree into a relative in-tree link', () => {
const { repo, out } = makeTree()
const link = join(out, 'node_modules/@deepseek-ai/dsh-core')
const devTarget = join(repo, 'packages/core/dsh-core')
symlinkSync(devTarget, link, dirLinkType())
const report = relinkHarness(out, repo)
expect(report.normalized).toBeGreaterThanOrEqual(1)
expect(report.errors).toEqual([])
expect(lstatSync(link).isSymbolicLink()).toBe(true)
const raw = readlinkSync(link)
expect(resolve(dirname(link), raw)).toBe(resolve(join(out, 'packages/core/dsh-core')))
expect(findEscapingLinks(out)).toEqual([])
})
it('mirrors a node_modules/.pnpm store link into the harness', () => {
const { repo, out } = makeTree()
// The store entry the workspace link would point at, in both trees.
for (const base of [repo, out]) {
mkdirSync(join(base, 'node_modules/.pnpm/b@1/node_modules/b'), { recursive: true })
writeFileSync(join(base, 'node_modules/.pnpm/b@1/node_modules/b/index.js'), 'export const b = 2\n')
}
const link = join(out, 'node_modules/@deepseek-ai/a')
symlinkSync(join(repo, 'node_modules/.pnpm/b@1/node_modules/b'), link, dirLinkType())
const report = relinkHarness(out, repo)
expect(report.errors).toEqual([])
expect(resolve(dirname(link), readlinkSync(link)))
.toBe(resolve(join(out, 'node_modules/.pnpm/b@1/node_modules/b')))
expect(findEscapingLinks(out)).toEqual([])
})
it('dereferences a genuinely external target into real content', () => {
const { root, out } = makeTree()
const external = join(root, 'external')
mkdirSync(external, { recursive: true })
writeFileSync(join(external, 'index.js'), 'export const ext = 3\n')
const link = join(out, 'node_modules/external')
symlinkSync(external, link, dirLinkType())
const report = relinkHarness(out, dirname(out))
expect(report.dereferenced).toBeGreaterThanOrEqual(1)
expect(report.errors).toEqual([])
expect(lstatSync(link).isSymbolicLink()).toBe(false)
expect(readFileSync(join(link, 'index.js'), 'utf8')).toContain('ext')
expect(findEscapingLinks(out)).toEqual([])
})
it('drops a link whose target contains the harness (e.g. the desktop shell)', () => {
const { repo, out } = makeTree()
// `repo/apps/desktop` contains `out` (the harness): it is a workspace
// package the harness deliberately does not ship, so the link cannot be
// made self-contained and must be dropped, not copied.
const link = join(out, 'node_modules/desktop-shell')
symlinkSync(join(repo, 'apps/desktop'), link, dirLinkType())
const report = relinkHarness(out, repo)
expect(report.removed).toBeGreaterThanOrEqual(1)
expect(report.errors).toEqual([])
expect(existsSync(link)).toBe(false)
expect(findEscapingLinks(out)).toEqual([])
})
it('drops a link that points at a missing in-tree target (unshipped package)', () => {
const { out } = makeTree()
// A link to `out/python/sdk-runtime` — inside the tree lexically but absent
// (python is not part of the harness): it cannot resolve, so it is dropped.
const link = join(out, 'node_modules/dsh-sdk-runtime')
symlinkSync('../python/sdk-runtime', link, dirLinkType())
const report = relinkHarness(out, dirname(out))
expect(report.removed).toBeGreaterThanOrEqual(1)
expect(report.errors).toEqual([])
expect(existsSync(link)).toBe(false)
expect(findEscapingLinks(out)).toEqual([])
})
it('reports a dangling link as an error and leaves it detectable', () => {
const { root, out } = makeTree()
const link = join(out, 'node_modules/ghost')
symlinkSync(join(root, 'does-not-exist'), link, dirLinkType())
const report = relinkHarness(out, dirname(out))
expect(report.errors.some(e => e.includes('dangling'))).toBe(true)
expect(findEscapingLinks(out)).toContain(link)
})
})
describe.skipIf(!canSymlink)('findEscapingLinks / assertSelfContained', () => {
it('flags an escaping link and assertSelfContained throws', () => {
const { repo, out } = makeTree()
const link = join(out, 'node_modules/outside')
symlinkSync(join(repo, 'packages/core/dsh-core'), link, 'dir')
expect(findEscapingLinks(out)).toContain(link)
expect(() => assertSelfContained(out)).toThrow(/self-contained/)
})
it('assertSelfContained passes on a clean tree', () => {
const { out } = makeTree()
expect(() => assertSelfContained(out)).not.toThrow()
})
})
describe('probeSymlinkSupport', () => {
it('reports a boolean without throwing', () => {
expect(typeof probeSymlinkSupport()).toBe('boolean')
})
})