newspaper_wedding/.aidlc/tools/aidlc-lifecycle.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

2150 lines
70 KiB
TypeScript

#!/usr/bin/env bun
import { randomUUID } from "node:crypto";
import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
realpathSync,
rmSync,
rmdirSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { extractTarGz } from "./aidlc-archive.ts";
import {
renderCompletion,
type Shell,
} from "./aidlc-completions.ts";
import {
EXIT,
type CommandResult,
emitResult,
failure,
globalOptions,
readTerminalLine,
success,
usage,
valueAfter,
} from "./aidlc-command.ts";
import {
success as successText,
warnVerdict,
} from "./aidlc-color.ts";
import {
compareVersions,
isReleaseChannel,
PREVIEW_CHANNEL,
RELEASE_CHANNELS,
type ReleaseChannel,
requireVersion,
VERSION_ID,
VERSION_ID_PATTERN,
versionChannel,
} from "./aidlc-channel.ts";
import {
projectionFiles,
sha256File,
walkFiles,
} from "./aidlc-distribution.ts";
import {
activeVersion,
activeExecutablePath,
activeVersionPath,
binRoot,
canonicalPolicyPath,
commandPath,
createRuntimeIntegrity,
installedExecutablePath,
inspectProjectPinTarget,
inspectInstalledVersion,
installRoot,
machineTransactionRoot,
packageManagerForExecutable,
projectPinTargetPath,
projectDirFrom,
readActiveExecutable,
rollbackVersionPath,
runtimeIntegrityPath,
runtimeRoot,
targetTriple,
versionRoot,
versionsRoot,
} from "./aidlc-install-paths.ts";
import {
channelPath,
defaultHarnessPath,
machineConfigPath,
readMachineChannel,
updateCachePath,
writeMachineChannel,
} from "./aidlc-machine-config.ts";
import {
acquireRelease,
digest,
releaseRuntimeAsset,
ReleaseUnavailableError,
resolvePreviewVersion,
} from "./aidlc-release.ts";
import {
executePlan,
transactionSourceHash,
transactionState,
writeOperation,
} from "./aidlc-transaction.ts";
import { refreshUpdateState, type UpdateState } from "./aidlc-update.ts";
import {
recoverWindowsUninstallContinuations,
scheduleWindowsUninstall as scheduleWindowsUninstallContinuation,
} from "./aidlc-windows-uninstall.ts";
import {
compiledExecutable,
discoverProjectHarnesses,
runtimeHarnessDir,
} from "./aidlc-runtime-paths.ts";
import { AIDLC_VERSION } from "./aidlc-version.ts";
class LifecycleCommandError extends Error {
constructor(
message: string,
readonly exitCode: number,
) {
super(message);
this.name = "LifecycleCommandError";
}
}
function commandError(message: string, exitCode: number): never {
throw new LifecycleCommandError(message, exitCode);
}
function requestedVersion(value: string): string {
try {
return requireVersion(value);
} catch (error) {
return commandError(
error instanceof Error ? error.message : String(error),
EXIT.usage,
);
}
}
function offline(argv: readonly string[]): boolean | undefined {
if (argv.includes("--offline") || process.env.AIDLC_OFFLINE === "1") return true;
if (process.env.AIDLC_OFFLINE === "0") return false;
return undefined;
}
type PublicLifecycleCommand = "update" | "use" | "uninstall";
type PublicLifecycleGrammar = {
values: ReadonlySet<string>;
bare: ReadonlySet<string>;
positionals: number;
};
const PUBLIC_LIFECYCLE_GRAMMARS: Readonly<
Record<PublicLifecycleCommand, PublicLifecycleGrammar>
> = {
update: {
values: new Set([
"--ca-bundle",
"--channel",
"--from",
"--project-dir",
"--release-api-url",
"--release-base-url",
"--version",
]),
bare: new Set([
"--check",
"--dry-run",
"--json",
"--no-color",
"--offline",
"--quiet",
]),
positionals: 0,
},
use: {
values: new Set([
"--ca-bundle",
"--from",
"--project-dir",
"--release-base-url",
]),
bare: new Set([
"--json",
"--no-color",
"--offline",
"--quiet",
]),
positionals: 1,
},
uninstall: {
values: new Set(["--project-dir"]),
bare: new Set([
"--json",
"--no-color",
"--purge",
"--quiet",
"--yes",
]),
positionals: 0,
},
};
export function validatePublicLifecycleArgs(
argv: readonly string[],
): string | null {
const command = argv[0] as PublicLifecycleCommand | undefined;
const grammar = command ? PUBLIC_LIFECYCLE_GRAMMARS[command] : undefined;
if (!grammar) return null;
const seen = new Set<string>();
const positionals: string[] = [];
let startIndex = 1;
if (command === "use") {
const version = argv[1];
if (!version || version.startsWith("--")) {
return "use requires the version before options";
}
positionals.push(version);
startIndex = 2;
}
for (let index = startIndex; index < argv.length; index++) {
const token = argv[index];
if (!token.startsWith("--")) {
positionals.push(token);
continue;
}
if (grammar.values.has(token)) {
if (seen.has(token)) return `${token} may be specified only once`;
const value = argv[index + 1];
if (!value || value.startsWith("--")) return `${token} requires a value`;
seen.add(token);
index++;
continue;
}
if (!grammar.bare.has(token)) return `unknown ${command} option ${token}`;
if (seen.has(token)) return `${token} may be specified only once`;
seen.add(token);
}
if (positionals.length !== grammar.positionals) {
if (command === "use" && positionals.length === 0) {
return "use requires exactly one version";
}
return `unexpected ${command} positional ${
JSON.stringify(positionals[grammar.positionals] ?? positionals[0])
}`;
}
if (argv.includes("--json") && argv.includes("--quiet")) {
return "--json and --quiet are mutually exclusive";
}
if (command === "update" && argv.includes("--check")) {
for (const flag of ["--dry-run", "--from", "--version"]) {
if (argv.includes(flag)) return `--check cannot be combined with ${flag}`;
}
}
if (command === "update" && argv.includes("--channel")) {
const channel = valueAfter(argv, "--channel");
if (!channel || !isReleaseChannel(channel)) {
return `--channel must be ${RELEASE_CHANNELS.join(" or ")}`;
}
for (const flag of ["--from", "--version"]) {
if (argv.includes(flag)) return `--channel cannot be combined with ${flag}`;
}
}
return null;
}
const COMPLETION_FILES: Readonly<Record<Shell, string>> = {
bash: "aidlc.bash",
zsh: "_aidlc",
fish: "aidlc.fish",
powershell: "aidlc.ps1",
};
function binaryAsset(target = targetTriple()): string {
return `aidlc-${target}${target.startsWith("windows-") ? ".exe" : ""}`;
}
function installedDistributions(version: string): string[] {
const root = runtimeRoot(version);
if (!existsSync(root)) return [];
return readdirSync(root).filter((entry) => {
try {
projectionFiles(join(root, entry));
return true;
} catch {
return false;
}
}).sort();
}
function completeVersion(version: string): boolean {
try {
return inspectInstalledVersion(version).complete;
} catch {
return false;
}
}
function reservationRoot(): string {
return join(installRoot(), "reservations");
}
function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
const RESERVATION_ENTRY = new RegExp(`^(${VERSION_ID_PATTERN})-(\\d+)-[a-f0-9-]+$`);
function reservedVersions(): Set<string> {
const reserved = new Set<string>();
const root = reservationRoot();
if (!existsSync(root)) return reserved;
for (const entry of readdirSync(root)) {
const match = RESERVATION_ENTRY.exec(entry);
const path = join(root, entry);
if (!match || !lstatSync(path).isFile()) {
reserved.add("*");
continue;
}
const pid = Number(match[2]);
if (!Number.isSafeInteger(pid) || pid <= 0 || !processIsAlive(pid)) {
rmSync(path, { force: true });
continue;
}
reserved.add(match[1]);
}
return reserved;
}
function reserveVersion(
version: string,
options: { requireComplete?: boolean } = {},
): () => void {
const root = machineTransactionRoot();
const path = join(
reservationRoot(),
`${requireVersion(version)}-${process.pid}-${randomUUID()}`,
);
executePlan({
schemaVersion: 1,
root,
operations: [writeOperation(
relative(root, path),
`${JSON.stringify({ version, pid: process.pid, createdAt: new Date().toISOString() })}\n`,
"absent",
0o600,
)],
}, {
validateLocked: options.requireComplete
? () => {
const inspection = inspectInstalledVersion(version);
if (!inspection.complete) {
commandError(
`cannot reserve incomplete retained version ${version}: ${
inspection.reason ?? "integrity validation failed"
}`,
EXIT.integrity,
);
}
}
: undefined,
});
return () => {
rmSync(path, { force: true });
try {
if (existsSync(reservationRoot()) && readdirSync(reservationRoot()).length === 0) {
rmdirSync(reservationRoot());
}
} catch {
// Stale reservations fail toward retention and are reaped by the next scan.
}
};
}
export function reserveDispatchedVersion(version: string): () => void {
return reserveVersion(version, { requireComplete: true });
}
function pathEntryExists(path: string): boolean {
try {
lstatSync(path);
return true;
} catch {
return false;
}
}
function requireConfirmation(argv: readonly string[], message: string): void {
if (argv.includes("--yes")) return;
if (!process.stdin.isTTY) {
commandError(`${message}; non-interactive use requires --yes`, EXIT.usage);
}
const answer = readTerminalLine(`${message}\nContinue [y/N]:`);
if (!/^y(?:es)?$/i.test(answer?.trim() ?? "")) {
commandError("operation cancelled", EXIT.failure);
}
}
function windowsLauncherOwnedByInstaller(): boolean {
try {
const helper = readFileSync(windowsShimPath(), "utf-8");
return readFileSync(commandPath(), "utf-8") === windowsShim() &&
[windowsShimHelper(), ...previousWindowsShimHelpers()].includes(helper);
} catch {
return false;
}
}
function unixLauncherOwnedByInstaller(): boolean {
try {
return lstatSync(commandPath()).isFile() &&
[unixShim(), stableOnlyUnixShim()].includes(readFileSync(commandPath(), "utf-8"));
} catch {
return false;
}
}
function commandOwnedByInstaller(version: string): boolean {
try {
if (process.platform === "win32") {
return windowsLauncherOwnedByInstaller() &&
readActiveExecutable() === resolve(installedExecutablePath(version));
}
if (
lstatSync(commandPath()).isSymbolicLink() &&
realpathSync(commandPath()) === realpathSync(installedExecutablePath(version))
) {
return true;
}
return unixLauncherOwnedByInstaller() &&
readActiveExecutable() === resolve(installedExecutablePath(version));
} catch {
return false;
}
}
type PinRegistry = {
// Usable registrations keyed by canonical project path.
pins: Record<string, string>;
// Every problem found; `versions list` reports them and prune refuses.
warnings: string[];
// The whole file is unreadable, so nothing in it can be trusted.
unreadable: boolean;
// Canonical projects whose equivalent keys disagree on the version.
conflicted: Set<string>;
// Raw entries that were not folded into `pins` (invalid, unresolvable, or
// conflicting for another project), kept as parsed so a rewrite serializes
// them unchanged and one project's pin cannot erase another project's record.
preserved: Record<string, unknown>;
};
function readPinRegistry(reconcileProject?: string): PinRegistry {
const path = join(installRoot(), "pins.json");
const empty: PinRegistry = {
pins: {},
warnings: [],
unreadable: false,
conflicted: new Set(),
preserved: {},
};
if (!existsSync(path)) return empty;
let value: unknown;
try {
value = JSON.parse(readFileSync(path, "utf-8"));
} catch (error) {
const warning = `${path} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`;
return { ...empty, warnings: [warning], unreadable: true };
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
const warning = `${path} must contain a project-to-version object`;
return { ...empty, warnings: [warning], unreadable: true };
}
const registry: PinRegistry = {
pins: {},
warnings: [],
unreadable: false,
conflicted: new Set(),
preserved: {},
};
const { pins, warnings, conflicted, preserved } = registry;
const groups = new Map<string, Array<[project: string, rawVersion: unknown]>>();
for (const [project, rawVersion] of Object.entries(value as Record<string, unknown>)) {
if (!isAbsolute(project)) {
warnings.push(`${path} contains an invalid pin entry for ${project}`);
preserved[project] = rawVersion;
continue;
}
let canonical: string;
try {
canonical = canonicalProjectPath(project);
} catch (error) {
warnings.push(
`${path} cannot resolve pin entry for ${project}: ${
error instanceof Error ? error.message : String(error)
}`,
);
preserved[project] = rawVersion;
continue;
}
// The project being re-pinned or unpinned replaces every equivalent key
// it owns, malformed ones included.
if (canonical === reconcileProject) continue;
const group = groups.get(canonical) ?? [];
group.push([project, rawVersion]);
groups.set(canonical, group);
}
for (const [canonical, entries] of groups) {
const malformed = entries.filter(([, rawVersion]) =>
typeof rawVersion !== "string" || !VERSION_ID.test(rawVersion)
);
if (malformed.length > 0) {
for (const [project] of malformed) {
warnings.push(`${path} contains an invalid pin entry for ${project}`);
}
// A filesystem-equivalent key group is one ownership record. If any
// member is unusable, preserve the complete raw group so an unrelated
// rewrite cannot hide the warning or silently choose another member.
for (const [project, rawVersion] of entries) preserved[project] = rawVersion;
continue;
}
const versions = new Set(entries.map(([, rawVersion]) => rawVersion as string));
if (versions.size === 1) {
pins[canonical] = entries[0][1] as string;
continue;
}
conflicted.add(canonical);
for (const [project, rawVersion] of entries) preserved[project] = rawVersion;
warnings.push(
`${path} contains conflicting equivalent pin entries for ${canonical}`,
);
}
return registry;
}
function canonicalProjectPath(projectDir: string): string {
return canonicalPolicyPath(projectDir);
}
// A registration protects its retained version while the project still
// declares that pin in `.aidlc-version`. The machine-local pin-target marker is
// a dispatch precondition (regenerated by `config --pin`), not an ownership
// record: losing it to `git clean` or an aliased install spelling must not turn
// a pinned version into prune fodder.
function projectDeclaresPin(projectDir: string, version: string): boolean {
try {
const pinPath = join(projectDir, ".aidlc-version");
return existsSync(pinPath) &&
statSync(pinPath).isFile() &&
readFileSync(pinPath, "utf-8").trim() === version;
} catch {
return false;
}
}
function registeredPins(): {
pins: Record<string, string>;
warnings: string[];
} {
const { pins: rawPins, warnings } = readPinRegistry();
const pins: Record<string, string> = {};
for (const [project, version] of Object.entries(rawPins)) {
if (existsSync(project) && !projectDeclaresPin(project, version)) continue;
pins[project] = version;
}
return { pins, warnings };
}
function commitProjectPin(projectDir: string, version: string | null): void {
const project = canonicalProjectPath(projectDir);
const pinPath = join(projectDir, ".aidlc-version");
const targetPath = projectPinTargetPath(projectDir);
const registryPath = join(installRoot(), "pins.json");
const registry = readPinRegistry(project);
if (registry.unreadable) commandError(registry.warnings.join("; "), EXIT.integrity);
// Other projects' entries ride along unchanged (usable ones canonicalized,
// unusable ones verbatim); this project's equivalent keys were skipped on
// read and collapse to the single canonical registration below.
const pins: Record<string, unknown> = { ...registry.preserved, ...registry.pins };
if (version !== null) pins[project] = version;
const projectOperations = version === null
? [
...(existsSync(pinPath)
? [{
kind: "remove" as const,
path: ".aidlc-version",
expected: transactionState(pinPath) as string,
}]
: []),
...(existsSync(targetPath)
? [{
kind: "remove" as const,
path: relative(projectDir, targetPath),
expected: transactionState(targetPath) as string,
}]
: []),
]
: [
writeOperation(
".aidlc-version",
`${version}\n`,
transactionState(pinPath),
),
writeOperation(
relative(projectDir, targetPath),
`${resolve(installedExecutablePath(version))}\n`,
transactionState(targetPath),
0o600,
),
];
const root = machineTransactionRoot();
executePlan({
schemaVersion: 1,
root,
operations: [writeOperation(
relative(root, registryPath),
`${JSON.stringify(pins, null, 2)}\n`,
transactionState(registryPath),
0o600,
)],
}, {
validateLocked: () => {
if (version === null) return;
const inspection = inspectInstalledVersion(version, projectDistribution(projectDir));
if (!inspection.complete) {
commandError(
`retained version ${version} became incomplete before the pin commit: ${
inspection.reason ?? "integrity validation failed"
}`,
EXIT.integrity,
);
}
},
validateCommitted: () => {
if (projectOperations.length === 0) return;
executePlan({
schemaVersion: 1,
root: projectDir,
operations: projectOperations,
});
},
});
}
export type PinnedDispatchResult =
| { kind: "none" }
| {
kind: "failure";
code: number;
message: string;
remediation: string;
}
| { kind: "execute"; executable: string; version: string };
function completePinnedVersion(
version: string,
distribution: string | null,
): boolean {
try {
return inspectInstalledVersion(version, distribution).complete;
} catch {
return false;
}
}
// The dispatcher resolves the project once (explicit flag before `--`, then the
// project environment, then cwd) and passes it here, so the pinned binary is
// always selected for the directory the route policy inspected. The argv
// overload only remains for direct callers and tests.
export function resolvePinnedDispatch(
argv: string[],
projectDir: string = projectDirFrom(argv),
): PinnedDispatchResult {
const pinPath = join(projectDir, ".aidlc-version");
if (!existsSync(pinPath)) return { kind: "none" };
const version = readFileSync(pinPath, "utf-8").trim();
if (!VERSION_ID.test(version)) {
return {
kind: "failure",
code: EXIT.usage,
message: `${pinPath} must contain one release version id`,
remediation: "aidlc config --unpin",
};
}
const remediation = `aidlc config --pin ${version}`;
const registry = readPinRegistry();
const project = canonicalProjectPath(projectDir);
// Another project's malformed or conflicting entries are reported by
// `versions list` and block prune; they never decide this project's dispatch.
if (registry.unreadable) {
return {
kind: "failure",
code: EXIT.integrity,
message: `this project's pin registry is invalid: ${registry.warnings.join("; ")}`,
remediation,
};
}
if (registry.conflicted.has(project)) {
return {
kind: "failure",
code: EXIT.integrity,
message: `this project's pin registry is invalid: ${
join(installRoot(), "pins.json")
} contains conflicting equivalent pin entries for ${project}`,
remediation,
};
}
if (registry.pins[project] !== version) {
return {
kind: "failure",
code: EXIT.failure,
message: `this project's ${version} pin is not registered on this machine`,
remediation,
};
}
const target = inspectProjectPinTarget(projectDir, version);
if (!target.valid) {
return {
kind: "failure",
code: EXIT.failure,
message: `this project's ${version} pin target is invalid: ${
target.reason ?? "resolved target validation failed"
}`,
remediation,
};
}
const distribution = projectDistribution(projectDir);
if (!completePinnedVersion(version, distribution)) {
return {
kind: "failure",
code: EXIT.failure,
message: `this project requires ${version}, which is not installed completely`,
remediation,
};
}
if (process.env.AIDLC_PIN_DISPATCHED === version) return { kind: "none" };
if (version === AIDLC_VERSION) return { kind: "none" };
return {
kind: "execute",
executable: target.target,
version,
};
}
function lifecycleFailureResult(error: unknown, argv: readonly string[]): CommandResult {
const message = error instanceof Error ? error.message : String(error);
const code = error instanceof LifecycleCommandError
? error.exitCode
: error instanceof ReleaseUnavailableError
? EXIT.unavailable
: /mutation scope cannot mutate/.test(message)
? EXIT.integrity
: valueAfter(argv, "--from") &&
/(checksum|version\.json|checksums\.txt|release is missing|invalid asset|size mismatch)/i
.test(message)
? EXIT.integrity
: EXIT.failure;
return failure(message, code);
}
// Same-release identity: identical path set and bytes. Modes are deliberately
// not compared - extraction inherits the caller's umask (install.sh uses 077,
// a later `aidlc update` uses the shell's), so modes differ between equally
// valid installs of one release. The installed tree's own modes are enforced
// against its recorded runtime-integrity baseline by completeVersion().
function treesMatch(left: string, right: string): boolean {
const leftFiles = walkFiles(left).map((path) => path.replaceAll("\\", "/"));
const rightFiles = walkFiles(right).map((path) => path.replaceAll("\\", "/"));
if (JSON.stringify(leftFiles) !== JSON.stringify(rightFiles)) return false;
return leftFiles.every((path) => sha256File(join(left, path)) === sha256File(join(right, path)));
}
type RetainedVersion = {
version: string;
channel: ReleaseChannel;
active: boolean;
rollback: boolean;
distributions: string[];
complete: boolean;
reserved: boolean;
pinPaths: string[];
stalePinPaths: string[];
};
// Preview retention is a bounded window on top of the protection every
// release has (active, rollback, in use, pinned): the newest complete previews
// in this window stay so a preview user always has a recent fallback, and
// every older preview outside it is pruned on the next update. Stable releases
// keep the unchanged active, rollback, in-use, and pinned protection.
const RETAINED_PREVIEWS = 2;
function retainedVersions(): {
versions: RetainedVersion[];
pinWarnings: string[];
} {
const { pins, warnings } = registeredPins();
if (!existsSync(versionsRoot())) return { versions: [], pinWarnings: warnings };
const active = activeVersion();
const reservations = reservedVersions();
const rollback = existsSync(rollbackVersionPath())
? readFileSync(rollbackVersionPath(), "utf-8").trim()
: null;
const versions = readdirSync(versionsRoot())
.filter((entry) => VERSION_ID.test(entry))
.sort(compareVersions)
.map((version) => ({
version,
channel: versionChannel(version),
active: version === active,
rollback: version === rollback,
distributions: installedDistributions(version),
complete: completeVersion(version),
reserved: reservations.has("*") || reservations.has(version),
pinPaths: Object.entries(pins)
.filter(([project, pinnedVersion]) => pinnedVersion === version && existsSync(project))
.map(([project]) => project)
.sort(),
stalePinPaths: Object.entries(pins)
.filter(([project, pinnedVersion]) => pinnedVersion === version && !existsSync(project))
.map(([project]) => project)
.sort(),
}));
return { versions, pinWarnings: warnings };
}
function recentPreviews(versions: readonly RetainedVersion[]): Set<string> {
return new Set(
versions
.filter((item) => item.channel === PREVIEW_CHANNEL && item.complete)
.sort((left, right) => compareVersions(right.version, left.version))
.slice(0, RETAINED_PREVIEWS)
.map((item) => item.version),
);
}
function protectionReasons(
item: RetainedVersion,
recent: ReadonlySet<string>,
): string[] {
return [
...(item.active ? ["active"] : []),
...(item.rollback ? ["rollback"] : []),
...(item.reserved ? ["in use"] : []),
...item.pinPaths.map((path) => `pinned by ${path}`),
...item.stalePinPaths.map((path) => `stale pin ${path}`),
...(item.channel === PREVIEW_CHANNEL && recent.has(item.version)
? [`recent ${PREVIEW_CHANNEL}`]
: []),
];
}
function partitionRetained(versions: readonly RetainedVersion[]): {
removable: RetainedVersion[];
protectedVersions: RetainedVersion[];
protection: string;
} {
const recent = recentPreviews(versions);
const removable: RetainedVersion[] = [];
const protectedVersions: RetainedVersion[] = [];
const reasons: string[] = [];
for (const item of versions) {
const why = protectionReasons(item, recent);
if (why.length === 0) {
removable.push(item);
} else {
protectedVersions.push(item);
reasons.push(`${item.version} (${why.join(", ")})`);
}
}
return { removable, protectedVersions, protection: reasons.join("; ") };
}
function assertVersionsRemainPrunable(versions: readonly string[]): void {
const refreshed = retainedVersions();
if (refreshed.pinWarnings.length > 0) {
commandError(
`prune cancelled because pin registry changed: ${refreshed.pinWarnings.join("; ")}`,
EXIT.integrity,
);
}
const stillRemovable = new Set(
partitionRetained(refreshed.versions).removable.map((item) => item.version),
);
const protectedVersions = versions.filter((version) => !stillRemovable.has(version));
if (protectedVersions.length > 0) {
commandError(
`prune cancelled because version protection changed: ${protectedVersions.join(", ")}`,
EXIT.failure,
);
}
}
function projectDistribution(projectDir: string): string | null {
const harnessDir = runtimeHarnessDir(projectDir);
return discoverProjectHarnesses(projectDir)
.find((candidate) => candidate.harnessDir === harnessDir)?.distribution ?? null;
}
function activateReserved(version: string, options: { failAfter?: number } = {}): void {
if (!completeVersion(version)) {
commandError(`retained version ${version} is incomplete`, EXIT.unavailable);
}
const previous = activeVersion();
const root = machineTransactionRoot();
const target = installedExecutablePath(version);
const windows = process.platform === "win32";
const shim = windows ? windowsShim() : unixShim();
const shimHelper = windows ? windowsShimHelper() : null;
if (
pathEntryExists(commandPath()) &&
(!previous ||
!(windows
? windowsLauncherOwnedByInstaller()
: commandOwnedByInstaller(previous)))
) {
commandError(
`existing ${commandPath()} is not owned by this AI-DLC install`,
EXIT.integrity,
);
}
if (windows && existsSync(commandPath()) && readFileSync(commandPath(), "utf-8") !== shim) {
commandError("existing aidlc.cmd is not owned by this AI-DLC install", EXIT.integrity);
}
if (
windows &&
existsSync(windowsShimPath()) &&
![shimHelper, ...previousWindowsShimHelpers()].includes(
readFileSync(windowsShimPath(), "utf-8"),
)
) {
commandError("existing aidlc-shim.ps1 is not owned by this AI-DLC install", EXIT.integrity);
}
const operations = [
...(previous && previous !== version
? [writeOperation(relative(root, rollbackVersionPath()), `${previous}\n`,
transactionState(rollbackVersionPath()))]
: []),
writeOperation(
relative(root, activeVersionPath()),
`${version}\n`,
transactionState(activeVersionPath()),
),
...(windows
? [
writeOperation(
relative(root, windowsShimPath()),
shimHelper as string,
transactionState(windowsShimPath()),
0o700,
),
...(!existsSync(commandPath())
? [writeOperation(
relative(root, commandPath()),
shim,
"absent",
0o700,
)]
: []),
]
: [writeOperation(
relative(root, commandPath()),
shim,
transactionState(commandPath()),
0o700,
)]),
writeOperation(
relative(root, activeExecutablePath()),
`${target}${windows ? "\r\n" : "\n"}`,
transactionState(activeExecutablePath()),
0o600,
),
...(Object.entries(COMPLETION_FILES) as Array<[Shell, string]>)
.map(([shell, file]) => {
const path = join(installRoot(), "completions", file);
return writeOperation(
relative(root, path),
renderCompletion(shell),
transactionState(path),
0o644,
);
}),
];
executePlan({
schemaVersion: 1,
root,
operations,
}, {
...options,
validateLocked: () => {
const inspection = inspectInstalledVersion(version);
if (!inspection.complete) {
commandError(
`retained version ${version} became incomplete before activation: ${
inspection.reason ?? "integrity validation failed"
}`,
EXIT.integrity,
);
}
},
validateCommitted: () => {
if (
readActiveExecutable() !== resolve(target) ||
(windows
? !windowsLauncherOwnedByInstaller()
: !unixLauncherOwnedByInstaller())
) {
throw new Error(`command pointer validation failed for ${version}`);
}
const probe = Bun.spawnSync([commandPath(), "version"], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const output = Buffer.from(probe.stdout ?? new Uint8Array()).toString("utf-8").trim();
if (probe.exitCode !== 0 || output !== `aidlc ${version} (runtime ${version})`) {
throw new Error(
`command pointer validation failed for ${version}: version probe returned ${
probe.exitCode ?? "no exit"
} ${JSON.stringify(output)}`,
);
}
},
});
}
export function activate(version: string, options: { failAfter?: number } = {}): void {
const releaseReservation = reserveVersion(version);
try {
activateReserved(version, options);
} finally {
releaseReservation();
}
}
function shellSingleQuote(value: string): string {
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}
// The launcher re-validates the active version marker before trusting the
// pointer, so its grammar must accept every id the lifecycle can activate. The
// stable-only text that shipped before preview ids is still recognised as
// installer-owned so an existing install can be updated in place.
const STABLE_ONLY_VALID_VERSION = [
"valid_version() {",
" version_value=$1",
" case \"$version_value\" in *[!0-9.]*|'') return 1 ;; esac",
" old_ifs=$IFS",
" IFS=.",
" set -- $version_value",
" IFS=$old_ifs",
" [ \"$#\" -eq 3 ] && valid_number \"$1\" && valid_number \"$2\" && valid_number \"$3\"",
"}",
];
// Shell literal of PREVIEW_CHANNEL: `<x.y.z>-preview.<YYYYMMDD>.<N>`.
const VALID_VERSION = [
"valid_build() {",
" case \"$1\" in ''|*[!0-9]*|0*) return 1 ;; *) return 0 ;; esac",
"}",
"valid_version() {",
" version_value=$1",
" preview_suffix=",
" case \"$version_value\" in",
` *-${PREVIEW_CHANNEL}.*)`,
` preview_suffix=\${version_value#*-${PREVIEW_CHANNEL}.}`,
` version_value=\${version_value%%-${PREVIEW_CHANNEL}.*}`,
" ;;",
" esac",
" case \"$version_value\" in *[!0-9.]*|'') return 1 ;; esac",
" old_ifs=$IFS",
" IFS=.",
" set -- $version_value",
" IFS=$old_ifs",
" [ \"$#\" -eq 3 ] && valid_number \"$1\" && valid_number \"$2\" && valid_number \"$3\" || return 1",
" [ -n \"$preview_suffix\" ] || return 0",
" case \"$preview_suffix\" in",
" [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].*) ;;",
" *) return 1 ;;",
" esac",
` valid_build "\${preview_suffix#*.}"`,
"}",
];
function renderUnixShim(validVersion: readonly string[]): string {
const pointer = shellSingleQuote(activeExecutablePath());
const versionPointer = shellSingleQuote(activeVersionPath());
const versions = shellSingleQuote(versionsRoot());
return [
"#!/bin/sh",
"# aidlc-native-launcher-v2",
`active_pointer=${pointer}`,
`active_version_pointer=${versionPointer}`,
`versions_root=${versions}`,
"read_one_line() {",
" line=",
" extra=",
" {",
" IFS= read -r line || [ -n \"$line\" ] || return 1",
" if IFS= read -r extra; then return 1; fi",
" } < \"$1\"",
" [ -n \"$line\" ] || return 1",
" return 0",
"}",
"valid_number() {",
" case \"$1\" in ''|*[!0-9]*) return 1 ;; 0) return 0 ;; 0*) return 1 ;; *) return 0 ;; esac",
"}",
...validVersion,
"if ! read_one_line \"$active_version_pointer\" || ! valid_version \"$line\"; then",
" printf 'aidlc: active version marker is missing or malformed\\n' >&2",
" printf 'Run: aidlc update --version <version> --from <release-directory>\\n' >&2",
" exit 4",
"fi",
"active_version=$line",
"if ! read_one_line \"$active_pointer\"; then",
" printf 'aidlc: active command target is missing or malformed\\n' >&2",
" printf 'Run: aidlc update --version <version> --from <release-directory>\\n' >&2",
" exit 4",
"fi",
"target=$line",
"expected=$versions_root/$active_version/aidlc",
"if [ \"$target\" != \"$expected\" ] || [ ! -f \"$target\" ] || [ ! -x \"$target\" ]; then exit 4; fi",
"exec \"$target\" \"$@\"",
"",
].join("\n");
}
function unixShim(): string {
return renderUnixShim(VALID_VERSION);
}
function stableOnlyUnixShim(): string {
return renderUnixShim(STABLE_ONLY_VALID_VERSION);
}
function windowsShim(): string {
const helper = windowsShimPath().replaceAll("%", "%%");
return [
"@echo off",
`powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${helper}" %*`,
"exit /b %ERRORLEVEL%",
"",
].join("\r\n");
}
function windowsShimPath(): string {
return join(installRoot(), "aidlc-shim.ps1");
}
function renderWindowsShimHelper(versionPattern: string): string {
const pointer = activeExecutablePath().replaceAll("'", "''");
const versionPointer = activeVersionPath().replaceAll("'", "''");
const root = versionsRoot().replaceAll("'", "''");
return [
"$ErrorActionPreference = 'Stop'",
`$pointer = '${pointer}'`,
`$versionPointer = '${versionPointer}'`,
`$versions = [IO.Path]::GetFullPath('${root}')`,
"try {",
" $versionRaw = [IO.File]::ReadAllText($versionPointer)",
` if ($versionRaw -notmatch '^${versionPattern}\\r?\\n?$') { exit 4 }`,
" $activeVersion = $versionRaw.TrimEnd(\"`r\", \"`n\")",
" $raw = [IO.File]::ReadAllText($pointer)",
" if ($raw -notmatch '^[^\\r\\n]+\\r?\\n?$') { exit 4 }",
" $executable = [IO.Path]::GetFullPath($raw.TrimEnd(\"`r\", \"`n\"))",
" $expected = [IO.Path]::Combine($versions, $activeVersion, 'aidlc.exe')",
" if (-not $executable.Equals($expected, [StringComparison]::OrdinalIgnoreCase)) { exit 4 }",
" if (-not [IO.File]::Exists($executable)) { exit 4 }",
" $env:AIDLC_SHIM_PID = [string]$PID",
" & $executable @args",
" exit $LASTEXITCODE",
"} catch {",
" exit 4",
"}",
"",
].join("\r\n");
}
// The .NET regex source is the shared VERSION_ID_PATTERN verbatim.
function windowsShimHelper(): string {
return renderWindowsShimHelper(VERSION_ID_PATTERN);
}
// Helper texts written by earlier installers, oldest last: the stable-only
// marker grammar, then the pointer-prefix check that predates the marker.
function previousWindowsShimHelpers(): string[] {
const pointer = activeExecutablePath().replaceAll("'", "''");
const root = versionsRoot().replaceAll("'", "''");
return [
renderWindowsShimHelper("(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)"),
[
"$ErrorActionPreference = 'Stop'",
`$pointer = '${pointer}'`,
`$versions = [IO.Path]::GetFullPath('${root}')`,
"try {",
" $raw = [IO.File]::ReadAllText($pointer)",
" if ($raw -notmatch '^[^\\r\\n]+\\r?\\n?$') { exit 4 }",
" $executable = [IO.Path]::GetFullPath($raw.TrimEnd(\"`r\", \"`n\"))",
" $prefix = $versions.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar",
" if (-not $executable.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { exit 4 }",
" $relative = $executable.Substring($prefix.Length)",
" if ($relative -notmatch '^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\\\aidlc\\.exe$') { exit 4 }",
" if (-not [IO.File]::Exists($executable)) { exit 4 }",
" $env:AIDLC_SHIM_PID = [string]$PID",
" & $executable @args",
" exit $LASTEXITCODE",
"} catch {",
" exit 4",
"}",
"",
].join("\r\n"),
];
}
async function installVersion(options: {
version?: string;
from?: string;
offline?: boolean;
activate: boolean;
dryRun: boolean;
baseUrl?: string;
caBundle?: string;
channel?: ReleaseChannel;
apiUrl?: string;
}): Promise<{ version: string; distributions: string[] }> {
// An explicit version or local directory bypasses discovery. Otherwise the
// stable channel is the `latest/download` redirect and the preview channel is
// the newest published preview, installed through the explicit-version path.
const wantedVersion = options.version
? requestedVersion(options.version)
: !options.from && options.channel === PREVIEW_CHANNEL
? await resolvePreviewVersion({
baseUrl: options.baseUrl,
apiUrl: options.apiUrl,
caBundle: options.caBundle,
})
: undefined;
const target = targetTriple();
const release = await acquireRelease({
version: wantedVersion,
from: options.from,
names: (manifest) => [
binaryAsset(target),
releaseRuntimeAsset(manifest.version),
],
offline: options.offline,
baseUrl: options.baseUrl,
caBundle: options.caBundle,
});
const version = release.manifest.version;
const runtimeAsset = releaseRuntimeAsset(version);
const required = [binaryAsset(target), runtimeAsset];
const releaseReservation = options.dryRun ? null : reserveVersion(version);
const temporary = mkdtempSync(join(tmpdir(), `aidlc-version-${version}-`));
try {
const candidate = join(temporary, version);
mkdirSync(join(candidate, "runtime"), { recursive: true });
const binarySource = join(release.directory, binaryAsset(target));
const candidateExecutable = join(
candidate,
process.platform === "win32" ? "aidlc.exe" : "aidlc",
);
writeFileSync(candidateExecutable, readFileSync(binarySource), { mode: 0o755 });
if (process.platform !== "win32") chmodSync(candidateExecutable, 0o755);
extractTarGz(join(release.directory, runtimeAsset), candidate, {
reservedTopLevelNames: ["aidlc", "aidlc.exe"],
});
const distributions = release.manifest.distributions.map((item) => item.name).sort();
for (const distribution of distributions) {
const root = join(candidate, "runtime", distribution);
const { stamp } = projectionFiles(root);
if (stamp.frameworkVersion !== version || stamp.distribution !== distribution) {
throw new Error(`${distribution} runtime stamp does not match release ${version}`);
}
}
const baselinePath = join(candidate, basename(runtimeIntegrityPath(version)));
writeFileSync(
baselinePath,
`${JSON.stringify(
createRuntimeIntegrity(version, join(candidate, "runtime")),
null,
2,
)}\n`,
{ mode: 0o600 },
);
writeFileSync(
join(candidate, "version.json"),
`${JSON.stringify({
...release.manifest,
installedRuntime: {
schemaVersion: 1,
baseline: basename(baselinePath),
sha256: sha256File(baselinePath),
},
}, null, 2)}\n`,
);
if (!options.dryRun) {
const destination = versionRoot(version);
if (existsSync(destination)) {
const priorManifestPath = join(destination, "version.json");
if (!existsSync(priorManifestPath)) {
commandError(`existing ${version} install has no release manifest`, EXIT.integrity);
}
const priorManifest = JSON.parse(readFileSync(priorManifestPath, "utf-8")) as {
assets?: Array<{ name: string; sha256: string }>;
};
const expectedAssets = new Map(
release.manifest.assets.map((asset) => [asset.name, asset.sha256]),
);
for (const assetName of required) {
const prior = priorManifest.assets?.find((asset) => asset.name === assetName);
if (!prior || prior.sha256 !== expectedAssets.get(assetName)) {
commandError(
`existing ${version} install came from a different ${assetName}`,
EXIT.integrity,
);
}
}
if (digest(installedExecutablePath(version)) !== expectedAssets.get(binaryAsset(target))) {
commandError(
`existing ${version} binary does not match the verified release`,
EXIT.integrity,
);
}
if (!treesMatch(join(destination, "runtime"), join(candidate, "runtime"))) {
commandError(
`existing ${version} runtime does not match the verified release`,
EXIT.integrity,
);
}
if (!completeVersion(version)) {
commandError(`existing ${version} install is incomplete`, EXIT.integrity);
}
} else {
executePlan({
schemaVersion: 1,
root: machineTransactionRoot(),
operations: [{
kind: "tree",
path: relative(machineTransactionRoot(), destination),
source: candidate,
sourceHash: transactionSourceHash(candidate),
expected: "absent",
}],
});
}
if (options.activate) activate(version);
}
return { version, distributions };
} finally {
releaseReservation?.();
rmSync(temporary, { recursive: true, force: true });
if (release.cleanup) rmSync(release.cleanup, { recursive: true, force: true });
}
}
async function versionsCommand(argv: string[]): Promise<ReturnType<typeof success>> {
const verb = argv[1];
if (verb === "list") {
const { versions, pinWarnings } = retainedVersions();
if (argv.includes("--completion-values")) {
return success(
versions.filter((item) => item.complete).map((item) => item.version).join("\n"),
);
}
return success(
(versions.length
? versions.map((item) =>
`${item.version}${item.active ? " active" : ""}${item.rollback ? " rollback" : ""} [${item.distributions.join(",")}] pins=${item.pinPaths.length} stale-pins=${item.stalePinPaths.length}${item.complete ? "" : " incomplete"}`
).join("\n")
: "no retained versions") +
(pinWarnings.length > 0 ? `\nwarning: ${pinWarnings.join("; ")}` : ""),
{ versions, pinWarnings },
);
}
if (verb === "prune") {
const { versions, pinWarnings } = retainedVersions();
if (pinWarnings.length > 0) {
commandError(
`cannot prune while pin registry is invalid: ${pinWarnings.join("; ")}`,
EXIT.integrity,
);
}
const { removable, protectedVersions, protection } = partitionRetained(versions);
if (removable.length === 0) {
return success(
protection
? `no versions eligible for pruning; protected: ${protection}`
: "no versions eligible for pruning",
{ removed: [], protected: protectedVersions },
);
}
requireConfirmation(
argv,
`Prune retained versions ${removable.map((item) => item.version).join(", ")}?`,
);
const refreshed = retainedVersions();
if (refreshed.pinWarnings.length > 0) {
commandError(
`prune cancelled because pin registry changed: ${refreshed.pinWarnings.join("; ")}`,
EXIT.failure,
);
}
const stillRemovable = new Set(
partitionRetained(refreshed.versions).removable.map((item) => item.version),
);
const newlyProtected = removable.filter((item) => !stillRemovable.has(item.version));
if (newlyProtected.length > 0) {
commandError(
`prune cancelled because version protection changed: ${
newlyProtected.map((item) => item.version).join(", ")
}`,
EXIT.failure,
);
}
const root = machineTransactionRoot();
executePlan({
schemaVersion: 1,
root,
operations: removable.map((item) => ({
kind: "remove" as const,
path: relative(root, versionRoot(item.version)),
expected: transactionState(versionRoot(item.version)) as string,
})),
}, {
validateLocked: () =>
assertVersionsRemainPrunable(removable.map((item) => item.version)),
});
return success(
`pruned ${removable.map((item) => item.version).join(", ")}${
protection ? `; protected: ${protection}` : ""
}`,
{ removed: removable.map((item) => item.version), protected: protectedVersions },
);
}
if (verb !== "install") return usage("usage: aidlc system versions <list|install|prune>");
const version = argv[2];
if (!version || version.startsWith("--")) return usage("versions install requires a release version");
if (argv.includes("--harness")) return usage("unknown argument: --harness");
const result = await installVersion({
version,
from: valueAfter(argv, "--from"),
offline: offline(argv),
activate: false,
dryRun: argv.includes("--dry-run"),
baseUrl: valueAfter(argv, "--release-base-url"),
caBundle: valueAfter(argv, "--ca-bundle"),
});
return success(
`installed ${result.version} side-by-side; active version remains ${activeVersion() ?? "unchanged"}`,
result,
);
}
function pruneUnprotectedVersions(): string[] {
const { versions, pinWarnings } = retainedVersions();
if (pinWarnings.length > 0) {
commandError(
`cannot prune while pin registry is invalid: ${pinWarnings.join("; ")}`,
EXIT.integrity,
);
}
const { removable } = partitionRetained(versions);
if (removable.length === 0) return [];
const root = machineTransactionRoot();
executePlan({
schemaVersion: 1,
root,
operations: removable.map((item) => ({
kind: "remove" as const,
path: relative(root, versionRoot(item.version)),
expected: transactionState(versionRoot(item.version)) as string,
})),
}, {
validateLocked: () =>
assertVersionsRemainPrunable(removable.map((item) => item.version)),
});
return removable.map((item) => item.version);
}
function removeEmptyInstallerDirectory(path: string): void {
try {
if (
existsSync(path) &&
lstatSync(path).isDirectory() &&
readdirSync(path).length === 0
) {
rmdirSync(path);
}
} catch {
// Non-empty or concurrently reused directories are preserved.
}
}
function uninstallCommand(argv: string[]): CommandResult {
const executable = compiledExecutable();
const manager = executable ? packageManagerForExecutable(executable) : null;
if (manager) {
return failure(
`AI-DLC is installed via ${manager.name}; self-uninstall is disabled`,
EXIT.integrity,
manager.remediation,
);
}
if (typeof process.getuid === "function" && process.getuid() === 0) {
return failure("refusing to uninstall a root-owned installation", EXIT.integrity);
}
const purge = argv.includes("--purge");
if (process.platform === "win32") {
const recovered = recoverWindowsUninstallContinuations(purge);
if (recovered > 0) {
return success(
`resumed ${recovered} pending Windows uninstall continuation(s)`,
{ purge, deferred: true, recovered },
);
}
}
const version = activeVersion();
if (!version || !completeVersion(version)) {
return failure(
"no complete native AI-DLC installation is active",
EXIT.unavailable,
);
}
if (!commandOwnedByInstaller(version)) {
return failure(
`existing ${commandPath()} is not owned by this AI-DLC install`,
EXIT.integrity,
);
}
const reservations = reservedVersions();
if (reservations.size > 0) {
return failure(
"cannot uninstall while a retained AI-DLC version is in use",
EXIT.failure,
"wait for running AI-DLC commands to exit, then retry",
);
}
const { versions } = retainedVersions();
const preserved = purge ? "nothing" : "global config, update cache, pins, and harness default";
requireConfirmation(
argv,
`Uninstall AI-DLC (${versions.length} retained version(s))? Project trees will not be changed; preserving ${preserved}.`,
);
if (process.platform === "win32") {
return scheduleWindowsUninstall(purge);
}
const root = machineTransactionRoot();
const paths = [
commandPath(),
versionsRoot(),
join(installRoot(), "completions"),
reservationRoot(),
activeVersionPath(),
rollbackVersionPath(),
activeExecutablePath(),
...(purge
? [
machineConfigPath(),
updateCachePath(),
join(installRoot(), "pins.json"),
defaultHarnessPath(),
channelPath(),
]
: []),
].filter(existsSync);
executePlan({
schemaVersion: 1,
root,
operations: paths.map((path) => ({
kind: "remove" as const,
path: relative(root, path),
expected: transactionState(path) as string,
})),
});
const resolvedInstallRoot = resolve(installRoot());
const resolvedBinRoot = resolve(binRoot());
if (
resolvedBinRoot !== resolvedInstallRoot &&
resolvedBinRoot.startsWith(`${resolvedInstallRoot}${sep}`)
) {
removeEmptyInstallerDirectory(resolvedBinRoot);
}
if (purge) removeEmptyInstallerDirectory(resolvedInstallRoot);
return success(
`uninstalled AI-DLC; ${purge ? "removed machine configuration and cache" : "preserved machine configuration and cache"}`,
{ purge, preserved: purge ? [] : ["config", "update-cache", "pins", "default-harness"] },
);
}
function scheduleWindowsUninstall(purge: boolean): CommandResult {
const preserved = [
machineConfigPath(),
updateCachePath(),
join(installRoot(), "pins.json"),
defaultHarnessPath(),
channelPath(),
];
scheduleWindowsUninstallContinuation(purge, preserved);
return success(
`uninstall scheduled; Windows cleanup will finish after this command exits`,
{ purge, deferred: true },
);
}
// The channel an update follows: the one-shot `--channel` override, else the
// machine marker (stable when absent). An explicit `--version` or `--from`
// selects an exact release regardless of channel.
function requestedChannel(argv: readonly string[]): ReleaseChannel {
const explicit = valueAfter(argv, "--channel");
if (explicit) {
if (!isReleaseChannel(explicit)) {
commandError(`--channel must be ${RELEASE_CHANNELS.join(" or ")}`, EXIT.usage);
}
return explicit;
}
try {
return readMachineChannel();
} catch (error) {
return commandError(
error instanceof Error ? error.message : String(error),
EXIT.integrity,
);
}
}
async function updateCommand(argv: string[]): Promise<CommandResult> {
const executable = compiledExecutable();
const manager = executable ? packageManagerForExecutable(executable) : null;
if (manager) {
return failure(
`AI-DLC is installed via ${manager.name}; self-update is disabled`,
EXIT.failure,
manager.remediation,
);
}
const current = activeVersion();
if (argv.includes("--harness")) return usage("unknown argument: --harness");
const channel = requestedChannel(argv);
const apiUrl = valueAfter(argv, "--release-api-url");
if (argv.includes("--check")) {
let state: UpdateState;
try {
state = await refreshUpdateState(15_000, {
offline: offline(argv),
baseUrl: valueAfter(argv, "--release-base-url"),
caBundle: valueAfter(argv, "--ca-bundle"),
channel,
apiUrl,
});
} catch (error) {
commandError(
error instanceof Error ? error.message : String(error),
error instanceof ReleaseUnavailableError ? EXIT.unavailable : EXIT.failure,
);
}
if (state.state === "behind") {
return {
...success(state.message, state),
code: EXIT.actionNeeded,
status: "action-needed",
};
}
if (state.state === "invalid-config") {
return failure(state.message, EXIT.usage, "repair or remove the invalid machine config");
}
if (
state.state === "unavailable" ||
state.state === "offline"
) {
return failure(state.message, EXIT.unavailable);
}
if (state.state === "disabled") {
return failure(state.message, EXIT.failure);
}
return success(state.message, state);
}
const dryRun = argv.includes("--dry-run");
const result = await installVersion({
version: valueAfter(argv, "--version"),
from: valueAfter(argv, "--from"),
offline: offline(argv),
activate: true,
dryRun,
baseUrl: valueAfter(argv, "--release-base-url"),
caBundle: valueAfter(argv, "--ca-bundle"),
channel,
apiUrl,
});
// Moving between channels is a switch, never a downgrade error: the newest
// stable sorts below a preview built after it, and converging on it is the
// documented way back.
const channelSwitch = current && versionChannel(current) !== versionChannel(result.version)
? { from: versionChannel(current), to: versionChannel(result.version) }
: undefined;
const switched = channelSwitch
? ` (switched channel ${channelSwitch.from} -> ${channelSwitch.to})`
: "";
let pruned: string[] = [];
let pruneWarning: string | undefined;
if (!dryRun) {
try {
pruned = pruneUnprotectedVersions();
} catch (error) {
pruneWarning = error instanceof Error ? error.message : String(error);
}
}
return success(
dryRun
? `update plan: ${current ?? "none"} -> ${result.version} [${result.distributions.join(",")}]${switched}`
: `updated ${current ?? "new install"} -> ${result.version}${switched}${
pruned.length > 0 ? `; pruned ${pruned.join(", ")}` : ""
}`,
{
...result,
channel,
...(channelSwitch ? { channelSwitch } : {}),
pruned,
...(pruneWarning ? { pruneWarning } : {}),
},
);
}
// `aidlc config --channel [stable|preview]`: with a value, persist the machine
// release channel; without one, report the channel in force.
export function configureChannel(argv: readonly string[]): CommandResult {
const requested = valueAfter(argv, "--channel");
try {
if (!requested || requested.startsWith("--")) {
const channel = readMachineChannel();
return success(`release channel: ${channel}`, {
channel,
source: existsSync(channelPath()) ? "machine" : "default",
});
}
if (!isReleaseChannel(requested)) {
return usage(`--channel must be ${RELEASE_CHANNELS.join(" or ")}`);
}
const channel = writeMachineChannel(requested);
return success(
`release channel set to ${channel}; run aidlc update to install its newest release`,
{ channel, source: "machine" },
);
} catch (error) {
return lifecycleFailureResult(error, argv);
}
}
function rollbackCommand(argv: string[]): ReturnType<typeof success> {
if (argv.includes("--list")) {
const { versions, pinWarnings } = retainedVersions();
const eligible = versions.filter((item) => item.complete && !item.active);
return success(
(eligible.length
? eligible.map((item) => `${item.version} [${item.distributions.join(",")}]`).join("\n")
: "no rollback target") +
(pinWarnings.length > 0 ? `\nwarning: ${pinWarnings.join("; ")}` : ""),
{ versions: eligible, pinWarnings },
);
}
const target = valueAfter(argv, "--version") ||
(existsSync(rollbackVersionPath()) ? readFileSync(rollbackVersionPath(), "utf-8").trim() : "");
if (!target) {
commandError("no prior version is recorded; run aidlc use <version>", EXIT.failure);
}
if (valueAfter(argv, "--version")) {
requestedVersion(target);
} else {
try {
requireVersion(target);
} catch (error) {
commandError(
`recorded rollback version is invalid: ${error instanceof Error ? error.message : String(error)}`,
EXIT.integrity,
);
}
}
const active = activeVersion();
const missing = active
? installedDistributions(active).filter((item) => !installedDistributions(target).includes(item))
: [];
if (missing.length > 0 && !argv.includes("--allow-harness-loss")) {
throw new Error(`rollback target lacks harnesses: ${missing.join(", ")}`);
}
activate(target);
return success(`rolled back to ${target}`, { version: target });
}
export async function configureProjectPin(argv: string[]): Promise<CommandResult> {
try {
const hasPin = argv.includes("--pin");
const hasUnpin = argv.includes("--unpin");
if (hasPin === hasUnpin) {
return usage("usage: aidlc config --pin <version> | aidlc config --unpin");
}
if (argv.includes("--harness")) return usage("unknown argument: --harness");
const projectDir = projectDirFrom(argv);
const responseProjectDir = canonicalProjectPath(projectDir);
const dryRun = argv.includes("--dry-run");
if (hasUnpin) {
if (dryRun) {
return success(
"Project pin removal plan; no files were changed.",
{
projectDir: responseProjectDir,
version: activeVersion(),
pinned: false,
dryRun: true,
},
);
}
commitProjectPin(projectDir, null);
return success(
"Removed this project's AI-DLC version pin; it now follows the active machine version.",
{ projectDir: responseProjectDir, version: activeVersion(), pinned: false },
);
}
const requested = valueAfter(argv, "--pin");
if (!requested) return usage("--pin requires a release version");
const version = requestedVersion(requested);
const releaseReservation = dryRun ? null : reserveVersion(version);
try {
if (existsSync(versionRoot(version)) && !completeVersion(version)) {
const reason = inspectInstalledVersion(version).reason ?? "integrity validation failed";
commandError(`retained version ${version} is incomplete: ${reason}`, EXIT.integrity);
}
let distributions = completeVersion(version)
? installedDistributions(version)
: null;
if (!distributions) {
const planned = await installVersion({
version,
from: valueAfter(argv, "--from"),
offline: offline(argv),
activate: false,
dryRun,
baseUrl: valueAfter(argv, "--release-base-url"),
caBundle: valueAfter(argv, "--ca-bundle"),
});
distributions = planned.distributions;
}
const distribution = projectDistribution(projectDir);
if (distribution && !distributions.includes(distribution)) {
commandError(`${version} does not contain this project's ${distribution} runtime`, EXIT.usage);
}
if (dryRun) {
return success(
`Project pin plan for aidlc ${version}; no files were changed.`,
{ projectDir: responseProjectDir, version, pinned: true, dryRun: true },
);
}
commitProjectPin(projectDir, version);
return success(
`Pinned this project to aidlc ${version}. Commit .aidlc-version to share the pin.`,
{ projectDir: responseProjectDir, version, pinned: true },
);
} finally {
releaseReservation?.();
}
} catch (error) {
return lifecycleFailureResult(error, argv);
}
}
async function useCommand(argv: string[]): Promise<CommandResult> {
const executable = compiledExecutable();
const manager = executable ? packageManagerForExecutable(executable) : null;
if (manager) {
return failure(
`AI-DLC is installed via ${manager.name}; self-version switching is disabled`,
EXIT.failure,
manager.remediation,
);
}
const value = argv[1];
if (!value || value.startsWith("--")) return usage("usage: aidlc use <version>");
if (argv.includes("--pin")) {
return usage("use --pin is not supported; run aidlc config --pin <version>");
}
if (value === "current") {
return usage("use current is not supported; run aidlc config --unpin");
}
if (argv.includes("--harness")) return usage("unknown argument: --harness");
const version = requestedVersion(value);
if (existsSync(versionRoot(version)) && !completeVersion(version)) {
const reason = inspectInstalledVersion(version).reason ?? "integrity validation failed";
commandError(`retained version ${version} is incomplete: ${reason}`, EXIT.integrity);
}
if (!completeVersion(version)) {
await installVersion({
version,
from: valueAfter(argv, "--from"),
offline: offline(argv),
activate: false,
dryRun: false,
baseUrl: valueAfter(argv, "--release-base-url"),
caBundle: valueAfter(argv, "--ca-bundle"),
});
}
activate(version);
return success(`active AI-DLC version set to ${version}`, { version });
}
function installProfileCommand(argv: string[]): CommandResult {
const profileValue = valueAfter(argv, "--profile");
const binValue = valueAfter(argv, "--bin-dir");
if (!profileValue || !binValue) {
return usage(
"install-profile writes the invoking user's shell profile; requires --profile <path> and --bin-dir <path>",
);
}
const profile = resolve(profileValue);
const bin = resolve(binValue);
const home = resolve(process.env.HOME || "");
if (!process.env.HOME) {
return failure("profile path must be inside the target user's home directory", EXIT.integrity);
}
let profileRelative: string;
try {
profileRelative = relative(
realpathSync(home),
join(realpathSync(dirname(profile)), basename(profile)),
);
} catch {
return failure(
"profile parent must exist inside the target user's home directory",
EXIT.integrity,
);
}
if (
profileRelative === ".." ||
profileRelative.startsWith(`..${sep}`) ||
isAbsolute(profileRelative)
) {
return failure("profile path must be inside the target user's home directory", EXIT.integrity);
}
let profileMode = 0o600;
let profileExists = false;
try {
const stat = lstatSync(profile);
profileExists = true;
profileMode = stat.mode & 0o777;
if (!stat.isFile()) {
return failure("profile path is not a regular file", EXIT.integrity);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const begin = "# BEGIN AI-DLC:PATH";
const end = "# END AI-DLC:PATH";
const current = profileExists ? readFileSync(profile, "utf-8") : "";
const lines = current.split(/\r?\n/);
const beginLines: number[] = [];
const endLines: number[] = [];
for (const [index, line] of lines.entries()) {
if (line === begin) beginLines.push(index);
if (line === end) endLines.push(index);
}
const beginOccurrences = current.split(begin).length - 1;
const endOccurrences = current.split(end).length - 1;
if (
beginOccurrences !== beginLines.length ||
endOccurrences !== endLines.length ||
beginLines.length > 1 ||
endLines.length > 1 ||
beginLines.length !== endLines.length ||
(beginLines.length === 1 && beginLines[0] >= endLines[0])
) {
return failure("profile AI-DLC PATH markers are missing, duplicated, or malformed", EXIT.integrity);
}
const escapedBin = bin.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")
.replaceAll("$", "\\$").replaceAll("`", "\\`");
const block = `${begin}\nexport PATH="${escapedBin}:$PATH"\n${end}`;
let next: string;
if (beginLines.length === 1) {
const start = current.indexOf(begin);
const finish = current.indexOf(end, start + begin.length) + end.length;
next = `${current.slice(0, start)}${block}${current.slice(finish)}`;
} else {
const prefix = current.length === 0 || current.endsWith("\n") ? current : `${current}\n`;
next = `${prefix}${prefix.length > 0 ? "\n" : ""}${block}\n`;
}
executePlan({
schemaVersion: 1,
root: dirname(profile),
operations: [writeOperation(
basename(profile),
next,
transactionState(profile),
profileMode,
)],
});
return success(`updated ${profile} with an owned AI-DLC PATH block`, { profile, bin });
}
function humanLifecycleNarration(
command: string | undefined,
argv: readonly string[],
before: string | null,
result: CommandResult,
): string | null {
if (!result.ok) return null;
if (command === "update" && !argv.includes("--check")) {
const data = result.data as {
version?: string;
channel?: ReleaseChannel;
channelSwitch?: { from: ReleaseChannel; to: ReleaseChannel };
pruned?: string[];
pruneWarning?: string;
} | undefined;
const target = data?.version;
if (!target) return null;
const channelWord = data?.channel && data.channel !== "stable" ? `${data.channel} ` : "";
const pruned = data?.pruned ?? [];
const pruneLine = pruned.length > 0
? `\nPruned unprotected releases: ${pruned.join(", ")}.`
: data?.pruneWarning
? `\nWarning: update succeeded, but old-release cleanup was skipped: ${data.pruneWarning}`
: "";
const switchLine = data?.channelSwitch
? `Switched release channel from ${data.channelSwitch.from} to ${data.channelSwitch.to}.`
: null;
if (argv.includes("--dry-run")) {
return before === target
? successText(
`You're on the latest version of aidlc (${target}); nothing to update.`,
process.stdout,
)
: warnVerdict(
`Would update aidlc from ${before ?? "not installed"} to ${target}${
switchLine ? ` (switching to the ${data?.channelSwitch?.to} channel)` : ""
}.`,
process.stdout,
);
}
if (before === target) {
return `${successText(
`You're on the latest ${channelWord}version of aidlc (${target}).`,
process.stdout,
)}${pruneLine}`;
}
return [
`Checking for ${channelWord}releases ... ${before ?? "not installed"} -> ${target}`,
`Downloading aidlc ${target} ... done (verified)`,
`Staging and switching ... done (${
before ? `${before} retained` : "no prior version retained"
})`,
...(switchLine ? [switchLine] : []),
...(pruned.length > 0 ? [`Pruned unprotected releases: ${pruned.join(", ")}.`] : []),
...(data?.pruneWarning
? [`Warning: old-release cleanup was skipped: ${data.pruneWarning}`]
: []),
"",
successText(
`Updated aidlc from ${before ?? "not installed"} to ${target}.`,
process.stdout,
),
"Project files were not changed. Run 'aidlc config' between workflows to refresh them.",
].join("\n");
}
if (command === "use") {
const target = (result.data as { version?: string } | undefined)?.version ?? argv[1];
if (!target) return null;
return successText(
before === target
? `Already using aidlc ${target}.`
: `Now using aidlc ${target} (was ${
before ?? "not installed"
}; retained locally, no project changes).`,
process.stdout,
);
}
if (command === "uninstall") {
const data = result.data as {
purge?: boolean;
deferred?: boolean;
} | undefined;
if (data?.deferred) return null;
return successText(
data?.purge
? "Removed aidlc, all retained releases, machine settings, update cache, pins, and harness default. Project files were kept."
: "Removed aidlc and all retained releases. Machine settings, update cache, pins, harness default, and project files were kept.",
process.stdout,
);
}
return null;
}
export async function main(input: string[]): Promise<void> {
const argv = input;
const options = globalOptions(argv);
const validation = validatePublicLifecycleArgs(argv);
if (validation) {
emitResult(usage(validation), options);
return;
}
try {
const command = argv[0];
const before = activeVersion();
const result = command === "versions"
? await versionsCommand(argv)
: command === "update"
? await updateCommand(argv)
: command === "rollback"
? rollbackCommand(argv)
: command === "use"
? await useCommand(argv)
: command === "uninstall"
? uninstallCommand(argv)
: command === "install-profile"
? installProfileCommand(argv)
: command === "install-apply"
? success(
`installed ${(await installVersion({
version: valueAfter(argv, "--version"),
from: valueAfter(argv, "--from"),
offline: true,
activate: true,
dryRun: false,
baseUrl: valueAfter(argv, "--release-base-url"),
caBundle: valueAfter(argv, "--ca-bundle"),
})).version}`,
)
: usage("unknown lifecycle command");
const narration = options.mode === "human"
? humanLifecycleNarration(command, argv, before, result)
: null;
if (narration !== null) {
process.stdout.write(`${narration}\n`);
process.exitCode = result.code;
} else {
emitResult(result, options);
}
} catch (error) {
emitResult(lifecycleFailureResult(error, argv), options);
}
}
if (import.meta.main) {
main(process.argv.slice(2)).catch((error) => {
process.stderr.write(`aidlc lifecycle: ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = EXIT.failure;
});
}