430 lines
21 KiB
TypeScript
430 lines
21 KiB
TypeScript
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { createHash } from "node:crypto";
|
|
import { join } from "node:path";
|
|
import type { UniDeskConfig } from "./config";
|
|
import { rootPath, stateRoot } from "./config";
|
|
import { runCommand, runCommandObserved } from "./command";
|
|
|
|
const configLabel = "config/platform-infra/hyperv-vms.yaml";
|
|
const configPath = rootPath(configLabel);
|
|
const bootstrapScriptPath = rootPath("scripts/assets/platform-infra/hyperv-vm-bootstrap.ps1");
|
|
const providerInstallTemplatePath = rootPath("scripts/assets/platform-infra/install-provider.sh");
|
|
|
|
interface HyperVConfig {
|
|
defaults: { targetId: string };
|
|
targets: Record<string, HyperVTarget>;
|
|
}
|
|
|
|
interface HyperVTarget {
|
|
enabled: boolean;
|
|
host: { route: string; computerName: string; stateDir: string; vmRoot: string; minimumFreeBytesBeforeInstall: number };
|
|
vm: Record<string, unknown> & { name: string; processorCount: number; memoryStartupBytes: number; memoryMinimumBytes: number; memoryMaximumBytes: number; hostReservedMemoryBytes: number; diskBytes: number };
|
|
image: Record<string, unknown> & { fileName: string; format: string; sha256: string; sources: Array<{ id: string; region: string; url: string }> };
|
|
network: Record<string, unknown> & { address: string };
|
|
ubuntu: Record<string, unknown> & { user: string };
|
|
docker: Record<string, unknown>;
|
|
k3s: Record<string, unknown> & { nodeName: string };
|
|
provider: Record<string, unknown> & {
|
|
id: string;
|
|
name: string;
|
|
masterServer: string;
|
|
root: string;
|
|
stateRoot: string;
|
|
composeProject: string;
|
|
containerName: string;
|
|
image: string;
|
|
systemdUnit: string;
|
|
};
|
|
verification: { probeTimeoutSeconds: number; repairReadyTimeoutSeconds: number; repairRetryIntervalSeconds: number };
|
|
status: Record<string, unknown>;
|
|
}
|
|
|
|
interface Options {
|
|
action: "plan" | "apply" | "status" | "logs" | "verify";
|
|
targetId: string;
|
|
confirm: boolean;
|
|
repairProviderChannel: boolean;
|
|
tail: number;
|
|
}
|
|
|
|
interface VerifyRoutesResult extends Record<string, unknown> {
|
|
ok: boolean;
|
|
repairable: boolean;
|
|
failureKind: string | null;
|
|
}
|
|
|
|
export async function runPlatformInfraHyperVVmCommand(_config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
|
const parsed = readConfig();
|
|
const options = parseOptions(args, parsed.defaults.targetId);
|
|
const target = parsed.targets[options.targetId];
|
|
if (!target) throw new Error(`${configLabel}.targets.${options.targetId} is missing`);
|
|
validateTarget(options.targetId, target);
|
|
if (options.action === "plan") return plan(options.targetId, target);
|
|
if (options.action === "status") return status(options.targetId, target);
|
|
if (options.action === "logs") return logs(options.targetId, target, options.tail);
|
|
if (options.action === "verify") return await verify(options.targetId, target, options);
|
|
if (!options.confirm) return { ...plan(options.targetId, target), mode: "dry-run", mutation: false };
|
|
return apply(options.targetId, target);
|
|
}
|
|
|
|
function readConfig(): HyperVConfig {
|
|
const raw = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configLabel);
|
|
if (raw.kind !== "platform-infra-hyperv-vms") throw new Error(`${configLabel}.kind must be platform-infra-hyperv-vms`);
|
|
const defaults = record(raw.defaults, `${configLabel}.defaults`);
|
|
const targets = record(raw.targets, `${configLabel}.targets`) as unknown as Record<string, HyperVTarget>;
|
|
return { defaults: { targetId: stringField(defaults, "targetId", `${configLabel}.defaults`) }, targets };
|
|
}
|
|
|
|
function parseOptions(args: string[], defaultTargetId: string): Options {
|
|
const action = args[0] ?? "plan";
|
|
if (action !== "plan" && action !== "apply" && action !== "status" && action !== "logs" && action !== "verify") {
|
|
throw new Error("platform-infra hyperv-vm usage: plan|apply|status|logs|verify --target <id> [--repair-provider-channel --confirm] [--tail 120]");
|
|
}
|
|
return {
|
|
action,
|
|
targetId: option(args, "--target") ?? defaultTargetId,
|
|
confirm: args.includes("--confirm"),
|
|
repairProviderChannel: args.includes("--repair-provider-channel"),
|
|
tail: integerOption(args, "--tail", 120, 1, 500),
|
|
};
|
|
}
|
|
|
|
function validateTarget(id: string, target: HyperVTarget): void {
|
|
if (target.enabled !== true) throw new Error(`${configLabel}.targets.${id}.enabled must be true`);
|
|
if (!target.host.route.includes(":win/")) throw new Error(`${configLabel}.targets.${id}.host.route must select a Windows workspace`);
|
|
if (target.provider.id !== id) throw new Error(`${configLabel}.targets.${id}.provider.id must equal target id`);
|
|
if (target.vm.memoryMinimumBytes <= 0 || target.vm.memoryStartupBytes < target.vm.memoryMinimumBytes || target.vm.memoryMaximumBytes < target.vm.memoryStartupBytes || target.vm.hostReservedMemoryBytes <= 0) {
|
|
throw new Error(`${configLabel}.targets.${id}.vm dynamic memory values are invalid`);
|
|
}
|
|
if (target.vm.diskBytes !== 274_877_906_944) throw new Error(`${configLabel}.targets.${id}.vm.diskBytes must describe the requested 256 GiB disk`);
|
|
if (!/^[a-f0-9]{64}$/u.test(target.image.sha256)) throw new Error(`${configLabel}.targets.${id}.image.sha256 must be lowercase SHA-256`);
|
|
if (target.image.sources.length < 2) throw new Error(`${configLabel}.targets.${id}.image.sources must include mirror and canonical fallback`);
|
|
const k3sManagement = record(target.k3s.management, `${configLabel}.targets.${id}.k3s.management`);
|
|
if (k3sManagement.mode !== "external-cluster" && k3sManagement.mode !== "standalone") {
|
|
throw new Error(`${configLabel}.targets.${id}.k3s.management.mode must be external-cluster or standalone`);
|
|
}
|
|
if (typeof k3sManagement.configRef !== "string" || !k3sManagement.configRef.startsWith("config/platform-infra/k3s-clusters.yaml#")) {
|
|
throw new Error(`${configLabel}.targets.${id}.k3s.management.configRef must reference the k3s cluster owning YAML`);
|
|
}
|
|
for (const value of [target.provider.id, target.provider.name, target.provider.composeProject, target.provider.containerName, target.provider.image, target.ubuntu.user, target.k3s.nodeName]) {
|
|
if (!/^[A-Za-z0-9._:/-]+$/u.test(value)) throw new Error(`${configLabel}.targets.${id} contains a shell-unsafe provider or user value`);
|
|
}
|
|
if (!/^[A-Za-z0-9_.@-]+$/u.test(target.provider.systemdUnit)) throw new Error(`${configLabel}.targets.${id}.provider.systemdUnit is invalid`);
|
|
for (const [key, value] of Object.entries(target.verification)) {
|
|
if (!Number.isInteger(value) || value <= 0) throw new Error(`${configLabel}.targets.${id}.verification.${key} must be a positive integer`);
|
|
}
|
|
}
|
|
|
|
function plan(id: string, target: HyperVTarget): Record<string, unknown> {
|
|
return {
|
|
ok: true,
|
|
mutation: false,
|
|
targetId: id,
|
|
source: configLabel,
|
|
host: { route: target.host.route, computerName: target.host.computerName, vmRoot: target.host.vmRoot },
|
|
vm: target.vm,
|
|
network: target.network,
|
|
image: { fileName: target.image.fileName, format: target.image.format, sha256: target.image.sha256, sources: target.image.sources, conversion: target.image.conversion },
|
|
runtime: { ubuntu: target.ubuntu, docker: target.docker, k3s: target.k3s, provider: target.provider },
|
|
constraints: {
|
|
gpu: "unsupported-on-windows-11-hyperv-geforce-linux-guest",
|
|
wslDependency: false,
|
|
hostMemoryReservedBytes: target.vm.hostReservedMemoryBytes,
|
|
guestMemoryMaximumBytes: target.vm.memoryMaximumBytes,
|
|
},
|
|
next: {
|
|
apply: `bun scripts/cli.ts platform-infra hyperv-vm apply --target ${id} --confirm`,
|
|
status: `bun scripts/cli.ts platform-infra hyperv-vm status --target ${id}`,
|
|
logs: `bun scripts/cli.ts platform-infra hyperv-vm logs --target ${id} --tail 120`,
|
|
verify: `bun scripts/cli.ts platform-infra hyperv-vm verify --target ${id}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
function apply(id: string, target: HyperVTarget): Record<string, unknown> {
|
|
const localDir = join(stateRoot, "hyperv-vm", id);
|
|
const stagingDir = join(localDir, "bundle");
|
|
const generatedConfigPath = join(localDir, "bootstrap-config.json");
|
|
const generatedScriptPath = join(localDir, "hyperv-vm-bootstrap.ps1");
|
|
const bundlePath = join(localDir, "unidesk-provider-bundle.tar.gz");
|
|
const k3sDir = join(localDir, "k3s");
|
|
mkdirSync(k3sDir, { recursive: true });
|
|
const k3sBinaryPath = join(k3sDir, "k3s");
|
|
const k3sInstallerPath = join(k3sDir, "install-k3s.sh");
|
|
ensureDownloadedArtifact(String(target.k3s.binaryUrl), String(target.k3s.binarySha256), k3sBinaryPath);
|
|
ensureDownloadedArtifact(String(target.k3s.installerUrl), "", k3sInstallerPath);
|
|
mkdirSync(localDir, { recursive: true });
|
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
mkdirSync(stagingDir, { recursive: true });
|
|
cpSync(rootPath("src/components/provider-gateway"), join(stagingDir, "src/components/provider-gateway"), { recursive: true });
|
|
cpSync(rootPath("src/components/shared"), join(stagingDir, "src/components/shared"), { recursive: true });
|
|
const renderedInstaller = renderProviderInstaller(target);
|
|
writeFileSync(join(stagingDir, "install-provider.sh"), renderedInstaller, { encoding: "utf8", mode: 0o755 });
|
|
const tar = runCommand(["tar", "-czf", bundlePath, "-C", stagingDir, "."], rootPath());
|
|
if (tar.exitCode !== 0) throw new Error(`provider bundle creation failed: ${tar.stderr.trim()}`);
|
|
|
|
const remoteScriptPath = `${target.host.stateDir}\\hyperv-vm-bootstrap.ps1`;
|
|
const remoteConfigPath = `${target.host.stateDir}\\bootstrap-config.json`;
|
|
const remoteBundlePath = `${target.host.stateDir}\\unidesk-provider-bundle.tar.gz`;
|
|
const generated = {
|
|
targetId: id,
|
|
...target,
|
|
artifacts: {
|
|
providerBundlePath: remoteBundlePath,
|
|
k3sBinaryPath: `${target.host.stateDir}\\k3s`,
|
|
k3sInstallerPath: `${target.host.stateDir}\\install-k3s.sh`,
|
|
},
|
|
};
|
|
writeFileSync(generatedConfigPath, `${JSON.stringify(generated, null, 2)}\n`, "utf8");
|
|
cpSync(bootstrapScriptPath, generatedScriptPath);
|
|
|
|
upload(target.host.route, generatedScriptPath, remoteScriptPath, true);
|
|
upload(target.host.route, generatedConfigPath, remoteConfigPath, true);
|
|
upload(target.host.route, bundlePath, remoteBundlePath, false);
|
|
upload(target.host.route, k3sBinaryPath, `${target.host.stateDir}\\k3s`, false);
|
|
upload(target.host.route, k3sInstallerPath, `${target.host.stateDir}\\install-k3s.sh`, true);
|
|
|
|
const launchSource = [
|
|
`$script=${psQuote(remoteScriptPath)}`,
|
|
`$config=${psQuote(remoteConfigPath)}`,
|
|
"$arguments=@('-NoProfile','-ExecutionPolicy','Bypass','-File',$script,'-ConfigPath',$config)",
|
|
"$process=Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $arguments -PassThru",
|
|
`[ordered]@{ok=$true;mutation=$true;state='uac-pending';processId=$process.Id;script=$script;config=$config} | ConvertTo-Json -Compress`,
|
|
].join("; ");
|
|
const launch = runCommand(["trans", target.host.route, "ps", launchSource], rootPath(), { timeoutMs: 30_000 });
|
|
if (launch.exitCode !== 0) throw new Error(`failed to launch elevated Hyper-V bootstrap: ${launch.stderr.trim() || launch.stdout.trim()}`);
|
|
return {
|
|
ok: true,
|
|
mutation: true,
|
|
state: "uac-pending",
|
|
targetId: id,
|
|
message: "Approve the Windows UAC prompt on D601-WIN; installation then continues in the background.",
|
|
remote: { scriptPath: remoteScriptPath, configPath: remoteConfigPath, bundlePath: remoteBundlePath },
|
|
statusCommand: `bun scripts/cli.ts platform-infra hyperv-vm status --target ${id}`,
|
|
launch: parseFirstJson(launch.stdout),
|
|
};
|
|
}
|
|
|
|
function ensureDownloadedArtifact(url: string, expectedSha256: string, destination: string): void {
|
|
if (existsSync(destination) && (!expectedSha256 || createHash("sha256").update(readFileSync(destination)).digest("hex") === expectedSha256)) return;
|
|
const download = runCommand(["curl", "-fsSL", url, "-o", destination], rootPath(), { timeoutMs: 120_000 });
|
|
if (download.exitCode !== 0) throw new Error(`k3s artifact download failed: ${url}: ${bounded(download.stderr || download.stdout)}`);
|
|
if (expectedSha256) {
|
|
const actual = createHash("sha256").update(readFileSync(destination)).digest("hex");
|
|
if (actual !== expectedSha256) throw new Error(`k3s artifact hash mismatch: expected=${expectedSha256} actual=${actual}`);
|
|
}
|
|
}
|
|
|
|
function status(id: string, target: HyperVTarget): Record<string, unknown> {
|
|
const statusPath = `${target.host.stateDir}\\status.json`;
|
|
const source = `$p=${psQuote(statusPath)}; if(Test-Path -LiteralPath $p){Get-Content -Raw -LiteralPath $p}else{[ordered]@{ok=$false;state='not-started';stage='none';message='status.json is not present'}|ConvertTo-Json -Compress}`;
|
|
const result = runCommand(["trans", target.host.route, "ps", source], rootPath(), { timeoutMs: 30_000 });
|
|
if (result.exitCode !== 0) return { ok: false, mutation: false, targetId: id, state: "read-failed", error: bounded(result.stderr || result.stdout) };
|
|
const payload = parseFirstJson(result.stdout);
|
|
return {
|
|
...payload,
|
|
mutation: false,
|
|
targetId: id,
|
|
address: target.network.address,
|
|
providerId: target.provider.id,
|
|
next: payload.state === "failed"
|
|
? { logs: `bun scripts/cli.ts platform-infra hyperv-vm logs --target ${id} --tail 200`, retry: `bun scripts/cli.ts platform-infra hyperv-vm apply --target ${id} --confirm` }
|
|
: { status: `bun scripts/cli.ts platform-infra hyperv-vm status --target ${id}` },
|
|
};
|
|
}
|
|
|
|
function logs(id: string, target: HyperVTarget, tail: number): Record<string, unknown> {
|
|
const logPath = `${target.host.stateDir}\\install.log`;
|
|
const source = `$p=${psQuote(logPath)}; if(Test-Path -LiteralPath $p){Get-Content -LiteralPath $p -Tail ${tail}}else{'install.log is not present'}`;
|
|
const result = runCommand(["trans", target.host.route, "ps", source], rootPath(), { timeoutMs: 30_000 });
|
|
return { ok: result.exitCode === 0, mutation: false, targetId: id, tail, logPath, lines: bounded(result.stdout, 30_000), error: bounded(result.stderr) };
|
|
}
|
|
|
|
async function verify(id: string, target: HyperVTarget, options: Options): Promise<Record<string, unknown>> {
|
|
const before = await verifyProviderRoutes(target);
|
|
if (before.ok) return {
|
|
ok: true,
|
|
mutation: false,
|
|
targetId: id,
|
|
providerId: target.provider.id,
|
|
repaired: false,
|
|
before,
|
|
after: before,
|
|
};
|
|
const repairCommand = `bun scripts/cli.ts platform-infra hyperv-vm verify --target ${id} --repair-provider-channel --confirm`;
|
|
if (!before.repairable) return {
|
|
ok: false,
|
|
mutation: false,
|
|
targetId: id,
|
|
providerId: target.provider.id,
|
|
repaired: false,
|
|
before,
|
|
next: { retry: `bun scripts/cli.ts platform-infra hyperv-vm verify --target ${id}` },
|
|
};
|
|
if (!options.repairProviderChannel || !options.confirm) return {
|
|
ok: false,
|
|
mutation: false,
|
|
targetId: id,
|
|
providerId: target.provider.id,
|
|
repaired: false,
|
|
before,
|
|
next: { repair: repairCommand },
|
|
};
|
|
const repair = await restartProviderChannel(target);
|
|
if (!repair.ok) return {
|
|
ok: false,
|
|
mutation: true,
|
|
targetId: id,
|
|
providerId: target.provider.id,
|
|
repaired: false,
|
|
before,
|
|
repair,
|
|
};
|
|
const deadline = Date.now() + target.verification.repairReadyTimeoutSeconds * 1_000;
|
|
let after = await verifyProviderRoutes(target);
|
|
while (!after.ok && Date.now() < deadline) {
|
|
await Bun.sleep(target.verification.repairRetryIntervalSeconds * 1_000);
|
|
after = await verifyProviderRoutes(target);
|
|
}
|
|
return {
|
|
ok: after.ok,
|
|
mutation: true,
|
|
targetId: id,
|
|
providerId: target.provider.id,
|
|
repaired: after.ok,
|
|
before,
|
|
repair,
|
|
after,
|
|
};
|
|
}
|
|
|
|
async function verifyProviderRoutes(target: HyperVTarget): Promise<VerifyRoutesResult> {
|
|
const timeoutMs = target.verification.probeTimeoutSeconds * 1_000;
|
|
const [host, k3s] = await Promise.all([
|
|
runCommandObserved(["trans", target.provider.id, "argv", "hostname"], rootPath(), { timeoutMs, maxCaptureChars: 4_000 }),
|
|
runCommandObserved(["trans", `${target.provider.id}:k3s`, "kubectl", "get", "node", target.k3s.nodeName, "-o", "name"], rootPath(), { timeoutMs, maxCaptureChars: 4_000 }),
|
|
]);
|
|
const failureKind = providerRouteFailureKind(`${host.stdout}\n${host.stderr}\n${k3s.stdout}\n${k3s.stderr}`);
|
|
return {
|
|
ok: host.exitCode === 0 && k3s.exitCode === 0,
|
|
repairable: failureKind === "provider-data-channel-missing",
|
|
failureKind,
|
|
host: commandEvidence(host),
|
|
k3s: commandEvidence(k3s),
|
|
};
|
|
}
|
|
|
|
function providerRouteFailureKind(text: string): string | null {
|
|
const normalized = text.toLowerCase();
|
|
if (normalized.includes("requested ssh tcp data channel is not ready") || normalized.includes("ssh tcp data channel is not available")) return "provider-data-channel-missing";
|
|
if (normalized.includes("ssh tcp data channel closed")) return "provider-data-channel-closed";
|
|
if (normalized.includes("provider ssh tcp data pool has no idle channel")) return "provider-data-pool-exhausted";
|
|
return null;
|
|
}
|
|
|
|
async function restartProviderChannel(target: HyperVTarget): Promise<Record<string, unknown> & { ok: boolean }> {
|
|
const keyPath = `${target.host.stateDir}\\id_ed25519`;
|
|
const guest = `${target.ubuntu.user}@${target.network.address}`;
|
|
const source = [
|
|
`$key=${psQuote(keyPath)}`,
|
|
`$guest=${psQuote(guest)}`,
|
|
`$unit=${psQuote(target.provider.systemdUnit)}`,
|
|
"$arguments=@('-i',$key,'-o','BatchMode=yes','-o','ConnectTimeout=8',$guest,'sudo','systemctl','restart',$unit)",
|
|
"& ssh.exe @arguments 2>&1",
|
|
"$code=$LASTEXITCODE",
|
|
"[ordered]@{ok=($code -eq 0);exitCode=$code}|ConvertTo-Json -Compress",
|
|
"exit $code",
|
|
].join("; ");
|
|
const result = await runCommandObserved(["trans", target.host.route, "ps", source], rootPath(), {
|
|
timeoutMs: target.verification.probeTimeoutSeconds * 1_000,
|
|
maxCaptureChars: 4_000,
|
|
});
|
|
return { ok: result.exitCode === 0, ...commandEvidence(result) };
|
|
}
|
|
|
|
function commandEvidence(result: { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean }): Record<string, unknown> {
|
|
return {
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
stdout: bounded(result.stdout, 1_000),
|
|
stderr: bounded(result.stderr, 2_000),
|
|
};
|
|
}
|
|
|
|
function renderProviderInstaller(target: HyperVTarget): string {
|
|
let source = readFileSync(providerInstallTemplatePath, "utf8");
|
|
const replacements: Record<string, string> = {
|
|
"@@PROVIDER_ROOT@@": target.provider.root,
|
|
"@@STATE_ROOT@@": target.provider.stateRoot,
|
|
"@@PROVIDER_ID@@": target.provider.id,
|
|
"@@PROVIDER_NAME@@": target.provider.name,
|
|
"@@MASTER_SERVER@@": target.provider.masterServer,
|
|
"@@COMPOSE_PROJECT@@": target.provider.composeProject,
|
|
"@@PROVIDER_IMAGE@@": target.provider.image,
|
|
"@@PROVIDER_CONTAINER@@": target.provider.containerName,
|
|
"@@UBUNTU_USER@@": target.ubuntu.user,
|
|
"@@K3S_NODE_NAME@@": target.k3s.nodeName,
|
|
};
|
|
for (const [placeholder, value] of Object.entries(replacements)) source = source.replaceAll(placeholder, value);
|
|
if (/@@[A-Z0-9_]+@@/u.test(source)) throw new Error("provider installer contains unresolved YAML placeholder");
|
|
return source;
|
|
}
|
|
|
|
function upload(route: string, localPath: string, remotePath: string, allowText: boolean): void {
|
|
if (!existsSync(localPath)) throw new Error(`upload source is missing: ${localPath}`);
|
|
const command = ["trans", route, "upload", localPath, remotePath];
|
|
if (allowText) command.push("--allow-text-transfer");
|
|
const result = runCommand(command, rootPath(), { timeoutMs: 60_000 });
|
|
if (result.exitCode !== 0) throw new Error(`upload failed for ${remotePath}: ${bounded(result.stderr || result.stdout)}`);
|
|
}
|
|
|
|
function parseFirstJson(output: string): Record<string, unknown> {
|
|
for (const line of output.split(/\r?\n/u)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith("{")) continue;
|
|
try { return record(JSON.parse(trimmed), "remote JSON"); } catch { /* continue */ }
|
|
}
|
|
const start = output.indexOf("{");
|
|
const end = output.lastIndexOf("}");
|
|
if (start >= 0 && end > start) {
|
|
try { return record(JSON.parse(output.slice(start, end + 1)), "remote JSON"); } catch { /* fall through */ }
|
|
}
|
|
throw new Error(`remote command returned no JSON: ${bounded(output)}`);
|
|
}
|
|
|
|
function psQuote(value: string): string {
|
|
return `'${value.replaceAll("'", "''")}'`;
|
|
}
|
|
|
|
function option(args: string[], name: string): string | null {
|
|
const index = args.indexOf(name);
|
|
if (index < 0) return null;
|
|
const value = args[index + 1];
|
|
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
function integerOption(args: string[], name: string, fallback: number, minimum: number, maximum: number): number {
|
|
const raw = option(args, name);
|
|
if (raw === null) return fallback;
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value < minimum || value > maximum) throw new Error(`${name} must be ${minimum}..${maximum}`);
|
|
return value;
|
|
}
|
|
|
|
function record(value: unknown, label: string): Record<string, any> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
return value as Record<string, any>;
|
|
}
|
|
|
|
function stringField(value: Record<string, unknown>, key: string, label: string): string {
|
|
const field = value[key];
|
|
if (typeof field !== "string" || field.length === 0) throw new Error(`${label}.${key} must be a non-empty string`);
|
|
return field;
|
|
}
|
|
|
|
function bounded(value: string, max = 4_000): string {
|
|
const text = value.trim();
|
|
return text.length <= max ? text : `${text.slice(0, max)}\n...[truncated ${text.length - max} chars]`;
|
|
}
|