From 446b194e2c6d2915e2990f11da526100dbaa8d38 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sun, 5 Apr 2026 22:34:16 +0900 Subject: [PATCH] Automate worktree dependency installs --- package.json | 1 + runners/agent-runner/src/ipc-mcp-stdio.ts | 2 +- src/container-runner.ts | 32 +- src/paired-workspace-manager.test.ts | 46 +++ src/paired-workspace-manager.ts | 41 +++ src/verification.test.ts | 57 ++- src/verification.ts | 76 ++-- src/workspace-package-manager.test.ts | 178 +++++++++ src/workspace-package-manager.ts | 419 ++++++++++++++++++++++ 9 files changed, 762 insertions(+), 90 deletions(-) create mode 100644 src/workspace-package-manager.test.ts create mode 100644 src/workspace-package-manager.ts diff --git a/package.json b/package.json index 71a47f0..f7ec7d2 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "ejclaw", "version": "0.1.0", "description": "Personal Claude assistant. Lightweight, secure, customizable.", + "packageManager": "bun@1.3.11", "type": "module", "main": "dist/index.js", "scripts": { diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index 83aab69..2c77deb 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -427,7 +427,7 @@ server.tool( profile: z .enum(VERIFICATION_PROFILES) .describe( - 'Fixed verification profile. test=npm test, typecheck=npm run typecheck, build=npm run build.', + 'Fixed verification profile. Runs the workspace test/typecheck/build scripts with the repo-configured package manager.', ), }, async (args) => { diff --git a/src/container-runner.ts b/src/container-runner.ts index d3cd7dd..283fc38 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -37,6 +37,7 @@ import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; import { logger } from './logger.js'; import type { AgentOutput } from './agent-runner.js'; import type { RegisteredGroup } from './types.js'; +import { detectPnpmStorePath } from './workspace-package-manager.js'; // ── Config ──────────────────────────────────────────────────────── @@ -161,37 +162,6 @@ function pushWorktreeGitMetadataMounts( } } -// ── pnpm store detection ───────────────────────────────────────── - -function detectPnpmStorePath(workspaceDir: string): string | null { - if (!fs.existsSync(path.join(workspaceDir, 'pnpm-lock.yaml'))) { - return null; - } - if (process.env.PNPM_STORE_DIR && fs.existsSync(process.env.PNPM_STORE_DIR)) { - return process.env.PNPM_STORE_DIR; - } - try { - const storePath = execFileSync('pnpm', ['store', 'path'], { - cwd: workspaceDir, - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 5000, - }).trim(); - if (storePath && fs.existsSync(storePath)) return storePath; - } catch { - /* pnpm not available */ - } - const defaultStore = path.join( - os.homedir(), - '.local', - 'share', - 'pnpm', - 'store', - ); - if (fs.existsSync(defaultStore)) return defaultStore; - return null; -} - // ── Pre-flight checks ──────────────────────────────────────────── let containerRuntimeChecked = false; diff --git a/src/paired-workspace-manager.test.ts b/src/paired-workspace-manager.test.ts index fd8e687..29fde61 100644 --- a/src/paired-workspace-manager.test.ts +++ b/src/paired-workspace-manager.test.ts @@ -213,6 +213,52 @@ describe('paired workspace manager', () => { ).toBe(null); }); + it('ensures owner workspace dependencies on provision and review handoff', async () => { + const ensureWorkspaceDependenciesInstalledMock = vi.fn(() => ({ + installed: false, + packageManager: 'pnpm' as const, + })); + vi.doMock('./workspace-package-manager.js', async () => { + const actual = + await vi.importActual( + './workspace-package-manager.js', + ); + return { + ...actual, + ensureWorkspaceDependenciesInstalled: + ensureWorkspaceDependenciesInstalledMock, + }; + }); + + const { db, manager } = await loadModules(); + db._initTestDatabase(); + + const canonicalDir = path.join(tempRoot, 'canonical'); + initCanonicalRepo(canonicalDir); + seedPairedTask(db, canonicalDir, { + taskId: 'paired-task-install-owner-deps', + groupFolder: 'install-room', + }); + + const ownerWorkspace = manager.provisionOwnerWorkspaceForPairedTask( + 'paired-task-install-owner-deps', + ); + + expect(ensureWorkspaceDependenciesInstalledMock).toHaveBeenCalledTimes(1); + expect(ensureWorkspaceDependenciesInstalledMock).toHaveBeenNthCalledWith( + 1, + ownerWorkspace.workspace_dir, + ); + + manager.markPairedTaskReviewReady('paired-task-install-owner-deps'); + + expect(ensureWorkspaceDependenciesInstalledMock).toHaveBeenCalledTimes(2); + expect(ensureWorkspaceDependenciesInstalledMock).toHaveBeenNthCalledWith( + 2, + ownerWorkspace.workspace_dir, + ); + }); + it('uses the shared DB owner workspace across service-local data dirs', async () => { const canonicalDir = path.join(tempRoot, 'canonical'); fs.mkdirSync(canonicalDir, { recursive: true }); diff --git a/src/paired-workspace-manager.ts b/src/paired-workspace-manager.ts index cb99dcf..3bfa486 100644 --- a/src/paired-workspace-manager.ts +++ b/src/paired-workspace-manager.ts @@ -14,6 +14,7 @@ import { import { resolvePairedTaskWorkspacePath } from './group-folder.js'; import { logger } from './logger.js'; import type { PairedTask, PairedWorkspace } from './types.js'; +import { ensureWorkspaceDependenciesInstalled } from './workspace-package-manager.js'; const REVIEWER_SNAPSHOT_STALE_BLOCK_MESSAGE = 'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.'; @@ -539,6 +540,18 @@ export function registerOwnerCanonicalWorkspace( canonicalWorkDir: string, ): PairedWorkspace { ensureGitRepository(canonicalWorkDir); + const installResult = ensureWorkspaceDependenciesInstalled(canonicalWorkDir); + if (installResult.installed) { + logger.info( + { + taskId, + workspaceDir: canonicalWorkDir, + packageManager: installResult.packageManager, + command: installResult.commandText ?? null, + }, + 'Installed owner workspace dependencies', + ); + } const workspace = makeWorkspaceRecord({ taskId, role: 'owner', @@ -644,6 +657,19 @@ export function provisionOwnerWorkspaceForPairedTask( } } + const installResult = ensureWorkspaceDependenciesInstalled(workspaceDir); + if (installResult.installed) { + logger.info( + { + taskId, + workspaceDir, + packageManager: installResult.packageManager, + command: installResult.commandText ?? null, + }, + 'Installed owner workspace dependencies', + ); + } + const workspace = makeWorkspaceRecord({ taskId, role: 'owner', @@ -741,6 +767,21 @@ export function markPairedTaskReviewReady(taskId: string): { return null; } + const installResult = ensureWorkspaceDependenciesInstalled( + ownerWorkspace.workspace_dir, + ); + if (installResult.installed) { + logger.info( + { + taskId, + ownerDir: ownerWorkspace.workspace_dir, + packageManager: installResult.packageManager, + command: installResult.commandText ?? null, + }, + 'Installed owner workspace dependencies before review handoff', + ); + } + // Reviewer runs in a container with the owner workspace mounted read-only. // No file-level snapshot copy needed — just register the path. const reviewerWorkspace = makeWorkspaceRecord({ diff --git a/src/verification.test.ts b/src/verification.test.ts index 35b02f9..1947dd4 100644 --- a/src/verification.test.ts +++ b/src/verification.test.ts @@ -8,6 +8,7 @@ import { buildVerificationCommand, computeVerificationSnapshot, isVerificationProfile, + runVerificationRequest, } from './verification.js'; describe('verification helpers', () => { @@ -19,23 +20,43 @@ describe('verification helpers', () => { }); it('builds deterministic commands for each profile', () => { - expect(buildVerificationCommand('test')).toMatchObject({ + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verification-')); + fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({})); + + expect(buildVerificationCommand('test', repoDir)).toMatchObject({ file: 'npm', args: ['test'], requiredScript: 'test', }); - expect(buildVerificationCommand('typecheck')).toMatchObject({ + expect(buildVerificationCommand('typecheck', repoDir)).toMatchObject({ file: 'npm', args: ['run', 'typecheck'], requiredScript: 'typecheck', }); - expect(buildVerificationCommand('build')).toMatchObject({ + expect(buildVerificationCommand('build', repoDir)).toMatchObject({ file: 'npm', args: ['run', 'build'], requiredScript: 'build', }); }); + it('selects the workspace package manager for verification commands', () => { + const repoDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-verification-pnpm-'), + ); + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ packageManager: 'pnpm@10.11.0' }), + ); + + expect(buildVerificationCommand('typecheck', repoDir)).toMatchObject({ + file: 'corepack', + args: ['pnpm', 'run', 'typecheck'], + commandText: 'corepack pnpm run typecheck', + requiredScript: 'typecheck', + }); + }); + it('computes a stable snapshot over the readable workspace inputs', () => { const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-snapshot-')); fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true }); @@ -58,4 +79,34 @@ describe('verification helpers', () => { expect(second).toBe(first); expect(third).not.toBe(first); }); + + it('fails verification when node_modules contains only cache noise', async () => { + const repoDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'ejclaw-verification-noise-'), + ); + fs.mkdirSync(path.join(repoDir, 'node_modules', '.vite'), { + recursive: true, + }); + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ + scripts: { + test: 'vitest run', + typecheck: 'tsc --noEmit', + build: 'tsc', + }, + }), + ); + + const result = await runVerificationRequest( + { + requestId: 'req-noise', + profile: 'typecheck', + }, + { repoDir }, + ); + + expect(result.ok).toBe(false); + expect(result.error).toContain('installed node_modules tree'); + }); }); diff --git a/src/verification.ts b/src/verification.ts index 5f42ce0..23e66df 100644 --- a/src/verification.ts +++ b/src/verification.ts @@ -13,6 +13,11 @@ import { writableMountArgs, } from './container-runtime.js'; import { resolveGroupIpcPath } from './group-folder.js'; +import { + buildWorkspaceScriptCommand, + detectPnpmStorePath, + hasInstalledNodeModules, +} from './workspace-package-manager.js'; export const VERIFICATION_PROFILES = ['test', 'typecheck', 'build'] as const; @@ -69,30 +74,17 @@ export function isVerificationProfile( export function buildVerificationCommand( profile: VerificationProfile, + repoDir: string = process.cwd(), ): VerificationCommandSpec { - switch (profile) { - case 'test': - return { - file: 'npm', - args: ['test'], - commandText: 'npm test', - requiredScript: 'test', - }; - case 'typecheck': - return { - file: 'npm', - args: ['run', 'typecheck'], - commandText: 'npm run typecheck', - requiredScript: 'typecheck', - }; - case 'build': - return { - file: 'npm', - args: ['run', 'build'], - commandText: 'npm run build', - requiredScript: 'build', - }; - } + const scriptName = + profile === 'test' ? 'test' : profile === 'typecheck' ? 'typecheck' : 'build'; + const command = buildWorkspaceScriptCommand(repoDir, scriptName); + return { + file: command.file, + args: command.args, + commandText: command.commandText, + requiredScript: scriptName, + }; } function shouldExcludePath(name: string): boolean { @@ -146,35 +138,6 @@ function truncateOutput(value: string | undefined): string { return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`; } -function detectPnpmStorePath(workspaceDir: string): string | null { - if (!fs.existsSync(path.join(workspaceDir, 'pnpm-lock.yaml'))) { - return null; - } - if (process.env.PNPM_STORE_DIR && fs.existsSync(process.env.PNPM_STORE_DIR)) { - return process.env.PNPM_STORE_DIR; - } - try { - const storePath = execFileSync('pnpm', ['store', 'path'], { - cwd: workspaceDir, - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 5000, - }).trim(); - if (storePath && fs.existsSync(storePath)) return storePath; - } catch { - /* ignore */ - } - - const defaultStore = path.join( - os.homedir(), - '.local', - 'share', - 'pnpm', - 'store', - ); - return fs.existsSync(defaultStore) ? defaultStore : null; -} - function readPackageScripts(repoDir: string): Record { const packageJsonPath = path.join(repoDir, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -248,7 +211,10 @@ function buildDockerRunArgs( args.push(...writableMountArgs(scratchWorkspace, PRIMARY_PROJECT_MOUNT)); const sourceNodeModulesDir = path.join(sourceRepoDir, 'node_modules'); - if (fs.existsSync(sourceNodeModulesDir)) { + if ( + hasInstalledNodeModules(sourceRepoDir) && + fs.existsSync(sourceNodeModulesDir) + ) { args.push( ...readonlyMountArgs( sourceNodeModulesDir, @@ -314,7 +280,7 @@ export async function runVerificationRequest( ): Promise { const repoDir = options?.repoDir || process.cwd(); const runtimeVersion = detectRuntimeVersion(); - const command = buildVerificationCommand(request.profile); + const command = buildVerificationCommand(request.profile, repoDir); const scripts = readPackageScripts(repoDir); if (!scripts[command.requiredScript]) { @@ -331,7 +297,7 @@ export async function runVerificationRequest( }; } - if (!fs.existsSync(path.join(repoDir, 'node_modules'))) { + if (!hasInstalledNodeModules(repoDir)) { return { ok: false, profile: request.profile, diff --git a/src/workspace-package-manager.test.ts b/src/workspace-package-manager.test.ts new file mode 100644 index 0000000..b942bd7 --- /dev/null +++ b/src/workspace-package-manager.test.ts @@ -0,0 +1,178 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execFileSyncMock } = vi.hoisted(() => ({ + execFileSyncMock: vi.fn(), +})); + +vi.mock('child_process', () => ({ + execFileSync: execFileSyncMock, +})); + +import { + buildWorkspaceScriptCommand, + detectWorkspacePackageManager, + ensureWorkspaceDependenciesInstalled, + hasInstalledNodeModules, + resolveWorkspaceInstallCommand, +} from './workspace-package-manager.js'; + +describe('workspace package manager helpers', () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-pkgmgr-')); + execFileSyncMock.mockReset(); + }); + + afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('detects package managers from packageManager field and lockfiles', () => { + const pnpmRepo = path.join(tempRoot, 'pnpm'); + fs.mkdirSync(pnpmRepo, { recursive: true }); + fs.writeFileSync( + path.join(pnpmRepo, 'package.json'), + JSON.stringify({ packageManager: 'pnpm@10.11.0' }), + ); + + const bunRepo = path.join(tempRoot, 'bun'); + fs.mkdirSync(bunRepo, { recursive: true }); + fs.writeFileSync(path.join(bunRepo, 'package.json'), JSON.stringify({})); + fs.writeFileSync(path.join(bunRepo, 'bun.lock'), ''); + + const yarnRepo = path.join(tempRoot, 'yarn'); + fs.mkdirSync(yarnRepo, { recursive: true }); + fs.writeFileSync(path.join(yarnRepo, 'package.json'), JSON.stringify({})); + fs.writeFileSync(path.join(yarnRepo, 'yarn.lock'), ''); + + const mixedRepo = path.join(tempRoot, 'mixed'); + fs.mkdirSync(mixedRepo, { recursive: true }); + fs.writeFileSync( + path.join(mixedRepo, 'package.json'), + JSON.stringify({ packageManager: 'bun@1.3.11' }), + ); + fs.writeFileSync(path.join(mixedRepo, 'bun.lock'), ''); + fs.writeFileSync(path.join(mixedRepo, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n'); + + expect(detectWorkspacePackageManager(pnpmRepo)).toBe('pnpm'); + expect(detectWorkspacePackageManager(bunRepo)).toBe('bun'); + expect(detectWorkspacePackageManager(yarnRepo)).toBe('yarn'); + expect(detectWorkspacePackageManager(mixedRepo)).toBe('bun'); + }); + + it('fails fast when multiple lockfiles exist without a packageManager field', () => { + const repoDir = path.join(tempRoot, 'ambiguous'); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({})); + fs.writeFileSync(path.join(repoDir, 'bun.lock'), ''); + fs.writeFileSync(path.join(repoDir, 'package-lock.json'), '{}\n'); + + expect(() => detectWorkspacePackageManager(repoDir)).toThrow( + /Ambiguous package manager/i, + ); + }); + + it('builds script and install commands for non-npm workspaces', () => { + const pnpmRepo = path.join(tempRoot, 'pnpm'); + fs.mkdirSync(pnpmRepo, { recursive: true }); + fs.writeFileSync( + path.join(pnpmRepo, 'package.json'), + JSON.stringify({ packageManager: 'pnpm@10.11.0' }), + ); + + const bunRepo = path.join(tempRoot, 'bun'); + fs.mkdirSync(bunRepo, { recursive: true }); + fs.writeFileSync(path.join(bunRepo, 'package.json'), JSON.stringify({})); + fs.writeFileSync(path.join(bunRepo, 'bun.lock'), ''); + + expect(buildWorkspaceScriptCommand(pnpmRepo, 'typecheck')).toMatchObject({ + file: 'corepack', + args: ['pnpm', 'run', 'typecheck'], + commandText: 'corepack pnpm run typecheck', + }); + expect(resolveWorkspaceInstallCommand(pnpmRepo)).toMatchObject({ + file: 'corepack', + args: ['pnpm', 'install', '--frozen-lockfile'], + commandText: 'corepack pnpm install --frozen-lockfile', + }); + expect(buildWorkspaceScriptCommand(bunRepo, 'build')).toMatchObject({ + file: 'bun', + args: ['run', 'build'], + commandText: 'bun run build', + }); + }); + + it('installs dependencies once and tracks install fingerprint', () => { + const repoDir = path.join(tempRoot, 'repo'); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ + name: 'repo', + packageManager: 'pnpm@10.11.0', + scripts: { typecheck: 'tsc --noEmit' }, + }), + ); + fs.writeFileSync(path.join(repoDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n'); + + execFileSyncMock.mockImplementation((_file, _args, options) => { + const cwd = (options as { cwd: string }).cwd; + fs.mkdirSync(path.join(cwd, 'node_modules', '.bin'), { recursive: true }); + fs.writeFileSync(path.join(cwd, 'node_modules', '.bin', 'tsc'), ''); + return ''; + }); + + const first = ensureWorkspaceDependenciesInstalled(repoDir); + expect(first).toMatchObject({ + installed: true, + packageManager: 'pnpm', + commandText: 'corepack pnpm install --frozen-lockfile', + }); + expect(hasInstalledNodeModules(repoDir)).toBe(true); + + execFileSyncMock.mockClear(); + const second = ensureWorkspaceDependenciesInstalled(repoDir); + expect(second).toMatchObject({ + installed: false, + packageManager: 'pnpm', + }); + expect(execFileSyncMock).not.toHaveBeenCalled(); + + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ + name: 'repo', + packageManager: 'pnpm@10.11.0', + scripts: { typecheck: 'tsc --noEmit', test: 'vitest run' }, + }), + ); + + expect(hasInstalledNodeModules(repoDir)).toBe(false); + }); + + it('rejects no-op installs that only leave the sentinel file behind', () => { + const repoDir = path.join(tempRoot, 'noop'); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ + name: 'noop', + packageManager: 'npm@10.0.0', + scripts: { typecheck: 'tsc --noEmit' }, + }), + ); + fs.writeFileSync(path.join(repoDir, 'package-lock.json'), '{}\n'); + + execFileSyncMock.mockImplementation(() => ''); + + expect(() => ensureWorkspaceDependenciesInstalled(repoDir)).toThrow( + /did not produce a usable node_modules tree/i, + ); + expect(hasInstalledNodeModules(repoDir)).toBe(false); + }); +}); diff --git a/src/workspace-package-manager.ts b/src/workspace-package-manager.ts new file mode 100644 index 0000000..7158d2c --- /dev/null +++ b/src/workspace-package-manager.ts @@ -0,0 +1,419 @@ +import { execFileSync } from 'child_process'; +import { createHash } from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +export type WorkspacePackageManager = 'bun' | 'pnpm' | 'npm' | 'yarn'; + +export interface WorkspaceCommandSpec { + packageManager: WorkspacePackageManager; + file: string; + args: string[]; + commandText: string; +} + +export interface WorkspaceDependencyInstallResult { + installed: boolean; + packageManager: WorkspacePackageManager | null; + commandText?: string; +} + +const INSTALL_STATE_FILENAME = '.ejclaw-install-state.json'; +const NODE_MODULES_NOISE_ENTRIES = new Set([ + '.cache', + '.vite', + '.vite-temp', + INSTALL_STATE_FILENAME, +]); +const LOCKFILE_NAMES = [ + 'bun.lock', + 'bun.lockb', + 'pnpm-lock.yaml', + 'package-lock.json', + 'npm-shrinkwrap.json', + 'yarn.lock', +] as const; + +type PackageJsonMetadata = { + packageManager?: string; +}; + +function readPackageJsonMetadata(repoDir: string): PackageJsonMetadata | null { + const packageJsonPath = path.join(repoDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return null; + } + + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as PackageJsonMetadata; +} + +function detectPackageManagerFromField( + packageManager: string | undefined, +): WorkspacePackageManager | null { + if (!packageManager) { + return null; + } + if (packageManager.startsWith('bun@')) return 'bun'; + if (packageManager.startsWith('pnpm@')) return 'pnpm'; + if (packageManager.startsWith('npm@')) return 'npm'; + if (packageManager.startsWith('yarn@')) return 'yarn'; + return null; +} + +function hasLockfile( + repoDir: string, + ...names: readonly string[] +): boolean { + return names.some((name) => fs.existsSync(path.join(repoDir, name))); +} + +function detectPackageManagersFromLockfiles( + repoDir: string, +): WorkspacePackageManager[] { + const detected = new Set(); + if (hasLockfile(repoDir, 'bun.lock', 'bun.lockb')) { + detected.add('bun'); + } + if (hasLockfile(repoDir, 'pnpm-lock.yaml')) { + detected.add('pnpm'); + } + if (hasLockfile(repoDir, 'package-lock.json', 'npm-shrinkwrap.json')) { + detected.add('npm'); + } + if (hasLockfile(repoDir, 'yarn.lock')) { + detected.add('yarn'); + } + return [...detected]; +} + +function detectYarnBerry( + repoDir: string, + packageManagerField: string | undefined, +): boolean { + if (packageManagerField?.startsWith('yarn@')) { + const version = packageManagerField.slice('yarn@'.length).split(/[+-]/, 1)[0]; + const major = Number.parseInt(version.split('.', 1)[0] ?? '', 10); + if (Number.isFinite(major) && major >= 2) { + return true; + } + } + return fs.existsSync(path.join(repoDir, '.yarnrc.yml')); +} + +function buildCommandText(file: string, args: string[]): string { + return [file, ...args].join(' '); +} + +function buildCorepackCommand( + packageManager: 'pnpm' | 'yarn', + args: string[], +): WorkspaceCommandSpec { + return { + packageManager, + file: 'corepack', + args: [packageManager, ...args], + commandText: buildCommandText('corepack', [packageManager, ...args]), + }; +} + +function computeInstallFingerprint(repoDir: string): string | null { + const packageJsonPath = path.join(repoDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return null; + } + + const hash = createHash('sha256'); + const fingerprintFiles = [packageJsonPath]; + for (const lockfileName of LOCKFILE_NAMES) { + const lockfilePath = path.join(repoDir, lockfileName); + if (fs.existsSync(lockfilePath)) { + fingerprintFiles.push(lockfilePath); + } + } + + for (const filePath of fingerprintFiles.sort()) { + hash.update(path.basename(filePath)); + hash.update('\0'); + hash.update(fs.readFileSync(filePath)); + hash.update('\0'); + } + + return hash.digest('hex'); +} + +function readInstallFingerprint(repoDir: string): string | null { + const statePath = path.join( + repoDir, + 'node_modules', + INSTALL_STATE_FILENAME, + ); + if (!fs.existsSync(statePath)) { + return null; + } + + try { + const state = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as { + fingerprint?: string; + }; + return typeof state.fingerprint === 'string' ? state.fingerprint : null; + } catch { + return null; + } +} + +function writeInstallFingerprint( + repoDir: string, + packageManager: WorkspacePackageManager, + fingerprint: string, +): void { + const nodeModulesDir = path.join(repoDir, 'node_modules'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, INSTALL_STATE_FILENAME), + JSON.stringify( + { + packageManager, + fingerprint, + installedAt: new Date().toISOString(), + }, + null, + 2, + ), + ); +} + +function hasRunnableNodeModulesTree(repoDir: string): boolean { + const nodeModulesDir = path.join(repoDir, 'node_modules'); + if (!fs.existsSync(nodeModulesDir)) { + return false; + } + + try { + return fs + .readdirSync(nodeModulesDir) + .some((entry) => !NODE_MODULES_NOISE_ENTRIES.has(entry)); + } catch { + return false; + } +} + +export function detectWorkspacePackageManager( + repoDir: string, +): WorkspacePackageManager | null { + const packageJson = readPackageJsonMetadata(repoDir); + if (!packageJson) { + return null; + } + + const fromField = detectPackageManagerFromField(packageJson.packageManager); + if (fromField) { + return fromField; + } + const lockfilePackageManagers = detectPackageManagersFromLockfiles(repoDir); + if (lockfilePackageManagers.length > 1) { + throw new Error( + `Ambiguous package manager for ${repoDir}: multiple lockfiles detected (${lockfilePackageManagers.join( + ', ', + )}). Add packageManager to package.json to disambiguate.`, + ); + } + if (lockfilePackageManagers.length === 1) { + return lockfilePackageManagers[0]!; + } + return 'npm'; +} + +export function buildWorkspaceScriptCommand( + repoDir: string, + scriptName: string, +): WorkspaceCommandSpec { + const packageManager = detectWorkspacePackageManager(repoDir) ?? 'npm'; + + switch (packageManager) { + case 'bun': + return { + packageManager, + file: 'bun', + args: ['run', scriptName], + commandText: buildCommandText('bun', ['run', scriptName]), + }; + case 'pnpm': + return buildCorepackCommand(packageManager, ['run', scriptName]); + case 'yarn': + return buildCorepackCommand(packageManager, ['run', scriptName]); + case 'npm': + if (scriptName === 'test') { + return { + packageManager, + file: 'npm', + args: ['test'], + commandText: 'npm test', + }; + } + return { + packageManager, + file: 'npm', + args: ['run', scriptName], + commandText: buildCommandText('npm', ['run', scriptName]), + }; + } +} + +export function resolveWorkspaceInstallCommand( + repoDir: string, +): WorkspaceCommandSpec | null { + const packageJson = readPackageJsonMetadata(repoDir); + if (!packageJson) { + return null; + } + + const packageManager = detectWorkspacePackageManager(repoDir) ?? 'npm'; + switch (packageManager) { + case 'bun': + return { + packageManager, + file: 'bun', + args: ['install', '--frozen-lockfile'], + commandText: buildCommandText('bun', ['install', '--frozen-lockfile']), + }; + case 'pnpm': + return buildCorepackCommand(packageManager, [ + 'install', + '--frozen-lockfile', + ]); + case 'yarn': { + const immutableArg = detectYarnBerry(repoDir, packageJson.packageManager) + ? '--immutable' + : '--frozen-lockfile'; + return buildCorepackCommand(packageManager, ['install', immutableArg]); + } + case 'npm': + if (hasLockfile(repoDir, 'package-lock.json', 'npm-shrinkwrap.json')) { + return { + packageManager, + file: 'npm', + args: ['ci'], + commandText: 'npm ci', + }; + } + return { + packageManager, + file: 'npm', + args: ['install'], + commandText: 'npm install', + }; + } +} + +export function hasInstalledNodeModules(repoDir: string): boolean { + if (!hasRunnableNodeModulesTree(repoDir)) { + return false; + } + + const expectedFingerprint = computeInstallFingerprint(repoDir); + if (!expectedFingerprint) { + return false; + } + + return readInstallFingerprint(repoDir) === expectedFingerprint; +} + +export function ensureWorkspaceDependenciesInstalled( + repoDir: string, +): WorkspaceDependencyInstallResult { + const packageManager = detectWorkspacePackageManager(repoDir); + if (!packageManager) { + return { installed: false, packageManager: null }; + } + if (hasInstalledNodeModules(repoDir)) { + return { installed: false, packageManager }; + } + + const command = resolveWorkspaceInstallCommand(repoDir); + if (!command) { + return { installed: false, packageManager }; + } + + try { + execFileSync(command.file, command.args, { + cwd: repoDir, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + const stderr = + typeof error === 'object' && + error !== null && + 'stderr' in error && + typeof (error as { stderr?: unknown }).stderr === 'string' + ? (error as { stderr: string }).stderr.trim() + : ''; + const stdout = + typeof error === 'object' && + error !== null && + 'stdout' in error && + typeof (error as { stdout?: unknown }).stdout === 'string' + ? (error as { stdout: string }).stdout.trim() + : ''; + const detail = + stderr || stdout || (error instanceof Error ? error.message : String(error)); + throw new Error( + `Failed to install workspace dependencies with "${command.commandText}" in ${repoDir}: ${detail}`, + ); + } + + const fingerprint = computeInstallFingerprint(repoDir); + if (fingerprint) { + writeInstallFingerprint(repoDir, packageManager, fingerprint); + } + + if (!hasInstalledNodeModules(repoDir)) { + throw new Error( + `Workspace dependency install did not produce a usable node_modules tree in ${repoDir}.`, + ); + } + + return { + installed: true, + packageManager, + commandText: command.commandText, + }; +} + +export function detectPnpmStorePath(workspaceDir: string): string | null { + if (detectWorkspacePackageManager(workspaceDir) !== 'pnpm') { + return null; + } + if (process.env.PNPM_STORE_DIR && fs.existsSync(process.env.PNPM_STORE_DIR)) { + return process.env.PNPM_STORE_DIR; + } + + const candidates: Array<[string, string[]]> = [ + ['pnpm', ['store', 'path']], + ['corepack', ['pnpm', 'store', 'path']], + ]; + for (const [file, args] of candidates) { + try { + const storePath = execFileSync(file, args, { + cwd: workspaceDir, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + }).trim(); + if (storePath && fs.existsSync(storePath)) { + return storePath; + } + } catch { + // Try the next candidate. + } + } + + const defaultStore = path.join( + process.env.HOME || '', + '.local', + 'share', + 'pnpm', + 'store', + ); + return fs.existsSync(defaultStore) ? defaultStore : null; +}