From c69f9d2f625c7b8ba9b7aabc77698473ebef372c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:55:34 -0700 Subject: [PATCH] Add manual cleanup for worktree Electron storage (#27414) * Add manual worktree storage cleanup command * Validate cleanup exclusions and preserve failure diagnostics --- config/scripts/dev-electron-bundle-cache.mjs | 11 +- .../scripts/dev-electron-bundle-cache.test.ts | 11 ++ .../install-electron-package-binary.mjs | 2 +- config/scripts/rebuild-native-deps.mjs | 14 +++ .../scripts/reclaim-dev-electron-bundles.mjs | 52 ++++++++- .../reclaim-dev-electron-bundles.test.ts | 62 +++++++++- config/scripts/reclaim-electron-dists.mjs | 7 +- config/scripts/reclaim-worktree-space.mjs | 107 ++++++++++++++++++ .../scripts/reclaim-worktree-space.test.mjs | 89 +++++++++++++++ docs/readme/worktree-storage-maintenance.md | 44 +++++++ package.json | 1 + 11 files changed, 388 insertions(+), 12 deletions(-) create mode 100644 config/scripts/reclaim-worktree-space.mjs create mode 100644 config/scripts/reclaim-worktree-space.test.mjs create mode 100644 docs/readme/worktree-storage-maintenance.md diff --git a/config/scripts/dev-electron-bundle-cache.mjs b/config/scripts/dev-electron-bundle-cache.mjs index 244d1c964c9..e4a38982246 100644 --- a/config/scripts/dev-electron-bundle-cache.mjs +++ b/config/scripts/dev-electron-bundle-cache.mjs @@ -58,12 +58,21 @@ export function isDevBundleInUse(dir, processTable) { return processTable.includes(`${dir}/`) || processTable.includes(`${alternate}/`) } -export function selectStaleDevBundleDirs({ bundles, currentDir, processTable, nowMs }) { +export function selectStaleDevBundleDirs({ + bundles, + currentDir, + processTable, + nowMs, + minAgeMs = 0 +}) { return bundles .filter(({ dir, hasMarker, mtimeMs }) => { if (dir === currentDir || isDevBundleInUse(dir, processTable)) { return false } + if (minAgeMs > 0 && nowMs - mtimeMs < minAgeMs) { + return false + } const buildInFlight = !hasMarker && nowMs - mtimeMs < IN_PROGRESS_WINDOW_MS return !buildInFlight }) diff --git a/config/scripts/dev-electron-bundle-cache.test.ts b/config/scripts/dev-electron-bundle-cache.test.ts index ca67a9e1946..6fdea49dbc1 100644 --- a/config/scripts/dev-electron-bundle-cache.test.ts +++ b/config/scripts/dev-electron-bundle-cache.test.ts @@ -22,6 +22,17 @@ function psLine(dir: string, appName = 'Orca: some-branch') { } describe('dev-electron-bundle-cache', () => { + it('keeps recently used bundles during automatic retention sweeps', () => { + expect( + selectStaleDevBundleDirs({ + bundles: [settled(A), { ...settled(B), mtimeMs: NOW - 8 * 24 * 60 * 60 * 1000 }], + currentDir: null, + processTable: '', + nowMs: NOW, + minAgeMs: 7 * 24 * 60 * 60 * 1000 + }) + ).toEqual([B]) + }) it('reclaims siblings while keeping the current bundle', () => { expect( selectStaleDevBundleDirs({ diff --git a/config/scripts/install-electron-package-binary.mjs b/config/scripts/install-electron-package-binary.mjs index a9d2bd8c000..ba6fe406f82 100644 --- a/config/scripts/install-electron-package-binary.mjs +++ b/config/scripts/install-electron-package-binary.mjs @@ -29,7 +29,6 @@ const projectDir = resolve(import.meta.dirname, '../..') const electronPackageDir = resolve(projectDir, 'node_modules/electron') const electronRequire = createRequire(resolve(electronPackageDir, 'package.json')) const { version: electronVersion } = electronRequire('./package.json') -const { downloadArtifact } = electronRequire('@electron/get') const targetPlatform = getElectronTargetPlatform() const targetArch = getElectronTargetArch() const platformPath = getElectronPlatformPath(targetPlatform) @@ -249,6 +248,7 @@ function formatShareError(error) { } async function downloadElectronArtifactWithRetry(downloadOptions, { cacheRootIsPersistent }) { + const { downloadArtifact } = electronRequire('@electron/get') const retryDelays = getDownloadRetryDelays() for (let attempt = 0; ; attempt += 1) { diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index e565eeca97e..f0f6a5352aa 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -19,6 +19,10 @@ */ import { rebuild } from '@electron/rebuild' +import { + hasAdoptedSharedElectronDist, + resolveSharedElectronDistEntry +} from './shared-electron-dist-cache.mjs' import { execFileSync, spawnSync } from 'node:child_process' import { ensureWindowsProcessTreeCommandLinePatch, @@ -310,6 +314,16 @@ function restoreNodePtyWindowsConptyRuntime() { function ensureElectronPackageInstalled() { repairElectronPathFile() if (electronPackageIsUsable()) { + const shared = resolveSharedElectronDistEntry({ + repoRoot: projectDir, + electronPackageDir, + version: electronVersion, + targetPlatform: electronInstallPlatform, + targetArch: electronInstallArch + }) + if (shared && !hasAdoptedSharedElectronDist(shared)) { + runElectronPackageBinaryInstall() + } return } diff --git a/config/scripts/reclaim-dev-electron-bundles.mjs b/config/scripts/reclaim-dev-electron-bundles.mjs index c2d8a9fd886..35df4fb39e0 100644 --- a/config/scripts/reclaim-dev-electron-bundles.mjs +++ b/config/scripts/reclaim-dev-electron-bundles.mjs @@ -11,7 +11,7 @@ // now shared, rebuilding is cheap. import { execFileSync } from 'node:child_process' -import { existsSync, readdirSync, rmSync, statSync } from 'node:fs' +import { existsSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs' import path from 'node:path' import { DEV_BUNDLE_MARKER_FILENAME, @@ -20,6 +20,14 @@ import { } from './dev-electron-bundle-cache.mjs' const apply = process.argv.includes('--apply') +const minAgeIndex = process.argv.indexOf('--min-age-days') +const minAgeDays = minAgeIndex === -1 ? 0 : Number(process.argv[minAgeIndex + 1]) +const excludeIndex = process.argv.indexOf('--exclude-worktree') +const excludeArgument = excludeIndex === -1 ? undefined : process.argv[excludeIndex + 1] +if (excludeIndex !== -1 && (!excludeArgument || excludeArgument.startsWith('-'))) { + throw new Error('--exclude-worktree requires a directory') +} +const excludeWorktree = excludeArgument ? realpathSync(excludeArgument) : null const repoRoot = process.argv.includes('--repo') ? path.resolve(process.argv[process.argv.indexOf('--repo') + 1]) : process.cwd() @@ -34,6 +42,14 @@ function listWorktrees(root) { .map((line) => line.slice('worktree '.length).trim()) } +function canonicalWorktreePath(worktree) { + try { + return realpathSync(worktree) + } catch { + return path.resolve(worktree) + } +} + function measure(targetPath) { let total = 0 let entries @@ -72,6 +88,9 @@ export function collectDevBundles(worktree) { } function main() { + if (!Number.isFinite(minAgeDays) || minAgeDays < 0) { + throw new Error('--min-age-days requires a non-negative number') + } // The patched dev bundle is only built on macOS; elsewhere the dev app runs from dist directly. if (process.platform !== 'darwin') { console.log('No dev Electron bundles on this platform; nothing to reclaim.') @@ -86,14 +105,17 @@ function main() { return } - const bundles = listWorktrees(repoRoot).flatMap((worktree) => collectDevBundles(worktree)) + const bundles = listWorktrees(repoRoot) + .filter((worktree) => canonicalWorktreePath(worktree) !== excludeWorktree) + .flatMap((worktree) => collectDevBundles(worktree)) // currentDir is null on purpose: unlike the dev runner, this sweep is not about to launch anything, // so the only thing protecting a bundle is a live process or an in-flight build. const stale = selectStaleDevBundleDirs({ bundles, currentDir: null, processTable, - nowMs: Date.now() + nowMs: Date.now(), + minAgeMs: minAgeDays * 24 * 60 * 60 * 1000 }) let reclaimed = 0 @@ -107,20 +129,40 @@ function main() { continue } try { + const currentProcessTable = getDevBundleProcessTable() + if (currentProcessTable === null) { + throw new Error('Process ownership is unverifiable') + } + const currentBundle = collectDevBundles(path.resolve(dir, '..', '..', '..')).find( + (bundle) => bundle.dir === dir + ) + if ( + !currentBundle || + !selectStaleDevBundleDirs({ + bundles: [currentBundle], + currentDir: null, + processTable: currentProcessTable, + nowMs: Date.now(), + minAgeMs: minAgeDays * 24 * 60 * 60 * 1000 + }).includes(dir) + ) { + continue + } rmSync(dir, { recursive: true, force: true }) reclaimed += size removed += 1 console.log(`removed ${dir} ${(size / 1024 ** 3).toFixed(2)} GiB`) } catch (error) { console.warn(`skip ${dir} (${error instanceof Error ? error.message : String(error)})`) + process.exitCode = 1 } } const inUse = bundles.length - stale.length console.log( `\n${apply ? 'Removed' : 'Would remove'} ${removed} bundle(s); ` + - `${apply ? 'reclaimed' : 'reclaimable'} ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB` + - `${inUse > 0 ? `; left ${inUse} in use or still building` : ''}` + + `logical file data ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB (shared blocks affect disk savings)` + + `${inUse > 0 ? `; retained ${inUse} protected or recent bundle(s)` : ''}` + `${apply ? '' : '\nRe-run with --apply to do it.'}` ) } diff --git a/config/scripts/reclaim-dev-electron-bundles.test.ts b/config/scripts/reclaim-dev-electron-bundles.test.ts index 6a77f970906..81d91e3683f 100644 --- a/config/scripts/reclaim-dev-electron-bundles.test.ts +++ b/config/scripts/reclaim-dev-electron-bundles.test.ts @@ -1,7 +1,8 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { runProcess } from '@orca/process-host' import { DEV_BUNDLE_MARKER_FILENAME, getDevBundleProcessTable, @@ -51,6 +52,65 @@ describe('collectDevBundles', () => { }) describe('sweeping across worktrees', () => { + it.each([ + ['--apply', '--exclude-worktree'], + ['--exclude-worktree', '--apply'], + ['--exclude-worktree', '-invalid', '--apply'] + ])('rejects invalid exclusion operands before cleanup: %j', async (...flags) => { + const worktree = makeWorktree([{ name: 'idle', marker: true }]) + const initialized = await runProcess({ program: 'git', args: ['-C', worktree, 'init', '-q'] }) + expect(initialized.code).toBe(0) + const result = await runProcess({ + program: process.execPath, + args: [ + path.join(import.meta.dirname, 'reclaim-dev-electron-bundles.mjs'), + '--repo', + worktree, + ...flags + ] + }) + expect(result.code).not.toBe(0) + expect(result.stderr).toContain('--exclude-worktree requires a directory') + expect(existsSync(path.join(worktree, 'out', 'electron-dev', 'idle'))).toBe(true) + }) + + it.skipIf(process.platform !== 'darwin')( + 'previews and removes only bundles outside the retention window', + async () => { + const worktree = makeWorktree([ + { name: 'old', marker: true }, + { name: 'recent', marker: true } + ]) + const initialized = await runProcess({ program: 'git', args: ['-C', worktree, 'init', '-q'] }) + expect(initialized.code).toBe(0) + const old = path.join(worktree, 'out', 'electron-dev', 'old') + const recent = path.join(worktree, 'out', 'electron-dev', 'recent') + const aged = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000) + utimesSync(old, aged, aged) + const args = [ + path.join(import.meta.dirname, 'reclaim-dev-electron-bundles.mjs'), + '--repo', + worktree, + '--min-age-days', + '7' + ] + const preview = await runProcess({ program: process.execPath, args }) + expect(preview.code, preview.stderr).toBe(0) + expect(preview.stdout).toContain('Would remove 1 bundle(s)') + expect(existsSync(old)).toBe(true) + const excluded = await runProcess({ + program: process.execPath, + args: [...args, '--exclude-worktree', worktree, '--apply'] + }) + expect(excluded.code, excluded.stderr).toBe(0) + expect(existsSync(old)).toBe(true) + const applied = await runProcess({ program: process.execPath, args: [...args, '--apply'] }) + expect(applied.code, applied.stderr).toBe(0) + expect(existsSync(old)).toBe(false) + expect(existsSync(recent)).toBe(true) + } + ) + it('spares a bundle a live process is running from, and takes the idle ones', () => { const worktree = makeWorktree([ { name: 'live', marker: true }, diff --git a/config/scripts/reclaim-electron-dists.mjs b/config/scripts/reclaim-electron-dists.mjs index c3a0fb5b42f..2276a5f862d 100644 --- a/config/scripts/reclaim-electron-dists.mjs +++ b/config/scripts/reclaim-electron-dists.mjs @@ -1,8 +1,7 @@ #!/usr/bin/env node // Converts worktrees that already have their own Electron dist over to the shared cache. -// A normal install only shares when Electron is (re)installed, and an existing healthy worktree -// never reaches that path -- so without this, sharing only arrives at the next Electron upgrade. +// Healthy installs also share now; this migrates worktrees without waiting for their next install. import { execFileSync } from 'node:child_process' import { randomUUID } from 'node:crypto' @@ -153,7 +152,7 @@ function main() { recordAdoptedSharedElectronDist(entry, writeFileSync) reclaimed += size converted += 1 - console.log(`shared ${worktree} reclaimed ${(size / 1024 ** 3).toFixed(2)} GiB`) + console.log(`shared ${worktree} ${(size / 1024 ** 3).toFixed(2)} GiB logical file data`) } } catch (error) { // A worktree that fails is left exactly as it was; it still has its own working dist. @@ -164,7 +163,7 @@ function main() { console.log( `\n${apply ? 'Shared' : 'Would share'} ${converted} worktree(s); ` + - `${apply ? 'reclaimed' : 'reclaimable'} ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB` + + `logical file data ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB (shared blocks affect disk savings)` + `${skipped > 0 ? `; skipped ${skipped}` : ''}` + `${apply ? '' : '\nRe-run with --apply to do it.'}` ) diff --git a/config/scripts/reclaim-worktree-space.mjs b/config/scripts/reclaim-worktree-space.mjs new file mode 100644 index 00000000000..561a5ea4623 --- /dev/null +++ b/config/scripts/reclaim-worktree-space.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +import { mkdir } from 'node:fs/promises' +import { resolve, join } from 'node:path' +import { lock } from 'proper-lockfile' +import { runProcess } from '@orca/process-host' + +export async function reclaimWorktreeSpace({ + repoRoot = process.cwd(), + apply = false, + execute = runProcess, + acquireLock = lock, + commonDirectory +} = {}) { + if (!commonDirectory) { + const result = await runProcess({ + program: 'git', + args: ['-C', repoRoot, 'rev-parse', '--git-common-dir'] + }) + if (result.code !== 0 || result.timedOut || !result.stdout.trim()) { + throw new Error('Run this command inside a local Git repository, or supply --repo') + } + commonDirectory = resolve(repoRoot, result.stdout.trim()) + } + const cacheRoot = join(commonDirectory, 'orca-cache') + const lockPath = join(cacheRoot, 'storage-cleanup') + await mkdir(cacheRoot, { recursive: true }) + let release + try { + release = await acquireLock(lockPath, { realpath: false, stale: 600_000, retries: 0 }) + } catch (error) { + if (error.code === 'ELOCKED') { + throw new Error('Another worktree cleanup is already running') + } + throw error + } + let cleanupFailed = false + let cleanupError + try { + const scripts = ['reclaim-electron-dists.mjs', 'reclaim-dev-electron-bundles.mjs'] + for (const script of scripts) { + const args = [resolve(import.meta.dirname, script), '--repo', repoRoot] + if (apply) { + args.push('--apply') + } + const result = await execute({ + program: process.execPath, + args, + env: process.env, + stdio: 'inherit', + timeoutMs: 10 * 60 * 1000, + terminationBarrier: true + }) + if (result.code !== 0 || result.timedOut) { + throw new Error(`${script} failed; rerun cleanup after resolving the error`) + } + } + } catch (error) { + cleanupFailed = true + cleanupError = error + } + try { + await release() + } catch (error) { + if (!cleanupFailed) { + throw error + } + console.error( + `Failed to release cleanup lock: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (cleanupFailed) { + throw cleanupError + } + return { completed: true } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + if (process.argv.includes('--help')) { + console.log('Usage: pnpm run reclaim:worktree-space [--repo DIRECTORY] [--apply]') + console.log( + 'Previews Electron distribution sharing and idle dev bundle removal; --apply performs them.' + ) + process.exit(0) + } + const repoIndex = process.argv.indexOf('--repo') + for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index] + if (argument === '--repo') { + index += 1 + } else if (argument !== '--apply') { + throw new Error(`Unknown argument: ${argument}`) + } + } + if ( + repoIndex !== -1 && + (!process.argv[repoIndex + 1] || process.argv[repoIndex + 1].startsWith('--')) + ) { + throw new Error('--repo requires a directory') + } + await reclaimWorktreeSpace({ + repoRoot: repoIndex !== -1 ? resolve(process.argv[repoIndex + 1]) : process.cwd(), + apply: process.argv.includes('--apply') + }).catch((error) => { + console.error(error.message) + process.exitCode = 1 + }) +} diff --git a/config/scripts/reclaim-worktree-space.test.mjs b/config/scripts/reclaim-worktree-space.test.mjs new file mode 100644 index 00000000000..f449408d204 --- /dev/null +++ b/config/scripts/reclaim-worktree-space.test.mjs @@ -0,0 +1,89 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { reclaimWorktreeSpace } from './reclaim-worktree-space.mjs' + +const roots = [] +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }) + } +}) +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-storage-maintenance-')) + roots.push(root) + const execute = vi.fn(async () => ({ code: 0, timedOut: false })) + return { commonDirectory: root, repoRoot: root, execute } +} + +it('previews both existing cleanup operations without applying either', async () => { + const options = await fixture() + await reclaimWorktreeSpace(options) + expect(options.execute).toHaveBeenCalledTimes(2) + expect(options.execute.mock.calls.flatMap(([spec]) => spec.args)).not.toContain('--apply') +}) + +it('forwards apply and repository selection to both cleanup operations', async () => { + const options = { ...(await fixture()), apply: true } + await reclaimWorktreeSpace(options) + expect(options.execute).toHaveBeenCalledTimes(2) + for (const [spec] of options.execute.mock.calls) { + expect(spec.args).toContain('--apply') + expect(spec.args).toContain(options.repoRoot) + expect(spec.terminationBarrier).toBe(true) + } +}) + +it('rejects competing sweeps and releases the lock after a failed operation', async () => { + const options = { ...(await fixture()), apply: true } + let release + const blocked = new Promise((resolve) => { + release = resolve + }) + let entered + const ready = new Promise((resolve) => { + entered = resolve + }) + options.execute.mockImplementation(async () => { + entered() + await blocked + return { code: 1, timedOut: false } + }) + const first = reclaimWorktreeSpace(options) + const rejected = expect(first).rejects.toThrow('failed') + await ready + await expect(reclaimWorktreeSpace(options)).rejects.toThrow('already running') + release() + await rejected + options.execute.mockResolvedValue({ code: 0, timedOut: false }) + await reclaimWorktreeSpace(options) + expect(options.execute).toHaveBeenCalledTimes(3) +}) + +it('preserves the cleanup failure and reports a concurrent lock release failure separately', async () => { + const options = await fixture() + const failure = new Error('original cleanup failure') + options.execute.mockRejectedValue(failure) + const release = vi.fn().mockRejectedValue(new Error('lock release failure')) + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + await expect( + reclaimWorktreeSpace({ ...options, acquireLock: async () => release }) + ).rejects.toBe(failure) + expect(release).toHaveBeenCalledTimes(1) + expect(report).toHaveBeenCalledWith('Failed to release cleanup lock: lock release failure') + } finally { + report.mockRestore() + } +}) + +it('fails if cleanup succeeds but the repository lock cannot be released', async () => { + const options = await fixture() + const failure = new Error('lock release failure') + const release = vi.fn().mockRejectedValue(failure) + await expect(reclaimWorktreeSpace({ ...options, acquireLock: async () => release })).rejects.toBe( + failure + ) + expect(options.execute).toHaveBeenCalledTimes(2) +}) diff --git a/docs/readme/worktree-storage-maintenance.md b/docs/readme/worktree-storage-maintenance.md new file mode 100644 index 00000000000..98e9b98f027 --- /dev/null +++ b/docs/readme/worktree-storage-maintenance.md @@ -0,0 +1,44 @@ +# Worktree storage maintenance + +Run from the repository to preview maintenance across its locally registered Git worktrees: + +```sh +pnpm run reclaim:worktree-space +``` + +Apply the previewed operations with: + +```sh +pnpm run reclaim:worktree-space --apply +``` + +The command reuses the existing Electron distribution migration and dev bundle cleanup scripts. +It shares complete Electron distributions through the repository cache and removes idle +`out/electron-dev` bundles, which development rebuilds on demand. It leaves source files, +dependency installs, worktree registrations, and other build output alone. Reported sizes are +logical file data; APFS clones and hardlinks mean actual disk savings can be smaller. + +Use `--repo DIRECTORY` to choose another local repository. Git's worktree list determines scope, +including registered worktrees outside Orca's workspace directory. Folder workspaces without Git +have no repository-wide sweep. These commands run on the local host: they do not inspect or delete +anything on SSH hosts. Run maintenance on the execution host when maintaining remote repositories. + +## Keeping installs shared + +Healthy Electron installs join the shared distribution cache during native dependency setup, +without waiting for an Electron upgrade. The manual command migrates older installs immediately. + +Cleanup runs only when explicitly requested. Starting `pnpm dev` does not trigger a sweep. +The repository lock prevents competing cleanup commands. Dev bundle removal checks active +processes and recent unfinished builds, then rechecks eligibility immediately before deletion. +An unreadable process table prevents removal. + +To retain older bundles during a manual sweep, run the underlying command with an age limit: + +```sh +node config/scripts/reclaim-dev-electron-bundles.mjs --min-age-days 7 --apply +``` + +That limit uses the bundle directory's modification time. The underlying command also accepts +`--exclude-worktree DIRECTORY` to protect an entire worktree. A missing exclusion path is an error; +filesystem aliases resolve to the same worktree. Neither option schedules cleanup. diff --git a/package.json b/package.json index 365980432c8..0082b331a63 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "reclaim:electron-dists": "node config/scripts/reclaim-electron-dists.mjs", "reclaim:dev-bundles": "node config/scripts/reclaim-dev-electron-bundles.mjs", + "reclaim:worktree-space": "node config/scripts/reclaim-worktree-space.mjs", "rebuild:node": "pnpm rebuild node-pty", "build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win",