mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
feat(desktop): ts-ify everything
This commit is contained in:
parent
fac85518fc
commit
39d09453f9
116 changed files with 2500 additions and 1756 deletions
|
|
@ -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)
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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-'))
|
||||
|
|
@ -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)
|
||||
}
|
||||
11
apps/desktop/scripts/assert-root-install.mjs
Normal file
11
apps/desktop/scripts/assert-root-install.mjs
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
60
apps/desktop/scripts/bundle-electron-main.mjs
Normal file
60
apps/desktop/scripts/bundle-electron-main.mjs
Normal 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}`)
|
||||
|
|
@ -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)'}`)
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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')
|
||||
|
||||
22
apps/desktop/scripts/rebuild-native.mjs
Normal file
22
apps/desktop/scripts/rebuild-native.mjs
Normal 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 })
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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 => {
|
||||
|
|
@ -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()
|
||||
137
apps/desktop/scripts/stage-native-deps.mjs
Normal file
137
apps/desktop/scripts/stage-native-deps.mjs
Normal 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 })
|
||||
}
|
||||
|
|
@ -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(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
8
apps/desktop/scripts/utils.mjs
Normal file
8
apps/desktop/scripts/utils.mjs
Normal 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;
|
||||
}
|
||||
|
|
@ -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 + ")" : "") +
|
||||
Loading…
Add table
Add a link
Reference in a new issue