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:
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Normalize every symlink/junction in a copied harness tree so the tree is
|
||||
* self-contained: no link may resolve to a path outside the tree.
|
||||
*
|
||||
* A pnpm workspace is held together by links inside node_modules. On Windows
|
||||
* those are often **junctions** (absolute-path reparse points) because real
|
||||
* symlinks need Developer Mode or an admin shell; even plain symlinks can be
|
||||
* absolute. `build-harness.mjs` copies the working tree with
|
||||
* `cpSync(..., { recursive: true })`, which — with the default
|
||||
* `dereference: false` — recreates every link verbatim, so the packaged harness
|
||||
* keeps pointing at the BUILD machine's dev tree: it reads dev resources on the
|
||||
* same machine and dangles on any other. This module rewrites each such link to
|
||||
* a RELATIVE target inside the harness (mirroring the dev layout), and copies
|
||||
* the real content of any genuinely external target in so nothing escapes.
|
||||
*
|
||||
* Pure Node, no Electron and no workspace TS program: imported by
|
||||
* `scripts/build-harness.mjs` / `scripts/verify-harness.mjs` and unit-tested
|
||||
* from `tests/harness-relink.spec.ts`.
|
||||
* @module dsh-desktop/harness-relink
|
||||
*/
|
||||
|
||||
import {
|
||||
cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readlinkSync, rmSync, symlinkSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
|
||||
/** True when `p` resolves inside `root` (or is `root` itself). */
|
||||
export function isWithin(root, p) {
|
||||
const rel = relative(resolve(root), resolve(p))
|
||||
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
||||
}
|
||||
|
||||
/** The link's own kind so Windows creates the right reparse point ('dir'/'file'). */
|
||||
function linkKind(target) {
|
||||
try {
|
||||
return lstatSync(target).isDirectory() ? 'dir' : 'file'
|
||||
} catch {
|
||||
return 'dir'
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a link and copy the target's real content into its place. */
|
||||
function dereferenceInto(link, target) {
|
||||
rmSync(link, { force: true })
|
||||
cpSync(target, link, { recursive: true, dereference: true })
|
||||
}
|
||||
|
||||
/** Rewrite `link` as a relative symlink to `mirrored`; dereference on failure. */
|
||||
function relinkTo(link, mirrored, report) {
|
||||
const rel = relative(dirname(link), mirrored)
|
||||
try {
|
||||
rmSync(link, { force: true })
|
||||
symlinkSync(rel, link, linkKind(mirrored))
|
||||
report.normalized += 1
|
||||
} catch (error) {
|
||||
// Symlink creation failed (e.g. no Developer Mode on Windows): a compact
|
||||
// portable link is impossible, so copy the target content in instead.
|
||||
try {
|
||||
dereferenceInto(link, mirrored)
|
||||
report.dereferenced += 1
|
||||
} catch (copyError) {
|
||||
report.errors.push(`${link} -> ${mirrored} (${String(copyError)})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk `out` and normalize every symlink/junction to a relative in-tree link.
|
||||
* Idempotent: a link already pointing inside the tree is left alone.
|
||||
* @param out - the harness root.
|
||||
* @param repoRoot - the repository root whose layout `out` mirrors.
|
||||
* @returns per-kind counts plus any unrecoverable errors.
|
||||
*/
|
||||
export function relinkHarness(out, repoRoot) {
|
||||
const report = { normalized: 0, dereferenced: 0, unchanged: 0, removed: 0, errors: [] }
|
||||
forEachLink(out, (link) => {
|
||||
const target = readlinkSync(link)
|
||||
const abs = resolve(dirname(link), target)
|
||||
if (isWithin(out, abs)) {
|
||||
if (!existsSync(abs)) {
|
||||
// A link inside the tree whose target is absent points at a workspace
|
||||
// package the harness does not ship (python/sdk-runtime, examples, …).
|
||||
// It cannot resolve and is not part of the runtime: drop it.
|
||||
rmSync(link, { force: true })
|
||||
report.removed += 1
|
||||
} else if (relative(dirname(link), abs) === target) {
|
||||
// Already inside the tree: make sure the link is a relative in-tree path.
|
||||
report.unchanged += 1
|
||||
} else {
|
||||
relinkTo(link, abs, report)
|
||||
}
|
||||
} else if (isWithin(repoRoot, abs)) {
|
||||
// Points at the dev tree: mirror the target into the harness and relink.
|
||||
const mirrored = join(out, relative(repoRoot, abs))
|
||||
if (existsSync(mirrored)) {
|
||||
relinkTo(link, mirrored, report)
|
||||
} else if (isWithin(abs, out)) {
|
||||
// The target contains the harness itself (e.g. the desktop shell
|
||||
// package, which the harness deliberately does not ship). Copying it in
|
||||
// would recurse into the bundle and it is not part of the runtime
|
||||
// closure, so the link cannot be made self-contained — drop it.
|
||||
rmSync(link, { force: true })
|
||||
report.removed += 1
|
||||
} else if (existsSync(abs)) {
|
||||
try {
|
||||
dereferenceInto(link, abs)
|
||||
report.dereferenced += 1
|
||||
} catch (error) {
|
||||
report.errors.push(`${link} -> ${abs} (${String(error)})`)
|
||||
}
|
||||
} else {
|
||||
report.errors.push(`${link} -> ${abs} (dangling)`)
|
||||
}
|
||||
} else if (existsSync(abs)) {
|
||||
// Genuinely external target (e.g. a global store): copy content in.
|
||||
try {
|
||||
dereferenceInto(link, abs)
|
||||
report.dereferenced += 1
|
||||
} catch (error) {
|
||||
report.errors.push(`${link} -> ${abs} (${String(error)})`)
|
||||
}
|
||||
} else {
|
||||
report.errors.push(`${link} -> ${abs} (dangling)`)
|
||||
}
|
||||
})
|
||||
return report
|
||||
}
|
||||
|
||||
/**
|
||||
* Every symlink/junction whose target escapes `out` or no longer exists. A
|
||||
* non-empty result means the harness is NOT detached from its build machine.
|
||||
*/
|
||||
export function findEscapingLinks(out) {
|
||||
const escaping = []
|
||||
forEachLink(out, (link) => {
|
||||
const abs = resolve(dirname(link), readlinkSync(link))
|
||||
if (!isWithin(out, abs) || !existsSync(abs)) escaping.push(link)
|
||||
})
|
||||
return escaping
|
||||
}
|
||||
|
||||
/** Throw unless the tree is self-contained (no escaping or dangling links). */
|
||||
export function assertSelfContained(out) {
|
||||
const escaping = findEscapingLinks(out)
|
||||
if (escaping.length === 0) return
|
||||
const listed = escaping.slice(0, 20).join('\n ')
|
||||
throw new Error(
|
||||
`harness is NOT self-contained: ${escaping.length} link(s) resolve outside it or are dangling:\n ${listed}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current host can create (relative) directory symlinks. Windows
|
||||
* without Developer Mode (and without an admin shell) cannot; without this
|
||||
* capability the packaged harness could not be relinked compactly.
|
||||
*/
|
||||
export function probeSymlinkSupport() {
|
||||
const base = mkdtempSync(join(tmpdir(), 'dsh-symlink-probe-'))
|
||||
const target = join(base, 'target')
|
||||
const link = join(base, 'link')
|
||||
try {
|
||||
// The target must exist: Windows refuses a directory link to a missing
|
||||
// target, and on POSIX existsSync() would report a dangling link as absent.
|
||||
mkdirSync(target, { recursive: true })
|
||||
symlinkSync(target, link, 'dir')
|
||||
return existsSync(link)
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit every symlink/junction under `root` without following into any link.
|
||||
* Junctions are reported as symlinks by `lstat` on Windows; because the walk
|
||||
* never recurses through a link, it cannot loop or escape the tree.
|
||||
*/
|
||||
function forEachLink(root, fn) {
|
||||
const stack = [resolve(root)]
|
||||
const seen = new Set()
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()
|
||||
if (dir === undefined || seen.has(dir)) continue
|
||||
seen.add(dir)
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry.name)
|
||||
let stat
|
||||
try {
|
||||
stat = lstatSync(path)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
fn(path)
|
||||
} else if (stat.isDirectory()) {
|
||||
stack.push(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user