369 lines
14 KiB
JavaScript
369 lines
14 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
import { arch, platform, tmpdir } from 'node:os';
|
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { Arch, Platform, build } from 'electron-builder';
|
|
import {
|
|
parseProbeArgs,
|
|
resolvePiRuntime,
|
|
runProbe,
|
|
validatePiIdentity,
|
|
} from './probe-pi-runtime.mjs';
|
|
import { runLocalProviderContracts } from './probe-pi-provider-contracts.mjs';
|
|
import {
|
|
PI_RUNTIME_MANIFEST,
|
|
defaultPiBundleTarget,
|
|
stagePiRuntimeBundle,
|
|
} from './lib/pi-runtime-bundle.mjs';
|
|
|
|
const PRODUCT_NAME = 'MakelorePiProbe';
|
|
const ARTIFACT_LABEL = 'controlled-electron-builder-dir-extra-resources';
|
|
|
|
async function pathExists(path) {
|
|
try {
|
|
await stat(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function currentTarget() {
|
|
let platformTarget;
|
|
if (platform() === 'win32') platformTarget = Platform.WINDOWS;
|
|
else if (platform() === 'darwin') platformTarget = Platform.MAC;
|
|
else if (platform() === 'linux') platformTarget = Platform.LINUX;
|
|
else throw new Error(`Unsupported packaging platform: ${platform()}`);
|
|
const architecture = Arch[arch()];
|
|
if (architecture == null) throw new Error(`Unsupported packaging architecture: ${arch()}`);
|
|
return { platformTarget, architecture };
|
|
}
|
|
|
|
async function findPackagedExecutable(root) {
|
|
const matches = [];
|
|
const visit = async (directory) => {
|
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
const path = join(directory, entry.name);
|
|
if (entry.isDirectory()) await visit(path);
|
|
else if (
|
|
(platform() === 'win32' && entry.name === `${PRODUCT_NAME}.exe`)
|
|
|| (platform() === 'darwin'
|
|
&& entry.name === PRODUCT_NAME
|
|
&& directory.endsWith(`${sep}Contents${sep}MacOS`))
|
|
|| (platform() === 'linux' && entry.name === PRODUCT_NAME)
|
|
) matches.push(path);
|
|
}
|
|
};
|
|
await visit(root);
|
|
if (matches.length !== 1) {
|
|
throw new Error(`Expected one packaged ${PRODUCT_NAME} executable, found ${matches.length}`);
|
|
}
|
|
return matches[0];
|
|
}
|
|
|
|
export function packagedResourcesDirectory(executable, currentPlatform = platform()) {
|
|
return currentPlatform === 'darwin'
|
|
? resolve(executable, '..', '..', 'Resources')
|
|
: join(dirname(executable), 'resources');
|
|
}
|
|
|
|
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 async function inspectPackagedClosure(executable, resourcesDirectory, assets) {
|
|
const script = String.raw`
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const resources = process.env.PI_PROBE_RESOURCES_DIRECTORY;
|
|
if (!resources) throw new Error('PI_PROBE_RESOURCES_DIRECTORY is required');
|
|
const root = path.join(resources, 'pi-runtime');
|
|
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
const runtimeManifestPath = path.join(root, '${PI_RUNTIME_MANIFEST}');
|
|
const runtimeManifest = fs.existsSync(runtimeManifestPath)
|
|
? JSON.parse(fs.readFileSync(runtimeManifestPath, 'utf8'))
|
|
: null;
|
|
const shrinkwrap = JSON.parse(fs.readFileSync(path.join(root, 'npm-shrinkwrap.json'), 'utf8'));
|
|
const assets = JSON.parse(process.env.PI_PROBE_ASSETS_JSON);
|
|
const libc = process.platform === 'linux'
|
|
? (process.report?.getReport?.().header?.glibcVersionRuntime ? 'glibc' : 'musl')
|
|
: undefined;
|
|
const 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));
|
|
};
|
|
const supportsCurrentPlatform = (entry) => constraintMatches(entry.os, process.platform)
|
|
&& constraintMatches(entry.cpu, process.arch)
|
|
&& constraintMatches(entry.libc, libc);
|
|
const missingPackages = [];
|
|
const relocatedPackages = [];
|
|
let expectedPackages = 0;
|
|
let platformSkippedPackages = 0;
|
|
for (const [packagePath, entry] of Object.entries(shrinkwrap.packages || {})) {
|
|
if (!packagePath || entry.dev) continue;
|
|
if (!supportsCurrentPlatform(entry)) {
|
|
platformSkippedPackages += 1;
|
|
continue;
|
|
}
|
|
expectedPackages += 1;
|
|
const exactPath = path.join(root, ...packagePath.split('/'));
|
|
if (fs.existsSync(exactPath)) continue;
|
|
const nestedMarker = '/node_modules/';
|
|
const packageTail = packagePath.includes(nestedMarker)
|
|
? packagePath.slice(packagePath.lastIndexOf(nestedMarker) + nestedMarker.length)
|
|
: packagePath.slice('node_modules/'.length);
|
|
const nameParts = packageTail.split('/');
|
|
const packageName = nameParts[0].startsWith('@') ? nameParts.slice(0, 2).join('/') : nameParts[0];
|
|
const flattenedPath = path.join(root, 'node_modules', ...packageName.split('/'));
|
|
const flattenedPackageJson = path.join(flattenedPath, 'package.json');
|
|
if (fs.existsSync(flattenedPackageJson)) {
|
|
const flattenedVersion = JSON.parse(fs.readFileSync(flattenedPackageJson, 'utf8')).version;
|
|
if (flattenedVersion === entry.version) {
|
|
relocatedPackages.push({ from: packagePath, to: 'node_modules/' + packageName, version: entry.version });
|
|
continue;
|
|
}
|
|
}
|
|
missingPackages.push(packagePath);
|
|
}
|
|
const missingAssets = assets.filter((asset) => !fs.existsSync(path.join(root, ...asset.split('/'))));
|
|
process.stdout.write(JSON.stringify({
|
|
packageName: packageJson.name,
|
|
packageVersion: packageJson.version,
|
|
runtimeManifestExists: Boolean(runtimeManifest),
|
|
runtimeManifestTarget: runtimeManifest?.target ?? null,
|
|
cliExists: fs.existsSync(path.join(root, 'dist', 'cli.js')),
|
|
lockedPackages: Math.max(0, Object.keys(shrinkwrap.packages || {}).length - 1),
|
|
expectedPackages,
|
|
platformSkippedPackages,
|
|
missingPackages,
|
|
relocatedPackages,
|
|
assetCount: assets.length,
|
|
missingAssets,
|
|
nativeAssetsOutsideAsar: assets.filter((asset) => asset.endsWith('.node')).length,
|
|
}));
|
|
`;
|
|
const { stdout } = await runCommand(executable, ['-e', script], {
|
|
env: {
|
|
...process.env,
|
|
ELECTRON_RUN_AS_NODE: '1',
|
|
PI_PROBE_RESOURCES_DIRECTORY: resourcesDirectory,
|
|
PI_PROBE_ASSETS_JSON: JSON.stringify(assets),
|
|
},
|
|
});
|
|
const result = JSON.parse(stdout.trim());
|
|
if (
|
|
!result.cliExists
|
|
|| !result.runtimeManifestExists
|
|
|| result.missingPackages.length > 0
|
|
|| result.missingAssets.length > 0
|
|
) {
|
|
throw new Error(`Packaged Pi closure is incomplete: ${JSON.stringify(result)}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function assertControlledOutput(projectRoot, outputDirectory) {
|
|
const relativePath = relative(projectRoot, outputDirectory);
|
|
if (!relativePath || relativePath.startsWith('..') || resolve(projectRoot, relativePath) !== outputDirectory) {
|
|
throw new Error(`Refusing uncontrolled packaged-probe output: ${outputDirectory}`);
|
|
}
|
|
if (relativePath.split(sep).join('/') !== 'release/pi-runtime-probe') {
|
|
throw new Error(`Unexpected packaged-probe output directory: ${outputDirectory}`);
|
|
}
|
|
}
|
|
|
|
async function buildControlledArtifact(projectRoot, appDirectory, runtimeDirectory, outputDirectory) {
|
|
const electronPackage = JSON.parse(await readFile(join(projectRoot, 'node_modules', 'electron', 'package.json'), 'utf8'));
|
|
const { platformTarget, architecture } = currentTarget();
|
|
const buildResources = join(appDirectory, 'build-resources');
|
|
await mkdir(buildResources, { recursive: true });
|
|
await writeFile(join(appDirectory, 'package.json'), `${JSON.stringify({
|
|
name: 'makelore-pi-production-shape-probe',
|
|
version: '1.0.0',
|
|
main: 'main.js',
|
|
}, null, 2)}\n`);
|
|
await writeFile(join(appDirectory, 'main.js'), 'process.exit(0);\n');
|
|
const builderArtifacts = await build({
|
|
// Keep the probe application minimal while copying the permanent stage to
|
|
// the same resources/pi-runtime location used by the product configuration.
|
|
projectDir: appDirectory,
|
|
targets: platformTarget.createTarget('dir', architecture),
|
|
publish: 'never',
|
|
config: {
|
|
appId: 'app.niancode.desktop.pi-probe',
|
|
productName: PRODUCT_NAME,
|
|
electronVersion: electronPackage.version,
|
|
electronDist: join(projectRoot, 'node_modules', 'electron', 'dist'),
|
|
directories: {
|
|
output: outputDirectory,
|
|
buildResources,
|
|
},
|
|
files: [
|
|
'main.js',
|
|
'package.json',
|
|
],
|
|
extraResources: [
|
|
{
|
|
from: runtimeDirectory,
|
|
to: 'pi-runtime',
|
|
filter: ['**/*', '!node_modules{,/**/*}'],
|
|
},
|
|
{
|
|
from: join(runtimeDirectory, 'node_modules'),
|
|
to: 'pi-runtime/node_modules',
|
|
filter: ['**/*'],
|
|
},
|
|
],
|
|
asar: true,
|
|
npmRebuild: false,
|
|
win: {
|
|
executableName: PRODUCT_NAME,
|
|
signAndEditExecutable: false,
|
|
verifyUpdateCodeSignature: false,
|
|
},
|
|
mac: {
|
|
identity: null,
|
|
hardenedRuntime: false,
|
|
},
|
|
linux: {
|
|
executableName: PRODUCT_NAME,
|
|
},
|
|
},
|
|
});
|
|
return builderArtifacts;
|
|
}
|
|
|
|
export async function runPackagedProbe(options, projectRoot = process.cwd()) {
|
|
const resolvedProjectRoot = resolve(projectRoot);
|
|
const outputDirectory = resolve(resolvedProjectRoot, 'release', 'pi-runtime-probe');
|
|
assertControlledOutput(resolvedProjectRoot, outputDirectory);
|
|
const scratchRoot = await mkdtemp(join(tmpdir(), 'makelore-pi-packaged-probe-'));
|
|
const appDirectory = join(scratchRoot, 'app');
|
|
const runtimeDirectory = join(scratchRoot, 'runtime');
|
|
try {
|
|
const sourceRuntime = await resolvePiRuntime(resolvedProjectRoot);
|
|
const identity = await validatePiIdentity(sourceRuntime);
|
|
const runtimeManifest = await stagePiRuntimeBundle({
|
|
projectRoot: resolvedProjectRoot,
|
|
sourcePackageRoot: sourceRuntime.packageRoot,
|
|
destination: runtimeDirectory,
|
|
target: defaultPiBundleTarget(),
|
|
});
|
|
const stagedClosure = {
|
|
lockfileVersion: runtimeManifest.lockfileVersion,
|
|
expectedPackages: runtimeManifest.productionPackages.length,
|
|
platformSkippedPackages: runtimeManifest.platformSkippedPackages,
|
|
assets: runtimeManifest.runtimeAssets,
|
|
omittedPublishedDevDependencies: runtimeManifest.staging.omittedPublishedDevDependencies,
|
|
manifest: runtimeManifest,
|
|
};
|
|
await rm(outputDirectory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
await mkdir(dirname(outputDirectory), { recursive: true });
|
|
const builderArtifacts = await buildControlledArtifact(
|
|
resolvedProjectRoot,
|
|
appDirectory,
|
|
runtimeDirectory,
|
|
outputDirectory,
|
|
);
|
|
const executable = await findPackagedExecutable(outputDirectory);
|
|
const resourcesDirectory = packagedResourcesDirectory(executable);
|
|
const appAsar = join(resourcesDirectory, 'app.asar');
|
|
const packagedRuntime = join(resourcesDirectory, 'pi-runtime');
|
|
const cliPath = join(packagedRuntime, 'dist', 'cli.js');
|
|
if (!await pathExists(appAsar)) throw new Error(`Packaged app.asar is missing: ${appAsar}`);
|
|
const packagedClosure = await inspectPackagedClosure(
|
|
executable,
|
|
resourcesDirectory,
|
|
stagedClosure.assets,
|
|
);
|
|
const runtime = await runProbe({
|
|
...options,
|
|
reportPath: undefined,
|
|
stage: false,
|
|
keepStage: false,
|
|
electronExecutablePath: executable,
|
|
cliPath,
|
|
artifactLabel: ARTIFACT_LABEL,
|
|
}, resolvedProjectRoot);
|
|
const providerContracts = await runLocalProviderContracts({
|
|
...options,
|
|
reportPath: undefined,
|
|
stage: false,
|
|
keepStage: false,
|
|
electronExecutablePath: executable,
|
|
cliPath,
|
|
artifactLabel: ARTIFACT_LABEL,
|
|
}, resolvedProjectRoot);
|
|
const report = {
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
identity,
|
|
artifact: {
|
|
label: ARTIFACT_LABEL,
|
|
outputDirectory,
|
|
executable,
|
|
appAsar,
|
|
runtimeRoot: packagedRuntime,
|
|
builderArtifacts,
|
|
},
|
|
stagedClosure,
|
|
packagedClosure,
|
|
runtime,
|
|
providerContracts,
|
|
result: runtime.result,
|
|
decision: 'incomplete',
|
|
};
|
|
if (options.reportPath) {
|
|
await mkdir(dirname(options.reportPath), { recursive: true });
|
|
await writeFile(options.reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
}
|
|
return report;
|
|
} finally {
|
|
await rm(scratchRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseProbeArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
process.stdout.write('Usage: node scripts/probe-pi-packaged-runtime.mjs [probe options]\n');
|
|
return;
|
|
}
|
|
if (options.stage || options.keepStage || options.electronExecutablePath) {
|
|
throw new Error('Packaged probe owns staging and runtime paths; do not pass staging or runtime override flags');
|
|
}
|
|
const report = await runPackagedProbe(options);
|
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
if (report.result === 'fail') process.exitCode = 1;
|
|
}
|
|
|
|
const isMain = process.argv[1]
|
|
&& pathToFileURL(resolve(process.argv[1])).href === import.meta.url;
|
|
if (isMain) {
|
|
main().catch((error) => {
|
|
process.stderr.write(`${error.stack ?? error.message}\n`);
|
|
process.exit(1);
|
|
});
|
|
}
|