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

344 lines
9.7 KiB
TypeScript

#!/usr/bin/env bun
import { existsSync, readFileSync } from "node:fs";
import { isAbsolute, join, relative } from "node:path";
import {
isReleaseChannel,
RELEASE_CHANNELS,
type ReleaseChannel,
requireReleaseChannel,
STABLE_CHANNEL,
} from "./aidlc-channel.ts";
import {
type CommandResult,
emitResult,
failure,
globalOptions,
success,
usage,
} from "./aidlc-command.ts";
import {
installRoot,
machineTransactionRoot,
} from "./aidlc-install-paths.ts";
import {
executePlan,
transactionState,
writeOperation,
} from "./aidlc-transaction.ts";
import {
invalidateSettingsCache,
machineSettingsPath,
normalizeAidlcSettings,
readSettingsTarget,
serializeAidlcSettings,
type AidlcSettingsFile,
} from "./aidlc-settings.ts";
export type MachineConfig = {
schemaVersion: 1;
"update-check"?: boolean;
offline?: boolean;
"release-base-url"?: string;
"ca-bundle"?: string;
};
export type MachineConfigKey = Exclude<keyof MachineConfig, "schemaVersion">;
const CONFIG_KEYS: readonly MachineConfigKey[] = [
"update-check",
"offline",
"release-base-url",
"ca-bundle",
];
export function machineConfigPath(): string {
return machineSettingsPath();
}
export function updateCachePath(): string {
return join(installRoot(), "update-check.json");
}
export function defaultHarnessPath(): string {
return join(installRoot(), "default-harness");
}
// The machine's release channel lives beside the update cache and pin registry
// as one plain marker file; an absent marker means stable.
export function channelPath(): string {
return join(installRoot(), "channel");
}
export function readMachineChannel(): ReleaseChannel {
const path = channelPath();
if (!existsSync(path)) return STABLE_CHANNEL;
const value = readFileSync(path, "utf-8").trim();
if (!isReleaseChannel(value)) {
throw new Error(
`${path} must contain one release channel (${RELEASE_CHANNELS.join(" or ")}); found ${
JSON.stringify(value)
}`,
);
}
return value;
}
export function writeMachineChannel(value: string): ReleaseChannel {
const channel = requireReleaseChannel(value);
const path = channelPath();
const root = machineTransactionRoot();
executePlan({
schemaVersion: 1,
root,
operations: [writeOperation(
relative(root, path),
`${channel}\n`,
transactionState(path),
0o600,
)],
});
return channel;
}
function validReleaseUrl(value: string): boolean {
try {
const parsed = new URL(value);
if (
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash
) return false;
return parsed.protocol === "https:" ||
(parsed.protocol === "http:" &&
(parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost"));
} catch {
return false;
}
}
function validateMachineConfig(value: unknown): MachineConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("machine config must be a JSON object");
}
const record = value as Record<string, unknown>;
const allowed = new Set(["schemaVersion", ...CONFIG_KEYS]);
const unknown = Object.keys(record).filter((key) => !allowed.has(key));
if (unknown.length > 0) {
throw new Error(`machine config contains unknown key(s): ${unknown.join(", ")}`);
}
if (record.schemaVersion !== 1) {
throw new Error(`unsupported machine config schema ${String(record.schemaVersion)}`);
}
for (const key of ["update-check", "offline"] as const) {
if (record[key] !== undefined && typeof record[key] !== "boolean") {
throw new Error(`machine config ${key} must be true or false`);
}
}
if (
record["release-base-url"] !== undefined &&
(typeof record["release-base-url"] !== "string" ||
!validReleaseUrl(record["release-base-url"]))
) {
throw new Error(
"machine config release-base-url must be HTTPS without credentials, query, or fragment",
);
}
if (
record["ca-bundle"] !== undefined &&
(typeof record["ca-bundle"] !== "string" ||
!isAbsolute(record["ca-bundle"]))
) {
throw new Error("machine config ca-bundle must be an absolute path");
}
return record as MachineConfig;
}
export function readMachineConfig(): MachineConfig {
const path = machineConfigPath();
try {
const settings = readSettingsTarget(process.cwd(), "global");
return validateMachineConfig({
schemaVersion: 1,
...(settings?.["update-check"] !== undefined
? { "update-check": settings["update-check"] }
: {}),
...(settings?.offline !== undefined ? { offline: settings.offline } : {}),
...(settings?.["release-base-url"] !== undefined
? { "release-base-url": settings["release-base-url"] }
: {}),
...(settings?.["ca-bundle"] !== undefined
? { "ca-bundle": settings["ca-bundle"] }
: {}),
});
} catch (error) {
throw new Error(
`${path}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
export function writeMachineConfig(config: MachineConfig): void {
const valid = validateMachineConfig(config);
const path = machineConfigPath();
const current = readSettingsTarget(process.cwd(), "global") ?? {
schemaVersion: 1,
};
const next: AidlcSettingsFile = JSON.parse(JSON.stringify(current));
for (const key of CONFIG_KEYS) delete next[key];
for (const key of CONFIG_KEYS) {
if (valid[key] !== undefined) Object.assign(next, { [key]: valid[key] });
}
const settings = normalizeAidlcSettings(next, "machine", path);
const root = machineTransactionRoot();
executePlan({
schemaVersion: 1,
root,
operations: [writeOperation(
relative(root, path),
serializeAidlcSettings(settings),
transactionState(path),
0o600,
)],
});
invalidateSettingsCache(path);
}
export function resolvedReleaseSettings(options: {
offline?: boolean;
baseUrl?: string;
caBundle?: string;
} = {}): {
offline: boolean;
baseUrl?: string;
caBundle?: string;
} {
const config = readMachineConfig();
const envOffline = process.env.AIDLC_OFFLINE;
return {
offline: options.offline ??
(envOffline === "1" ? true : envOffline === "0" ? false : undefined) ??
config.offline ??
false,
baseUrl: options.baseUrl ||
process.env.AIDLC_RELEASE_BASE_URL ||
config["release-base-url"],
caBundle: options.caBundle ||
process.env.AIDLC_CA_BUNDLE ||
config["ca-bundle"],
};
}
function parseBoolean(value: string): boolean | null {
if (["true", "on", "1", "yes"].includes(value.toLowerCase())) return true;
if (["false", "off", "0", "no"].includes(value.toLowerCase())) return false;
return null;
}
function parseValue(key: MachineConfigKey, value: string): string | boolean {
if (key === "update-check" || key === "offline") {
const parsed = parseBoolean(value);
if (parsed === null) {
throw new Error(`${key} expects true/false or on/off`);
}
return parsed;
}
if (key === "release-base-url") {
if (!validReleaseUrl(value)) {
throw new Error(
"release-base-url must be HTTPS without credentials, query, or fragment",
);
}
return value.replace(/\/+$/, "");
}
if (!isAbsolute(value)) throw new Error("ca-bundle must be an absolute path");
return value;
}
function machineConfigCommand(argv: string[]): CommandResult {
if (argv[0] !== "global") {
return usage("usage: aidlc config global <get|set|clear|list>");
}
const verb = argv[1];
let config: MachineConfig;
try {
config = readMachineConfig();
} catch (error) {
return failure(
error instanceof Error ? error.message : String(error),
2,
"repair or remove the invalid machine config",
);
}
if (verb === "list") {
return success(
CONFIG_KEYS.map((key) =>
`${key}=${config[key] === undefined ? "<default>" : String(config[key])}`
).join("\n"),
{ config },
);
}
const key = argv[2] as MachineConfigKey | undefined;
if (!key || !CONFIG_KEYS.includes(key)) {
return usage(`config global ${verb ?? "<verb>"} requires one of: ${CONFIG_KEYS.join(", ")}`);
}
if (verb === "get") {
return success(
`${key}=${config[key] === undefined ? "<default>" : String(config[key])}`,
{ key, value: config[key] ?? null },
);
}
if (verb === "clear") {
try {
delete config[key];
writeMachineConfig(config);
return success(`cleared machine config ${key}`, { key, value: null });
} catch (error) {
return failure(
error instanceof Error ? error.message : String(error),
);
}
}
if (verb !== "set") {
return usage("usage: aidlc config global <get|set|clear|list>");
}
const raw = argv[3];
if (!raw || raw.startsWith("--")) {
return usage(`config global set ${key} requires a value`);
}
try {
const value = parseValue(key, raw);
Object.assign(config, { [key]: value });
writeMachineConfig(config);
return success(`set machine config ${key}=${String(value)}`, { key, value });
} catch (error) {
return failure(
error instanceof Error ? error.message : String(error),
2,
);
}
}
export async function main(argv: string[]): Promise<void> {
let result = machineConfigCommand(argv);
if (
result.ok &&
argv[1] === "list" &&
globalOptions(argv).mode === "human" &&
process.stdout.isTTY
) {
try {
const { cachedUpdateNotice } = await import("./aidlc-update.ts");
const notice = cachedUpdateNotice();
if (notice) result = { ...result, message: `${result.message}\n${notice}` };
} catch {
// Ambient config discovery is cache-only and never makes listing fail.
}
}
emitResult(result, globalOptions(argv));
}
if (import.meta.main) {
void main(process.argv.slice(2));
}