#!/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`. * * 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') // 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`) } } rmSync(out, { recursive: true, force: true }) 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 }) } // 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 `, …). 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}`) // 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()}`)