* docs(policy): allow low-impact contributor courtesy cleanup * chore(aiwg): migrate to shared BT6 maintainer plugin * chore: reconcile delivery validation
134 lines
5.1 KiB
JavaScript
134 lines
5.1 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import {
|
|
cpSync,
|
|
existsSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
renameSync,
|
|
rmSync,
|
|
statSync,
|
|
} from 'node:fs';
|
|
import { createHash } from 'node:crypto';
|
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
|
|
const projectRoot = resolve(import.meta.dirname, '..');
|
|
const lockPath = join(projectRoot, '.aiwg', 'bt6-maintainer.lock.json');
|
|
const lock = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
const checkOnly = process.argv.includes('--check');
|
|
|
|
function run(command, args, options = {}) {
|
|
return execFileSync(command, args, {
|
|
cwd: projectRoot,
|
|
encoding: 'utf8',
|
|
stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
});
|
|
}
|
|
|
|
function capture(command, args) {
|
|
return run(command, args, { capture: true });
|
|
}
|
|
|
|
function filesUnder(root, current = root) {
|
|
const files = [];
|
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
const path = join(current, entry.name);
|
|
if (entry.isDirectory()) files.push(...filesUnder(root, path));
|
|
if (entry.isFile()) files.push(relative(root, path));
|
|
}
|
|
return files.sort();
|
|
}
|
|
|
|
function digest(path) {
|
|
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
}
|
|
|
|
function compareTrees(expectedRoot, actualRoot) {
|
|
const expectedFiles = filesUnder(expectedRoot);
|
|
const actualFiles = existsSync(actualRoot) ? filesUnder(actualRoot) : [];
|
|
const missing = expectedFiles.filter((path) => !actualFiles.includes(path));
|
|
const extra = actualFiles.filter((path) => !expectedFiles.includes(path));
|
|
const changed = expectedFiles.filter(
|
|
(path) => actualFiles.includes(path) && digest(join(expectedRoot, path)) !== digest(join(actualRoot, path)),
|
|
);
|
|
return { equal: missing.length === 0 && extra.length === 0 && changed.length === 0, missing, extra, changed };
|
|
}
|
|
|
|
function assertContained(parent, child, label) {
|
|
const parentPath = resolve(parent);
|
|
const childPath = resolve(child);
|
|
if (childPath !== parentPath && !childPath.startsWith(`${parentPath}/`)) {
|
|
throw new Error(`${label} escapes ${parentPath}: ${childPath}`);
|
|
}
|
|
}
|
|
|
|
const installRef = `${lock.source}@${lock.ref}`;
|
|
run('aiwg', ['install', installRef, '--refresh']);
|
|
|
|
const info = capture('aiwg', ['packages', 'info', lock.registryKey]);
|
|
const plainInfo = info.replace(/\x1b\[[0-9;]*m/g, '');
|
|
const cacheMatch = plainInfo.match(/^\s*Cache:\s*(.+)$/m);
|
|
if (!cacheMatch) throw new Error(`Unable to resolve AIWG package cache for ${lock.registryKey}`);
|
|
|
|
const cacheRoot = resolve(cacheMatch[1].trim());
|
|
const cachedCommit = capture('git', ['-C', cacheRoot, 'rev-parse', 'HEAD']).trim();
|
|
const cachedTree = capture('git', ['-C', cacheRoot, 'rev-parse', `HEAD:${lock.sourceWrapperPath}`]).trim();
|
|
if (cachedCommit !== lock.commit) {
|
|
throw new Error(`Pinned commit mismatch: expected ${lock.commit}, received ${cachedCommit}`);
|
|
}
|
|
if (cachedTree !== lock.wrapperTree) {
|
|
throw new Error(`Pinned wrapper tree mismatch: expected ${lock.wrapperTree}, received ${cachedTree}`);
|
|
}
|
|
|
|
const sourceWrapper = join(cacheRoot, lock.sourceWrapperPath);
|
|
assertContained(cacheRoot, sourceWrapper, 'source wrapper');
|
|
const targetWrapper = join(projectRoot, lock.wrapperPath);
|
|
assertContained(join(projectRoot, '.aiwg', 'plugins'), targetWrapper, 'target wrapper');
|
|
|
|
const manifest = JSON.parse(readFileSync(join(sourceWrapper, 'manifest.json'), 'utf8'));
|
|
if (manifest.id !== lock.package || manifest.type !== 'plugin' || manifest.version !== lock.version) {
|
|
throw new Error(`Wrapper identity mismatch for ${lock.package}@${lock.version}`);
|
|
}
|
|
|
|
const before = compareTrees(sourceWrapper, targetWrapper);
|
|
if (checkOnly) {
|
|
if (!before.equal) {
|
|
throw new Error(`Vendored wrapper differs: ${JSON.stringify(before)}`);
|
|
}
|
|
console.log(`bt6-maintainer ${lock.version} matches ${lock.commit} (${lock.wrapperTree})`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const pluginsRoot = dirname(targetWrapper);
|
|
const stage = mkdtempSync(join(pluginsRoot, '.bt6-maintainer-stage-'));
|
|
const stagedWrapper = join(stage, basename(targetWrapper));
|
|
cpSync(sourceWrapper, stagedWrapper, { recursive: true, errorOnExist: true });
|
|
|
|
const backupRoot = join(projectRoot, '.aiwg', 'working', 'bt6-maintainer-sync-backup');
|
|
rmSync(backupRoot, { recursive: true, force: true });
|
|
if (existsSync(targetWrapper)) {
|
|
cpSync(targetWrapper, backupRoot, { recursive: true });
|
|
rmSync(targetWrapper, { recursive: true, force: true });
|
|
}
|
|
|
|
try {
|
|
renameSync(stagedWrapper, targetWrapper);
|
|
rmSync(stage, { recursive: true, force: true });
|
|
run('aiwg', ['use', lock.package]);
|
|
run('aiwg', ['use', lock.package, '--provider', 'codex']);
|
|
run('aiwg', ['index', 'build']);
|
|
run('aiwg', ['doctor', '--project-local']);
|
|
const after = compareTrees(sourceWrapper, targetWrapper);
|
|
if (!after.equal) throw new Error(`Post-deploy wrapper differs: ${JSON.stringify(after)}`);
|
|
rmSync(backupRoot, { recursive: true, force: true });
|
|
console.log(`Synchronized ${lock.package} ${lock.version} from ${lock.commit}`);
|
|
} catch (error) {
|
|
rmSync(targetWrapper, { recursive: true, force: true });
|
|
if (existsSync(backupRoot) && statSync(backupRoot).isDirectory()) {
|
|
renameSync(backupRoot, targetWrapper);
|
|
}
|
|
throw error;
|
|
}
|