feat(build-harness): implement node_modules pruning and add exclusion configuration
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

This commit is contained in:
Pine
2026-08-15 20:34:38 +08:00
parent 89eef567e1
commit 9855358304
3 changed files with 256 additions and 1 deletions
+162 -1
View File
@@ -10,10 +10,17 @@
* 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.
*
* Run from the repository root before `desktop:pack`.
*/
import { cpSync, chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
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'
@@ -37,6 +44,12 @@ 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`.
@@ -84,4 +97,152 @@ if (process.platform === 'win32') {
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. 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}`)
@@ -0,0 +1,63 @@
{
"$schema": "harness-excludes.schema.json",
"version": 1,
"description": "Packages excluded from the packaged harness node_modules (.pnpm store). The app must stay fully self-contained — it is not a launcher, it lets users in the most barren environments use dsh entirely through the app. So an entry here is a HARD guarantee that the package is NOT part of the dsh runtime dependency closure: the build refuses to exclude anything a kept workspace package actually depends on (see build-harness.mjs pruneNodeModules). Add entries only after verifying the package is build-time / packaging-time tooling that the harness never imports at runtime.",
"excludes": [
{
"name": "electron",
"reason": "Electron shell runtime. electron-builder ships it separately into resources/app; the dsh harness runs under the bundled system-Node child and never imports Electron. Verified: only apps/desktop/package.json depends on it, only apps/desktop/lib/types/{main,preload}.js import it (those are the shell, already packaged)."
},
{
"name": "electron-builder",
"reason": "Packaging toolchain (group). The harness is pre-built and distributed, so nothing packages installers at runtime. The electron-builder toolchain is mutually interdependent, so it is excluded as a group; build-harness.mjs refuses any exclusion a kept package still depends on, which is how this group stays coherent."
},
{
"name": "app-builder-lib",
"reason": "electron-builder toolchain (group): app-builder-lib is packaging logic only, never imported by the dsh runtime."
},
{
"name": "builder-util",
"reason": "electron-builder toolchain (group): build utilities (spawns app-builder-bin, 7zip-bin). Build-time only."
},
{
"name": "builder-util-runtime",
"reason": "electron-builder toolchain (group): runtime helpers used only by the builder at package time, not by dsh."
},
{
"name": "dmg-builder",
"reason": "electron-builder toolchain (group): mac .dmg packaging; not present on this Windows build and never used at runtime."
},
{
"name": "electron-builder-squirrel-windows",
"reason": "electron-builder toolchain (group): Squirrel.Windows packaging; build-time only."
},
{
"name": "app-builder-bin",
"reason": "electron-builder's build binary (7za/signtool/app-builder). Build-time only, never imported by the dsh runtime. Depended on by builder-util, which is itself excluded in this group."
},
{
"name": "electron-publish",
"reason": "electron-builder toolchain (group): publishing (upload to GitHub etc.); never used by the dsh runtime."
},
{
"name": "7zip-bin",
"reason": "electron-builder toolchain (group): 7-zip binary used to compress installers at package time; not a dsh runtime dependency."
},
{
"name": "@electron/asar",
"reason": "electron-builder toolchain (group): asar archive packing; build-time only (the desktop app ships asar:false anyway)."
},
{
"name": "@electron/notarize",
"reason": "electron-builder toolchain (group): macOS notarization; build-time only, and irrelevant on this Windows build."
},
{
"name": "@electron/osx-sign",
"reason": "electron-builder toolchain (group): macOS code signing; build-time only."
},
{
"name": "@electron/universal",
"reason": "electron-builder toolchain (group): macOS universal binaries; build-time only."
}
]
}
@@ -0,0 +1,31 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://deepseek-harness.dev/schemas/harness-excludes.schema.json",
"title": "Harness dependency exclusion list",
"type": "object",
"additionalProperties": false,
"required": ["version", "description", "excludes"],
"properties": {
"version": { "type": "integer", "const": 1 },
"description": { "type": "string" },
"excludes": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "reason"],
"properties": {
"name": {
"type": "string",
"description": "Package name to exclude, e.g. \"electron\" or \"@scope/name\". Matches pnpm store entries by unscoped/scoped base name.",
"minLength": 1
},
"reason": {
"type": "string",
"description": "Why this package is safely outside the dsh runtime dependency closure. Required so the exclusion stays auditable."
}
}
}
}
}
}