newspaper_wedding/.aidlc/tools/aidlc-install-paths.ts
Andrew Ridgway bec1eaac87
Some checks failed
Test / test (push) Has been cancelled
first pass at the newspaper builder
2026-09-14 11:57:22 +10:00

573 lines
20 KiB
TypeScript

import { createHash } from "node:crypto";
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
import { homedir, platform } from "node:os";
import {
basename,
dirname,
isAbsolute,
join,
parse,
relative,
resolve,
sep,
} from "node:path";
import { requireVersion, VERSION_ID } from "./aidlc-channel.ts";
import { projectionFiles, sha256File, walkFiles } from "./aidlc-distribution.ts";
// Machine roots are canonical paths. Every derived path (launcher body, the
// active-executable pointer, pin targets, registry keys) is rendered from them,
// so equivalent spellings of the same directories (macOS /tmp and /private/tmp,
// a symlinked XDG_DATA_HOME, a re-spelled AIDLC_INSTALL_ROOT) describe one
// install instead of rejecting each other's launcher as tampered.
function lexicalInstallRoot(): string {
const explicit = process.env.AIDLC_INSTALL_ROOT?.trim();
if (explicit) return isAbsolute(explicit) ? explicit : resolve(process.cwd(), explicit);
if (platform() === "win32") {
return join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "aidlc");
}
return join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "aidlc");
}
export function installRoot(): string {
return canonicalPolicyPath(lexicalInstallRoot());
}
export function versionsRoot(): string {
return join(installRoot(), "versions");
}
export function versionRoot(version: string): string {
return join(versionsRoot(), requireVersion(version));
}
function lexicalBinRoot(): string {
const explicit = process.env.AIDLC_BIN_DIR?.trim();
if (explicit) return isAbsolute(explicit) ? explicit : resolve(process.cwd(), explicit);
return platform() === "win32"
? join(lexicalInstallRoot(), "bin")
: join(homedir(), ".local", "bin");
}
export function binRoot(): string {
return canonicalPolicyPath(lexicalBinRoot());
}
export function canonicalPolicyPath(path: string): string {
const absolute = resolve(path);
let cursor = absolute;
const suffix: string[] = [];
while (!existsSync(cursor)) {
const parent = dirname(cursor);
if (parent === cursor) break;
suffix.unshift(basename(cursor));
cursor = parent;
}
const base = existsSync(cursor) ? realpathSync(cursor) : cursor;
return suffix.reduce((current, entry) => join(current, entry), base);
}
export function policyPathWithin(path: string, root: string): boolean {
const rel = relative(canonicalPolicyPath(root), canonicalPolicyPath(path));
return rel === "" ||
(!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
}
function resolvedPathWithin(path: string, root: string): boolean {
const rel = relative(resolve(root), resolve(path));
return rel === "" ||
(!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
}
export function isMachineOwnedPath(path: string): boolean {
return [installRoot(), binRoot()].some((root) => policyPathWithin(path, root));
}
// A project overlaps the machine roots when either contains the other. The
// configured (lexical) spellings are checked alongside the canonical ones, and
// the engine's workspace `<project>/aidlc` is resolved as well: whether the
// root is configured as that symlink or as the real tree it points to, writes
// under `<project>/aidlc` would land in the machine tree (t230 covers both).
export function projectPathOverlapsMachineRoots(projectDir: string): boolean {
const lexical = [lexicalInstallRoot(), lexicalBinRoot()];
const roots = new Set([...lexical, ...lexical.map(canonicalPolicyPath)]);
const workspace = join(projectDir, "aidlc");
return [...roots].some((root) =>
policyPathWithin(projectDir, root) ||
policyPathWithin(root, projectDir) ||
resolvedPathWithin(projectDir, root) ||
resolvedPathWithin(root, projectDir) ||
policyPathWithin(workspace, root)
);
}
export function machineTransactionRoot(): string {
const paths = [installRoot(), binRoot()].map((path) => resolve(path));
const filesystemRoot = parse(paths[0]).root;
if (paths.some((path) => parse(path).root !== filesystemRoot)) {
throw new Error("machine install and command directories must be on one filesystem root");
}
const parts = paths.map((path) =>
path.slice(filesystemRoot.length).split(/[\\/]/).filter(Boolean)
);
const shared: string[] = [];
for (let index = 0; index < Math.min(...parts.map((value) => value.length)); index++) {
if (parts.some((value) => value[index] !== parts[0][index])) break;
shared.push(parts[0][index]);
}
const common = join(filesystemRoot, ...shared);
if (common === filesystemRoot) {
throw new Error("machine install and command directories need a writable shared parent");
}
return common;
}
export function commandPath(): string {
return join(binRoot(), platform() === "win32" ? "aidlc.cmd" : "aidlc");
}
export function packageManagerForExecutable(
executable: string,
): { name: string; remediation: string } | null {
let path = resolve(executable);
try {
path = realpathSync(path);
} catch {
// A missing executable is not manager-owned.
}
const normalized = path.replaceAll("\\", "/");
if (
normalized.includes("/Cellar/") ||
normalized.startsWith("/opt/homebrew/") ||
normalized.startsWith("/home/linuxbrew/.linuxbrew/")
) {
return { name: "Homebrew", remediation: "brew upgrade aidlc" };
}
if (normalized.startsWith("/nix/store/") || normalized.includes("/.nix-profile/")) {
return { name: "Nix", remediation: "upgrade aidlc through Nix" };
}
return null;
}
export function activeVersionPath(): string {
return join(installRoot(), "active-version");
}
export function activeExecutablePath(): string {
return join(installRoot(), "active-executable");
}
export function projectPinTargetPath(projectDir: string): string {
return join(resolve(projectDir), "aidlc", ".aidlc-sessions", "pin-target");
}
export function windowsUninstallFencePath(): string {
const key = createHash("sha256")
.update(resolve(installRoot()))
.digest("hex")
.slice(0, 16);
return join(machineTransactionRoot(), `.aidlc-uninstall-${key}.json`);
}
export function rollbackVersionPath(): string {
return join(installRoot(), "rollback-version");
}
export function readVersionMarker(path: string): string | null {
if (!existsSync(path)) return null;
const value = readFileSync(path, "utf-8").trim();
return VERSION_ID.test(value) ? value : null;
}
export function activeVersion(): string | null {
try {
const executable = readActiveExecutable();
if (executable) return basename(dirname(executable));
} catch {
// The marker fallback lets doctor report a damaged pointer.
}
for (const candidatePath of [process.execPath, commandPath()]) {
try {
const executable = realpathSync(candidatePath);
const parent = dirname(executable);
const candidate = basename(parent);
if (dirname(parent) === realpathOrResolved(versionsRoot()) && VERSION_ID.test(candidate)) {
return candidate;
}
} catch {
// The command link does not exist before the first install.
}
}
return readVersionMarker(activeVersionPath());
}
export function readActiveExecutable(): string | null {
const path = activeExecutablePath();
if (!existsSync(path)) return null;
const raw = readFileSync(path, "utf-8");
if (!/^[^\r\n]+\r?\n?$/.test(raw)) {
throw new Error(`${path} must contain exactly one executable path`);
}
const executable = raw.endsWith("\r\n")
? raw.slice(0, -2)
: raw.endsWith("\n")
? raw.slice(0, -1)
: raw;
if (!isAbsolute(executable)) {
throw new Error(`${path} must contain an absolute executable path`);
}
const normalized = canonicalPolicyPath(executable);
const parent = dirname(normalized);
const version = basename(parent);
const expectedName = platform() === "win32" ? "aidlc.exe" : "aidlc";
if (
basename(normalized).toLowerCase() !== expectedName ||
dirname(parent) !== resolve(versionsRoot()) ||
!VERSION_ID.test(version)
) {
throw new Error(`${path} points outside the installed versions root`);
}
return normalized;
}
export function inspectProjectPinTarget(
projectDir: string,
version: string,
): { valid: boolean; target: string; reason?: string } {
const path = projectPinTargetPath(projectDir);
if (!existsSync(path)) {
return { valid: false, target: path, reason: "resolved target marker is missing" };
}
let raw: string;
try {
if (!statSync(path).isFile()) {
return { valid: false, target: path, reason: "resolved target marker is not a regular file" };
}
raw = readFileSync(path, "utf-8");
} catch (error) {
return {
valid: false,
target: path,
reason: `resolved target marker is unreadable: ${
error instanceof Error ? error.message : String(error)
}`,
};
}
if (!/^[^\r\n]+\r?\n?$/.test(raw)) {
return { valid: false, target: path, reason: "resolved target marker must contain one path" };
}
const target = canonicalPolicyPath(raw.replace(/\r?\n$/, ""));
const expected = canonicalPolicyPath(installedExecutablePath(version));
if (target !== expected) {
return {
valid: false,
target,
reason: `resolved target does not select retained version ${version}`,
};
}
try {
const stat = statSync(target);
if (!stat.isFile()) {
return { valid: false, target, reason: "resolved target is not a regular file" };
}
if (platform() !== "win32" && (stat.mode & 0o111) === 0) {
return { valid: false, target, reason: "resolved target is not executable" };
}
} catch {
return { valid: false, target, reason: "resolved target is missing" };
}
return { valid: true, target };
}
function realpathOrResolved(path: string): string {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
}
export function runtimeRoot(version: string): string {
return join(versionRoot(version), "runtime");
}
export function installedExecutablePath(version: string): string {
return join(versionRoot(version), platform() === "win32" ? "aidlc.exe" : "aidlc");
}
export type InstalledRuntimeIntegrity = {
schemaVersion: 1;
version: string;
files: Array<{
path: string;
mode: number;
sha256: string;
}>;
};
export function runtimeIntegrityPath(version: string): string {
return join(versionRoot(version), "runtime-integrity.json");
}
export function createRuntimeIntegrity(
version: string,
root = runtimeRoot(version),
): InstalledRuntimeIntegrity {
requireVersion(version);
return {
schemaVersion: 1,
version,
files: walkFiles(root).map((file) => {
const path = join(root, file);
return {
path: file.replaceAll("\\", "/"),
mode: statSync(path).mode & 0o777,
sha256: sha256File(path),
};
}),
};
}
export function installedVersionFingerprint(version: string): string | null {
try {
const root = versionRoot(version);
const rows = walkFiles(root).map((file) => {
const path = join(root, file);
return `${file.replaceAll("\\", "/")}:${statSync(path).mode & 0o777}:${sha256File(path)}`;
});
return createHash("sha256").update(rows.join("\n")).digest("hex");
} catch {
return null;
}
}
export function inspectInstalledVersion(
version: string,
requiredDistribution?: string | null,
): { complete: boolean; distributions: string[]; reason?: string } {
requireVersion(version);
const executable = installedExecutablePath(version);
const manifestPath = join(versionRoot(version), "version.json");
if (!existsSync(executable) || !statSync(executable).isFile()) {
return { complete: false, distributions: [], reason: "executable is missing" };
}
if (platform() !== "win32" && (statSync(executable).mode & 0o111) === 0) {
return { complete: false, distributions: [], reason: "executable mode is invalid" };
}
let manifest: {
schemaVersion?: unknown;
version?: unknown;
distributions?: unknown;
assets?: unknown;
installedRuntime?: unknown;
};
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as typeof manifest;
} catch {
return { complete: false, distributions: [], reason: "version.json is missing or malformed" };
}
if (manifest.schemaVersion !== 1 || manifest.version !== version || !Array.isArray(manifest.assets)) {
return { complete: false, distributions: [], reason: "version.json identity is invalid" };
}
const binaryName = `aidlc-${targetTriple()}${platform() === "win32" ? ".exe" : ""}`;
const binary = manifest.assets.find((asset): asset is { name: string; sha256: string } =>
Boolean(asset) &&
typeof asset === "object" &&
(asset as { name?: unknown }).name === binaryName &&
/^[a-f0-9]{64}$/.test(String((asset as { sha256?: unknown }).sha256 ?? ""))
);
if (
!binary ||
createHash("sha256").update(readFileSync(executable)).digest("hex") !== binary.sha256
) {
return { complete: false, distributions: [], reason: "executable does not match version.json" };
}
const runtime = runtimeRoot(version);
if (!existsSync(runtime)) {
return { complete: false, distributions: [], reason: "runtime directory is missing" };
}
const runtimeAssetDeclared = manifest.assets.some((asset) =>
Boolean(asset) &&
typeof asset === "object" &&
(asset as { name?: unknown }).name === `aidlc-runtime-${version}.tar.gz`
);
if (runtimeAssetDeclared || manifest.installedRuntime !== undefined) {
const installedRuntime = manifest.installedRuntime as {
schemaVersion?: unknown;
baseline?: unknown;
sha256?: unknown;
} | undefined;
if (
installedRuntime?.schemaVersion !== 1 ||
installedRuntime.baseline !== "runtime-integrity.json" ||
typeof installedRuntime.sha256 !== "string" ||
!/^sha256:[a-f0-9]{64}$/.test(installedRuntime.sha256)
) {
return {
complete: false,
distributions: [],
reason: "installed runtime integrity metadata is missing or invalid",
};
}
const baselinePath = runtimeIntegrityPath(version);
let baseline: InstalledRuntimeIntegrity;
try {
if (!statSync(baselinePath).isFile()) {
return {
complete: false,
distributions: [],
reason: "runtime integrity baseline is not a regular file",
};
}
if (sha256File(baselinePath) !== installedRuntime.sha256) {
return {
complete: false,
distributions: [],
reason: "runtime integrity baseline does not match version.json",
};
}
baseline = JSON.parse(readFileSync(baselinePath, "utf-8")) as InstalledRuntimeIntegrity;
} catch {
return {
complete: false,
distributions: [],
reason: "runtime integrity baseline is missing or malformed",
};
}
if (
baseline.schemaVersion !== 1 ||
baseline.version !== version ||
!Array.isArray(baseline.files)
) {
return {
complete: false,
distributions: [],
reason: "runtime integrity baseline identity is invalid",
};
}
let actual: InstalledRuntimeIntegrity;
try {
actual = createRuntimeIntegrity(version, runtime);
} catch (error) {
return {
complete: false,
distributions: [],
reason: error instanceof Error ? error.message : String(error),
};
}
if (baseline.files.length !== actual.files.length) {
return {
complete: false,
distributions: [],
reason: "runtime file inventory does not match the installed baseline",
};
}
for (let index = 0; index < actual.files.length; index++) {
const expected = baseline.files[index];
const current = actual.files[index];
if (
!expected ||
typeof expected.path !== "string" ||
!Number.isInteger(expected.mode) ||
typeof expected.sha256 !== "string" ||
!/^sha256:[a-f0-9]{64}$/.test(expected.sha256)
) {
return {
complete: false,
distributions: [],
reason: "runtime integrity baseline contains an invalid file entry",
};
}
if (expected.path !== current.path) {
return {
complete: false,
distributions: [],
reason: "runtime file inventory does not match the installed baseline",
};
}
if (expected.mode !== current.mode || expected.sha256 !== current.sha256) {
return {
complete: false,
distributions: [],
reason: `runtime file ${current.path} does not match the installed baseline`,
};
}
}
}
const declared = new Set(
Array.isArray(manifest.distributions)
? manifest.distributions.flatMap((entry) =>
entry && typeof entry === "object" && typeof (entry as { name?: unknown }).name === "string"
? [(entry as { name: string }).name]
: []
)
: [],
);
const distributions: string[] = [];
try {
for (const distribution of readdirSync(runtime).sort()) {
const root = join(runtime, distribution);
if (!statSync(root).isDirectory() || !declared.has(distribution)) {
return { complete: false, distributions, reason: `runtime ${distribution} is not declared` };
}
const { stamp } = projectionFiles(root);
if (stamp.frameworkVersion !== version || stamp.distribution !== distribution) {
return { complete: false, distributions, reason: `runtime ${distribution} stamp is invalid` };
}
distributions.push(distribution);
}
} catch (error) {
return {
complete: false,
distributions,
reason: error instanceof Error ? error.message : String(error),
};
}
const missing = [...declared].filter((distribution) =>
!distributions.includes(distribution)
).sort();
if (missing.length > 0) {
return {
complete: false,
distributions,
reason: `declared runtime is missing: ${missing.join(", ")}`,
};
}
if (requiredDistribution && !distributions.includes(requiredDistribution)) {
return {
complete: false,
distributions,
reason: `${requiredDistribution} runtime is not installed`,
};
}
return { complete: true, distributions };
}
export function targetTriple(): string {
const os = platform() === "darwin" ? "darwin" : platform() === "win32" ? "windows" : "linux";
const arch = process.arch === "arm64" ? "arm64" : "x64";
const report = process.report?.getReport?.() as { header?: { glibcVersionRuntime?: string } } | undefined;
const muslArch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : process.arch;
const muslLoader = os === "linux" && [
"/lib/ld-musl-" + muslArch + ".so.1",
"/usr/lib/ld-musl-" + muslArch + ".so.1",
"/lib/libc.musl-" + muslArch + ".so.1",
"/usr/lib/libc.musl-" + muslArch + ".so.1",
].some(existsSync);
// Bun-compiled binaries may not expose process.report; absence alone does not mean musl.
const libc = process.env.AIDLC_LIBC?.trim().toLowerCase() ||
(os === "linux" && !report?.header?.glibcVersionRuntime && muslLoader ? "musl" : "glibc");
return os + "-" + arch + (os === "linux" && libc === "musl" ? "-musl" : "");
}
// Only a `--project-dir` before the `--` delimiter selects the project; tokens
// after it are literal text and must never reroute a lifecycle or pin decision.
export function projectDirFrom(argv: readonly string[]): string {
const delimiter = argv.indexOf("--");
const options = delimiter < 0 ? argv : argv.slice(0, delimiter);
const index = options.indexOf("--project-dir");
const explicit = index >= 0 ? options[index + 1] : undefined;
const value = explicit || process.env.AIDLC_PROJECT_DIR ||
process.env.CLAUDE_PROJECT_DIR || process.env.KIRO_PROJECT_DIR;
return value ? (isAbsolute(value) ? value : resolve(process.cwd(), value)) : process.cwd();
}