2150 lines
70 KiB
TypeScript
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;
|
|
});
|
|
}
|