387 lines
14 KiB
JavaScript
387 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import {
|
|
closeSync,
|
|
createReadStream,
|
|
existsSync,
|
|
openSync,
|
|
readSync,
|
|
readFileSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
const args = process.argv.slice(2);
|
|
const valueOptions = new Set([
|
|
'--app-exe',
|
|
'--installer',
|
|
'--expected-electron',
|
|
'--expected-node',
|
|
'--expected-commit',
|
|
'--expected-build-id',
|
|
'--manifest',
|
|
]);
|
|
const flagOptions = new Set(['--allow-dirty']);
|
|
const seenOptions = new Set();
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === '--') continue;
|
|
if (!valueOptions.has(arg) && !flagOptions.has(arg)) {
|
|
throw new Error(`Unknown option: ${arg}`);
|
|
}
|
|
if (seenOptions.has(arg)) {
|
|
throw new Error(`Duplicate option: ${arg}`);
|
|
}
|
|
seenOptions.add(arg);
|
|
if (valueOptions.has(arg)) {
|
|
const value = args[index + 1];
|
|
if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`);
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
function option(name, fallback) {
|
|
const index = args.indexOf(name);
|
|
if (index === -1) return fallback;
|
|
if (!args[index + 1]) throw new Error(`${name} requires a value`);
|
|
return args[index + 1];
|
|
}
|
|
|
|
function run(command, commandArgs, options = {}) {
|
|
const result = spawnSync(command, commandArgs, {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
...options,
|
|
});
|
|
if (result.error) throw result.error;
|
|
if (result.signal) throw new Error(`${command} ended by signal ${result.signal}`);
|
|
if (result.status !== 0) {
|
|
throw new Error(`${command} exited ${result.status}: ${result.stderr || result.stdout}`);
|
|
}
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
function sha256(filePath) {
|
|
return new Promise((resolveHash, reject) => {
|
|
const hash = createHash('sha256');
|
|
const stream = createReadStream(filePath);
|
|
stream.on('error', reject);
|
|
stream.on('data', (chunk) => hash.update(chunk));
|
|
stream.on('end', () => resolveHash(hash.digest('hex').toUpperCase()));
|
|
});
|
|
}
|
|
|
|
function assertEqual(actual, expected, label) {
|
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
}
|
|
}
|
|
|
|
function assertPeMachine(filePath, expectedMachine, label) {
|
|
const handle = openSync(filePath, 'r');
|
|
try {
|
|
const dosHeader = Buffer.alloc(64);
|
|
if (readSync(handle, dosHeader, 0, dosHeader.length, 0) !== dosHeader.length) {
|
|
throw new Error(`${label}: truncated DOS header`);
|
|
}
|
|
if (dosHeader.toString('ascii', 0, 2) !== 'MZ') {
|
|
throw new Error(`${label}: missing MZ signature`);
|
|
}
|
|
|
|
const peOffset = dosHeader.readUInt32LE(0x3c);
|
|
const coffHeader = Buffer.alloc(6);
|
|
if (readSync(handle, coffHeader, 0, coffHeader.length, peOffset) !== coffHeader.length) {
|
|
throw new Error(`${label}: truncated PE header`);
|
|
}
|
|
if (coffHeader.readUInt32LE(0) !== 0x00004550) {
|
|
throw new Error(`${label}: missing PE signature`);
|
|
}
|
|
|
|
const machine = coffHeader.readUInt16LE(4);
|
|
if (machine !== expectedMachine) {
|
|
throw new Error(
|
|
`${label}: expected PE machine 0x${expectedMachine.toString(16)}, got 0x${machine.toString(16)}`,
|
|
);
|
|
}
|
|
} finally {
|
|
closeSync(handle);
|
|
}
|
|
}
|
|
|
|
function isPathInside(parentPath, candidatePath) {
|
|
const relativePath = relative(resolve(parentPath), resolve(candidatePath));
|
|
return relativePath !== ''
|
|
&& relativePath !== '..'
|
|
&& !relativePath.startsWith(`..${sep}`)
|
|
&& !isAbsolute(relativePath);
|
|
}
|
|
|
|
if (process.platform !== 'win32') {
|
|
throw new Error('Packaged Electron verification must run on Windows');
|
|
}
|
|
|
|
const appExecutable = resolve(option(
|
|
'--app-exe',
|
|
join(root, 'release', 'win-unpacked', 'Makelore.exe'),
|
|
));
|
|
const installerPath = resolve(option(
|
|
'--installer',
|
|
join(root, 'release', `Makelore-${packageJson.version}-win-x64.exe`),
|
|
));
|
|
const expectedElectron = option('--expected-electron', '40.10.6');
|
|
const expectedNode = option('--expected-node', '24.15.0');
|
|
const expectedCommit = option('--expected-commit', null);
|
|
const expectedBuildId = option('--expected-build-id', null);
|
|
const manifestPath = option('--manifest', null);
|
|
const resourcesDir = join(dirname(appExecutable), 'resources');
|
|
const appAsarPath = join(resourcesDir, 'app.asar');
|
|
const appAsarUnpackedPath = join(resourcesDir, 'app.asar.unpacked');
|
|
const opencodeRuntimeDir = join(resourcesDir, 'opencode-ai');
|
|
const opencodePackagePath = join(opencodeRuntimeDir, 'package.json');
|
|
const opencodeExecutable = join(opencodeRuntimeDir, 'bin', 'opencode.exe');
|
|
const pythonRuntimeDir = join(resourcesDir, 'python');
|
|
const pythonExecutable = join(pythonRuntimeDir, 'python.exe');
|
|
const toolsBinDir = join(resourcesDir, 'bin');
|
|
const uvExecutable = join(toolsBinDir, 'uv.exe');
|
|
for (const filePath of [
|
|
appExecutable,
|
|
installerPath,
|
|
appAsarPath,
|
|
opencodePackagePath,
|
|
opencodeExecutable,
|
|
pythonExecutable,
|
|
uvExecutable,
|
|
]) {
|
|
if (!existsSync(filePath)) throw new Error(`Missing artifact: ${filePath}`);
|
|
}
|
|
|
|
const PE_MACHINE_AMD64 = 0x8664;
|
|
for (const [label, executable] of [
|
|
['Makelore executable', appExecutable],
|
|
['OpenCode executable', opencodeExecutable],
|
|
['Python executable', pythonExecutable],
|
|
['uv executable', uvExecutable],
|
|
]) {
|
|
assertPeMachine(executable, PE_MACHINE_AMD64, label);
|
|
}
|
|
|
|
const declaredOpencodeVersion = packageJson.devDependencies?.['opencode-ai']
|
|
?? packageJson.dependencies?.['opencode-ai'];
|
|
const packagedOpencode = JSON.parse(readFileSync(opencodePackagePath, 'utf8'));
|
|
assertEqual(packagedOpencode.version, declaredOpencodeVersion, 'packaged OpenCode version');
|
|
|
|
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR ?? 'C:\\Windows';
|
|
const installLocalProbeEnv = {
|
|
...process.env,
|
|
PATH: [join(systemRoot, 'System32'), systemRoot].join(';'),
|
|
};
|
|
for (const key of ['NODE_PATH', 'BUN_INSTALL', 'OPENCODE_BIN', 'OPENCODE_PATH', 'NIANCODE_PYTHON_PATH', 'NIANCODE_UV_PATH']) {
|
|
delete installLocalProbeEnv[key];
|
|
}
|
|
const opencodeVersion = run(opencodeExecutable, ['--version'], {
|
|
cwd: opencodeRuntimeDir,
|
|
env: installLocalProbeEnv,
|
|
});
|
|
assertEqual(opencodeVersion, declaredOpencodeVersion, 'OpenCode executable version');
|
|
const uvVersion = run(uvExecutable, ['--version'], {
|
|
cwd: toolsBinDir,
|
|
env: installLocalProbeEnv,
|
|
});
|
|
if (!/^uv\s+\d+\.\d+\.\d+/.test(uvVersion)) {
|
|
throw new Error(`Invalid bundled uv version output: ${uvVersion}`);
|
|
}
|
|
const pythonProbe = JSON.parse(run(pythonExecutable, [
|
|
'-I',
|
|
'-c',
|
|
'import json,pip,sqlite3,ssl,sys; print(json.dumps({"executable":sys.executable,"pip":pip.__file__,"sqlite3":sqlite3.__file__,"ssl":ssl.__file__}))',
|
|
], {
|
|
cwd: pythonRuntimeDir,
|
|
env: installLocalProbeEnv,
|
|
}));
|
|
assertEqual(resolve(pythonProbe.executable), resolve(pythonExecutable), 'Python executable path');
|
|
for (const [moduleName, modulePath] of Object.entries(pythonProbe).filter(([name]) => name !== 'executable')) {
|
|
if (typeof modulePath !== 'string' || !isPathInside(pythonRuntimeDir, modulePath)) {
|
|
throw new Error(`Python ${moduleName} was not loaded from the installation: ${modulePath}`);
|
|
}
|
|
}
|
|
|
|
const verificationHead = run('git', ['rev-parse', 'HEAD']);
|
|
const gitStatus = run('git', ['status', '--porcelain']);
|
|
if (gitStatus && !args.includes('--allow-dirty')) {
|
|
throw new Error(`Tracked worktree is not clean:\n${gitStatus}`);
|
|
}
|
|
|
|
const probeCode = String.raw`
|
|
const { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } = require('node:fs');
|
|
const { createRequire } = require('node:module');
|
|
const { tmpdir } = require('node:os');
|
|
const { dirname, join, sep } = require('node:path');
|
|
const appRequire = createRequire(join(dirname(process.execPath), 'resources', 'app.asar', 'package.json'));
|
|
const packagedPackage = appRequire('./package.json');
|
|
const msgpackResolved = appRequire.resolve('msgpackr');
|
|
const canvasResolved = appRequire.resolve('@napi-rs/canvas');
|
|
const playwrightPackageResolved = appRequire.resolve('@playwright/mcp/package.json');
|
|
const playwrightCliResolved = join(dirname(playwrightPackageResolved), 'cli.js');
|
|
const { pack, unpack } = appRequire('msgpackr');
|
|
const { createCanvas } = appRequire('@napi-rs/canvas');
|
|
const packedValue = unpack(pack({ ok: true, text: '\u5362\u6b22' }));
|
|
const canvas = createCanvas(1, 1);
|
|
const nativeModules = Object.keys(require.cache)
|
|
.filter((modulePath) => modulePath.endsWith('.node'))
|
|
.map((modulePath) => {
|
|
const physicalPath = modulePath.replace(
|
|
sep + 'app.asar' + sep,
|
|
sep + 'app.asar.unpacked' + sep,
|
|
);
|
|
return { modulePath, physicalPath, exists: existsSync(physicalPath) };
|
|
});
|
|
const copyRoot = mkdtempSync(join(tmpdir(), 'niancode-packaged-copy-'));
|
|
let copyEvidence;
|
|
try {
|
|
const source = join(copyRoot, 'source');
|
|
const destination = join(copyRoot, '\u5362\u6b22', 'AppData', 'Roaming', 'niancode', 'nested');
|
|
mkdirSync(join(source, 'child'), { recursive: true });
|
|
writeFileSync(join(source, 'child', 'proof.txt'), 'unicode-copy-proof', 'utf8');
|
|
cpSync(source, destination, { recursive: true, force: true, filter: () => true });
|
|
copyEvidence = {
|
|
content: readFileSync(join(destination, 'child', 'proof.txt'), 'utf8'),
|
|
rootEntries: readdirSync(copyRoot).sort(),
|
|
};
|
|
} finally {
|
|
rmSync(copyRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
}
|
|
console.log('NIANCODE_ARTIFACT_PROBE=' + JSON.stringify({
|
|
electron: process.versions.electron,
|
|
node: process.versions.node,
|
|
packagedMetadata: {
|
|
buildCommit: packagedPackage.buildCommit,
|
|
buildId: packagedPackage.buildId,
|
|
},
|
|
msgpackResolved,
|
|
canvasResolved,
|
|
playwrightPackageResolved,
|
|
playwrightCliResolved,
|
|
nativeModules,
|
|
packedValue,
|
|
canvas: [canvas.width, canvas.height],
|
|
copyEvidence,
|
|
}));
|
|
`;
|
|
|
|
const probeResult = spawnSync(appExecutable, ['-e', probeCode], {
|
|
cwd: dirname(appExecutable),
|
|
encoding: 'utf8',
|
|
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
|
});
|
|
if (probeResult.error) throw probeResult.error;
|
|
if (probeResult.signal) throw new Error(`Makelore.exe ended by signal ${probeResult.signal}`);
|
|
if (probeResult.status !== 0) {
|
|
throw new Error(`Makelore.exe exited ${probeResult.status}: ${probeResult.stderr || probeResult.stdout}`);
|
|
}
|
|
const probeLine = probeResult.stdout
|
|
.split(/\r?\n/)
|
|
.find((line) => line.startsWith('NIANCODE_ARTIFACT_PROBE='));
|
|
if (!probeLine) throw new Error(`Missing packaged probe output: ${probeResult.stdout}`);
|
|
const probe = JSON.parse(probeLine.slice('NIANCODE_ARTIFACT_PROBE='.length));
|
|
|
|
assertEqual(probe.electron, expectedElectron, 'Electron version');
|
|
assertEqual(probe.node, expectedNode, 'Node.js version');
|
|
if (expectedCommit) {
|
|
assertEqual(probe.packagedMetadata.buildCommit, expectedCommit, 'packaged build commit');
|
|
}
|
|
if (expectedBuildId) {
|
|
assertEqual(probe.packagedMetadata.buildId, expectedBuildId, 'packaged build id');
|
|
}
|
|
assertEqual(probe.packedValue, { ok: true, text: '\u5362\u6b22' }, 'msgpackr round trip');
|
|
assertEqual(probe.canvas, [1, 1], 'canvas dimensions');
|
|
assertEqual(probe.copyEvidence, {
|
|
content: 'unicode-copy-proof',
|
|
rootEntries: ['source', '\u5362\u6b22'],
|
|
}, 'packaged Unicode copy');
|
|
if (!isPathInside(appAsarPath, probe.msgpackResolved)) {
|
|
throw new Error(`msgpackr was not resolved from app.asar: ${probe.msgpackResolved}`);
|
|
}
|
|
if (!isPathInside(appAsarPath, probe.canvasResolved)) {
|
|
throw new Error(`@napi-rs/canvas was not resolved from app.asar: ${probe.canvasResolved}`);
|
|
}
|
|
if (!isPathInside(appAsarPath, probe.playwrightPackageResolved)) {
|
|
throw new Error(`@playwright/mcp was not resolved from app.asar: ${probe.playwrightPackageResolved}`);
|
|
}
|
|
if (!isPathInside(appAsarPath, probe.playwrightCliResolved)) {
|
|
throw new Error(`@playwright/mcp CLI was not resolved from app.asar: ${probe.playwrightCliResolved}`);
|
|
}
|
|
run(appExecutable, [probe.playwrightCliResolved, '--help'], {
|
|
cwd: dirname(appExecutable),
|
|
env: { ...installLocalProbeEnv, ELECTRON_RUN_AS_NODE: '1' },
|
|
});
|
|
const msgpackNative = probe.nativeModules.find(({ modulePath, physicalPath, exists }) => (
|
|
exists
|
|
&& isPathInside(appAsarPath, modulePath)
|
|
&& isPathInside(appAsarUnpackedPath, physicalPath)
|
|
&& modulePath.includes('@msgpackr-extract')
|
|
&& modulePath.endsWith('node.napi.node')
|
|
));
|
|
if (!msgpackNative) {
|
|
throw new Error(`Packaged msgpackr N-API addon was not loaded: ${JSON.stringify(probe.nativeModules)}`);
|
|
}
|
|
const canvasNative = probe.nativeModules.find(({ modulePath, physicalPath, exists }) => (
|
|
exists
|
|
&& isPathInside(appAsarPath, modulePath)
|
|
&& isPathInside(appAsarUnpackedPath, physicalPath)
|
|
&& modulePath.includes('@napi-rs')
|
|
&& modulePath.includes('canvas-win32-x64-msvc')
|
|
&& modulePath.endsWith('skia.win32-x64-msvc.node')
|
|
));
|
|
if (!canvasNative) {
|
|
throw new Error(`Packaged x64 canvas addon was not loaded: ${JSON.stringify(probe.nativeModules)}`);
|
|
}
|
|
|
|
const installerBytes = statSync(installerPath).size;
|
|
if (installerBytes <= 0) throw new Error(`Installer is empty: ${installerPath}`);
|
|
const installerSha256 = await sha256(installerPath);
|
|
if (!/^[A-F0-9]{64}$/.test(installerSha256)) {
|
|
throw new Error(`Invalid installer SHA-256: ${installerSha256}`);
|
|
}
|
|
|
|
const evidence = {
|
|
gitCommit: probe.packagedMetadata.buildCommit ?? verificationHead,
|
|
verificationHead,
|
|
packagedMetadata: probe.packagedMetadata,
|
|
appExecutable,
|
|
installerPath,
|
|
installerBytes,
|
|
installerSha256,
|
|
runtime: { electron: probe.electron, node: probe.node },
|
|
installLocalRuntimes: {
|
|
opencode: {
|
|
executable: opencodeExecutable,
|
|
version: opencodeVersion,
|
|
sha256: await sha256(opencodeExecutable),
|
|
},
|
|
playwrightMcp: {
|
|
packageJson: probe.playwrightPackageResolved,
|
|
cli: probe.playwrightCliResolved,
|
|
},
|
|
python: pythonProbe,
|
|
uv: { executable: uvExecutable, version: uvVersion },
|
|
},
|
|
nativeModules: {
|
|
msgpackr: probe.msgpackResolved,
|
|
canvas: probe.canvasResolved,
|
|
msgpackAddon: msgpackNative,
|
|
canvasAddon: canvasNative,
|
|
},
|
|
unicodeCopy: probe.copyEvidence,
|
|
};
|
|
if (manifestPath) {
|
|
writeFileSync(resolve(manifestPath), `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
|
|
}
|
|
console.log(JSON.stringify(evidence, null, 2));
|