Files
stack/scripts/install-hooks.mjs
T

147 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
import { access, lstat, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises';
import { execFile, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const execFileAsync = promisify(execFile);
function run(command, args, options) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, options);
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) resolve();
else reject(new Error(signal ? `husky terminated by ${signal}` : `husky exited ${code}`));
});
});
}
async function pathExists(target) {
try {
await access(target);
return true;
} catch (error) {
if (error.code === 'ENOENT') return false;
throw error;
}
}
async function directorySnapshot(root) {
const snapshot = [];
async function walk(current) {
const children = await readdir(current, { withFileTypes: true });
for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
const target = path.join(current, child.name);
const relative = path.relative(root, target);
const stats = await lstat(target);
if (child.isDirectory()) {
snapshot.push([relative, 'directory', stats.mode & 0o777]);
await walk(target);
} else {
snapshot.push([
relative,
'file',
stats.mode & 0o777,
(await readFile(target)).toString('base64'),
]);
}
}
}
await walk(root);
return JSON.stringify(snapshot);
}
async function directoriesMatch(left, right) {
return (await directorySnapshot(left)) === (await directorySnapshot(right));
}
export async function installHooks({
root = process.cwd(),
disabled = process.env.HUSKY === '0',
quarantineRoot = path.join(root, '.mosaic-test-work', 'husky-quarantine'),
runHusky = async (_stagingHooks, stagingRepo) => {
await execFileAsync('git', ['init', '--quiet', stagingRepo]);
await run(path.join(root, 'node_modules', '.bin', 'husky'), ['.husky'], {
cwd: stagingRepo,
stdio: 'inherit',
});
},
activateHooks = async () => {
await run('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: root, stdio: 'inherit' });
},
} = {}) {
if (disabled) return;
const huskyDir = path.join(root, '.husky');
const active = path.join(huskyDir, '_');
const nonce = `${Date.now()}-${process.pid}`;
const stagingRepo = path.join(root, '.mosaic-test-work', `husky-stage-${nonce}`);
const stagingHooks = path.join(stagingRepo, '.husky');
const quarantined = path.join(quarantineRoot, `${path.basename(root)}-${nonce}`);
await mkdir(huskyDir, { recursive: true });
await mkdir(quarantineRoot, { recursive: true });
const previousComplete = (await pathExists(active)) && (await pathExists(path.join(active, 'h')));
let previousQuarantined = false;
try {
if ((await pathExists(active)) && !previousComplete) {
await rename(active, quarantined);
previousQuarantined = true;
}
await mkdir(stagingRepo, { recursive: true });
await runHusky(stagingHooks, stagingRepo);
const staged = path.join(stagingHooks, '_');
if (!(await pathExists(path.join(staged, 'h')))) {
throw new Error('husky did not produce its required h shim');
}
if (previousComplete) {
if (!(await directoriesMatch(active, staged))) {
throw new Error('existing complete hook set differs from the installed Husky version');
}
await rm(stagingRepo, { recursive: true, force: true });
} else {
await rename(staged, active);
await rm(stagingRepo, { recursive: true, force: true });
}
await activateHooks();
if (previousQuarantined) {
try {
await rm(quarantined, { recursive: true, force: true });
} catch {
console.warn(
`Previous hook state was deactivated but remains quarantined at ${quarantined}`,
);
}
}
} catch (error) {
const cleanupFailures = [];
try {
if (await pathExists(stagingRepo)) {
await rename(stagingRepo, `${quarantined}-staging`);
}
} catch (cleanupError) {
cleanupFailures.push(`staging hooks: ${cleanupError.message}`);
}
const cleanup =
cleanupFailures.length === 0
? 'No partial hook set was activated.'
: `Automatic cleanup was incomplete (${cleanupFailures.join('; ')}).`;
throw new Error(
`Hook installation failed: ${error.message}. ${cleanup} Fix: rm -rf .husky/_ && git config core.hooksPath .husky/_ && pnpm install --frozen-lockfile`,
{ cause: error },
);
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
await installHooks();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}