#!/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; bare: ReadonlySet; positionals: number; }; const PUBLIC_LIFECYCLE_GRAMMARS: Readonly< Record > = { 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(); 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> = { 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 { const reserved = new Set(); 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; // 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; // 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; }; 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>(); for (const [project, rawVersion] of Object.entries(value as Record)) { 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; warnings: string[]; } { const { pins: rawPins, warnings } = readPinRegistry(); const pins: Record = {}; 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 = { ...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 { 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[] { 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: `-preview..`. 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 --from \\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 --from \\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> { 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 "); 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 { 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 { 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 ", 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 { try { const hasPin = argv.includes("--pin"); const hasUnpin = argv.includes("--unpin"); if (hasPin === hasUnpin) { return usage("usage: aidlc config --pin | 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 { 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 "); if (argv.includes("--pin")) { return usage("use --pin is not supported; run aidlc config --pin "); } 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 and --bin-dir ", ); } 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 { 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; }); }