Automate worktree dependency installs
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
"name": "ejclaw",
|
"name": "ejclaw",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
|
"packageManager": "bun@1.3.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -427,7 +427,7 @@ server.tool(
|
|||||||
profile: z
|
profile: z
|
||||||
.enum(VERIFICATION_PROFILES)
|
.enum(VERIFICATION_PROFILES)
|
||||||
.describe(
|
.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) => {
|
async (args) => {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import type { AgentOutput } from './agent-runner.js';
|
import type { AgentOutput } from './agent-runner.js';
|
||||||
import type { RegisteredGroup } from './types.js';
|
import type { RegisteredGroup } from './types.js';
|
||||||
|
import { detectPnpmStorePath } from './workspace-package-manager.js';
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────
|
// ── 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 ────────────────────────────────────────────
|
// ── Pre-flight checks ────────────────────────────────────────────
|
||||||
|
|
||||||
let containerRuntimeChecked = false;
|
let containerRuntimeChecked = false;
|
||||||
|
|||||||
@@ -213,6 +213,52 @@ describe('paired workspace manager', () => {
|
|||||||
).toBe(null);
|
).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<typeof import('./workspace-package-manager.js')>(
|
||||||
|
'./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 () => {
|
it('uses the shared DB owner workspace across service-local data dirs', async () => {
|
||||||
const canonicalDir = path.join(tempRoot, 'canonical');
|
const canonicalDir = path.join(tempRoot, 'canonical');
|
||||||
fs.mkdirSync(canonicalDir, { recursive: true });
|
fs.mkdirSync(canonicalDir, { recursive: true });
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { resolvePairedTaskWorkspacePath } from './group-folder.js';
|
import { resolvePairedTaskWorkspacePath } from './group-folder.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import type { PairedTask, PairedWorkspace } from './types.js';
|
import type { PairedTask, PairedWorkspace } from './types.js';
|
||||||
|
import { ensureWorkspaceDependenciesInstalled } from './workspace-package-manager.js';
|
||||||
|
|
||||||
const REVIEWER_SNAPSHOT_STALE_BLOCK_MESSAGE =
|
const REVIEWER_SNAPSHOT_STALE_BLOCK_MESSAGE =
|
||||||
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.';
|
'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,
|
canonicalWorkDir: string,
|
||||||
): PairedWorkspace {
|
): PairedWorkspace {
|
||||||
ensureGitRepository(canonicalWorkDir);
|
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({
|
const workspace = makeWorkspaceRecord({
|
||||||
taskId,
|
taskId,
|
||||||
role: 'owner',
|
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({
|
const workspace = makeWorkspaceRecord({
|
||||||
taskId,
|
taskId,
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
@@ -741,6 +767,21 @@ export function markPairedTaskReviewReady(taskId: string): {
|
|||||||
return null;
|
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.
|
// Reviewer runs in a container with the owner workspace mounted read-only.
|
||||||
// No file-level snapshot copy needed — just register the path.
|
// No file-level snapshot copy needed — just register the path.
|
||||||
const reviewerWorkspace = makeWorkspaceRecord({
|
const reviewerWorkspace = makeWorkspaceRecord({
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
buildVerificationCommand,
|
buildVerificationCommand,
|
||||||
computeVerificationSnapshot,
|
computeVerificationSnapshot,
|
||||||
isVerificationProfile,
|
isVerificationProfile,
|
||||||
|
runVerificationRequest,
|
||||||
} from './verification.js';
|
} from './verification.js';
|
||||||
|
|
||||||
describe('verification helpers', () => {
|
describe('verification helpers', () => {
|
||||||
@@ -19,23 +20,43 @@ describe('verification helpers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('builds deterministic commands for each profile', () => {
|
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',
|
file: 'npm',
|
||||||
args: ['test'],
|
args: ['test'],
|
||||||
requiredScript: 'test',
|
requiredScript: 'test',
|
||||||
});
|
});
|
||||||
expect(buildVerificationCommand('typecheck')).toMatchObject({
|
expect(buildVerificationCommand('typecheck', repoDir)).toMatchObject({
|
||||||
file: 'npm',
|
file: 'npm',
|
||||||
args: ['run', 'typecheck'],
|
args: ['run', 'typecheck'],
|
||||||
requiredScript: 'typecheck',
|
requiredScript: 'typecheck',
|
||||||
});
|
});
|
||||||
expect(buildVerificationCommand('build')).toMatchObject({
|
expect(buildVerificationCommand('build', repoDir)).toMatchObject({
|
||||||
file: 'npm',
|
file: 'npm',
|
||||||
args: ['run', 'build'],
|
args: ['run', 'build'],
|
||||||
requiredScript: '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', () => {
|
it('computes a stable snapshot over the readable workspace inputs', () => {
|
||||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-snapshot-'));
|
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-snapshot-'));
|
||||||
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
||||||
@@ -58,4 +79,34 @@ describe('verification helpers', () => {
|
|||||||
expect(second).toBe(first);
|
expect(second).toBe(first);
|
||||||
expect(third).not.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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
writableMountArgs,
|
writableMountArgs,
|
||||||
} from './container-runtime.js';
|
} from './container-runtime.js';
|
||||||
import { resolveGroupIpcPath } from './group-folder.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;
|
export const VERIFICATION_PROFILES = ['test', 'typecheck', 'build'] as const;
|
||||||
|
|
||||||
@@ -69,30 +74,17 @@ export function isVerificationProfile(
|
|||||||
|
|
||||||
export function buildVerificationCommand(
|
export function buildVerificationCommand(
|
||||||
profile: VerificationProfile,
|
profile: VerificationProfile,
|
||||||
|
repoDir: string = process.cwd(),
|
||||||
): VerificationCommandSpec {
|
): VerificationCommandSpec {
|
||||||
switch (profile) {
|
const scriptName =
|
||||||
case 'test':
|
profile === 'test' ? 'test' : profile === 'typecheck' ? 'typecheck' : 'build';
|
||||||
return {
|
const command = buildWorkspaceScriptCommand(repoDir, scriptName);
|
||||||
file: 'npm',
|
return {
|
||||||
args: ['test'],
|
file: command.file,
|
||||||
commandText: 'npm test',
|
args: command.args,
|
||||||
requiredScript: 'test',
|
commandText: command.commandText,
|
||||||
};
|
requiredScript: scriptName,
|
||||||
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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldExcludePath(name: string): boolean {
|
function shouldExcludePath(name: string): boolean {
|
||||||
@@ -146,35 +138,6 @@ function truncateOutput(value: string | undefined): string {
|
|||||||
return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`;
|
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<string, string> {
|
function readPackageScripts(repoDir: string): Record<string, string> {
|
||||||
const packageJsonPath = path.join(repoDir, 'package.json');
|
const packageJsonPath = path.join(repoDir, 'package.json');
|
||||||
if (!fs.existsSync(packageJsonPath)) {
|
if (!fs.existsSync(packageJsonPath)) {
|
||||||
@@ -248,7 +211,10 @@ function buildDockerRunArgs(
|
|||||||
args.push(...writableMountArgs(scratchWorkspace, PRIMARY_PROJECT_MOUNT));
|
args.push(...writableMountArgs(scratchWorkspace, PRIMARY_PROJECT_MOUNT));
|
||||||
|
|
||||||
const sourceNodeModulesDir = path.join(sourceRepoDir, 'node_modules');
|
const sourceNodeModulesDir = path.join(sourceRepoDir, 'node_modules');
|
||||||
if (fs.existsSync(sourceNodeModulesDir)) {
|
if (
|
||||||
|
hasInstalledNodeModules(sourceRepoDir) &&
|
||||||
|
fs.existsSync(sourceNodeModulesDir)
|
||||||
|
) {
|
||||||
args.push(
|
args.push(
|
||||||
...readonlyMountArgs(
|
...readonlyMountArgs(
|
||||||
sourceNodeModulesDir,
|
sourceNodeModulesDir,
|
||||||
@@ -314,7 +280,7 @@ export async function runVerificationRequest(
|
|||||||
): Promise<VerificationResult> {
|
): Promise<VerificationResult> {
|
||||||
const repoDir = options?.repoDir || process.cwd();
|
const repoDir = options?.repoDir || process.cwd();
|
||||||
const runtimeVersion = detectRuntimeVersion();
|
const runtimeVersion = detectRuntimeVersion();
|
||||||
const command = buildVerificationCommand(request.profile);
|
const command = buildVerificationCommand(request.profile, repoDir);
|
||||||
const scripts = readPackageScripts(repoDir);
|
const scripts = readPackageScripts(repoDir);
|
||||||
|
|
||||||
if (!scripts[command.requiredScript]) {
|
if (!scripts[command.requiredScript]) {
|
||||||
@@ -331,7 +297,7 @@ export async function runVerificationRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(path.join(repoDir, 'node_modules'))) {
|
if (!hasInstalledNodeModules(repoDir)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
profile: request.profile,
|
profile: request.profile,
|
||||||
|
|||||||
178
src/workspace-package-manager.test.ts
Normal file
178
src/workspace-package-manager.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
419
src/workspace-package-manager.ts
Normal file
419
src/workspace-package-manager.ts
Normal file
@@ -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<WorkspacePackageManager>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user