test: add PI release proof harness
This commit is contained in:
340
scripts/lib/pi-product-artifact.mjs
Normal file
340
scripts/lib/pi-product-artifact.mjs
Normal file
@@ -0,0 +1,340 @@
|
||||
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,
|
||||
});
|
||||
Reference in New Issue
Block a user