302 lines
11 KiB
JavaScript
302 lines
11 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
import { realpathSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { arch as hostArch, platform as hostPlatform } from 'node:os';
|
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
|
|
export const PI_RUNTIME_PACKAGE = '@earendil-works/pi-coding-agent';
|
|
export const PI_RUNTIME_VERSION = '0.84.2';
|
|
export const PI_RUNTIME_CLI_ENTRY = 'dist/cli.js';
|
|
export const PI_RUNTIME_MANIFEST = 'makelore-pi-runtime.json';
|
|
export const PI_RUNTIME_STAGING_NPM_VERSION = '11.6.2';
|
|
|
|
function readJson(path) {
|
|
return readFile(path, 'utf8').then((source) => JSON.parse(source));
|
|
}
|
|
|
|
async function pathExists(path) {
|
|
try {
|
|
await stat(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function portable(path) {
|
|
return path.split(sep).join('/');
|
|
}
|
|
|
|
function constraintMatches(constraints, current) {
|
|
if (!constraints || constraints.length === 0 || !current) return true;
|
|
const positive = constraints.filter((value) => !value.startsWith('!'));
|
|
const negative = constraints.filter((value) => value.startsWith('!')).map((value) => value.slice(1));
|
|
return !negative.includes(current) && (positive.length === 0 || positive.includes(current));
|
|
}
|
|
|
|
export function lockEntrySupportsTarget(entry, target) {
|
|
return constraintMatches(entry.os, target.platform)
|
|
&& constraintMatches(entry.cpu, target.arch)
|
|
&& constraintMatches(entry.libc, target.libc);
|
|
}
|
|
|
|
function packageNameFromLockPath(packagePath) {
|
|
const marker = packagePath.lastIndexOf('node_modules/');
|
|
if (marker === -1) return null;
|
|
const remainder = packagePath.slice(marker + 'node_modules/'.length).split('/');
|
|
return remainder[0]?.startsWith('@') ? remainder.slice(0, 2).join('/') : remainder[0];
|
|
}
|
|
|
|
function packageIdentity(name, version) {
|
|
return `${name}@${version}`;
|
|
}
|
|
|
|
export function requiredPackageIdentities(shrinkwrap, target) {
|
|
const identities = new Set();
|
|
let platformSkippedPackages = 0;
|
|
for (const [packagePath, entry] of Object.entries(shrinkwrap.packages ?? {})) {
|
|
if (!packagePath || entry.dev) continue;
|
|
if (!lockEntrySupportsTarget(entry, target)) {
|
|
platformSkippedPackages += 1;
|
|
continue;
|
|
}
|
|
const name = entry.name ?? packageNameFromLockPath(packagePath);
|
|
if (!name || !entry.version) {
|
|
throw new Error(`Pi shrinkwrap package identity is incomplete at ${packagePath}`);
|
|
}
|
|
identities.add(packageIdentity(name, entry.version));
|
|
}
|
|
return {
|
|
identities: [...identities].sort(),
|
|
platformSkippedPackages,
|
|
};
|
|
}
|
|
|
|
async function visitInstalledPackages(nodeModulesPath, identities) {
|
|
if (!await pathExists(nodeModulesPath)) return;
|
|
for (const entry of await readdir(nodeModulesPath, { withFileTypes: true })) {
|
|
if (!entry.isDirectory() || entry.name === '.bin') continue;
|
|
if (entry.name.startsWith('@')) {
|
|
const scopePath = join(nodeModulesPath, entry.name);
|
|
for (const scopedEntry of await readdir(scopePath, { withFileTypes: true })) {
|
|
if (scopedEntry.isDirectory()) {
|
|
await visitInstalledPackage(join(scopePath, scopedEntry.name), identities);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
await visitInstalledPackage(join(nodeModulesPath, entry.name), identities);
|
|
}
|
|
}
|
|
|
|
async function visitInstalledPackage(packageRoot, identities) {
|
|
const packagePath = join(packageRoot, 'package.json');
|
|
if (!await pathExists(packagePath)) return;
|
|
const packageJson = await readJson(packagePath);
|
|
if (packageJson.name && packageJson.version) {
|
|
identities.add(packageIdentity(packageJson.name, packageJson.version));
|
|
}
|
|
await visitInstalledPackages(join(packageRoot, 'node_modules'), identities);
|
|
}
|
|
|
|
export async function installedPackageIdentities(stageRoot) {
|
|
const identities = new Set();
|
|
const rootPackage = await readJson(join(stageRoot, 'package.json'));
|
|
identities.add(packageIdentity(rootPackage.name, rootPackage.version));
|
|
await visitInstalledPackages(join(stageRoot, 'node_modules'), identities);
|
|
return [...identities].sort();
|
|
}
|
|
|
|
export function assertPackageIdentities(required, installed) {
|
|
const installedSet = new Set(installed);
|
|
const missing = required.filter((identity) => !installedSet.has(identity));
|
|
if (missing.length > 0) {
|
|
throw new Error(`Pi production closure is missing package identities: ${missing.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
async function collectRuntimeAssets(stageRoot) {
|
|
const assets = [];
|
|
const visit = async (directory) => {
|
|
if (!await pathExists(directory)) return;
|
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
const path = join(directory, entry.name);
|
|
if (entry.isDirectory()) await visit(path);
|
|
else if (entry.name.endsWith('.node') || entry.name.endsWith('.wasm')) {
|
|
assets.push(portable(relative(stageRoot, path)));
|
|
}
|
|
}
|
|
};
|
|
await visit(join(stageRoot, 'dist'));
|
|
await visit(join(stageRoot, 'node_modules'));
|
|
return assets.sort();
|
|
}
|
|
|
|
export async function preparePublishedPackageRoot(sourcePackageRoot, destination) {
|
|
await mkdir(dirname(destination), { recursive: true });
|
|
await rm(destination, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
await cp(sourcePackageRoot, destination, {
|
|
recursive: true,
|
|
filter(source) {
|
|
return relative(sourcePackageRoot, source).split(sep)[0] !== 'node_modules';
|
|
},
|
|
});
|
|
|
|
const packagePath = join(destination, 'package.json');
|
|
const packageJson = await readJson(packagePath);
|
|
const omittedDevDependencies = Object.keys(packageJson.devDependencies ?? {}).sort();
|
|
delete packageJson.devDependencies;
|
|
await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
return { omittedDevDependencies };
|
|
}
|
|
|
|
function runCommand(executable, args, options) {
|
|
return new Promise((resolvePromise, reject) => {
|
|
const child = spawn(executable, args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
child.once('error', reject);
|
|
child.once('exit', (code, signal) => {
|
|
if (code === 0) resolvePromise({ stdout, stderr });
|
|
else {
|
|
reject(new Error(
|
|
`${executable} exited with code ${code ?? 'null'} signal ${signal ?? 'none'}: ${stderr || stdout}`,
|
|
));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
export function resolvePinnedNpmCli(projectRoot) {
|
|
const requireFromProject = createRequire(join(resolve(projectRoot), 'package.json'));
|
|
return join(realpathSync(dirname(requireFromProject.resolve('npm/package.json'))), 'bin', 'npm-cli.js');
|
|
}
|
|
|
|
export async function validatePinnedNpm(projectRoot) {
|
|
const resolvedRoot = resolve(projectRoot);
|
|
const requireFromProject = createRequire(join(resolvedRoot, 'package.json'));
|
|
const projectPackage = await readJson(join(resolvedRoot, 'package.json'));
|
|
const npmPackagePath = requireFromProject.resolve('npm/package.json');
|
|
const npmPackage = await readJson(npmPackagePath);
|
|
if (projectPackage.dependencies?.npm !== PI_RUNTIME_STAGING_NPM_VERSION) {
|
|
throw new Error(`Root npm dependency must be exactly ${PI_RUNTIME_STAGING_NPM_VERSION}`);
|
|
}
|
|
if (npmPackage.version !== PI_RUNTIME_STAGING_NPM_VERSION) {
|
|
throw new Error(
|
|
`Installed npm must be ${PI_RUNTIME_STAGING_NPM_VERSION}, got ${npmPackage.version ?? 'missing'}`,
|
|
);
|
|
}
|
|
return npmPackagePath;
|
|
}
|
|
|
|
export async function installProductionShrinkwrap({
|
|
projectRoot,
|
|
stageRoot,
|
|
target,
|
|
npmCli = resolvePinnedNpmCli(projectRoot),
|
|
}) {
|
|
await validatePinnedNpm(projectRoot);
|
|
return await runCommand(process.execPath, [
|
|
npmCli,
|
|
'ci',
|
|
'--omit=dev',
|
|
'--ignore-scripts',
|
|
'--no-audit',
|
|
'--no-fund',
|
|
`--os=${target.platform}`,
|
|
`--cpu=${target.arch}`,
|
|
], {
|
|
cwd: stageRoot,
|
|
env: {
|
|
...process.env,
|
|
npm_config_update_notifier: 'false',
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function createPiRuntimeManifest(stageRoot, target, omittedDevDependencies) {
|
|
const rootPackage = await readJson(join(stageRoot, 'package.json'));
|
|
const shrinkwrap = await readJson(join(stageRoot, 'npm-shrinkwrap.json'));
|
|
if (rootPackage.name !== PI_RUNTIME_PACKAGE || rootPackage.version !== PI_RUNTIME_VERSION) {
|
|
throw new Error(
|
|
`Pi runtime must be ${PI_RUNTIME_PACKAGE}@${PI_RUNTIME_VERSION}, got ${rootPackage.name}@${rootPackage.version}`,
|
|
);
|
|
}
|
|
if (rootPackage.bin?.pi !== PI_RUNTIME_CLI_ENTRY) {
|
|
throw new Error(`Pi CLI entry must be ${PI_RUNTIME_CLI_ENTRY}`);
|
|
}
|
|
if (!await pathExists(join(stageRoot, ...PI_RUNTIME_CLI_ENTRY.split('/')))) {
|
|
throw new Error(`Pi CLI is missing at ${PI_RUNTIME_CLI_ENTRY}`);
|
|
}
|
|
|
|
const required = requiredPackageIdentities(shrinkwrap, target);
|
|
const installed = await installedPackageIdentities(stageRoot);
|
|
assertPackageIdentities(required.identities, installed);
|
|
const assets = await collectRuntimeAssets(stageRoot);
|
|
const requiredPhotonAsset = 'node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm';
|
|
if (!assets.includes(requiredPhotonAsset)) {
|
|
throw new Error(`Pi runtime asset is missing: ${requiredPhotonAsset}`);
|
|
}
|
|
|
|
return {
|
|
schemaVersion: 1,
|
|
runtime: {
|
|
packageName: PI_RUNTIME_PACKAGE,
|
|
version: PI_RUNTIME_VERSION,
|
|
cliEntry: PI_RUNTIME_CLI_ENTRY,
|
|
nodeEngine: rootPackage.engines?.node ?? null,
|
|
},
|
|
target: {
|
|
platform: target.platform,
|
|
arch: target.arch,
|
|
...(target.libc ? { libc: target.libc } : {}),
|
|
},
|
|
lockfileVersion: shrinkwrap.lockfileVersion,
|
|
productionPackages: required.identities,
|
|
platformSkippedPackages: required.platformSkippedPackages,
|
|
runtimeAssets: assets,
|
|
staging: {
|
|
npmVersion: PI_RUNTIME_STAGING_NPM_VERSION,
|
|
omitDev: true,
|
|
ignoreScripts: true,
|
|
omittedPublishedDevDependencies: [...omittedDevDependencies].sort(),
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function stagePiRuntimeBundle({
|
|
projectRoot,
|
|
sourcePackageRoot,
|
|
destination,
|
|
target,
|
|
}) {
|
|
const prepared = await preparePublishedPackageRoot(sourcePackageRoot, destination);
|
|
await installProductionShrinkwrap({ projectRoot, stageRoot: destination, target });
|
|
const manifest = await createPiRuntimeManifest(
|
|
destination,
|
|
target,
|
|
prepared.omittedDevDependencies,
|
|
);
|
|
await writeFile(
|
|
join(destination, PI_RUNTIME_MANIFEST),
|
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
);
|
|
return manifest;
|
|
}
|
|
|
|
export function defaultPiBundleTarget() {
|
|
return {
|
|
platform: hostPlatform(),
|
|
arch: hostArch(),
|
|
...(hostPlatform() === 'linux' ? { libc: 'glibc' } : {}),
|
|
};
|
|
}
|