Files
deepseek-harness/apps/desktop/scripts/build-harness.mjs
T
Pine 43c4b825e8
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / node 22.19 (push) Has been cancelled
CI / node 26 (push) Has been cancelled
CI / python 3.10 / keyless SDK (push) Has been cancelled
CI / windows node 24 / wine blocking (push) Has been cancelled
CI / wine apt cache (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Has been cancelled
Release (vendor) / Pack npm tarballs (push) Has been cancelled
Release (vendor) / Publish to npm (push) Has been cancelled
Release (dsh) / Pack npm tarballs (push) Has been cancelled
Release (dsh) / Publish to npm (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Has been cancelled
feat(build-harness): enhance robustness of harness removal and update exclusion list
2026-08-16 10:53:01 +08:00

337 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Assemble the self-contained harness runtime for the packaged app.
*
* The harness's pnpm workspace does not cleanly materialize via `pnpm deploy` or
* electron-builder's dependency resolution (per-package symlinks to vendored
* packages, native addons, a separate frontend dist). The only known-good
* runtime is the repository's own working tree, so this copies the parts the
* harness needs at the same relative layout — node_modules, vendor, packages,
* native, apps/cli, apps/web — plus the platform Node binary, into
* `build/harness`, which electron-builder ships as `extraResources`.
*
* The packaged harness must stay fully self-contained: the app is not a mere
* launcher, it must let users in the most barren environments use dsh entirely
* through the app. We therefore copy the whole workspace node_modules and then
* prune a curated, VERIFIED-safe exclusion list (see pruneNodeModules below and
* `scripts/harness-excludes.json`). Nothing is excluded unless it is provably
* outside the dsh runtime dependency closure.
*
* 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, then by a
* self-containment check and a `dsh --version` smoke test.
*
* Run from the repository root before `desktop:pack`.
*/
import { cpSync, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, 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')
// Dirs whose relative layout must be preserved so node_modules symlinks resolve.
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`)
}
}
// Recursively removing the previous (multi-GB, symlink-heavy) harness can hit
// ENOTEMPTY/EBUSY transiently — on Windows especially, and on macOS under load.
// Retry so the rebuild is robust; maxRetries/retryDelay are Node's built-in
// answer to exactly these codes.
rmSync(out, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 })
mkdirSync(join(out, 'apps'), { recursive: true })
mkdirSync(join(out, 'bin'), { recursive: true })
for (const d of harnessDirs) {
cpSync(join(root, d), join(out, d), { recursive: true })
}
for (const [src, dst] of extraDirs) {
cpSync(join(root, src), join(out, dst), { recursive: true })
}
// Prune verified-safe build/packaging tooling out of the copied node_modules.
// This is the ONLY sanctioned way to shrink the self-contained runtime: every
// exclusion is validated against the dependency closure of the kept packages
// and refused if any kept package depends on it.
pruneNodeModules(join(out, 'node_modules'))
// 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}`)
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
// (which has network); a failure only disables registry install, never the
// offline bundle install, so warn rather than abort.
try {
execFileSync('npm', ['install', '--prefix', join(out, 'pnpm'), 'pnpm@11.7.0'], { stdio: 'pipe' })
console.log('vendored pnpm into harness')
} catch {
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).
//
// The POSIX launcher resolves its own real path through symlinks before looking
// up `bin/node` and `apps/cli/lib/bin.js`: the desktop registers `dsh` on PATH
// as a symlink into a bin dir, so a bare `dirname "$0"` would resolve to the
// link's directory (no siblings there). `readlink -f` is unavailable on macOS,
// so resolve portably with the readlink loop below. `dsh.cmd` needs no such
// handling because `%~dp0` already expands to the real script location.
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\nSELF="$0"\nwhile [ -h "$SELF" ]; do\n DIR=$(cd "$(dirname "$SELF")" && pwd)\n LINK=$(readlink "$SELF")\n case "$LINK" in /*) SELF="$LINK";; *) SELF="$DIR/$LINK";; esac\ndone\nDIR=$(cd "$(dirname "$SELF")" && pwd)\nexec "$DIR/bin/node" "$DIR/apps/cli/lib/bin.js" "$@"\n',
)
chmodSync(dshLauncher, 0o755)
}
/**
* Remove the curated build/packaging-time packages from a copied pnpm
* `node_modules`. Safe by construction:
*
* 1. Reads `scripts/harness-excludes.json` — a visible, configurable list of
* package names + reasons (users add/remove entries without touching the
* copy logic).
* 2. For each exclusion, walks every KEPT `.pnpm/<pkg>@<ver>` entry and checks
* whether any kept package symlinks this name as a dependency. If so, the
* exclusion is REFUSED (throws) — the runtime must stay whole.
* 3. If safe, deletes the `.pnpm/<name>@*` entries, the top-level
* `node_modules/<name>` link, and any `.bin` shims that pointed into them,
* reporting bytes freed per entry and a total.
*/
function pruneNodeModules(nmDir) {
const cfgPath = join(root, 'apps/desktop/scripts/harness-excludes.json')
if (!existsSync(cfgPath)) return
const { excludes } = JSON.parse(readFileSync(cfgPath, 'utf8'))
if (!Array.isArray(excludes) || excludes.length === 0) return
// De-dupe by name so a duplicate config entry can't double-report or double-count.
const unique = new Map()
for (const e of excludes) if (e && e.name && !unique.has(e.name)) unique.set(e.name, e)
const list = [...unique.values()]
const pnpmDir = join(nmDir, '.pnpm')
if (!existsSync(pnpmDir)) return
const entries = readdirSync(pnpmDir)
// All packages that will be removed — used to ignore cross-excluded refs
// during the safety check (a dependency among two removed packages is fine).
const excludedSet = new Set(list.map((e) => e.name))
// Package name of a .pnpm entry. Entries look like "name@1.0.0" for a bare
// package or "@scope+name@1.0.0" for a scoped one, but peer-dependency
// suffixes add further "@"s (e.g. "dmg-builder@25.1.8_peer@25.1.8"). Parse
// from the FIRST "@" — the base name never contains one after the version
// separator — so peer suffixes can't leak into the parsed name.
const entryPkg = (entry) => {
const at = entry.indexOf('@')
if (at === -1) return entry
if (at === 0) {
const rest = entry.slice(1) // "@scope+name@ver..." -> "scope+name@ver..."
const sep = rest.indexOf('@')
const name = sep === -1 ? rest : rest.slice(0, sep)
return '@' + name.replace('+', '/')
}
return entry.slice(0, at)
}
let totalFreed = 0
for (const ex of list) {
const name = ex.name
if (excludedSet.size === 0) continue
// --- 2. Safety: does any KEPT package depend on this package? ---
const dependents = []
for (const entry of entries) {
const ep = entryPkg(entry)
if (ep === name || excludedSet.has(ep)) continue // itself or also excluded
const depDir = join(pnpmDir, entry, 'node_modules')
if (!existsSync(depDir)) continue
if (readdirSync(depDir).includes(name)) dependents.push(ep)
}
if (dependents.length > 0) {
throw new Error(
`[harness-excludes] REFUSING to exclude "${name}": kept packages depend on it ` +
`(${dependents.join(', ')}). The runtime must stay whole — remove it from ` +
`${cfgPath} or it is not actually safe to exclude.`,
)
}
// --- 3. Delete the .pnpm store entries for this package ---
const matching = entries.filter((e) => entryPkg(e) === name)
for (const m of matching) {
const p = join(pnpmDir, m)
const sz = dirSize(p)
rmSync(p, { recursive: true, force: true })
totalFreed += sz
console.log(` [exclude] ${name} — removed .pnpm/${m} (${fmtSize(sz)})`)
}
// --- 4. Delete the top-level link/dir (node_modules/@scope/name too) ---
const top = join(nmDir, ...name.split('/'))
if (existsSync(top)) {
const sz = dirSize(top)
rmSync(top, { recursive: true, force: true })
totalFreed += sz
console.log(` [exclude] ${name} — removed top-level link (${fmtSize(sz)})`)
}
// --- 5. Delete the flat .pnpm/node_modules/<name> copy. cpSync dereferences
// links while copying, so an excluded package lands here as a REAL directory
// (e.g. electron → 837 MB) that steps 24 never touch; it was shipping anyway.
// This shares the same safety check in step 2 — no kept package depends on it.
// Probe with lstatSync (not existsSync): it inspects the link itself rather
// than following the target, so a dangling symlink/junction (its .pnpm store
// entry already removed in step 3) is still found and removed on Windows. ---
const flat = join(pnpmDir, 'node_modules', ...name.split('/'))
let flatStat
try {
flatStat = lstatSync(flat)
} catch {
flatStat = null
}
if (flatStat) {
const sz = flatStat.isSymbolicLink() || flatStat.isFile() ? flatStat.size : dirSize(flat)
rmSync(flat, { recursive: true, force: true })
totalFreed += sz
console.log(` [exclude] ${name} — removed flat .pnpm/node_modules copy (${fmtSize(sz)})`)
}
// --- 6. Drop .bin shims that pointed into the removed store entries ---
removeBinShims(join(nmDir, '.bin'), matching, pnpmDir)
}
if (totalFreed > 0) {
console.log(
`\n[harness-excludes] freed ${fmtSize(totalFreed)} by excluding ` +
`${list.map((e) => e.name).join(', ')} (config: ${cfgPath})`,
)
}
}
/** Recursive size of a dir or link target. Symlinks are counted as 0 bytes and
* never followed (a pnpm store is full of links; following would loop). */
function dirSize(p) {
let st
try {
st = lstatSync(p)
} catch {
return 0
}
if (st.isSymbolicLink() || st.isFile()) return st.size
if (!st.isDirectory()) return 0
let total = 0
for (const child of readdirSync(p)) total += dirSize(join(p, child))
return total
}
/** Remove `.bin` shims whose resolved target lives inside any removed store
* entry, so no dangling executables remain. */
function removeBinShims(binDir, removedEntries, pnpmDir) {
if (!existsSync(binDir)) return
const removedStoreDirs = removedEntries.map((e) => join(pnpmDir, e))
for (const shim of readdirSync(binDir)) {
const p = join(binDir, shim)
let target
try {
target = readlinkSync(p)
} catch {
continue // not a symlink (e.g. a real .cmd file) — leave it
}
const resolved = resolve(binDir, target)
if (removedStoreDirs.some((d) => resolved.startsWith(d))) {
rmSync(p, { recursive: true, force: true })
}
}
}
/** Human-readable byte size, e.g. "553 MiB". */
function fmtSize(bytes) {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GiB`
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MiB`
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`
return `${bytes} B`
}
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()}`)