Files
hermes-agent/apps/desktop/scripts/prepared-packaging.mjs
ethernet 2efa4ff94f refactor(desktop): prepare dependencies before saving build caches
Dependency acquisition during packaging left native wheels and packager
inputs outside the pre-build cache save. Compose PM and existing providers
into a preparation phase, then require builds to consume admitted inputs.

Share native preparation with PM Bundle. Keep path-bound environments and
signing outputs separate from reusable caches. Use read-only cache tokens
for commit builds and preserve the one-command local build path.

Verify pinned tools through PM, probe PTYs under the prepared Electron,
and supply dmgbuild through a build-only PM package. Resolve bundled tool
stores from their payload manifest so relocation preserves discovery.

Validation: focused Python and JS tests, checkJs, Ruff, Windows checks,
anti-slop, cache relocation, and network-denied Linux AppImage builds.
Relocated runtime smoke passed with NixOS host libraries supplied.
Native Windows/macOS signing and live GitHub cache behavior remain untested.
2026-09-13 14:28:31 -04:00

133 lines
6.1 KiB
JavaScript

import fs from 'node:fs'
import path from 'node:path'
import { createHash } from 'node:crypto'
/** @typedef {{ path: string, digest: string }} PreparedFile */
/** @typedef {{ sevenZip: string, icons: string, winCodeSign?: string, appimage?: string, fpm?: string }} PackagingToolsets */
/** @typedef {{ schema: number, source: string, out: string, identity: string, target: string, formats: string[], electron: string, toolsets: PackagingToolsets, windows: import('./windows-bundle-tools.mjs').WindowsBundleTools | null, dmgbuild: string | null, files: PreparedFile[] }} PreparedPackaging */
/** @param {string} message @returns {Error} */
export function preparationRequired(message) {
return new Error(`${message}; run preparation again`)
}
/** @param {string} file @returns {string} */
export function fileDigest(file) {
return createHash('sha256').update(fs.readFileSync(file)).digest('hex')
}
/**
* Include symlink destinations and modes, not timestamps. A copied supplier
* may use internal symlinks; external links would make its receipt incomplete.
* @param {string} root
* @returns {string}
*/
export function treeDigest(root) {
const hash = createHash('sha256')
const canonicalRoot = fs.realpathSync(root)
/** @param {string} entry @returns {void} */
function visit(entry) {
const relative = path.relative(root, entry)
const stat = fs.lstatSync(entry)
hash.update(JSON.stringify([relative, stat.mode & 0o777]))
if (stat.isSymbolicLink()) {
const target = fs.realpathSync(entry)
if (target !== canonicalRoot && !target.startsWith(canonicalRoot + path.sep)) {
throw preparationRequired(`Prepared input contains an external link: ${entry}`)
}
hash.update(fs.readlinkSync(entry))
} else if (stat.isDirectory()) {
for (const name of fs.readdirSync(entry).sort()) visit(path.join(entry, name))
} else if (stat.isFile()) {
hash.update(fileDigest(entry))
} else {
throw preparationRequired(`Unsupported prepared input: ${entry}`)
}
}
visit(root)
return hash.digest('hex')
}
/** @param {string} source @returns {string} */
export function packagingIdentity(source) {
const files = ['package-lock.json', 'apps/desktop/package.json', 'apps/desktop/electron-builder.config.cjs']
const recipe = ['prepare-packaging-tools.mjs', 'prepared-packaging.mjs', 'prepare-dmgbuild.mjs', 'prepare_dmgbuild.py', 'windows-bundle-tools.mjs', 'run-electron-builder.mjs']
return createHash('sha256').update(JSON.stringify([
...files.map(file => fileDigest(path.join(source, file))),
...recipe.map(file => fileDigest(path.join(import.meta.dirname, file))),
fileDigest(path.join(import.meta.dirname, '../../../pm/lock.json')),
])).digest('hex')
}
/** @param {string} root @param {string} file @returns {void} */
function assertOwned(root, file) {
const canonicalRoot = fs.realpathSync(root)
const canonicalFile = fs.realpathSync(file)
if (!path.isAbsolute(file) || !canonicalFile.startsWith(canonicalRoot + path.sep)) {
throw preparationRequired(`Prepared input is outside its work directory: ${file}`)
}
}
/**
* Publish only after every selected supplier completed. The receipt is job-local,
* not a cache attestation; trusted cache writers remain a prerequisite.
* @param {{ source: string, out: string, target: string, formats: string[], electron: string, toolsets: PackagingToolsets, windows?: import('./windows-bundle-tools.mjs').WindowsBundleTools | null, dmgbuild?: string | null }} inputs
* @returns {Promise<string>}
*/
export async function publishPackagingInputs(inputs) {
const out = fs.realpathSync(inputs.out)
const paths = [inputs.electron, ...Object.values(inputs.toolsets)]
if (inputs.windows?.dotnetRoot) paths.push(inputs.windows.dotnetRoot)
if (inputs.dmgbuild) paths.push(path.dirname(inputs.dmgbuild))
const files = paths.map(file => {
assertOwned(out, file)
return { path: file, digest: treeDigest(file) }
})
/** @type {PreparedPackaging} */
const result = {
schema: 1, source: fs.realpathSync(inputs.source), out,
identity: packagingIdentity(inputs.source), target: inputs.target,
formats: inputs.formats, electron: inputs.electron, toolsets: inputs.toolsets,
windows: inputs.windows ?? null, dmgbuild: inputs.dmgbuild ?? null, files,
}
const manifest = path.join(out, 'prepared.json')
fs.writeFileSync(`${manifest}.tmp`, JSON.stringify(result, null, 2) + '\n')
fs.renameSync(`${manifest}.tmp`, manifest)
return manifest
}
/**
* Read-only admission. No supplier or installer may be imported on this path.
* @param {string} manifest
* @param {string} source
* @param {string} [target]
* @returns {PreparedPackaging}
*/
export function readPackagingInputs(manifest, source, target = `${process.platform}-${process.arch}`) {
try {
/** @type {PreparedPackaging} */
const result = JSON.parse(fs.readFileSync(manifest, 'utf8'))
if (result.schema !== 1 || result.source !== fs.realpathSync(source) || result.out !== fs.realpathSync(path.dirname(manifest)) ||
result.target !== target || result.identity !== packagingIdentity(source)) {
throw preparationRequired('Stale or foreign packaging inputs')
}
const required = [result.electron, result.toolsets.sevenZip, result.toolsets.icons, ...Object.values(result.toolsets)]
if (target.startsWith('win32-')) {
if (!result.windows || !result.toolsets.winCodeSign || !result.windows.dotnetRoot) throw preparationRequired('Missing Windows tool selection')
required.push(result.windows.dotnetRoot)
}
if (result.formats.includes('dmg')) {
if (!result.dmgbuild) throw preparationRequired('Missing prepared dmgbuild')
required.push(path.dirname(result.dmgbuild))
}
for (const file of new Set(required)) {
assertOwned(result.out, file)
const record = result.files.find(entry => entry.path === file)
if (!record || record.digest !== treeDigest(file)) throw preparationRequired(`Missing or changed packaging input: ${file}`)
}
return result
} catch (error) {
throw preparationRequired(`Cannot consume packaging inputs ${manifest}: ${error instanceof Error ? error.message : String(error)}`)
}
}