agent-ecosystem/scripts/stage-runtime.mjs
2026-05-17 11:25:22 +03:00

281 lines
8.2 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const runtimeLockPath = path.join(repoRoot, 'runtime.lock.json');
const runtimeDir = path.join(repoRoot, 'resources', 'runtime');
const downloadRoot = path.join(repoRoot, '.runtime-download');
function printUsage() {
process.stdout.write(`Usage: node scripts/stage-runtime.mjs [options]
Options:
--platform <key> Runtime platform key. Defaults to the current platform.
--release-tag <tag> Release tag to download from. Defaults to runtime.lock.json.
--clean Remove staged runtime files and keep resources/runtime/.gitkeep.
--help Show this message.
`);
}
function parseArgs(argv) {
const parsed = {
platform: null,
releaseTag: null,
clean: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--help' || arg === '-h') {
parsed.help = true;
continue;
}
if (arg === '--clean') {
parsed.clean = true;
continue;
}
if (arg === '--platform') {
parsed.platform = argv[index + 1] ?? null;
index += 1;
continue;
}
if (arg === '--release-tag') {
parsed.releaseTag = argv[index + 1] ?? null;
index += 1;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
return parsed;
}
function runOrThrow(command, args) {
const result = spawnSync(command, args, {
cwd: repoRoot,
stdio: 'inherit',
shell: false,
});
if (result.error) {
throw new Error(`Failed to run ${command}: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
}
}
function readRuntimeLock() {
return JSON.parse(fs.readFileSync(runtimeLockPath, 'utf8'));
}
function getDefaultPlatformKey() {
const key = `${process.platform}-${process.arch}`;
if (
key === 'darwin-arm64' ||
key === 'darwin-x64' ||
key === 'linux-x64' ||
key === 'win32-x64'
) {
return key;
}
throw new Error(`No bundled runtime asset is configured for ${key}`);
}
function getReleaseTag(runtimeLock, override) {
const tag = override?.trim() || runtimeLock.releaseTag?.trim() || runtimeLock.sourceRef?.trim();
if (!tag) {
throw new Error('runtime.lock.json does not define releaseTag or sourceRef');
}
return tag;
}
function getReleaseAssetUrl(runtimeLock, releaseTag, asset) {
return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${releaseTag}/${encodeURIComponent(asset.file)}`;
}
function cleanRuntimeDir() {
fs.mkdirSync(runtimeDir, { recursive: true });
for (const entry of fs.readdirSync(runtimeDir, { withFileTypes: true })) {
if (entry.name === '.gitkeep') {
continue;
}
fs.rmSync(path.join(runtimeDir, entry.name), { recursive: true, force: true });
}
}
async function downloadFile(url, destinationPath) {
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
const response = await fetch(url, {
headers: {
'user-agent': 'agent-teams-runtime-stager',
...(process.env.GH_TOKEN ? { authorization: `Bearer ${process.env.GH_TOKEN}` } : {}),
},
redirect: 'follow',
});
if (!response.ok || !response.body) {
throw new Error(`Failed to download runtime asset: ${response.status} ${response.statusText}`);
}
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(destinationPath));
}
function downloadReleaseAssetWithGh(runtimeLock, releaseTag, asset, destinationPath) {
if (!process.env.GH_TOKEN) {
return false;
}
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
const result = spawnSync(
'gh',
[
'release',
'download',
releaseTag,
'--repo',
runtimeLock.releaseRepository,
'--pattern',
asset.file,
'--dir',
path.dirname(destinationPath),
'--clobber',
],
{
cwd: repoRoot,
stdio: 'inherit',
shell: false,
env: process.env,
}
);
return result.status === 0 && fs.existsSync(destinationPath);
}
function extractArchive(archivePath, extractDir, archiveKind) {
fs.mkdirSync(extractDir, { recursive: true });
if (archiveKind === 'tar.gz') {
runOrThrow('tar', ['-xzf', archivePath, '-C', extractDir]);
return;
}
if (archiveKind === 'zip') {
if (process.platform === 'win32') {
runOrThrow('powershell', [
'-NoProfile',
'-Command',
`Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${extractDir.replace(/'/g, "''")}' -Force`,
]);
return;
}
runOrThrow('unzip', ['-oq', archivePath, '-d', extractDir]);
return;
}
throw new Error(`Unsupported runtime archive kind: ${archiveKind}`);
}
function findRuntimePayloadDir(extractDir, binaryName) {
const candidates = [path.join(extractDir, 'runtime'), extractDir];
for (const candidate of candidates) {
if (
fs.existsSync(path.join(candidate, 'VERSION')) &&
fs.existsSync(path.join(candidate, binaryName))
) {
return candidate;
}
}
throw new Error(`Extracted runtime archive does not contain runtime/VERSION and ${binaryName}`);
}
function verifyStagedRuntime(runtimeLock, asset, platformKey) {
const versionPath = path.join(runtimeDir, 'VERSION');
const binaryPath = path.join(runtimeDir, asset.binaryName);
if (!fs.existsSync(versionPath)) {
throw new Error('Staged runtime is missing resources/runtime/VERSION');
}
if (!fs.existsSync(binaryPath)) {
throw new Error(`Staged runtime is missing resources/runtime/${asset.binaryName}`);
}
const versionText = fs.readFileSync(versionPath, 'utf8').trim();
if (!versionText.includes(runtimeLock.version)) {
throw new Error(
`Staged runtime version mismatch for ${platformKey}. Expected ${runtimeLock.version}, got ${versionText}`
);
}
}
async function stageRuntime(options) {
const runtimeLock = readRuntimeLock();
const platformKey = options.platform?.trim() || getDefaultPlatformKey();
const asset = runtimeLock.assets?.[platformKey];
if (!asset) {
throw new Error(`runtime.lock.json has no asset for ${platformKey}`);
}
const releaseTag = getReleaseTag(runtimeLock, options.releaseTag);
const workDir = path.join(downloadRoot, `stage-${platformKey}-${process.pid}-${Date.now()}`);
const archivePath = path.join(workDir, asset.file);
const extractDir = path.join(workDir, 'extracted');
fs.rmSync(workDir, { recursive: true, force: true });
fs.mkdirSync(workDir, { recursive: true });
try {
const url = getReleaseAssetUrl(runtimeLock, releaseTag, asset);
process.stdout.write(
`Downloading ${asset.file} from ${runtimeLock.releaseRepository}@${releaseTag}\n`
);
if (!downloadReleaseAssetWithGh(runtimeLock, releaseTag, asset, archivePath)) {
await downloadFile(url, archivePath);
}
process.stdout.write(`Extracting ${asset.file}\n`);
extractArchive(archivePath, extractDir, asset.archiveKind);
const payloadDir = findRuntimePayloadDir(extractDir, asset.binaryName);
cleanRuntimeDir();
fs.cpSync(payloadDir, runtimeDir, { recursive: true });
if (process.platform !== 'win32' && platformKey !== 'win32-x64') {
fs.chmodSync(path.join(runtimeDir, asset.binaryName), 0o755);
}
verifyStagedRuntime(runtimeLock, asset, platformKey);
process.stdout.write(`Staged runtime ${runtimeLock.version} for ${platformKey}\n`);
} finally {
fs.rmSync(workDir, { recursive: true, force: true });
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printUsage();
return;
}
if (options.clean) {
cleanRuntimeDir();
process.stdout.write('Cleaned resources/runtime\n');
return;
}
await stageRuntime(options);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});