agent-ecosystem/scripts/dev-with-runtime.mjs

541 lines
16 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { once } from 'node:events';
import readline from 'node:readline';
import { fileURLToPath } from 'node:url';
import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const uiRepoRoot = path.resolve(scriptDir, '..');
const runtimeRepoRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim() ?? '';
const explicitRuntimeCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() ?? '';
const runtimeLockPath = path.join(uiRepoRoot, 'runtime.lock.json');
const defaultRuntimeCacheRoot = path.join(os.homedir(), '.agent-teams', 'runtime-cache');
const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim()
? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim())
: defaultRuntimeCacheRoot;
const scriptArgs = process.argv.slice(2);
const shouldPrintRuntimePath = scriptArgs.includes('--print-runtime-path');
const electronViteArgs = scriptArgs.filter((arg) => arg !== '--print-runtime-path' && arg !== '--');
const runtimeDisplayName = 'teams orchestrator';
function runOrExit(cmd, args, options = {}) {
const result = spawnSyncWithWindowsShell(cmd, args, {
stdio: 'inherit',
...options,
});
if (result.error) {
console.error(`Failed to run ${cmd}: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function runAndCapture(cmd, args, options = {}) {
const result = spawnSyncWithWindowsShell(cmd, args, {
encoding: 'utf8',
...options,
});
if (result.error) {
throw new Error(`Failed to run ${cmd}: ${result.error.message}`);
}
if (result.status !== 0) {
const details = [result.stdout, result.stderr]
.map((value) => value?.trim())
.filter(Boolean)
.join('\n');
throw new Error(`Command failed: ${cmd} ${args.join(' ')}${details ? `\n${details}` : ''}`);
}
return result.stdout?.trim() ?? '';
}
function readPackageManagerCommand(repoRoot) {
const packageJsonPath = path.join(repoRoot, 'package.json');
const rawPackageJson = fs.readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(rawPackageJson);
const rawPackageManager = packageJson.packageManager;
if (typeof rawPackageManager !== 'string' || rawPackageManager.trim().length === 0) {
return 'pnpm';
}
const [packageManagerName] = rawPackageManager.trim().split('@', 1);
if (!packageManagerName) {
return 'pnpm';
}
return packageManagerName;
}
function readRuntimeLock() {
return JSON.parse(fs.readFileSync(runtimeLockPath, 'utf8'));
}
function getPlatformAssetKey() {
const platformKey = `${process.platform}-${process.arch}`;
switch (platformKey) {
case 'darwin-arm64':
case 'darwin-x64':
case 'linux-x64':
case 'win32-x64':
return platformKey;
default:
throw new Error(
`Dev runtime bootstrap does not support this platform yet: ${process.platform}/${process.arch}`
);
}
}
function getReleaseAssetUrl(runtimeLock, asset) {
const releaseTag =
typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
? runtimeLock.releaseTag.trim()
: runtimeLock.sourceRef;
return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${releaseTag}/${encodeURIComponent(asset.file)}`;
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
function truncateMiddle(value, maxLength) {
if (value.length <= maxLength) {
return value;
}
if (maxLength <= 3) {
return value.slice(0, maxLength);
}
const visibleChars = maxLength - 3;
const headLength = Math.ceil(visibleChars / 2);
const tailLength = Math.floor(visibleChars / 2);
return `${value.slice(0, headLength)}...${value.slice(value.length - tailLength)}`;
}
function buildProgressBar(progressRatio, width) {
const safeWidth = Math.max(10, width);
const clampedRatio = Number.isFinite(progressRatio) ? Math.min(1, Math.max(0, progressRatio)) : 0;
const filledWidth = Math.round(safeWidth * clampedRatio);
return `${'='.repeat(filledWidth)}${'-'.repeat(safeWidth - filledWidth)}`;
}
function supportsProgressRedraw() {
return Boolean(process.stdout.isTTY && process.env.TERM && process.env.TERM !== 'dumb');
}
function formatProgressLine(label, writtenBytes, totalBytes, hasTotal) {
const columns =
process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100;
const ratio = hasTotal ? writtenBytes / totalBytes : 0;
const percentText = hasTotal ? ` ${Math.floor(ratio * 100)}%` : '';
const bytesText = hasTotal
? `${formatBytes(writtenBytes)} / ${formatBytes(totalBytes)}`
: `${formatBytes(writtenBytes)}`;
const barWidth = hasTotal ? Math.min(24, Math.max(10, Math.floor(columns * 0.18))) : 0;
const barText = hasTotal ? ` [${buildProgressBar(ratio, barWidth)}]` : '';
const fixedParts = `${barText} ${bytesText}${percentText}`.trimStart();
const availableLabelWidth = Math.max(16, columns - fixedParts.length - 1);
const labelText = truncateMiddle(label, availableLabelWidth);
return `${labelText}${fixedParts ? ` ${fixedParts}` : ''}`;
}
function formatProgressSummary(writtenBytes, totalBytes, hasTotal) {
if (hasTotal) {
const ratio = writtenBytes / totalBytes;
return `Runtime download ${Math.floor(ratio * 100)}% - ${formatBytes(writtenBytes)} / ${formatBytes(totalBytes)}`;
}
return `Runtime download - ${formatBytes(writtenBytes)}`;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function readBinaryVersion(binaryPath) {
return runAndCapture(binaryPath, ['--version']);
}
function formatRuntimeVersionForDisplay(versionText) {
const trimmed = versionText.trim();
if (!trimmed) {
return runtimeDisplayName;
}
const versionOnly = trimmed.replace(/\s*\([^)]*\)\s*$/, '');
return `${versionOnly} (${runtimeDisplayName})`;
}
function isExecutable(filePath) {
if (!fs.existsSync(filePath)) {
return false;
}
if (process.platform === 'win32') {
return fs.statSync(filePath).isFile();
}
try {
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function isCachedBinaryValid(binaryPath, expectedVersion) {
if (!isExecutable(binaryPath)) {
return false;
}
try {
return readBinaryVersion(binaryPath).includes(expectedVersion);
} catch {
return false;
}
}
async function downloadWithProgress(url, destinationPath) {
const response = await fetch(url, {
headers: {
'user-agent': 'claude-team-dev-runtime-bootstrap',
},
redirect: 'follow',
});
if (!response.ok || !response.body) {
throw new Error(`Failed to download runtime asset: ${response.status} ${response.statusText}`);
}
const totalBytes = Number.parseInt(response.headers.get('content-length') ?? '', 10);
const hasTotal = Number.isFinite(totalBytes) && totalBytes > 0;
const writer = fs.createWriteStream(destinationPath);
const reader = response.body.getReader();
let writtenBytes = 0;
let lastPrintedAt = 0;
let lastLoggedPercent = -1;
let lastLoggedBytes = 0;
const label = `Downloading runtime ${path.basename(destinationPath)}`;
const canRedraw = supportsProgressRedraw();
if (canRedraw) {
process.stdout.write(formatProgressLine(label, 0, totalBytes, hasTotal));
} else {
process.stdout.write(`${label}\n`);
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!writer.write(Buffer.from(value))) {
await once(writer, 'drain');
}
writtenBytes += value.byteLength;
const now = Date.now();
if (canRedraw && (now - lastPrintedAt >= 150 || writtenBytes === totalBytes)) {
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
process.stdout.write(formatProgressLine(label, writtenBytes, totalBytes, hasTotal));
lastPrintedAt = now;
} else if (!canRedraw) {
const nextPercent = hasTotal ? Math.floor((writtenBytes / totalBytes) * 100) : null;
const shouldLogPercent =
nextPercent !== null && (nextPercent === 100 || nextPercent >= lastLoggedPercent + 5);
const shouldLogBytes =
nextPercent === null && writtenBytes >= lastLoggedBytes + 5 * 1024 * 1024;
if (shouldLogPercent || shouldLogBytes) {
process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`);
if (nextPercent !== null) {
lastLoggedPercent = nextPercent;
} else {
lastLoggedBytes = writtenBytes;
}
}
}
}
} finally {
await new Promise((resolve, reject) => {
writer.end((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
if (canRedraw) {
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
process.stdout.write(`${formatProgressLine(label, writtenBytes, totalBytes, hasTotal)}\n`);
} else if (
(hasTotal && lastLoggedPercent < 100) ||
(!hasTotal && writtenBytes !== lastLoggedBytes)
) {
process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`);
}
}
function extractArchive(archivePath, extractDir, archiveKind) {
ensureDir(extractDir);
if (archiveKind === 'tar.gz') {
runOrExit('tar', ['-xzf', archivePath, '-C', extractDir]);
return;
}
if (archiveKind === 'zip') {
if (process.platform === 'win32') {
runOrExit('powershell', [
'-NoProfile',
'-Command',
`Expand-Archive -Path '${archivePath.replace(/'/g, "''")}' -DestinationPath '${extractDir.replace(/'/g, "''")}' -Force`,
]);
return;
}
runOrExit('unzip', ['-oq', archivePath, '-d', extractDir]);
return;
}
throw new Error(`Unsupported runtime archive kind: ${archiveKind}`);
}
function findExtractedBinary(extractDir, binaryName) {
const directCandidate = path.join(extractDir, 'runtime', binaryName);
if (fs.existsSync(directCandidate)) {
return directCandidate;
}
const fallbackCandidate = path.join(extractDir, binaryName);
if (fs.existsSync(fallbackCandidate)) {
return fallbackCandidate;
}
throw new Error(`Extracted runtime archive does not contain ${binaryName}`);
}
async function acquireBootstrapLock(lockPath) {
const waitDeadline = Date.now() + 120_000;
let announcedWait = false;
while (true) {
try {
return await fs.promises.open(lockPath, 'wx');
} catch (error) {
if (error?.code !== 'EEXIST') {
throw error;
}
if (!announcedWait) {
process.stdout.write('Waiting for another runtime bootstrap to finish...\n');
announcedWait = true;
}
if (Date.now() >= waitDeadline) {
throw new Error(`Timed out waiting for runtime bootstrap lock: ${lockPath}`);
}
await sleep(750);
}
}
}
async function ensureBootstrappedRuntime() {
const runtimeLock = readRuntimeLock();
const platformKey = getPlatformAssetKey();
const asset = runtimeLock.assets[platformKey];
if (!asset) {
throw new Error(`No runtime asset configured for ${platformKey}`);
}
const cacheDir = path.join(runtimeCacheRoot, runtimeLock.version, platformKey);
const cachedBinaryPath = path.join(cacheDir, asset.binaryName);
if (isCachedBinaryValid(cachedBinaryPath, runtimeLock.version)) {
return {
binaryPath: cachedBinaryPath,
versionText: readBinaryVersion(cachedBinaryPath),
sourceLabel: `cached release ${runtimeLock.sourceRef}`,
cacheDir,
downloaded: false,
};
}
ensureDir(cacheDir);
const lockHandle = await acquireBootstrapLock(path.join(cacheDir, '.bootstrap.lock'));
try {
if (isCachedBinaryValid(cachedBinaryPath, runtimeLock.version)) {
return {
binaryPath: cachedBinaryPath,
versionText: readBinaryVersion(cachedBinaryPath),
sourceLabel: `cached release ${runtimeLock.sourceRef}`,
cacheDir,
downloaded: false,
};
}
const workDir = path.join(cacheDir, `.bootstrap-${process.pid}-${Date.now()}`);
ensureDir(workDir);
try {
const archivePath = path.join(workDir, asset.file);
await downloadWithProgress(getReleaseAssetUrl(runtimeLock, asset), archivePath);
const extractDir = path.join(workDir, 'extracted');
extractArchive(archivePath, extractDir, asset.archiveKind);
const extractedBinaryPath = findExtractedBinary(extractDir, asset.binaryName);
const nextBinaryPath = `${cachedBinaryPath}.tmp`;
await fs.promises.copyFile(extractedBinaryPath, nextBinaryPath);
try {
if (process.platform !== 'win32') {
await fs.promises.chmod(nextBinaryPath, 0o755);
}
await fs.promises.rm(cachedBinaryPath, { force: true });
await fs.promises.rename(nextBinaryPath, cachedBinaryPath);
const versionText = readBinaryVersion(cachedBinaryPath);
if (!versionText.includes(runtimeLock.version)) {
await fs.promises.rm(cachedBinaryPath, { force: true });
throw new Error(
`Bootstrapped runtime version mismatch. Expected ${runtimeLock.version}, got: ${versionText}`
);
}
return {
binaryPath: cachedBinaryPath,
versionText,
sourceLabel: `downloaded release ${runtimeLock.sourceRef}`,
cacheDir,
downloaded: true,
};
} finally {
await fs.promises.rm(nextBinaryPath, { force: true });
}
} finally {
fs.rmSync(workDir, { recursive: true, force: true });
}
} finally {
await lockHandle.close();
await fs.promises.rm(path.join(cacheDir, '.bootstrap.lock'), { force: true });
}
}
function validateRuntimeRepoRoot(repoRoot) {
const runtimePackageJsonPath = path.join(repoRoot, 'package.json');
if (!fs.existsSync(runtimePackageJsonPath)) {
console.error(`CLAUDE_DEV_RUNTIME_ROOT does not look like a repo root: ${repoRoot}`);
process.exit(1);
}
}
async function resolveRuntimeCli() {
if (explicitRuntimeCliPath) {
if (!isExecutable(explicitRuntimeCliPath)) {
throw new Error(
`CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH is not executable: ${explicitRuntimeCliPath}`
);
}
return {
binaryPath: explicitRuntimeCliPath,
versionText: readBinaryVersion(explicitRuntimeCliPath),
sourceLabel: `explicit runtime override ${explicitRuntimeCliPath}`,
};
}
if (runtimeRepoRoot) {
validateRuntimeRepoRoot(runtimeRepoRoot);
const runtimePackageManager = readPackageManagerCommand(runtimeRepoRoot);
runOrExit(runtimePackageManager, ['run', 'build:dev'], { cwd: runtimeRepoRoot });
const runtimeCliName = process.platform === 'win32' ? 'cli-dev.cmd' : 'cli-dev';
const runtimeCliPath = path.join(runtimeRepoRoot, runtimeCliName);
return {
binaryPath: runtimeCliPath,
versionText: readBinaryVersion(runtimeCliPath),
sourceLabel: `local runtime repo ${runtimeRepoRoot}`,
};
}
return ensureBootstrappedRuntime();
}
async function main() {
const resolvedRuntime = await resolveRuntimeCli();
if (shouldPrintRuntimePath) {
process.stdout.write(`${resolvedRuntime.binaryPath}\n`);
return;
}
process.stdout.write(`Using runtime from ${resolvedRuntime.sourceLabel}\n`);
if ('cacheDir' in resolvedRuntime && resolvedRuntime.cacheDir) {
process.stdout.write(`Runtime cache: ${resolvedRuntime.cacheDir}\n`);
}
process.stdout.write(
`Runtime version: ${formatRuntimeVersionForDisplay(resolvedRuntime.versionText)}\n`
);
const uiEnv = {
...process.env,
UV_THREADPOOL_SIZE: process.env.UV_THREADPOOL_SIZE?.trim() || '16',
CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH: resolvedRuntime.binaryPath,
};
delete uiEnv.CLAUDE_CLI_PATH;
const uiPackageManager = readPackageManagerCommand(uiRepoRoot);
runOrExit(uiPackageManager, ['exec', 'electron-vite', 'dev', ...electronViteArgs], {
cwd: uiRepoRoot,
env: uiEnv,
});
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});