chore: guard host runtime and simplify packaging

This commit is contained in:
Peter Steinberger
2025-12-09 00:59:09 +01:00
parent 34d2527606
commit cf36f5a23b
10 changed files with 405 additions and 60 deletions

View File

@@ -9,6 +9,7 @@ import { createDefaultDeps } from "./cli/deps.js";
import { promptYesNo } from "./cli/prompt.js";
import { waitForever } from "./cli/wait.js";
import { loadConfig } from "./config/config.js";
import { assertSupportedRuntime } from "./infra/runtime-guard.js";
import {
deriveSessionKey,
loadSessionStore,
@@ -33,6 +34,9 @@ dotenv.config({ quiet: true });
// Capture all console output into structured logs while keeping stdout/stderr behavior.
enableConsoleCapture();
// Enforce the minimum supported runtime before doing any work.
assertSupportedRuntime();
import { buildProgram } from "./cli/program.js";
const program = buildProgram();

View File

@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
import {
assertSupportedRuntime,
detectRuntime,
isAtLeast,
parseSemver,
runtimeSatisfies,
type RuntimeDetails,
} from "./runtime-guard.js";
describe("runtime-guard", () => {
it("parses semver with or without leading v", () => {
expect(parseSemver("v22.1.3")).toEqual({ major: 22, minor: 1, patch: 3 });
expect(parseSemver("1.3.0")).toEqual({ major: 1, minor: 3, patch: 0 });
expect(parseSemver("invalid")).toBeNull();
});
it("compares versions correctly", () => {
expect(isAtLeast({ major: 22, minor: 0, patch: 0 }, { major: 22, minor: 0, patch: 0 })).toBe(true);
expect(isAtLeast({ major: 22, minor: 1, patch: 0 }, { major: 22, minor: 0, patch: 0 })).toBe(true);
expect(isAtLeast({ major: 21, minor: 9, patch: 0 }, { major: 22, minor: 0, patch: 0 })).toBe(false);
});
it("validates runtime thresholds", () => {
const bunOk: RuntimeDetails = {
kind: "bun",
version: "1.3.0",
execPath: "/usr/bin/bun",
pathEnv: "/usr/bin",
};
const bunOld: RuntimeDetails = { ...bunOk, version: "1.2.9" };
expect(runtimeSatisfies(bunOk)).toBe(true);
expect(runtimeSatisfies(bunOld)).toBe(false);
});
it("throws via exit when runtime is too old", () => {
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
const details: RuntimeDetails = {
kind: "node",
version: "20.0.0",
execPath: "/usr/bin/node",
pathEnv: "/usr/bin",
};
expect(() => assertSupportedRuntime(runtime, details)).toThrow("exit");
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("requires Node"));
});
it("returns silently when runtime meets requirements", () => {
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const details: RuntimeDetails = {
...detectRuntime(),
kind: "node",
version: "22.0.0",
execPath: "/usr/bin/node",
};
expect(() => assertSupportedRuntime(runtime, details)).not.toThrow();
expect(runtime.exit).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,90 @@
import process from "node:process";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
export type RuntimeKind = "node" | "bun" | "unknown";
type Semver = {
major: number;
minor: number;
patch: number;
};
const MIN_NODE: Semver = { major: 22, minor: 0, patch: 0 };
const MIN_BUN: Semver = { major: 1, minor: 3, patch: 0 };
export type RuntimeDetails = {
kind: RuntimeKind;
version: string | null;
execPath: string | null;
pathEnv: string;
};
const SEMVER_RE = /(\d+)\.(\d+)\.(\d+)/;
export function parseSemver(version: string | null): Semver | null {
if (!version) return null;
const match = version.match(SEMVER_RE);
if (!match) return null;
const [, major, minor, patch] = match;
return {
major: Number.parseInt(major, 10),
minor: Number.parseInt(minor, 10),
patch: Number.parseInt(patch, 10),
};
}
export function isAtLeast(version: Semver | null, minimum: Semver): boolean {
if (!version) return false;
if (version.major !== minimum.major) return version.major > minimum.major;
if (version.minor !== minimum.minor) return version.minor > minimum.minor;
return version.patch >= minimum.patch;
}
export function detectRuntime(): RuntimeDetails {
const isBun = Boolean(process.versions?.bun);
const kind: RuntimeKind = isBun ? "bun" : process.versions?.node ? "node" : "unknown";
const version = isBun
? process.versions?.bun ?? (globalThis as any)?.Bun?.version ?? null
: process.versions?.node ?? null;
return {
kind,
version,
execPath: process.execPath ?? null,
pathEnv: process.env.PATH ?? "(not set)",
};
}
export function runtimeSatisfies(details: RuntimeDetails): boolean {
const parsed = parseSemver(details.version);
if (details.kind === "bun") return isAtLeast(parsed, MIN_BUN);
if (details.kind === "node") return isAtLeast(parsed, MIN_NODE);
return false;
}
export function assertSupportedRuntime(
runtime: RuntimeEnv = defaultRuntime,
details: RuntimeDetails = detectRuntime(),
): void {
if (runtimeSatisfies(details)) return;
const versionLabel = details.version ?? "unknown";
const runtimeLabel = details.kind === "unknown" ? "unknown runtime" : `${details.kind} ${versionLabel}`;
const execLabel = details.execPath ?? "unknown";
runtime.error(
[
"clawdis requires Node >=22.0.0 or Bun >=1.3.0.",
`Detected: ${runtimeLabel} (exec: ${execLabel}).`,
`PATH searched: ${details.pathEnv}`,
"Install Node: https://nodejs.org/en/download",
"Install Bun: https://bun.sh/docs/installation",
details.kind === "bun"
? "Upgrade Bun or re-run with Node by setting CLAWDIS_RUNTIME=node."
: "Upgrade Node or re-run with Bun by setting CLAWDIS_RUNTIME=bun.",
].join("\n"),
);
runtime.exit(1);
}