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
+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()}`)