GitNexus/gitnexus/scripts/install-move-flow.cjs

371 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* Optional native dependency probe: download a pinned `move-flow` release
* into `gitnexus/vendor/move-flow/<platform>/`.
*
* Same shape as `build-tree-sitter-grammars.cjs`: an opt-out env var, a soft-fail
* contract that always exits 0 (the gitnexus install must succeed even when
* the binary can't be provisioned), idempotency (skip when the on-disk
* version already matches), and an offline platform detect that warns and
* exits cleanly on unsupported targets.
*
* The runtime side (`tryCreateMoveFlowClient`) resolves the binary in this
* order: $MOVE_FLOW → bundled `vendor/move-flow/<platform>/move-flow[.exe]`
* → $PATH. When this probe fails, the Move ingest phase no-ops for non-Move
* repos and the analyze CLI emits a one-line stderr notice for Move repos.
*
* Supported env:
* GITNEXUS_SKIP_MOVE_FLOW=1 skip this probe
* GITNEXUS_MOVE_FLOW_VERSION=x.y.z override the pinned version
* GITNEXUS_MOVE_FLOW_REPO=owner/repo override the release repository
* GITNEXUS_MOVE_FLOW_TAG=tag override the release tag
* GITNEXUS_MOVE_FLOW_COMPAT=1 force Linux compat artifact
*/
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const https = require('node:https');
const crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');
const os = require('node:os');
const { pipeline } = require('node:stream');
/** Pinned move-flow release from https://github.com/aptos-labs/aptos-ai. */
const MOVE_FLOW_VERSION = process.env.GITNEXUS_MOVE_FLOW_VERSION || '2.0.0';
const RELEASE_REPO = process.env.GITNEXUS_MOVE_FLOW_REPO || 'aptos-labs/aptos-ai';
const RELEASE_TAG = process.env.GITNEXUS_MOVE_FLOW_TAG || `move-flow-v${MOVE_FLOW_VERSION}`;
const RELEASE_BASE = `https://github.com/${RELEASE_REPO}/releases/download/${RELEASE_TAG}`;
const BIN_NAME = process.platform === 'win32' ? 'move-flow.exe' : 'move-flow';
const SKIP_FLAGS = ['GITNEXUS_SKIP_MOVE_FLOW', 'GITNEXUS_SKIP_OPTIONAL_GRAMMARS'];
const vendorRoot = path.join(__dirname, '..', 'vendor', 'move-flow');
function platformKey() {
const { platform, arch } = process;
if (platform === 'linux' && arch === 'x64') return 'linux-x64';
if (platform === 'linux' && arch === 'arm64') return 'linux-arm64';
if (platform === 'darwin' && arch === 'arm64') return 'darwin-arm64';
if (platform === 'darwin' && arch === 'x64') return 'darwin-x64';
if (platform === 'win32' && arch === 'x64') return 'win32-x64';
return null;
}
function targetBinaryPath(key) {
const dir = path.join(vendorRoot, key);
return { dir, file: path.join(dir, BIN_NAME) };
}
function versionMatches(file) {
try {
const out = execFileSync(file, ['--version'], {
encoding: 'utf8',
timeout: 5000,
stdio: ['ignore', 'pipe', 'ignore'],
});
return out.includes(MOVE_FLOW_VERSION);
} catch {
return false;
}
}
function linuxNeedsCompatBuild() {
if (process.platform !== 'linux') return false;
if (process.env.GITNEXUS_MOVE_FLOW_COMPAT === '1') return true;
if (process.arch === 'x64') {
try {
const cpuinfo = fs.readFileSync('/proc/cpuinfo', 'utf8');
if (!/(^|\s)avx2(\s|$)/m.test(cpuinfo)) return true;
} catch {
return true;
}
}
try {
const out = execFileSync('ldd', ['--version'], {
encoding: 'utf8',
timeout: 3000,
stdio: ['ignore', 'pipe', 'ignore'],
});
const match = /(\d+)\.(\d+)/.exec(out.split('\n')[0] ?? '');
if (!match) return false;
const major = Number(match[1]);
const minor = Number(match[2]);
return major < 2 || (major === 2 && minor < 34);
} catch {
return false;
}
}
function releaseTargetForPlatform(key) {
switch (key) {
case 'darwin-arm64':
return 'aarch64-apple-darwin';
case 'darwin-x64':
return 'x86_64-apple-darwin';
case 'linux-arm64':
return `aarch64-unknown-linux-gnu${linuxNeedsCompatBuild() ? '-compat' : ''}`;
case 'linux-x64':
return `x86_64-unknown-linux-gnu${linuxNeedsCompatBuild() ? '-compat' : ''}`;
case 'win32-x64':
return 'x86_64-pc-windows-msvc';
default:
return null;
}
}
function downloadToFile(url, dest, get = https.get) {
return new Promise((resolve, reject) => {
const tmp = `${dest}.partial`;
let settled = false;
const fail = (err) => {
if (settled) return;
settled = true;
try {
fs.rmSync(tmp, { force: true });
} catch {
/* the caller's temp-directory cleanup gets a second chance */
}
reject(err);
};
const followRedirect = (target, hops) => {
if (hops > 5) {
fail(new Error('too many redirects'));
return;
}
const req = get(target, (res) => {
const status = res.statusCode || 0;
if (status >= 300 && status < 400 && res.headers.location) {
res.on('error', fail);
res.resume();
followRedirect(new URL(res.headers.location, target).toString(), hops + 1);
return;
}
if (status !== 200) {
res.on('error', fail);
res.resume();
fail(new Error(`HTTP ${status} for ${target}`));
return;
}
// pipeline owns both streams and reports source (mid-download) and
// destination errors through one callback instead of allowing an
// unhandled stream 'error' event to escape the postinstall soft-fail.
pipeline(res, fs.createWriteStream(tmp), (err) => {
if (settled) return;
if (err) {
fail(err);
return;
}
try {
fs.renameSync(tmp, dest);
settled = true;
resolve();
} catch (renameErr) {
fail(renameErr);
}
});
});
req.on('error', fail);
};
followRedirect(url, 0);
});
}
function sha256(file) {
const hash = crypto.createHash('sha256');
hash.update(fs.readFileSync(file));
return hash.digest('hex');
}
function powershellExpandArchiveInvocation(archive, dest) {
const archiveEnv = 'GITNEXUS_MOVE_FLOW_ARCHIVE_PATH';
const destinationEnv = 'GITNEXUS_MOVE_FLOW_DESTINATION_PATH';
return {
args: [
'-NoProfile',
'-NonInteractive',
'-Command',
`Expand-Archive -LiteralPath $env:${archiveEnv} -DestinationPath $env:${destinationEnv} -Force`,
],
env: {
...process.env,
[archiveEnv]: archive,
[destinationEnv]: dest,
},
};
}
function extractZip(archive, dest) {
fs.mkdirSync(dest, { recursive: true });
try {
if (process.platform === 'win32') {
const ps = process.env.ComSpec ? 'powershell.exe' : 'powershell';
const invocation = powershellExpandArchiveInvocation(archive, dest);
execFileSync(ps, invocation.args, {
stdio: 'ignore',
timeout: 30000,
env: invocation.env,
});
return;
}
execFileSync('unzip', ['-q', archive, '-d', dest], { stdio: 'ignore', timeout: 30000 });
} catch (err) {
throw new Error(
`could not extract ${path.basename(archive)} (${err instanceof Error ? err.message : err}). ` +
'Install unzip, or set MOVE_FLOW to an existing move-flow binary.',
);
}
}
function findExtractedBinary(root) {
const stack = [root];
const names = new Set([BIN_NAME, 'move-flow']);
while (stack.length > 0) {
const current = stack.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (names.has(entry.name)) {
return full;
}
}
}
return null;
}
function expectedSha(sumsText, assetName) {
for (const raw of sumsText.split('\n')) {
const line = raw.trim();
if (!line) continue;
// Format: "<sha256> <filename>" (BSD-style "SHA256 (file) = <hex>" also tolerated).
const m = /^([0-9a-f]{64})[ \t*]+(\S.*)$/i.exec(line);
if (m && m[2] === assetName) return m[1].toLowerCase();
const bsd = /^SHA256\s*\((.*)\)\s*=\s*([0-9a-f]{64})$/i.exec(line);
if (bsd && bsd[1] === assetName) return bsd[2].toLowerCase();
}
return null;
}
function verifyArchiveChecksum(sumsText, assetName, archive) {
const expected = expectedSha(sumsText, assetName);
if (!expected) return { status: 'missing' };
const actual = sha256(archive);
if (actual !== expected) return { status: 'mismatch', expected, actual };
return { status: 'match', expected, actual };
}
async function main() {
for (const flag of SKIP_FLAGS) {
if (process.env[flag] === '1') {
console.warn(`[move-flow] Skipping install (${flag}=1).`);
process.exit(0);
}
}
const key = platformKey();
if (!key) {
console.warn(
`[move-flow] Unsupported platform ${process.platform}-${process.arch} — skipping. ` +
'Set $MOVE_FLOW to provide a binary, or set GITNEXUS_SKIP_MOVE_FLOW=1 to silence this notice.',
);
process.exit(0);
}
const releaseTarget = releaseTargetForPlatform(key);
const { dir, file } = targetBinaryPath(key);
if (fs.existsSync(file) && versionMatches(file)) {
// Idempotent: already provisioned at the right version.
process.exit(0);
}
const assetName = `${RELEASE_TAG}-${releaseTarget}.zip`;
const sumsName = 'SHA256SUMS';
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'move-flow-'));
const tmpArchive = path.join(tmpDir, assetName);
const extractDir = path.join(tmpDir, 'extract');
const tmpSums = path.join(tmpDir, sumsName);
try {
await downloadToFile(`${RELEASE_BASE}/${sumsName}`, tmpSums);
await downloadToFile(`${RELEASE_BASE}/${assetName}`, tmpArchive);
const verification = verifyArchiveChecksum(
fs.readFileSync(tmpSums, 'utf8'),
assetName,
tmpArchive,
);
if (verification.status === 'missing') {
console.warn(
`[move-flow] ${sumsName} does not list ${assetName} — refusing to install. ` +
'Move ingestion will be unavailable. Non-Move functionality is unaffected.',
);
process.exit(0);
}
if (verification.status === 'mismatch') {
console.warn(
`[move-flow] Checksum mismatch for ${assetName} (expected ${verification.expected}, got ${verification.actual}) — refusing to install. ` +
'Move ingestion will be unavailable. Non-Move functionality is unaffected.',
);
process.exit(0);
}
extractZip(tmpArchive, extractDir);
const extracted = findExtractedBinary(extractDir);
if (!extracted) {
console.warn(
`[move-flow] ${assetName} did not contain ${BIN_NAME} — refusing to install. ` +
'Move ingestion will be unavailable. Non-Move functionality is unaffected.',
);
process.exit(0);
}
fs.mkdirSync(dir, { recursive: true });
fs.copyFileSync(extracted, file);
try {
fs.chmodSync(file, 0o755);
} catch {
/* no-op on Windows */
}
if (!versionMatches(file)) {
fs.rmSync(file, { force: true });
console.warn(
`[move-flow] Installed binary did not report version ${MOVE_FLOW_VERSION} — refusing to keep it. ` +
'Move ingestion will be unavailable. Non-Move functionality is unaffected.',
);
}
} catch (err) {
console.warn(`[move-flow] Download failed: ${err instanceof Error ? err.message : err}`);
console.warn(
'[move-flow] Move ingestion will be unavailable. Set $MOVE_FLOW to a local binary, ' +
'or set GITNEXUS_SKIP_MOVE_FLOW=1 to silence this notice. Non-Move functionality is unaffected.',
);
process.exit(0);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
module.exports = {
downloadToFile,
expectedSha,
sha256,
verifyArchiveChecksum,
powershellExpandArchiveInvocation,
};
if (require.main === module) {
main().catch((err) => {
// Never hard-fail the gitnexus install.
console.warn(`[move-flow] Unexpected error during install probe: ${err?.message ?? err}`);
process.exit(0);
});
}