341 lines
12 KiB
JavaScript
341 lines
12 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
import { arch as hostArch, platform as hostPlatform } from 'node:os';
|
|
import {
|
|
dirname,
|
|
isAbsolute,
|
|
join,
|
|
relative,
|
|
resolve,
|
|
sep,
|
|
} from 'node:path';
|
|
import YAML from 'yaml';
|
|
|
|
import {
|
|
PI_RUNTIME_CLI_ENTRY,
|
|
PI_RUNTIME_MANIFEST,
|
|
PI_RUNTIME_PACKAGE,
|
|
PI_RUNTIME_VERSION,
|
|
} from './pi-runtime-bundle.mjs';
|
|
import {
|
|
inspectPackagedClosure,
|
|
packagedResourcesDirectory,
|
|
} from '../probe-pi-packaged-runtime.mjs';
|
|
|
|
const PRODUCT_NAME = 'Makelore';
|
|
const EXTENSION_MARKER = 'makelore-runtime-v3.mjs';
|
|
|
|
async function pathExists(path) {
|
|
try {
|
|
await stat(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function portable(path) {
|
|
return path.split(sep).join('/');
|
|
}
|
|
|
|
function normalizeLockedVersion(value) {
|
|
return typeof value === 'string' ? value.split('(', 1)[0] : null;
|
|
}
|
|
|
|
function runCommand(executable, args, options = {}) {
|
|
return new Promise((resolvePromise, reject) => {
|
|
const child = spawn(executable, args, {
|
|
cwd: options.cwd,
|
|
env: options.env ?? process.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 defaultProductExecutable(projectRoot, currentPlatform = hostPlatform()) {
|
|
const root = resolve(projectRoot);
|
|
if (currentPlatform === 'win32') {
|
|
return join(root, 'release', 'win-unpacked', `${PRODUCT_NAME}.exe`);
|
|
}
|
|
if (currentPlatform === 'darwin') {
|
|
return join(root, 'release', 'mac', `${PRODUCT_NAME}.app`, 'Contents', 'MacOS', PRODUCT_NAME);
|
|
}
|
|
if (currentPlatform === 'linux') {
|
|
return join(root, 'release', 'linux-unpacked', PRODUCT_NAME.toLowerCase());
|
|
}
|
|
throw new Error(`Unsupported product artifact platform: ${currentPlatform}`);
|
|
}
|
|
|
|
export function assertNodeEngineCompatible(engine, nodeVersion) {
|
|
const match = /^>=(\d+)\.(\d+)\.(\d+)$/.exec(engine ?? '');
|
|
if (!match) throw new Error(`Unsupported Pi Node engine expression: ${engine ?? 'missing'}`);
|
|
const required = match.slice(1).map(Number);
|
|
const actualMatch = /^(\d+)\.(\d+)\.(\d+)/.exec(nodeVersion ?? '');
|
|
if (!actualMatch) throw new Error(`Invalid packaged Node version: ${nodeVersion ?? 'missing'}`);
|
|
const actual = actualMatch.slice(1).map(Number);
|
|
for (let index = 0; index < required.length; index += 1) {
|
|
if (actual[index] > required[index]) return;
|
|
if (actual[index] < required[index]) {
|
|
throw new Error(`Packaged Node ${nodeVersion} does not satisfy Pi engine ${engine}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function validatePiArtifactMetadata({
|
|
rootPackage,
|
|
lockfile,
|
|
packagedPackage,
|
|
runtimePackage,
|
|
manifest,
|
|
runtimePlatform,
|
|
}) {
|
|
const rootDependency = rootPackage.dependencies?.[PI_RUNTIME_PACKAGE];
|
|
const packagedDependency = packagedPackage.dependencies?.[PI_RUNTIME_PACKAGE];
|
|
const lockDependency = lockfile.importers?.['.']?.dependencies?.[PI_RUNTIME_PACKAGE];
|
|
const lockedVersion = normalizeLockedVersion(lockDependency?.version);
|
|
const versions = {
|
|
expected: PI_RUNTIME_VERSION,
|
|
rootDependency,
|
|
packagedDependency,
|
|
lockSpecifier: lockDependency?.specifier ?? null,
|
|
lockedVersion,
|
|
runtimePackage: runtimePackage.version,
|
|
manifest: manifest.runtime?.version ?? null,
|
|
};
|
|
const mismatched = Object.entries(versions)
|
|
.filter(([key, value]) => key !== 'expected' && value !== PI_RUNTIME_VERSION);
|
|
if (mismatched.length > 0) {
|
|
throw new Error(`Pi artifact versions do not match: ${JSON.stringify(versions)}`);
|
|
}
|
|
if (runtimePackage.name !== PI_RUNTIME_PACKAGE) {
|
|
throw new Error(`Packaged Pi package name is ${runtimePackage.name ?? 'missing'}`);
|
|
}
|
|
if (runtimePackage.bin?.pi !== PI_RUNTIME_CLI_ENTRY) {
|
|
throw new Error(`Packaged Pi CLI entry is ${runtimePackage.bin?.pi ?? 'missing'}`);
|
|
}
|
|
if (manifest.runtime?.packageName !== PI_RUNTIME_PACKAGE
|
|
|| manifest.runtime?.cliEntry !== PI_RUNTIME_CLI_ENTRY) {
|
|
throw new Error(`Packaged Pi manifest identity is invalid: ${JSON.stringify(manifest.runtime)}`);
|
|
}
|
|
if (manifest.target?.platform !== runtimePlatform.platform
|
|
|| manifest.target?.arch !== runtimePlatform.arch) {
|
|
throw new Error(
|
|
`Packaged Pi target ${manifest.target?.platform ?? 'missing'}-${manifest.target?.arch ?? 'missing'} `
|
|
+ `does not match executable ${runtimePlatform.platform}-${runtimePlatform.arch}`,
|
|
);
|
|
}
|
|
assertNodeEngineCompatible(manifest.runtime?.nodeEngine, runtimePlatform.node);
|
|
return versions;
|
|
}
|
|
|
|
export function collectAbsoluteManifestValues(value, at = '$', results = []) {
|
|
if (typeof value === 'string') {
|
|
if (isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)) results.push({ at, value });
|
|
return results;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
value.forEach((entry, index) => collectAbsoluteManifestValues(entry, `${at}[${index}]`, results));
|
|
return results;
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
for (const [key, entry] of Object.entries(value)) {
|
|
collectAbsoluteManifestValues(entry, `${at}.${key}`, results);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
export async function collectForbiddenResourcePaths(root, pattern = /opencode/i) {
|
|
const matches = [];
|
|
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 (pattern.test(entry.name)) matches.push(portable(relative(root, path)));
|
|
if (entry.isDirectory()) await visit(path);
|
|
}
|
|
};
|
|
await visit(root);
|
|
return matches.sort();
|
|
}
|
|
|
|
async function filesContainingNeedles(root, needles) {
|
|
const matches = [];
|
|
const visit = async (path) => {
|
|
const details = await stat(path);
|
|
if (details.isDirectory()) {
|
|
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
await visit(join(path, entry.name));
|
|
}
|
|
return;
|
|
}
|
|
const contents = await readFile(path);
|
|
const found = needles.filter((needle) => needle.length > 0 && contents.includes(needle));
|
|
if (found.length > 0) matches.push({ path, needles: found.map((value) => value.toString()) });
|
|
};
|
|
await visit(root);
|
|
return matches;
|
|
}
|
|
|
|
async function inspectProductRuntime(executable, resourcesDirectory) {
|
|
const script = String.raw`
|
|
const { createRequire } = require('node:module');
|
|
const path = require('node:path');
|
|
const resources = process.env.MAKELORE_PI_PRODUCT_RESOURCES;
|
|
const appRequire = createRequire(path.join(resources, 'app.asar', 'package.json'));
|
|
const packagedPackage = appRequire('./package.json');
|
|
process.stdout.write(JSON.stringify({
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
node: process.versions.node,
|
|
electron: process.versions.electron,
|
|
packagedPackage,
|
|
}));
|
|
`;
|
|
const { stdout } = await runCommand(executable, ['-e', script], {
|
|
env: {
|
|
...process.env,
|
|
ELECTRON_RUN_AS_NODE: '1',
|
|
MAKELORE_PI_PRODUCT_RESOURCES: resourcesDirectory,
|
|
},
|
|
});
|
|
return JSON.parse(stdout.trim());
|
|
}
|
|
|
|
async function packagedSkillIds(resourcesDirectory) {
|
|
const root = join(resourcesDirectory, 'resources', 'coding-skills');
|
|
if (!await pathExists(root)) throw new Error(`Packaged coding skills are missing: ${root}`);
|
|
const entries = await readdir(root, { withFileTypes: true });
|
|
const ids = [];
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (!await pathExists(join(root, entry.name, 'SKILL.md'))) {
|
|
throw new Error(`Packaged coding skill has no SKILL.md: ${entry.name}`);
|
|
}
|
|
ids.push(entry.name);
|
|
}
|
|
return ids.sort();
|
|
}
|
|
|
|
async function sourceSkillIds(projectRoot) {
|
|
const root = join(projectRoot, 'resources', 'coding-skills');
|
|
const entries = await readdir(root, { withFileTypes: true });
|
|
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
}
|
|
|
|
export async function verifyPiProductArtifact({ projectRoot, executable }) {
|
|
const root = resolve(projectRoot);
|
|
const appExecutable = resolve(executable ?? defaultProductExecutable(root));
|
|
if (!await pathExists(appExecutable)) throw new Error(`Product executable is missing: ${appExecutable}`);
|
|
const resourcesDirectory = packagedResourcesDirectory(appExecutable);
|
|
const runtimeRoot = join(resourcesDirectory, 'pi-runtime');
|
|
const appAsar = join(resourcesDirectory, 'app.asar');
|
|
for (const required of [runtimeRoot, appAsar]) {
|
|
if (!await pathExists(required)) throw new Error(`Product artifact resource is missing: ${required}`);
|
|
}
|
|
|
|
const [rootPackageSource, lockfileSource, runtimePackageSource, manifestSource] = await Promise.all([
|
|
readFile(join(root, 'package.json'), 'utf8'),
|
|
readFile(join(root, 'pnpm-lock.yaml'), 'utf8'),
|
|
readFile(join(runtimeRoot, 'package.json'), 'utf8'),
|
|
readFile(join(runtimeRoot, PI_RUNTIME_MANIFEST), 'utf8'),
|
|
]);
|
|
const rootPackage = JSON.parse(rootPackageSource);
|
|
const lockfile = YAML.parse(lockfileSource);
|
|
const runtimePackage = JSON.parse(runtimePackageSource);
|
|
const manifest = JSON.parse(manifestSource);
|
|
const runtimePlatform = await inspectProductRuntime(appExecutable, resourcesDirectory);
|
|
const versions = validatePiArtifactMetadata({
|
|
rootPackage,
|
|
lockfile,
|
|
packagedPackage: runtimePlatform.packagedPackage,
|
|
runtimePackage,
|
|
manifest,
|
|
runtimePlatform,
|
|
});
|
|
|
|
const absoluteManifestValues = collectAbsoluteManifestValues(manifest);
|
|
if (absoluteManifestValues.length > 0) {
|
|
throw new Error(`Pi runtime manifest contains absolute paths: ${JSON.stringify(absoluteManifestValues)}`);
|
|
}
|
|
const forbiddenResourcePaths = await collectForbiddenResourcePaths(resourcesDirectory);
|
|
if (forbiddenResourcePaths.length > 0) {
|
|
throw new Error(`Product resources contain OpenCode paths: ${forbiddenResourcePaths.join(', ')}`);
|
|
}
|
|
|
|
const expectedSkills = await sourceSkillIds(root);
|
|
const actualSkills = await packagedSkillIds(resourcesDirectory);
|
|
if (JSON.stringify(actualSkills) !== JSON.stringify(expectedSkills)) {
|
|
throw new Error(`Packaged coding skills differ: expected ${expectedSkills}, got ${actualSkills}`);
|
|
}
|
|
const appAsarContents = await readFile(appAsar);
|
|
if (!appAsarContents.includes(Buffer.from(EXTENSION_MARKER))) {
|
|
throw new Error(`Packaged app.asar does not contain ${EXTENSION_MARKER}`);
|
|
}
|
|
|
|
const sourceNeedles = [
|
|
Buffer.from(root),
|
|
Buffer.from(portable(root)),
|
|
];
|
|
const devPathResidue = [
|
|
...await filesContainingNeedles(appAsar, sourceNeedles),
|
|
...await filesContainingNeedles(runtimeRoot, sourceNeedles),
|
|
];
|
|
if (devPathResidue.length > 0) {
|
|
throw new Error(`Product artifact contains development path residue: ${JSON.stringify(devPathResidue)}`);
|
|
}
|
|
|
|
const packagedClosure = await inspectPackagedClosure(
|
|
appExecutable,
|
|
resourcesDirectory,
|
|
manifest.runtimeAssets,
|
|
);
|
|
return {
|
|
schemaVersion: 1,
|
|
artifact: {
|
|
executable: appExecutable,
|
|
resourcesDirectory,
|
|
appAsar,
|
|
runtimeRoot,
|
|
cliPath: join(runtimeRoot, ...PI_RUNTIME_CLI_ENTRY.split('/')),
|
|
},
|
|
platform: {
|
|
platform: runtimePlatform.platform,
|
|
arch: runtimePlatform.arch,
|
|
electron: runtimePlatform.electron,
|
|
node: runtimePlatform.node,
|
|
},
|
|
versions,
|
|
manifest: {
|
|
target: manifest.target,
|
|
packageCount: manifest.productionPackages.length,
|
|
assetCount: manifest.runtimeAssets.length,
|
|
nodeEngine: manifest.runtime.nodeEngine,
|
|
},
|
|
packagedClosure,
|
|
extension: { marker: EXTENSION_MARKER, packaged: true },
|
|
skills: actualSkills,
|
|
openCodeResourcePaths: [],
|
|
developmentPathResidue: [],
|
|
result: 'pass',
|
|
};
|
|
}
|
|
|
|
export const PI_PRODUCT_ARTIFACT_DEFAULTS = Object.freeze({
|
|
platform: hostPlatform(),
|
|
arch: hostArch(),
|
|
extensionMarker: EXTENSION_MARKER,
|
|
});
|