244 lines
8 KiB
TypeScript
244 lines
8 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
type DependencyMap = Record<string, string>;
|
|
|
|
type LockWorkspace = {
|
|
name?: string;
|
|
dependencies?: DependencyMap;
|
|
devDependencies?: DependencyMap;
|
|
optionalDependencies?: DependencyMap;
|
|
peerDependencies?: DependencyMap;
|
|
};
|
|
|
|
type BunLock = {
|
|
lockfileVersion?: number;
|
|
configVersion?: number;
|
|
workspaces?: Record<string, LockWorkspace>;
|
|
packages?: Record<string, unknown>;
|
|
};
|
|
|
|
type RootPackageManifest = {
|
|
workspaces?: string[];
|
|
};
|
|
|
|
const repoRoot = path.resolve(import.meta.dir, "..");
|
|
const deploymentRoot = path.join(repoRoot, "deployment/docker/workspace-root");
|
|
|
|
const rootPackagePath = path.join(repoRoot, "package.json");
|
|
const deploymentPackagePath = path.join(deploymentRoot, "package.json");
|
|
const rootTsconfigPath = path.join(repoRoot, "tsconfig.base.json");
|
|
const deploymentTsconfigPath = path.join(deploymentRoot, "tsconfig.base.json");
|
|
const rootLockPath = path.join(repoRoot, "bun.lock");
|
|
const deploymentLockPath = path.join(deploymentRoot, "bun.lock");
|
|
|
|
const readUtf8 = async (filePath: string): Promise<string> => {
|
|
return readFile(filePath, "utf8");
|
|
};
|
|
|
|
const parseObjectLiteral = async <T>(filePath: string): Promise<T> => {
|
|
const raw = await readUtf8(filePath);
|
|
try {
|
|
const parsed = Function(`"use strict"; return (${raw});`)() as T;
|
|
return parsed;
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`Failed to parse ${filePath}: ${message}`);
|
|
}
|
|
};
|
|
|
|
const stableSortObject = (value: unknown): unknown => {
|
|
if (Array.isArray(value)) {
|
|
return value.map(stableSortObject);
|
|
}
|
|
if (value && typeof value === "object") {
|
|
const entries = Object.entries(value as Record<string, unknown>)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([key, nested]) => [key, stableSortObject(nested)] as const);
|
|
return Object.fromEntries(entries);
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const stableStringify = (value: unknown): string => {
|
|
return JSON.stringify(stableSortObject(value));
|
|
};
|
|
|
|
const listWorkspacePaths = async (workspacePatterns: string[]): Promise<string[]> => {
|
|
const paths = new Set<string>();
|
|
|
|
for (const pattern of workspacePatterns) {
|
|
const globPattern = pattern.endsWith("/") ? `${pattern}package.json` : `${pattern}/package.json`;
|
|
const glob = new Bun.Glob(globPattern);
|
|
for await (const match of glob.scan({ cwd: repoRoot })) {
|
|
const normalized = match.replaceAll("\\", "/");
|
|
paths.add(path.posix.dirname(normalized));
|
|
}
|
|
}
|
|
|
|
return Array.from(paths).sort((a, b) => a.localeCompare(b));
|
|
};
|
|
|
|
const normalizedDependencyMap = (input: DependencyMap | undefined): DependencyMap => {
|
|
if (!input) {
|
|
return {};
|
|
}
|
|
return Object.fromEntries(
|
|
Object.entries(input)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([name, version]) => [name, version])
|
|
);
|
|
};
|
|
|
|
const formatDependencyDiff = (
|
|
workspacePath: string,
|
|
section: string,
|
|
expected: DependencyMap,
|
|
actual: DependencyMap
|
|
): string[] => {
|
|
const issues: string[] = [];
|
|
const expectedKeys = new Set(Object.keys(expected));
|
|
const actualKeys = new Set(Object.keys(actual));
|
|
|
|
for (const key of expectedKeys) {
|
|
if (!actualKeys.has(key)) {
|
|
issues.push(`${workspacePath} ${section}: missing ${key}@${expected[key]}`);
|
|
continue;
|
|
}
|
|
if (expected[key] !== actual[key]) {
|
|
issues.push(
|
|
`${workspacePath} ${section}: ${key} expected ${expected[key]} but found ${actual[key]}`
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const key of actualKeys) {
|
|
if (!expectedKeys.has(key)) {
|
|
issues.push(`${workspacePath} ${section}: extra ${key}@${actual[key]}`);
|
|
}
|
|
}
|
|
|
|
return issues;
|
|
};
|
|
|
|
const check = async (): Promise<number> => {
|
|
const issues: string[] = [];
|
|
|
|
const [rootPackage, deploymentPackage, rootTsconfig, deploymentTsconfig, rootLock, deploymentLock] =
|
|
await Promise.all([
|
|
parseObjectLiteral<RootPackageManifest>(rootPackagePath),
|
|
parseObjectLiteral(deploymentPackagePath),
|
|
parseObjectLiteral(rootTsconfigPath),
|
|
parseObjectLiteral(deploymentTsconfigPath),
|
|
parseObjectLiteral<BunLock>(rootLockPath),
|
|
parseObjectLiteral<BunLock>(deploymentLockPath)
|
|
]);
|
|
|
|
const rootPackageSnapshot = stableStringify(rootPackage);
|
|
const deploymentPackageSnapshot = stableStringify(deploymentPackage);
|
|
if (rootPackageSnapshot !== deploymentPackageSnapshot) {
|
|
issues.push(
|
|
"deployment/docker/workspace-root/package.json does not match repo-root package.json"
|
|
);
|
|
}
|
|
|
|
const rootTsconfigSnapshot = stableStringify(rootTsconfig);
|
|
const deploymentTsconfigSnapshot = stableStringify(deploymentTsconfig);
|
|
if (rootTsconfigSnapshot !== deploymentTsconfigSnapshot) {
|
|
issues.push(
|
|
"deployment/docker/workspace-root/tsconfig.base.json does not match repo-root tsconfig.base.json"
|
|
);
|
|
}
|
|
|
|
const rootWorkspaces = rootLock.workspaces ?? {};
|
|
const deploymentWorkspaces = deploymentLock.workspaces ?? {};
|
|
|
|
const workspacePatterns = rootPackage.workspaces ?? [];
|
|
const workspacePackagePaths = await listWorkspacePaths(workspacePatterns);
|
|
for (const workspacePath of workspacePackagePaths) {
|
|
const packageJsonPath = path.join(repoRoot, workspacePath, "package.json");
|
|
const workspacePackage = (await parseObjectLiteral(packageJsonPath)) as LockWorkspace;
|
|
const deploymentWorkspace = deploymentWorkspaces[workspacePath];
|
|
|
|
if (!deploymentWorkspace) {
|
|
issues.push(`deployment lock is missing workspace entry: ${workspacePath}`);
|
|
continue;
|
|
}
|
|
|
|
const sections: Array<keyof LockWorkspace> = [
|
|
"dependencies",
|
|
"devDependencies",
|
|
"optionalDependencies",
|
|
"peerDependencies"
|
|
];
|
|
for (const section of sections) {
|
|
const expectedMap = normalizedDependencyMap(workspacePackage[section] as DependencyMap | undefined);
|
|
const actualMap = normalizedDependencyMap(
|
|
deploymentWorkspace[section] as DependencyMap | undefined
|
|
);
|
|
issues.push(...formatDependencyDiff(workspacePath, section, expectedMap, actualMap));
|
|
}
|
|
}
|
|
|
|
const workspacePaths = Array.from(
|
|
new Set([...Object.keys(rootWorkspaces), ...Object.keys(deploymentWorkspaces)])
|
|
).sort((a, b) => a.localeCompare(b));
|
|
|
|
for (const workspacePath of workspacePaths) {
|
|
const rootWorkspace = rootWorkspaces[workspacePath];
|
|
const deploymentWorkspace = deploymentWorkspaces[workspacePath];
|
|
|
|
if (!rootWorkspace) {
|
|
issues.push(`deployment lock has unexpected workspace entry: ${workspacePath}`);
|
|
continue;
|
|
}
|
|
if (!deploymentWorkspace) {
|
|
issues.push(`deployment lock is missing workspace entry: ${workspacePath}`);
|
|
continue;
|
|
}
|
|
|
|
if ((rootWorkspace.name ?? "") !== (deploymentWorkspace.name ?? "")) {
|
|
issues.push(
|
|
`${workspacePath} name mismatch: expected ${rootWorkspace.name ?? "(none)"} but found ${
|
|
deploymentWorkspace.name ?? "(none)"
|
|
}`
|
|
);
|
|
}
|
|
|
|
const sections: Array<keyof LockWorkspace> = [
|
|
"dependencies",
|
|
"devDependencies",
|
|
"optionalDependencies",
|
|
"peerDependencies"
|
|
];
|
|
for (const section of sections) {
|
|
const expectedMap = normalizedDependencyMap(rootWorkspace[section] as DependencyMap | undefined);
|
|
const actualMap = normalizedDependencyMap(
|
|
deploymentWorkspace[section] as DependencyMap | undefined
|
|
);
|
|
issues.push(...formatDependencyDiff(workspacePath, section, expectedMap, actualMap));
|
|
}
|
|
}
|
|
|
|
const rootPackagesSnapshot = stableStringify(rootLock.packages ?? {});
|
|
const deploymentPackagesSnapshot = stableStringify(deploymentLock.packages ?? {});
|
|
if (rootPackagesSnapshot !== deploymentPackagesSnapshot) {
|
|
issues.push(
|
|
"deployment/docker/workspace-root/bun.lock package resolutions differ from repo-root bun.lock"
|
|
);
|
|
}
|
|
|
|
if (issues.length > 0) {
|
|
console.error("Docker workspace snapshot is out of sync:");
|
|
for (const issue of issues) {
|
|
console.error(`- ${issue}`);
|
|
}
|
|
console.error("Run: bun run sync:docker-workspace");
|
|
return 1;
|
|
}
|
|
|
|
console.log("Docker workspace snapshot is in sync.");
|
|
return 0;
|
|
};
|
|
|
|
process.exitCode = await check();
|