import { spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import * as fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; const BUNDLE_ARGS = ['bundle', '--release', '--format', 'osx', '--bin', 'galaxy-oss', '--package', 'galaxy', '--features', 'release_bundle,extern_plist']; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const sha = (value) => createHash('sha256').update(value).digest('hex'); async function runCommand(command, args, { cwd, env, onLog, capture = true }) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, env, shell: false, stdio: ['ignore', 'pipe', 'pipe'] }); let output = ''; const consume = (stream, collect, emit) => { let pending = ''; stream.on('data', (chunk) => { const text = chunk.toString(); if (collect) output += text; const lines = (pending + text).split(/\r\n|\r|\n/); pending = lines.pop(); for (const line of lines) if (emit && line.trim()) onLog?.(line); }); stream.on('end', () => { if (emit && pending.trim()) onLog?.(pending); }); }; const metadata = command === 'cargo' && args[0] === 'metadata'; consume(child.stdout, capture, !metadata); consume(child.stderr, false, true); child.on('error', reject); child.on('close', (code) => code === 0 ? resolve(output.trim()) : reject(new Error(`${command} ${args.join(' ')} failed (exit ${code})`))); }); } async function processStart(pid) { try { return await runCommand('ps', ['-o', 'lstart=', '-p', String(pid)], { capture: true }); } catch { return null; } } async function acquireLock(cacheDir) { await fs.mkdir(cacheDir, { recursive: true }); const lock = path.join(cacheDir, 'prepare.lock'); const start = await processStart(process.pid); if (!start) throw new Error('Cannot establish process identity for Galaxy build lock'); const owner = JSON.stringify({ pid: process.pid, start, token: randomUUID() }); while (true) { try { // A symlink publishes the complete owner record in a single atomic operation. await fs.symlink(owner, lock); return async () => { if (await fs.readlink(lock) !== owner) throw new Error('Galaxy build lock ownership changed'); await fs.unlink(lock); }; } catch (error) { if (error.code !== 'EEXIST') throw error; } let other; try { other = JSON.parse(await fs.readlink(lock)); } catch (error) { if (error.code === 'ENOENT') continue; throw new Error(`Invalid Galaxy build lock at ${lock}; remove it manually after confirming no build is running`, { cause: error }); } if (!Number.isSafeInteger(other.pid) || other.pid <= 0 || typeof other.start !== 'string') { throw new Error(`Invalid Galaxy build lock at ${lock}; remove it manually after confirming no build is running`); } const current = await processStart(other.pid); if (current === other.start) { await sleep(1000); continue; } if (!current) { try { process.kill(other.pid, 0); await sleep(1000); continue; } catch (error) { if (error.code !== 'ESRCH') throw error; } } // Never reclaim a lock automatically: another waiter could race a new owner. throw new Error(`Stale Galaxy build lock at ${lock}; remove it manually after confirming no build is running`); } } async function optionalHash(file) { try { return sha(await fs.readFile(file)); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } } async function cargoConfigs(root, env, home) { const result = {}; let dir = path.resolve(root); while (true) { for (const name of ['config', 'config.toml']) { const file = path.join(dir, '.cargo', name); result[file] = await optionalHash(file); } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } const cargoHome = env.CARGO_HOME || path.join(home, '.cargo'); for (const name of ['config', 'config.toml']) { const file = path.resolve(cargoHome, name); result[file] = await optionalHash(file); } return result; } // These two UI packages contribute only their own launch shims, not Cargo build inputs. // Strip their bin directories from both the key and subprocess environment. function buildEnvironment(root, env) { const bins = new Set(['build-and-install-to-applications', 'build-and-deploy-hermes'].map((name) => path.join(root, 'script', name, 'node_modules', '.bin'))); return { ...env, PATH: (env.PATH || '').split(path.delimiter).filter((entry) => !bins.has(path.resolve(entry)) && !(entry.startsWith('/var/folders/') && /^yarn--[0-9]+-[0-9]+\.[0-9]+$/.test(path.basename(entry)))).join(path.delimiter) }; } // Hash build-affecting values without persisting secrets. Yarn/npm package metadata, // lifecycle context, and upload credentials differ between the two entrypoints. function environmentDigest(env) { const ignored = new Set(['PWD', 'OLDPWD', 'SHLVL', '_', 'TERM', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'INIT_CWD', 'HERMES_PASS', 'HERMES_USER', 'npm_config_argv', 'npm_lifecycle_event', 'npm_lifecycle_script', 'npm_execpath', 'npm_node_execpath']); return sha(JSON.stringify(Object.entries(env).filter(([key]) => !ignored.has(key) && !key.startsWith('npm_package_')).sort(([a], [b]) => a.localeCompare(b)))); } async function tree(root) { const entries = []; async function visit(relative) { const current = path.join(root, relative); const stat = await fs.lstat(current); const base = { path: relative, mode: stat.mode & 0o7777 }; if (stat.isSymbolicLink()) entries.push({ ...base, type: 'link', target: await fs.readlink(current) }); else if (stat.isFile()) entries.push({ ...base, type: 'file', size: stat.size, hash: sha(await fs.readFile(current)) }); else if (stat.isDirectory()) { entries.push({ ...base, type: 'dir' }); for (const name of (await fs.readdir(current)).sort()) await visit(path.join(relative, name)); } else throw new Error(`Unsupported bundle entry: ${current}`); } await visit(''); return sha(JSON.stringify(entries)); } async function identity(root, env, run, home, platform, arch, bundleArgs) { const cmd = (name, args) => run(name, args, { cwd: root, env }); const [commit, rustc, cargo, bundle, clang, sdkPath, sdkVersion, sdkBuild, developerDir, ignoredResources, configs] = await Promise.all([ cmd('git', ['rev-parse', '--verify', 'HEAD']), cmd('rustc', ['--version', '--verbose']), cmd('cargo', ['--version', '--verbose']), cmd('cargo', ['bundle', '--version']), cmd('clang', ['--version']), cmd('xcrun', ['--show-sdk-path']), cmd('xcrun', ['--show-sdk-version']), cmd('xcrun', ['--show-sdk-build-version']), cmd('xcode-select', ['-p']), cmd('git', ['ls-files', '--others', '--ignored', '--exclude-standard', '--', 'app/assets/onboarding', 'app/channels/oss/icon/no-padding']), cargoConfigs(root, env, home), ]); // Overrides and generated resource caches may refer to mutable inputs outside Git. // A build using these inputs is safe to perform, but is not reusable. const external = Object.keys(env).some((key) => /^(GALAXY_WORMHOLE_HELPERS_DIR|SETTINGS_SCHEMA_CACHE|CARGO_TARGET_DIR|RUSTC_WRAPPER|RUSTC_WORKSPACE_WRAPPER|CC|CXX|AR|RUSTFLAGS|CARGO_ENCODED_RUSTFLAGS|GIT_RELEASE_TAG|GALAXY_GIT_COMMIT|GALAXY_GIT_DIRTY|GITHUB_SHA|CI_COMMIT_SHA|SDKROOT|DEVELOPER_DIR)$/.test(key)); const externalConfig = Object.entries(configs).some(([file, hash]) => hash && !file.startsWith(path.join(root, '.cargo') + path.sep)); return { commit: commit.trim(), rustc, cargo, bundle, clang, sdkPath, sdkVersion, sdkBuild, developerDir, platform, arch, command: bundleArgs, environment: environmentDigest(env), configs, external: external || externalConfig || Boolean(ignoredResources.trim()) }; } /** * Prepare a release bundle. observer receives (stepIndex, {status, detail?}) and * log(stepIndex, line), or may be { updateStep, appendLog } as in the Ink apps. * Returns { appPath, reused, identity, release }. appPath is a private copy: * callers may sign or install it, and must call release() in a finally block. * options.run is for standalone tests; normal callers should omit options. */ export async function prepareGalaxyBuild(workspaceRoot, observer = {}, options = {}) { const root = path.resolve(workspaceRoot); const cache = path.join(root, 'target', 'galaxy-build-cache'); const run = options.run || runCommand; const env = buildEnvironment(root, options.env || process.env); const update = typeof observer === 'function' ? observer : observer.updateStep?.bind(observer) || (() => {}); const log = typeof observer === 'function' ? (() => {}) : observer.appendLog?.bind(observer) || observer.log?.bind(observer) || (() => {}); const command = (name, args, step) => run(name, args, { cwd: root, env, capture: !(name === 'cargo' && args[0] === 'bundle' && args.includes('--release')), onLog: (line) => log(step, line) }); const unlock = await acquireLock(cache); try { update(0, { status: 'running' }); const changes = await command('git', ['status', '--porcelain', '--untracked-files=all'], 0); if (changes.trim()) { for (const line of changes.split('\n')) log(0, line); throw new Error('Working directory has uncommitted changes. Commit or stash them before building.'); } update(0, { status: 'done' }); update(1, { status: 'running' }); const branch = (await command('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], 1)).trim(); if (!branch) throw new Error('Cannot build from a detached HEAD'); await command('git', ['pull', '--ff-only', 'origin', branch], 1); if ((await command('git', ['status', '--porcelain', '--untracked-files=all'], 1)).trim()) throw new Error('Working directory changed after pull'); const pulledCommit = (await command('git', ['rev-parse', '--verify', 'HEAD'], 1)).trim(); if (!/^[0-9a-f]{40,64}$/i.test(pulledCommit)) throw new Error('Cannot verify pulled Git commit'); update(1, { status: 'done', detail: branch }); update(2, { status: 'running' }); const metadata = JSON.parse(await command('cargo', ['metadata', '--format-version', '1', '--no-deps'], 2)); if (!path.isAbsolute(metadata.target_directory)) throw new Error('Cargo metadata did not supply an absolute target directory'); const targetDir = metadata.target_directory; // Explicit --target takes precedence over Cargo build.target and CARGO_BUILD_TARGET. const rustcVersion = await command('rustc', ['--version', '--verbose'], 2); const host = rustcVersion.match(/^host:\s*(\S+)\s*$/m)?.[1]; if (!host || !/^[a-zA-Z0-9_-]+-apple-darwin$/.test(host)) throw new Error('Cannot determine native macOS Rust host'); const bundleArgs = [...BUNDLE_ARGS, '--target', host]; // Unknown tool versions fail closed for reuse, while still permitting a normal build. let key; try { key = await identity(root, env, run, options.home || os.homedir(), options.platform || process.platform, options.arch || process.arch, bundleArgs); } catch (error) { log(2, `Cannot verify cache identity: ${error.message}`); } const snapshot = path.join(cache, 'Galaxy.app'); const manifestPath = path.join(cache, 'manifest.json'); let reused = false; if (key && key.commit !== pulledCommit) key = undefined; if (key && !key.external) { key.targetDir = targetDir; try { const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); reused = manifest.version === 1 && JSON.stringify(manifest.identity) === JSON.stringify(key) && manifest.tree === await tree(snapshot); } catch { /* Missing or damaged cache is a miss. */ } } if (!reused) { const builtApp = path.join(targetDir, host, 'release', 'bundle', 'osx', 'Galaxy.app'); // Prevent cargo-bundle from leaving us with an old app after an incomplete build. await fs.rm(builtApp, { recursive: true, force: true }); await command('cargo', bundleArgs, 2); const digest = await tree(builtApp); if ((await command('git', ['status', '--porcelain', '--untracked-files=all'], 2)).trim() || (await command('git', ['rev-parse', '--verify', 'HEAD'], 2)).trim() !== pulledCommit) { throw new Error('Checkout changed during bundle preparation'); } const staged = path.join(cache, `stage-${randomUUID()}`); try { await fs.cp(builtApp, staged, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true, force: false }); if (await tree(staged) !== digest) throw new Error('Bundled app changed while caching'); await fs.rm(manifestPath, { force: true }); await fs.rm(snapshot, { recursive: true, force: true }); await fs.rename(staged, snapshot); if (key && !key.external) { const tempManifest = path.join(cache, `manifest-${randomUUID()}.json`); await fs.writeFile(tempManifest, JSON.stringify({ version: 1, identity: key, tree: digest })); await fs.rename(tempManifest, manifestPath); } } finally { await fs.rm(staged, { recursive: true, force: true }); } } const leaseDir = path.join(cache, `lease-${randomUUID()}`); const appPath = path.join(leaseDir, 'Galaxy.app'); try { await fs.mkdir(leaseDir); await fs.cp(snapshot, appPath, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true, force: false }); if (await tree(appPath) !== await tree(snapshot)) throw new Error('Bundled app changed while copying'); if ((await command('git', ['status', '--porcelain', '--untracked-files=all'], 2)).trim() || (await command('git', ['rev-parse', '--verify', 'HEAD'], 2)).trim() !== pulledCommit) { throw new Error('Checkout changed during bundle preparation'); } } catch (error) { await fs.rm(leaseDir, { recursive: true, force: true }); throw error; } update(2, { status: 'done', detail: reused ? 'Reused verified bundle' : 'Built release bundle' }); return { appPath, reused, identity: key, release: async () => fs.rm(leaseDir, { recursive: true, force: true }) }; } finally { await unlock(); } }