Merge branch 'feat/desktop-electron-app'
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
Deploy documentation / build (push) Has been cancelled
Landlock Run / Matrix (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Deploy documentation / deploy (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
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
Deploy documentation / build (push) Has been cancelled
Landlock Run / Matrix (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Deploy documentation / deploy (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
# Conflicts: # apps/desktop/scripts/build-harness.mjs
This commit is contained in:
@@ -17,6 +17,11 @@
|
||||
* `scripts/harness-excludes.json`). Nothing is excluded unless it is provably
|
||||
* outside the dsh runtime dependency closure.
|
||||
*
|
||||
* 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, then by a
|
||||
* self-containment check and a `dsh --version` smoke test.
|
||||
*
|
||||
* Run from the repository root before `desktop:pack`.
|
||||
*/
|
||||
|
||||
@@ -24,6 +29,7 @@ import { cpSync, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, read
|
||||
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')
|
||||
@@ -32,13 +38,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 })
|
||||
@@ -246,3 +280,32 @@ function fmtSize(bytes) {
|
||||
}
|
||||
|
||||
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()}`)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Type declarations for the pure relink/verify helpers in harness-relink.mjs. */
|
||||
export interface RelinkReport {
|
||||
normalized: number
|
||||
dereferenced: number
|
||||
unchanged: number
|
||||
removed: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export function isWithin(root: string, p: string): boolean
|
||||
export function relinkHarness(out: string, repoRoot: string): RelinkReport
|
||||
export function findEscapingLinks(out: string): string[]
|
||||
export function assertSelfContained(out: string): void
|
||||
export function probeSymlinkSupport(): boolean
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify a harness tree is self-contained and actually boots from the bundle.
|
||||
*
|
||||
* Default: check the assembled `build/harness`. With `--app <dir>`, locate the
|
||||
* `resources/harness` inside a packed/unpacked app instead — this additionally
|
||||
* proves electron-builder's `extraResources` copy preserved the relative
|
||||
* in-tree links. `--boot` also spawns the web profile straight from the bundle
|
||||
* (temp DSH_HOME, telemetry off) and waits for its readiness line, proving the
|
||||
* relinked node_modules resolves without the repository present.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-harness.mjs # build/harness
|
||||
* node scripts/verify-harness.mjs --boot # + web boot smoke
|
||||
* node scripts/verify-harness.mjs --app dist # a packed app's harness
|
||||
* node scripts/verify-harness.mjs --app dist --boot
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { assertSelfContained } from './harness-relink.mjs'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
|
||||
const desktopDir = join(root, 'apps/desktop')
|
||||
const defaultHarness = join(desktopDir, 'build/harness')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const boot = args.includes('--boot')
|
||||
const appIndex = args.indexOf('--app')
|
||||
const appValue = args.find((a) => a.startsWith('--app='))?.slice('--app='.length) ?? (appIndex !== -1 ? args[appIndex + 1] : undefined)
|
||||
const appRoot = appValue === undefined
|
||||
? undefined
|
||||
: isAbsolute(appValue)
|
||||
? resolve(appValue)
|
||||
: resolve(desktopDir, appValue)
|
||||
|
||||
const timeoutMs = 60_000
|
||||
const READY_RE = /http:\/\/127\.0\.0\.1:(\d+)/
|
||||
|
||||
function fail(message) {
|
||||
console.error(`verify-harness: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/** Find every `resources/harness` under an unpacked electron-builder app. */
|
||||
function findHarnessUnder(appDir) {
|
||||
const found = []
|
||||
// electron-builder unpacked layout:
|
||||
// win: <root>/win-unpacked/resources/harness
|
||||
// mac: <root>/mac(-arch)/<Name>.app/Contents/Resources/harness
|
||||
const visit = (dir, depth) => {
|
||||
if (depth > 5) return
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const p = join(dir, entry.name)
|
||||
if (basename(p) === 'harness' && basename(dirname(p)) === 'resources') found.push(p)
|
||||
if (entry.isDirectory()) visit(p, depth + 1)
|
||||
}
|
||||
}
|
||||
visit(appDir, 0)
|
||||
return found
|
||||
}
|
||||
|
||||
function nodeBinary(harness) {
|
||||
return join(harness, 'bin', process.platform === 'win32' ? 'node.exe' : 'node')
|
||||
}
|
||||
|
||||
function cliEntry(harness) {
|
||||
return join(harness, 'apps/cli/lib/bin.js')
|
||||
}
|
||||
|
||||
/** Run the bundled CLI's `--version` straight from the bundle. */
|
||||
function checkVersion(harness) {
|
||||
const result = spawnSync(nodeBinary(harness), [cliEntry(harness), '--version'], {
|
||||
cwd: harness,
|
||||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
})
|
||||
if (result.error !== undefined || result.status !== 0) {
|
||||
fail(`the bundled CLI failed to run: ${result.error?.message ?? result.stderr?.trim() ?? `exit ${String(result.status)}`}`)
|
||||
}
|
||||
console.log(` dsh --version: ${result.stdout?.trim()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the web profile from the bundle and resolve once it prints its
|
||||
* readiness URL. Hermetic: a temp DSH_HOME and telemetry off, so the check
|
||||
* neither touches the real profile nor the repository.
|
||||
*/
|
||||
function bootWeb(harness) {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-verify-home-'))
|
||||
const child = spawn(
|
||||
nodeBinary(harness),
|
||||
[cliEntry(harness), '--profile', 'web', '--host', '127.0.0.1', '--port', '0'],
|
||||
{
|
||||
cwd: harness,
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
env: { ...process.env, DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DSH_ALLOW_PLUGIN_INSTALL: '1' },
|
||||
},
|
||||
)
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
let settled = false
|
||||
let buffered = ''
|
||||
const cleanup = () => {
|
||||
try {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Best effort; a locked file on Windows is not fatal for a verify tool.
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
child.kill()
|
||||
cleanup()
|
||||
reject(new Error(`web boot timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
child.stdout?.setEncoding('utf8')
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
if (settled) return
|
||||
buffered += chunk
|
||||
if (READY_RE.test(buffered)) {
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
child.kill()
|
||||
cleanup()
|
||||
resolvePromise()
|
||||
}
|
||||
})
|
||||
child.on('error', (error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
cleanup()
|
||||
reject(new Error(`harness exited before serving (code ${String(code)})`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const harness = (() => {
|
||||
if (appRoot !== undefined) {
|
||||
const found = findHarnessUnder(appRoot)
|
||||
if (found.length === 0) fail(`no resources/harness found under ${appRoot}`)
|
||||
if (found.length > 1) {
|
||||
console.warn(`verify-harness: ${found.length} harness dirs found; checking the first:\n ${found.join('\n ')}`)
|
||||
}
|
||||
return found[0]
|
||||
}
|
||||
if (!existsSync(defaultHarness)) fail(`build/harness not found at ${defaultHarness} — run build:harness first`)
|
||||
return defaultHarness
|
||||
})()
|
||||
|
||||
console.log(`verify-harness: ${harness}`)
|
||||
if (!existsSync(nodeBinary(harness))) fail(`bundled node not found at ${nodeBinary(harness)}`)
|
||||
try {
|
||||
assertSelfContained(harness)
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
console.log(' links: self-contained (no link escapes the bundle)')
|
||||
checkVersion(harness)
|
||||
if (boot) {
|
||||
try {
|
||||
await bootWeb(harness)
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
console.log(' web boot: ok')
|
||||
}
|
||||
console.log('verify-harness: ok')
|
||||
}
|
||||
|
||||
main().catch((error) => fail(error instanceof Error ? error.message : String(error)))
|
||||
Reference in New Issue
Block a user