feat(desktop): ts-ify everything

This commit is contained in:
ethernet 2026-06-29 16:31:36 -04:00
parent fac85518fc
commit 39d09453f9
116 changed files with 2500 additions and 1756 deletions

View file

@ -1,8 +1,8 @@
/**
* after-pack.cjs electron-builder afterPack hook.
* after-pack.mjs electron-builder afterPack hook.
*
* Stamps the Hermes icon + identity onto the packed Windows Hermes.exe via
* rcedit (delegated to set-exe-identity.cjs). This runs for EVERY packed build
* rcedit (delegated to set-exe-identity.mjs). This runs for EVERY packed build
* first install, `hermes desktop`, the installer's --update rebuild, and a
* dev's manual `npm run pack` so the branded exe can never silently revert
* to the stock "Electron" icon/name (the bug when the stamp lived only in
@ -19,18 +19,18 @@
* - packager.appInfo.productFilename: the exe basename (e.g. 'Hermes')
*/
const path = require('node:path')
import path from 'node:path'
const { stampExeIdentity } = require('./set-exe-identity.cjs')
import { stampExeIdentity } from './set-exe-identity.mjs'
exports.default = async function afterPack(context) {
export default async function afterPack(context) {
if (context.electronPlatformName !== 'win32') {
return
}
const productName = context.packager?.appInfo?.productFilename || 'Hermes'
const exe = path.join(context.appOutDir, `${productName}.exe`)
const desktopRoot = path.resolve(__dirname, '..')
const desktopRoot = path.resolve(import.meta.dirname, '..')
try {
await stampExeIdentity(exe, desktopRoot)

View file

@ -13,31 +13,32 @@
// inherits it. It fails loud and early instead of shipping a broken bundle.
// See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404).
const fs = require("fs")
const path = require("path")
import { existsSync, statSync, readdirSync } from "fs"
import { join, resolve } from "path"
import { isMain } from "./utils.mjs"
// Pure check — returns { ok: true } or { ok: false, error: "..." }.
// Kept side-effect-free so it can be unit tested without spawning a process.
function checkDistBuilt(distDir) {
if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) {
export function checkDistBuilt(distDir) {
if (!existsSync(distDir) || !statSync(distDir).isDirectory()) {
return { ok: false, error: `no dist directory at ${distDir}` }
}
const indexHtml = path.join(distDir, "index.html")
if (!fs.existsSync(indexHtml) || !fs.statSync(indexHtml).isFile()) {
const indexHtml = join(distDir, "index.html")
if (!existsSync(indexHtml) || !statSync(indexHtml).isFile()) {
return { ok: false, error: `dist/index.html is missing at ${indexHtml}` }
}
if (fs.statSync(indexHtml).size === 0) {
if (statSync(indexHtml).size === 0) {
return { ok: false, error: `dist/index.html is empty at ${indexHtml}` }
}
// index.html alone isn't enough — vite emits hashed JS into dist/assets.
// An index.html with no script bundle still blank-pages.
const assetsDir = path.join(distDir, "assets")
const assetsDir = join(distDir, "assets")
const hasAssets =
fs.existsSync(assetsDir) &&
fs.statSync(assetsDir).isDirectory() &&
fs.readdirSync(assetsDir).some(name => name.endsWith(".js"))
existsSync(assetsDir) &&
statSync(assetsDir).isDirectory() &&
readdirSync(assetsDir).some(name => name.endsWith(".js"))
if (!hasAssets) {
return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` }
}
@ -46,8 +47,8 @@ function checkDistBuilt(distDir) {
}
function main() {
const desktopRoot = path.resolve(__dirname, "..")
const distDir = path.join(desktopRoot, "dist")
const desktopRoot = resolve(import.meta.dirname, "..")
const distDir = join(desktopRoot, "dist")
const result = checkDistBuilt(distDir)
if (!result.ok) {
@ -63,8 +64,8 @@ function main() {
console.log("✓ assert-dist-built: dist/index.html + assets present")
}
if (require.main === module) {
if (isMain(import.meta.url)) {
main()
}
module.exports = { checkDistBuilt }
export default { checkDistBuilt }

View file

@ -1,10 +1,10 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs')
import { checkDistBuilt } from '../scripts/assert-dist-built.mjs'
function makeDist(extra) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))

View file

@ -1,13 +0,0 @@
"use strict"
const fs = require("fs")
const path = require("path")
const root = path.resolve(__dirname, "..", "..", "..")
try {
fs.accessSync(path.join(root, "node_modules", "vite", "package.json"))
} catch {
console.error(`Run from repo root: cd ${root} && npm ci`)
process.exit(1)
}

View file

@ -0,0 +1,11 @@
import { accessSync } from "fs"
import { resolve, join } from "path"
const root = resolve(import.meta.dirname, "..", "..", "..")
try {
accessSync(join(root, "node_modules", "vite", "package.json"))
} catch {
console.error(`Run from repo root: cd ${root} && npm ci`)
process.exit(1)
}

View file

@ -4,8 +4,8 @@
* avoids workspace dependency graph explosions and keeps packaging
* deterministic across environments. The Hermes Agent Python payload is no
* longer bundled; the Electron app fetches it at first launch via
* `install.ps1`'s stage protocol (Windows). See `electron/main.cjs`.
* `install.ps1`'s stage protocol (Windows). See `electron/main.ts`.
*/
module.exports = async function beforeBuild() {
export default async function beforeBuild() {
return false
}

View file

@ -1,10 +1,11 @@
'use strict'
/**
* before-pack.cjs electron-builder beforePack hook.
* before-pack.mjs electron-builder beforePack hook.
*
* Removes any stale unpacked app directory (`appOutDir`) before
* electron-builder stages the Electron binaries into it.
* Two responsibilities:
*
* 1. Removes any stale unpacked app directory (`appOutDir`) before
* electron-builder stages the Electron binaries into it.
*
* WHY THIS EXISTS
* ---------------
@ -41,30 +42,41 @@
* resolve rather than throw worst case electron-builder hits the original
* ENOENT, which is no worse than not having this hook at all.
*
* 2. Re-stages node-pty's native files for the ACTUAL target platform/arch
* of this pack. `npm run build` already staged node-pty once for the
* host machine (see scripts/stage-native-deps.mjs), which is correct for
* single-arch builds matching the host. But electron-builder can target
* a different arch than the host (cross-build), or pack multiple archs
* from one `npm run build` (e.g. `dist:mac` => x64 + arm64). Only this
* hook knows the real per-target arch, via `context.arch` /
* `context.electronPlatformName` so it re-stages on top of whatever
* `npm run build` left behind, per target, right before files are read
* for packing.
*
* electron-builder passes a context with:
* - appOutDir: the unpacked app directory about to be staged
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
* - arch: Arch enum (0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal)
*/
import { existsSync, rmSync } from 'node:fs'
import { Arch } from 'electron-builder'
import { stageNodePty } from './stage-native-deps.mjs'
const fs = require('node:fs')
function cleanStaleAppOutDir(appOutDir) {
export function cleanStaleAppOutDir(appOutDir) {
if (!appOutDir || typeof appOutDir !== 'string') {
return false
}
if (!fs.existsSync(appOutDir)) {
if (!existsSync(appOutDir)) {
return false
}
// Recursive + force so a half-written tree (read-only bits, partial files)
// can't block the wipe. retry/maxRetries rides out transient EBUSY on
// Windows where an AV/indexer may briefly hold a handle.
fs.rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
return true
}
exports.cleanStaleAppOutDir = cleanStaleAppOutDir
exports.default = async function beforePack(context) {
export default async function beforePack(context) {
const appOutDir = context && context.appOutDir
try {
if (cleanStaleAppOutDir(appOutDir)) {
@ -75,4 +87,26 @@ exports.default = async function beforePack(context) {
// directory (permissions, mount) is still diagnosable.
console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`)
}
}
try {
const platform = context && context.electronPlatformName
const archName = context && typeof context.arch === 'number' ? Arch[context.arch] : undefined
if (platform && archName) {
if (archName === 'universal') {
console.warn(
'[before-pack] target arch is "universal" — node-pty has no universal prebuild; ' +
'staged binary will be whichever single-arch copy npm run build left behind. ' +
'lipo-merge x64/arm64 .node files manually if you need a true universal build.'
)
} else {
await stageNodePty({ platform, arch: archName })
console.log(`[before-pack] re-staged node-pty for target ${platform}-${archName}`)
}
}
} catch (err) {
// This one SHOULD fail the build — a missing/wrong native binary for the
// target arch means a broken package shipped to users, which is worse
// than a build that fails loudly here.
throw new Error(`[before-pack] failed to stage node-pty for this target: ${err.message}`)
}
}

View file

@ -1,10 +1,10 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
const { cleanStaleAppOutDir } = require('../scripts/before-pack.cjs')
import beforePack, { cleanStaleAppOutDir } from '../scripts/before-pack.mjs'
test('cleanStaleAppOutDir removes a populated unpacked directory', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
@ -45,7 +45,6 @@ test('cleanStaleAppOutDir ignores empty or invalid input', () => {
})
test('beforePack default export resolves even when cleanup throws', async () => {
const { default: beforePack } = require('../scripts/before-pack.cjs')
// A directory path that rmSync can't remove is simulated by passing a
// context whose appOutDir is a file the hook will try (and be allowed) to
// remove; the contract under test is that the hook never rejects.

View file

@ -0,0 +1,60 @@
#!/usr/bin/env node
// bundle-electron-main.mjs — bundles electron/main.ts and electron/preload.ts
// into self-contained js files in dist/ so the packaged app doesn't need
// node_modules/ or tsx at runtime.
//
// Output:
// dist/electron-main.mjs (MJS bundle — entry point for packaged app)
// dist/electron-preload.js (CJS bundle — loaded via BrowserWindow preload)
//
// `electron` and `node-pty` are external (provided by the runtime / staged
// separately via stage-native-deps).
import { build } from 'esbuild'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { mkdirSync } from 'node:fs'
const here = dirname(fileURLToPath(import.meta.url))
const root = resolve(here, '..')
const distDir = resolve(root, 'dist')
mkdirSync(distDir, { recursive: true })
const mainEntry = resolve(root, 'electron/main.ts')
const mainOut = resolve(distDir, 'electron-main.mjs')
const preloadEntry = resolve(root, 'electron/preload.ts')
const preloadOut = resolve(distDir, 'electron-preload.js')
const external = ['electron', 'node-pty', 'fs']
const define = {
'process.env.HERMES_DESKTOP_IS_PACKAGED': JSON.stringify(true)
}
// Bundle main.ts → dist/electron-main.mjs
await build({
entryPoints: [mainEntry],
bundle: true,
platform: 'node',
format: 'esm',
target: 'node20',
outfile: mainOut,
external,
banner: {
js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
},
define,
logLevel: 'info',
})
console.log(`bundled ${mainOut}`)
// Bundle preload.ts → dist/electron-preload.cjs
await build({
entryPoints: [preloadEntry],
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
outfile: preloadOut,
external,
define,
logLevel: 'info',
})
console.log(`bundled ${preloadOut}`)

View file

@ -1,7 +1,7 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFile } = require('node:child_process')
import { existsSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { execFile } from 'node:child_process'
function run(command, args) {
return new Promise((resolve, reject) => {
@ -26,7 +26,7 @@ function resolveApiKeyPath(rawValue) {
const value = String(rawValue || '').trim()
if (!value) return { keyPath: '', cleanup: () => {} }
if (fs.existsSync(value)) {
if (existsSync(value)) {
return { keyPath: value, cleanup: () => {} }
}
@ -34,17 +34,17 @@ function resolveApiKeyPath(rawValue) {
throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content')
}
const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`)
fs.writeFileSync(tempPath, value, 'utf8')
const tempPath = join(tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`)
writeFileSync(tempPath, value, 'utf8')
return {
keyPath: tempPath,
cleanup: () => fs.rmSync(tempPath, { force: true })
cleanup: () => rmSync(tempPath, { force: true })
}
}
async function main() {
const artifactPath = process.argv[2]
if (!artifactPath || !fs.existsSync(artifactPath)) {
if (!artifactPath || !existsSync(artifactPath)) {
throw new Error(`Missing artifact to notarize: ${artifactPath || '(none)'}`)
}

View file

@ -1,7 +1,7 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFile } = require('node:child_process')
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFile } from 'node:child_process'
function run(command, args) {
return new Promise((resolve, reject) => {
@ -49,7 +49,7 @@ function resolveApiKeyPath(rawValue) {
}
}
exports.default = async function notarize(context) {
export default async function notarize(context) {
const { electronPlatformName, appOutDir, packager } = context
if (electronPlatformName !== 'darwin') return

View file

@ -1,11 +1,11 @@
const fs = require('node:fs')
const path = require('node:path')
import fs from 'node:fs'
import path from 'node:path'
if (process.platform !== 'darwin') {
process.exit(0)
}
const desktopRoot = path.resolve(__dirname, '..')
const desktopRoot = path.resolve(import.meta.dirname, '..')
const repoRoot = path.resolve(desktopRoot, '..', '..')
const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js')

View file

@ -0,0 +1,22 @@
// rebuild-native.mjs
import { rebuild } from '@electron/rebuild'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { isMain } from './utils.mjs'
import packageJson from '../package.json' with { type: 'json' }
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
export async function rebuildNodePty({ arch = process.arch } = {}) {
await rebuild({
buildPath: projectRoot, // where node_modules lives
electronVersion: packageJson.devDependencies.electron.replace('^', ''),
arch,
onlyModules: ['node-pty'],
force: true
})
}
if (isMain(import.meta.url)) {
const [arch] = process.argv.slice(2)
await rebuildNodePty({ arch })
}

View file

@ -1,14 +1,15 @@
"use strict"
// Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can
// re-unpack a broken Electron.app; reusing the installed dist dodges that.
// npm workspace hoisting is non-deterministic — require.resolve finds electron
// wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let
// electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR).
const fs = require("node:fs")
const path = require("node:path")
const { spawnSync } = require("node:child_process")
import fs from "node:fs"
import path from "node:path"
import { spawnSync } from "node:child_process"
import { createRequire } from "node:module"
const require = createRequire(import.meta.url)
function electronDistDir() {
try {

View file

@ -1,5 +1,5 @@
#!/usr/bin/env node
// set-exe-identity.cjs — stamp the Hermes icon + version metadata onto the
// set-exe-identity.mjs — stamp the Hermes icon + version metadata onto the
// built Hermes.exe using rcedit, completely decoupled from electron-builder's
// signing path.
//
@ -20,7 +20,7 @@
//
// HOW IT RUNS
// -----------
// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.cjs),
// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.mjs),
// so EVERY packed build — first install, `hermes desktop`, the installer's
// --update rebuild, or a dev's manual `npm run pack` — gets a branded exe from
// one place. Previously this stamp lived only in install.ps1, so the update
@ -28,40 +28,34 @@
// shipped a stock "Electron" exe. Keeping it in afterPack closes that gap.
//
// Also runnable standalone for ad-hoc re-stamping:
// node scripts/set-exe-identity.cjs <path-to-Hermes.exe>
// node scripts/set-exe-identity.mjs <path-to-Hermes.exe>
//
// Exits 0 on success, non-zero on failure when run as a CLI. As a hook,
// stampExeIdentity() resolves on success and rejects on failure; the caller
// (after-pack.cjs) swallows the rejection so a stamp failure never fails an
// (after-pack.mjs) swallows the rejection so a stamp failure never fails an
// otherwise-good build (worst case: stock icon, not a broken app).
const path = require('node:path')
const fs = require('node:fs')
import { resolve, join } from 'node:path'
import { existsSync } from 'node:fs'
import { rcedit } from 'rcedit'
import { isMain } from './utils.mjs'
// Stamp the Hermes icon + identity onto `exe`. Resolves on success, throws on
// failure. `desktopRoot` defaults to this script's package root so the icon and
// the rcedit dependency resolve regardless of cwd.
async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..')) {
if (!exe || !fs.existsSync(exe)) {
async function stampExeIdentity(exe, desktopRoot = resolve(import.meta.dirname, '..')) {
if (!exe || !existsSync(exe)) {
throw new Error(`target exe not found: ${exe}`)
}
// Icon lives at apps/desktop/assets/icon.ico
const icon = path.join(desktopRoot, 'assets', 'icon.ico')
if (!fs.existsSync(icon)) {
const icon = join(desktopRoot, 'assets', 'icon.ico')
if (!existsSync(icon)) {
throw new Error(`icon not found: ${icon}`)
}
// rcedit is a direct devDependency of apps/desktop, so it resolves whether
// we're run from the desktop dir or the repo root (workspace hoist).
// rcedit@5 exports a NAMED `rcedit` function (CommonJS: { rcedit }), not a
// default export.
const mod = require('rcedit')
const rcedit = typeof mod === 'function' ? mod : mod.rcedit
if (typeof rcedit !== 'function') {
throw new Error(`unexpected rcedit export shape: ${typeof mod} keys=${Object.keys(mod)}`)
}
console.log(`[set-exe-identity] stamping ${exe}`)
console.log(`[set-exe-identity] icon: ${icon}`)
@ -78,13 +72,13 @@ async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..')
console.log('[set-exe-identity] done — Hermes icon + identity stamped')
}
module.exports = { stampExeIdentity }
export { stampExeIdentity }
// CLI entry point: `node scripts/set-exe-identity.cjs <exe>`.
if (require.main === module) {
// CLI entry point: `node scripts/set-exe-identity.mjs <exe>`.
if (isMain(import.meta.url)) {
const exe = process.argv[2]
if (!exe) {
console.error('[set-exe-identity] usage: set-exe-identity.cjs <path-to-exe>')
console.error('[set-exe-identity] usage: set-exe-identity.mjs <path-to-exe>')
process.exit(2)
}
stampExeIdentity(exe).catch(err => {

View file

@ -1,283 +0,0 @@
'use strict'
/**
* Stage native node-modules dependencies for electron-builder packaging.
*
* Workspace dedup hoists `node-pty` into the root `node_modules/`, which
* electron-builder's default file collector (when `files:` is explicitly set
* in package.json) cannot reach. The result: packaged builds ship with no
* .node binaries and PTY initialization fails at runtime ("PTY support is
* unavailable").
*
* Rather than restructure the workspace dedup (would require nohoist /
* package.json shenanigans and risk breaking dev) or balloon the package
* with the whole node_modules tree, we copy ONLY the runtime-essential
* files of the native dep into apps/desktop/build/native-deps/ and ship
* THAT subtree via extraResources. main.cjs falls back to require()-ing
* from process.resourcesPath when the hoisted-root require fails.
*
* Runs as part of `npm run build`. Idempotent -- always re-stages on each
* build to pick up native binary updates.
*
* Layout note: upstream node-pty (microsoft/node-pty 1.x) is N-API based
* and ships its prebuilts under `prebuilds/<platform>-<arch>/` instead of
* `build/Release/`. Its runtime resolver (lib/utils.js) checks
* build/Release first and falls through to the per-arch prebuilds dir, so
* shipping only the latter is sufficient for packaged runs. Per-arch
* staging keeps the resource bundle lean -- we only need the target
* arch's prebuilt, not all of them.
*/
const fs = require('node:fs')
const path = require('node:path')
const APP_ROOT = path.resolve(__dirname, '..')
const REPO_ROOT = path.resolve(APP_ROOT, '..', '..')
const STAGE_ROOT = path.join(APP_ROOT, 'build', 'native-deps')
// The target arch may be overridden by electron-builder via npm_config_arch
// (e.g. `npm run dist -- --arm64`); fall back to the build host's arch.
const TARGET_ARCH = process.env.npm_config_arch || process.arch
const TARGET_PLATFORM = process.platform
// Modules to stage. The "from" path is the hoisted location in the workspace
// root; "to" is the layout we want inside build/native-deps/. The "include"
// globs (relative to "from") select the runtime-essential files. Anything
// outside the include list is left behind (source, deps/, scripts/, etc.).
const NATIVE_DEPS = [
{
from: path.join(REPO_ROOT, 'node_modules', 'node-pty'),
to: path.join(STAGE_ROOT, 'node-pty'),
include: [
'package.json',
'lib/*.js',
'lib/**/*.js',
'build/Release/*.node',
// Per-arch runtime payload. Explicit file types so we don't ship the
// ~25 MB of .pdb debug symbols that prebuild-install bundles for
// Windows crash analysis -- not used at runtime, would just bloat
// the installer.
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.node`,
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.dll`,
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.exe`,
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/spawn-helper`,
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/conpty/*`
]
}
]
// Pure-JS runtime dependencies that the packaged electron main require()s but
// that workspace dedup hoists into the repo-root node_modules -- out of reach
// of electron-builder's file collector, exactly like node-pty above. Unlike
// node-pty there is no native binary to select; we stage each package's whole
// directory into build/native-deps/vendor/node_modules/<name> so the dep's own
// internal require()s resolve against a real node_modules tree, and the
// requiring file (electron/git-review-ops.cjs) falls back to that path via
// process.resourcesPath when the normal require() fails. See issue #52735
// (packaged app crashed at launch on `Cannot find module 'simple-git'`).
//
// The closure is resolved at stage time by walking dependencies +
// optionalDependencies, so a simple-git version bump that pulls in a new
// transitive dep can't silently re-introduce the crash.
//
// Layout note: the closure lands in build/native-deps/vendor/node_modules/,
// NOT build/native-deps/node_modules/. electron-builder's file collector
// hard-drops a `node_modules` directory that sits at the ROOT of an
// extraResources copy (app-builder-lib/out/util/filter.js: `if (relative ===
// "node_modules") return false`), but keeps a NESTED one. Nesting under
// `vendor/` makes node_modules a subdirectory so it survives packing; the
// require() fallback in git-review-ops.cjs resolves the matching
// vendor/node_modules path.
const JS_DEP_ROOTS = ['simple-git']
const JS_DEP_STAGE_ROOT = path.join(STAGE_ROOT, 'vendor', 'node_modules')
function rmrf(target) {
fs.rmSync(target, { recursive: true, force: true })
}
function ensureDir(target) {
fs.mkdirSync(target, { recursive: true })
}
function walk(root) {
const results = []
const stack = [root]
while (stack.length) {
const current = stack.pop()
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(current, entry.name)
if (entry.isDirectory()) {
stack.push(full)
} else if (entry.isFile()) {
results.push(full)
}
}
}
return results
}
// Match a relative path against simple ** and * glob patterns. Implementation
// is intentionally tiny -- the include lists are small and don't need full
// minimatch support.
function matchGlob(rel, pattern) {
const r = rel.replace(/\\/g, '/')
const re = new RegExp(
'^' +
pattern
.replace(/\\/g, '/')
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '__DOUBLE_STAR__')
.replace(/\*/g, '[^/]*')
.replace(/__DOUBLE_STAR__/g, '.*') +
'$'
)
return re.test(r)
}
function stageOne(spec) {
if (!fs.existsSync(spec.from)) {
throw new Error(
`stage-native-deps: source missing at ${spec.from}. Run \`npm install\` ` +
`at the workspace root first.`
)
}
rmrf(spec.to)
ensureDir(spec.to)
const files = walk(spec.from)
let copied = 0
for (const abs of files) {
const rel = path.relative(spec.from, abs)
const included = spec.include.some(g => matchGlob(rel, g))
if (!included) continue
const dest = path.join(spec.to, rel)
ensureDir(path.dirname(dest))
fs.copyFileSync(abs, dest)
// node-pty's darwin spawn-helper and the Windows helper binaries
// (OpenConsole.exe, winpty-agent.exe) are invoked via posix_spawn /
// CreateProcess at runtime, so they must remain executable in the
// staged tree. fs.copyFileSync preserves source mode on POSIX, but we
// re-assert +x defensively for the darwin spawn-helper (no extension
// means a stripped mode would be silently broken at runtime).
if (path.basename(rel) === 'spawn-helper' && process.platform !== 'win32') {
try { fs.chmodSync(dest, 0o755) } catch { /* best-effort */ }
}
copied += 1
}
console.log(`[stage-native-deps] ${path.relative(APP_ROOT, spec.to)}: ${copied} files`)
}
// Resolve a package's directory by name, searching the repo-root node_modules
// first (where workspace dedup hoists everything) and then the requiring
// package's own node_modules for any non-hoisted nested copy.
//
// We deliberately do NOT use require.resolve(`${name}/package.json`): packages
// with an "exports" map that doesn't list "./package.json" (e.g. simple-git
// 3.x) make that subpath unresolvable under Node's exports enforcement
// (ERR_PACKAGE_PATH_NOT_EXPORTED), which fails on CI even though it happened to
// work locally. Instead resolve the package's main entry (exports-aware) and
// walk up to the directory whose package.json's "name" matches.
function resolvePkgDir(name, fromDir) {
const searchPaths = [fromDir, REPO_ROOT, path.join(REPO_ROOT, 'node_modules')]
let entry
try {
entry = require.resolve(name, { paths: searchPaths })
} catch {
return null
}
// Walk up from the resolved entry file to the package root: the first
// ancestor dir whose package.json declares this package's name.
let dir = path.dirname(entry)
while (true) {
const pjPath = path.join(dir, 'package.json')
try {
const pj = JSON.parse(fs.readFileSync(pjPath, 'utf8'))
if (pj.name === name) {
return dir
}
} catch {
// no package.json here (or unreadable) — keep walking up
}
const parent = path.dirname(dir)
if (parent === dir) {
return null
}
dir = parent
}
}
// Walk dependencies + optionalDependencies from each root package and return
// the set of resolved package directories in the runtime closure. Keyed by
// package name so a dep reached via two paths is staged once.
function resolveJsClosure(roots) {
const closure = new Map() // name -> absolute package dir
const stack = roots.map(name => ({ name, fromDir: REPO_ROOT }))
while (stack.length) {
const { name, fromDir } = stack.pop()
if (closure.has(name)) continue
const dir = resolvePkgDir(name, fromDir)
if (!dir) {
throw new Error(
`stage-native-deps: could not resolve '${name}' for the simple-git ` +
`closure. Run \`npm install\` at the workspace root first.`
)
}
closure.set(name, dir)
let pj
try {
pj = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
} catch {
continue
}
const deps = { ...(pj.dependencies || {}), ...(pj.optionalDependencies || {}) }
for (const depName of Object.keys(deps)) {
stack.push({ name: depName, fromDir: dir })
}
}
return closure
}
// Stage the resolved JS dependency closure into build/native-deps/vendor/node_modules/
// so the packaged app (and the nix output) can require() it from
// process.resourcesPath when the hoisted-root require() isn't reachable. Each
// package is copied whole (minus node_modules/ — the closure is flattened so
// every dep already has its own top-level entry) into a real node_modules
// layout, which keeps the deps' own internal require()s working unchanged.
function stageJsClosure(roots) {
const closure = resolveJsClosure(roots)
rmrf(JS_DEP_STAGE_ROOT)
ensureDir(JS_DEP_STAGE_ROOT)
let staged = 0
for (const [name, fromDir] of closure) {
const dest = path.join(JS_DEP_STAGE_ROOT, name)
ensureDir(path.dirname(dest))
// Copy the package directory but skip any nested node_modules/ — the
// closure is flattened, so nested copies would just bloat the bundle.
fs.cpSync(fromDir, dest, {
recursive: true,
filter: src => path.basename(src) !== 'node_modules'
})
staged += 1
}
console.log(
`[stage-native-deps] vendor/node_modules/: ${staged} package(s) ` +
`(${[...closure.keys()].sort().join(', ')})`
)
}
function main() {
rmrf(STAGE_ROOT)
ensureDir(STAGE_ROOT)
for (const spec of NATIVE_DEPS) {
stageOne(spec)
}
stageJsClosure(JS_DEP_ROOTS)
}
main()

View file

@ -0,0 +1,137 @@
#!/usr/bin/env node
// stage-native-deps.mjs — stages node-pty's native runtime dependencies
//
// Usage:
// node scripts/stage-native-deps.mjs # host platform/arch
// node scripts/stage-native-deps.mjs win32 arm64 # explicit target
//
// Also exported as `stageNodePty({ platform, arch })` for use from
// before-pack.mjs, where electron-builder gives you the real per-target
// platform/arch during multi-arch builds.
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { dirname, resolve, join } from 'node:path'
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
rmSync
} from 'node:fs'
import { isMain } from './utils.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const projectRoot = resolve(here, '..')
const require = createRequire(import.meta.url)
/**
* Locate node-pty's package root via real module resolution, so this
* works whether it's hoisted to a workspace root or local to this app.
*/
function resolveNodePtyRoot() {
const pkgJsonPath = require.resolve('node-pty/package.json', {
paths: [projectRoot]
})
return dirname(pkgJsonPath)
}
function copyGlobByExt(srcDir, destDir, extensions) {
if (!existsSync(srcDir)) return
mkdirSync(destDir, { recursive: true })
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
copyGlobByExt(join(srcDir, entry.name), join(destDir, entry.name), extensions)
continue
}
if (extensions.some((ext) => entry.name.endsWith(ext))) {
mkdirSync(destDir, { recursive: true })
cpSync(join(srcDir, entry.name), join(destDir, entry.name))
}
}
}
/**
* Copies the locally-compiled build/Release output (used when no prebuild
* was available and node-pty was built from source for the host machine).
*
* Filters by name/pattern rather than extension only: macOS builds a
* separate `spawn-helper` executable (no file extension) that
* lib/unixTerminal.js requires at a fixed relative path. Filtering this
* directory by ['.node'] silently drops it the package then looks
* fine, ships fine, and crashes the first time a terminal is spawned.
* Directories are copied wholesale to also cover any nested native
* payload (e.g. a conpty/ subfolder some build layouts produce).
*/
function copyBuildRelease(srcDir, destDir) {
if (!existsSync(srcDir)) return
mkdirSync(destDir, { recursive: true })
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
cpSync(join(srcDir, entry.name), join(destDir, entry.name), { recursive: true })
continue
}
if (entry.name === 'spawn-helper' || /\.(node|dll|exe)$/.test(entry.name)) {
cpSync(join(srcDir, entry.name), join(destDir, entry.name))
}
}
}
export function stageNodePty({ platform = process.platform, arch = process.arch } = {}) {
const srcRoot = resolveNodePtyRoot()
const destRoot = resolve(projectRoot, 'dist/node_modules/node-pty')
rmSync(destRoot, { recursive: true, force: true })
mkdirSync(destRoot, { recursive: true })
// package.json — needed so `require('node-pty')` resolves the package
// (reads "main") rather than treating it as a directory with no entry.
cpSync(join(srcRoot, 'package.json'), join(destRoot, 'package.json'))
// lib/**/*.js — the JS surface node-pty's `main` points into.
copyGlobByExt(join(srcRoot, 'lib'), join(destRoot, 'lib'), ['.js'])
// build/Release/* — present when node-pty was compiled locally
// (e.g. no prebuild available for this Electron ABI/platform combo).
// Some installs won't have this at all if prebuild-install succeeded.
copyBuildRelease(join(srcRoot, 'build/Release'), join(destRoot, 'build/Release'))
// prebuilds/<platform>-<arch>/* — the prebuild-install payload for the
// *target* we're packaging, not necessarily the host running this script.
// Explicit extensions only, to skip the ~25MB of Windows .pdb symbols
// prebuild-install bundles alongside the .node/.dll.
const prebuildDir = join(srcRoot, 'prebuilds', `${platform}-${arch}`)
if (existsSync(prebuildDir)) {
const destPrebuild = join(destRoot, 'prebuilds', `${platform}-${arch}`)
mkdirSync(destPrebuild, { recursive: true })
for (const entry of readdirSync(prebuildDir, { withFileTypes: true })) {
if (entry.name === 'conpty' && entry.isDirectory()) {
cpSync(join(prebuildDir, 'conpty'), join(destPrebuild, 'conpty'), { recursive: true })
continue
}
if (entry.isFile() && /\.(node|dll|exe)$/.test(entry.name)) {
cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name))
continue
}
if (entry.name === 'spawn-helper') {
cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name))
}
}
} else {
console.warn(
`[stage-native-deps] no prebuild found at prebuilds/${platform}-${arch} for node-pty. ` +
`If build/Release/* above is also empty, this target will fail at runtime. ` +
`Run "npx electron-rebuild -w node-pty" for this target, or check that ` +
`node-pty's published prebuilds cover ${platform}-${arch}.`
)
}
console.log(`[stage-native-deps] staged node-pty (${platform}-${arch}) -> ${destRoot}`)
return destRoot
}
// Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch]
if (isMain(import.meta.url)) {
const [platform, arch] = process.argv.slice(2)
stageNodePty({ platform, arch })
}

View file

@ -5,10 +5,11 @@ import { spawn, spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { listPackage } from '@electron/asar'
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8'))
import PACKAGE_JSON from '../package.json' with { type: 'json' }
const MODE = process.argv[2] || 'help'
const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
const PLATFORM = process.platform
@ -48,7 +49,7 @@ const APP = (() => {
}
})()
// Default HERMES_HOME for non-sandboxed runs -- matches main.cjs's
// Default HERMES_HOME for non-sandboxed runs -- matches main.ts's
// resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere
// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own
// HERMES_HOME and never touches this.
@ -83,17 +84,23 @@ function exists(target) {
return fs.existsSync(target)
}
// Match nodepty native binding location to what main.cjs's resolver fallback
// expects (apps/desktop/electron/main.cjs, packaged-build branch). Upstream
// node-pty 1.x is N-API based and ships per-arch prebuilts under
// prebuilds/<platform>-<arch>/ instead of build/Release/. We check the
// per-arch dir since that's what stage-native-deps actually copies.
// Match node-pty native binding location to what the bundled electron-main.cjs
// resolves at runtime. stage-native-deps.mjs stages node-pty into
// dist/node_modules/node-pty, and dist/** is asarUnpacked (see package.json
// build.asarUnpack), so in a packaged build it lands under
// resources/app.asar.unpacked/dist/node_modules/node-pty — reachable by a bare
// require('node-pty') from the bundle. Upstream node-pty 1.x is N-API based and
// ships per-arch prebuilts under prebuilds/<platform>-<arch>/; nix/local builds
// instead compile from source into build/Release/. The stage script copies
// whichever is present, so we accept either as the native payload.
function expectedNativeDepPaths() {
const root = path.join(APP.resourcesPath, 'native-deps', 'node-pty')
const root = path.join(APP.resourcesPath, 'app.asar.unpacked', 'dist', 'node_modules', 'node-pty')
const prebuildsDir = path.join(root, 'prebuilds', `${PLATFORM}-${ARCH}`)
const buildReleaseDir = path.join(root, 'build', 'Release')
return {
packageJson: path.join(root, 'package.json'),
prebuildsDir,
buildReleaseDir,
libIndex: path.join(root, 'lib', 'index.js')
}
}
@ -279,8 +286,8 @@ function launchFresh() {
// - The Hermes Agent Python payload is NOT shipped (it's fetched at first
// launch via install.ps1's stage protocol).
// - install-stamp.json IS shipped in resources/ with a valid commit + branch.
// - native-deps/@homebridge/node-pty-prebuilt-multiarch/ IS shipped with
// the package.json + lib/ + at least one .node binary (the renderer's
// - node-pty IS shipped inside app.asar.unpacked/dist/node_modules/node-pty
// with package.json + lib/ + at least one .node binary (the renderer's
// integrated terminal needs this; see Phase 1F.6).
// - The renderer's dist/index.html is reachable (either unpacked or
// inside app.asar).
@ -320,24 +327,35 @@ function validateBundle() {
// Positive assertion: node-pty native deps shipped
const native = expectedNativeDepPaths()
if (!exists(native.packageJson)) {
die(`Missing node-pty package.json in resources/native-deps: ${native.packageJson}`)
die(`Missing node-pty package.json in app.asar.unpacked: ${native.packageJson}`)
}
if (!exists(native.libIndex)) {
die(`Missing node-pty lib/index.js in resources/native-deps: ${native.libIndex}`)
die(`Missing node-pty lib/index.js in app.asar.unpacked: ${native.libIndex}`)
}
if (!exists(native.prebuildsDir)) {
die(`Missing node-pty prebuilds dir for ${PLATFORM}-${ARCH}: ${native.prebuildsDir}`)
// The native binary lands in prebuilds/<platform>-<arch>/ (downloaded prebuild)
// OR build/Release/ (compiled from source). stage-native-deps.mjs copies
// whichever is present, so accept either.
const nativeBinaryDirs = [native.prebuildsDir, native.buildReleaseDir].filter(exists)
if (nativeBinaryDirs.length === 0) {
die(
`Missing node-pty native binary dir for ${PLATFORM}-${ARCH}: neither ` +
`${native.prebuildsDir} nor ${native.buildReleaseDir} exists`
)
}
const nodeBinaries = fs.readdirSync(native.prebuildsDir).filter(name => name.endsWith('.node'))
const nodeBinaries = nativeBinaryDirs.flatMap(dir =>
fs.readdirSync(dir).filter(name => name.endsWith('.node'))
)
if (nodeBinaries.length === 0) {
die(`No .node native binaries found in: ${native.prebuildsDir}`)
die(`No .node native binaries found in: ${nativeBinaryDirs.join(', ')}`)
}
// Darwin requires a runtime-execed spawn-helper alongside pty.node; missing
// it manifests as "ENOENT: spawn-helper" on first pty.spawn() call.
if (PLATFORM === 'darwin') {
const spawnHelper = path.join(native.prebuildsDir, 'spawn-helper')
if (!exists(spawnHelper)) {
die(`Missing node-pty spawn-helper (required on darwin): ${spawnHelper}`)
const spawnHelper = nativeBinaryDirs
.map(dir => path.join(dir, 'spawn-helper'))
.find(exists)
if (!spawnHelper) {
die(`Missing node-pty spawn-helper (required on darwin) in: ${nativeBinaryDirs.join(', ')}`)
}
}

View file

@ -0,0 +1,8 @@
import { pathToFileURL } from 'node:url';
// returns true if the passsed file is being invoked from node,
// not imported.
export function isMain(importMetaUrl) {
return importMetaUrl === pathToFileURL(process.argv[1]).href;
}

View file

@ -4,7 +4,7 @@
* Writes apps/desktop/build/install-stamp.json with the git ref the desktop
* .exe should pin to at first-launch bootstrap time. This file ships inside
* the packaged app via electron-builder's extraResources entry and is read
* by electron/main.cjs to drive the install.ps1 stage bootstrap flow.
* by electron/main.ts to drive the install.ps1 stage bootstrap flow.
*
* Schema (subject to bump via STAMP_SCHEMA_VERSION):
* {
@ -26,16 +26,16 @@
* bootstrap without a stamp.
*/
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
import { mkdirSync, writeFileSync } from "fs"
import { resolve, join, relative } from "path"
import { execSync } from "child_process"
const STAMP_SCHEMA_VERSION = 1
const DESKTOP_ROOT = path.resolve(__dirname, "..")
const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..")
const OUT_DIR = path.join(DESKTOP_ROOT, "build")
const OUT_FILE = path.join(OUT_DIR, "install-stamp.json")
const DESKTOP_ROOT = resolve(import.meta.dirname, "..")
const REPO_ROOT = resolve(DESKTOP_ROOT, "..", "..")
const OUT_DIR = join(DESKTOP_ROOT, "build")
const OUT_FILE = join(OUT_DIR, "install-stamp.json")
function tryExec(cmd, opts) {
try {
@ -111,11 +111,11 @@ function main() {
source: stamp.source
}
fs.mkdirSync(OUT_DIR, { recursive: true })
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8")
mkdirSync(OUT_DIR, { recursive: true })
writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8")
console.log(
"[write-build-stamp] wrote " +
path.relative(REPO_ROOT, OUT_FILE) +
relative(REPO_ROOT, OUT_FILE) +
" -> " +
stamp.commit.slice(0, 12) +
(stamp.branch ? " (" + stamp.branch + ")" : "") +