diff --git a/apps/desktop/scripts/build-harness.mjs b/apps/desktop/scripts/build-harness.mjs index d77829be56..9c9a763463 100644 --- a/apps/desktop/scripts/build-harness.mjs +++ b/apps/desktop/scripts/build-harness.mjs @@ -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/@` 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/@*` entries, the top-level + * `node_modules/` 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}`) diff --git a/apps/desktop/scripts/harness-excludes.json b/apps/desktop/scripts/harness-excludes.json new file mode 100644 index 0000000000..19f29b1456 --- /dev/null +++ b/apps/desktop/scripts/harness-excludes.json @@ -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." + } + ] +} diff --git a/apps/desktop/scripts/harness-excludes.schema.json b/apps/desktop/scripts/harness-excludes.schema.json new file mode 100644 index 0000000000..c4bdef321d --- /dev/null +++ b/apps/desktop/scripts/harness-excludes.schema.json @@ -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." + } + } + } + } + } +}