forked from mosaicstack/stack
128 lines
4.0 KiB
JavaScript
128 lines
4.0 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { constants } from 'node:fs';
|
|
import { access, lstat, readdir } from 'node:fs/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
import path from 'node:path';
|
|
|
|
export const MISSING_DEPS_EXIT = 42;
|
|
export const GENERATED_STATE_EXIT = 43;
|
|
|
|
async function entries(root) {
|
|
const result = [];
|
|
async function walk(current) {
|
|
let children;
|
|
try {
|
|
children = await readdir(current, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return;
|
|
throw error;
|
|
}
|
|
for (const child of children) {
|
|
const target = path.join(current, child.name);
|
|
result.push(target);
|
|
if (child.isDirectory() && !child.isSymbolicLink()) await walk(target);
|
|
}
|
|
}
|
|
await walk(root);
|
|
return result;
|
|
}
|
|
|
|
async function fileMtimeRange(paths) {
|
|
let newest = 0;
|
|
let oldest = Number.POSITIVE_INFINITY;
|
|
for (const target of paths) {
|
|
const stats = await lstat(target);
|
|
if (stats.isFile()) {
|
|
newest = Math.max(newest, stats.mtimeMs);
|
|
oldest = Math.min(oldest, stats.mtimeMs);
|
|
}
|
|
}
|
|
return { newest, oldest: Number.isFinite(oldest) ? oldest : 0 };
|
|
}
|
|
|
|
export async function runPreflight({ root = process.cwd(), uid = process.getuid?.() } = {}) {
|
|
const binDir = path.join(root, 'node_modules', '.bin');
|
|
const requiredBinaries = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest'];
|
|
const missingBinaries = [];
|
|
for (const binary of requiredBinaries) {
|
|
try {
|
|
await access(path.join(binDir, binary), constants.X_OK);
|
|
} catch {
|
|
missingBinaries.push(binary);
|
|
}
|
|
}
|
|
if (missingBinaries.length > 0) {
|
|
return {
|
|
code: MISSING_DEPS_EXIT,
|
|
message: `MOSAIC_PREFLIGHT_MISSING_DEPS: dependency installation is missing ${missingBinaries.join(', ')}; run pnpm install --frozen-lockfile`,
|
|
};
|
|
}
|
|
|
|
const nextDir = path.join(root, 'apps', 'web', '.next');
|
|
let generated = [];
|
|
try {
|
|
await lstat(nextDir);
|
|
generated = [nextDir, ...(await entries(nextDir))];
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') throw error;
|
|
}
|
|
|
|
if (generated.length > 0) {
|
|
const foreign = [];
|
|
if (uid !== undefined) {
|
|
for (const target of generated) {
|
|
if ((await lstat(target)).uid !== uid) foreign.push(path.relative(root, target));
|
|
}
|
|
}
|
|
|
|
const sourceRoots = [
|
|
path.join(root, 'apps', 'web', 'src'),
|
|
path.join(root, 'apps', 'web', 'next.config.ts'),
|
|
path.join(root, 'apps', 'web', 'package.json'),
|
|
path.join(root, 'apps', 'web', 'tsconfig.json'),
|
|
];
|
|
const source = [];
|
|
for (const sourceRoot of sourceRoots) {
|
|
try {
|
|
const stats = await lstat(sourceRoot);
|
|
source.push(sourceRoot);
|
|
if (stats.isDirectory()) source.push(...(await entries(sourceRoot)));
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') throw error;
|
|
}
|
|
}
|
|
|
|
const generatedTypes = generated.filter((target) =>
|
|
target.startsWith(path.join(nextDir, 'types') + path.sep),
|
|
);
|
|
const sourceMtime = await fileMtimeRange(source);
|
|
const generatedMtime = await fileMtimeRange(generatedTypes);
|
|
const stale =
|
|
source.length > 0 && generatedTypes.length > 0 && sourceMtime.newest > generatedMtime.oldest;
|
|
if (foreign.length > 0 || stale) {
|
|
const reasons = [
|
|
foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '',
|
|
stale ? 'generated output is older than web source/configuration' : '',
|
|
].filter(Boolean);
|
|
return {
|
|
code: GENERATED_STATE_EXIT,
|
|
message: `MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next is not trustworthy (${reasons.join('; ')}); run pnpm clean:generated, then rerun the gate`,
|
|
};
|
|
}
|
|
}
|
|
|
|
return { code: 0, message: 'checkout preflight passed' };
|
|
}
|
|
|
|
async function main() {
|
|
const result = await runPreflight();
|
|
const stream = result.code === 0 ? process.stdout : process.stderr;
|
|
stream.write(`${result.message}\n`);
|
|
process.exitCode = result.code;
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
await main();
|
|
}
|