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

@@ -48,6 +48,8 @@ Only the Pi/Tau CLI is supported now; legacy Claude/Codex/Gemini paths have been
## Quick Start
Mac signing tip: set `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` in your shell profile so `scripts/restart-mac.sh` signs with your cert (defaults to ad-hoc). Debug bundle ID remains `com.steipete.clawdis.debug`.
Runtime requirement: **Node ≥22.0.0 or Bun ≥1.3.0** (not bundled). The macOS app and CLI both use the host runtime; install via Homebrew or official installers before running `clawdis`.
```bash
# Install
npm install -g warelay # (still warelay on npm for now)

View File

@@ -0,0 +1,149 @@
import Foundation
enum RuntimeKind: String {
case bun
case node
}
struct RuntimeVersion: Comparable, CustomStringConvertible {
let major: Int
let minor: Int
let patch: Int
var description: String { "\(major).\(minor).\(patch)" }
static func < (lhs: RuntimeVersion, rhs: RuntimeVersion) -> Bool {
if lhs.major != rhs.major { return lhs.major < rhs.major }
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
return lhs.patch < rhs.patch
}
static func from(string: String) -> RuntimeVersion? {
// Accept optional leading "v" and ignore trailing metadata.
let pattern = #"(\d+)\.(\d+)\.(\d+)"#
guard let match = string.range(of: pattern, options: .regularExpression) else { return nil }
let versionString = String(string[match])
let parts = versionString.split(separator: ".")
guard parts.count == 3,
let major = Int(parts[0]),
let minor = Int(parts[1]),
let patch = Int(parts[2])
else { return nil }
return RuntimeVersion(major: major, minor: minor, patch: patch)
}
}
struct RuntimeResolution {
let kind: RuntimeKind
let path: String
let version: RuntimeVersion
}
enum RuntimeResolutionError: Error {
case notFound(searchPaths: [String], preferred: String?)
case unsupported(kind: RuntimeKind, found: RuntimeVersion, required: RuntimeVersion, path: String, searchPaths: [String])
case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String])
}
enum RuntimeLocator {
private static let minNode = RuntimeVersion(major: 22, minor: 0, patch: 0)
private static let minBun = RuntimeVersion(major: 1, minor: 3, patch: 0)
static func resolve(
preferred: String? = ProcessInfo.processInfo.environment["CLAWDIS_RUNTIME"],
searchPaths: [String] = CommandResolver.preferredPaths()
) -> Result<RuntimeResolution, RuntimeResolutionError> {
let order = runtimeOrder(preferred: preferred)
let pathEnv = searchPaths.joined(separator: ":")
for runtime in order {
guard let binary = findExecutable(named: runtime.binaryName, searchPaths: searchPaths) else { continue }
guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else {
return .failure(.versionParse(kind: runtime, raw: "(unreadable)", path: binary, searchPaths: searchPaths))
}
guard let parsed = RuntimeVersion.from(string: rawVersion) else {
return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths))
}
let minimum = runtime == .bun ? minBun : minNode
guard parsed >= minimum else {
return .failure(.unsupported(kind: runtime, found: parsed, required: minimum, path: binary, searchPaths: searchPaths))
}
return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed))
}
return .failure(.notFound(searchPaths: searchPaths, preferred: preferred?.lowercased()))
}
static func describeFailure(_ error: RuntimeResolutionError) -> String {
switch error {
case let .notFound(searchPaths, preferred):
let preference = preferred?.isEmpty == false ? "CLAWDIS_RUNTIME=\(preferred!)" : "bun or node"
return [
"clawdis needs Node >=22.0.0 or Bun >=1.3.0 but found no runtime.",
"Tried preference: \(preference)",
"PATH searched: \(searchPaths.joined(separator: ":"))",
"Install Node: https://nodejs.org/en/download",
"Install Bun: https://bun.sh/docs/installation",
].joined(separator: "\n")
case let .unsupported(kind, found, required, path, searchPaths):
return [
"Found \(kind.rawValue) \(found) at \(path) but need >= \(required).",
"PATH searched: \(searchPaths.joined(separator: ":"))",
"Upgrade \(kind.rawValue) or set CLAWDIS_RUNTIME=\(kind == .bun ? "node" : "bun") to try the other runtime.",
].joined(separator: "\n")
case let .versionParse(kind, raw, path, searchPaths):
return [
"Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).",
"PATH searched: \(searchPaths.joined(separator: ":"))",
"Try reinstalling or pinning a supported version (Node >=22.0.0, Bun >=1.3.0).",
].joined(separator: "\n")
}
}
// MARK: - Internals
private static func runtimeOrder(preferred: String?) -> [RuntimeKind] {
let normalized = preferred?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch normalized {
case "bun": return [.bun]
case "node": return [.node]
default: return [.bun, .node]
}
}
private static func findExecutable(named name: String, searchPaths: [String]) -> String? {
let fm = FileManager.default
for dir in searchPaths {
let candidate = (dir as NSString).appendingPathComponent(name)
if fm.isExecutableFile(atPath: candidate) {
return candidate
}
}
return nil
}
private static func readVersion(of binary: String, pathEnv: String) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: binary)
process.arguments = ["--version"]
process.environment = ["PATH": pathEnv]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
} catch {
return nil
}
}
}
private extension RuntimeKind {
var binaryName: String { self == .bun ? "bun" : "node" }
}

View File

@@ -207,14 +207,42 @@ enum CommandResolver {
return FileManager.default.fileExists(atPath: relay.path) ? relay : nil
}
private static func bundledRelayCommand(subcommand: String, extraArgs: [String]) -> [String]? {
guard let relay = self.bundledRelayRoot() else { return nil }
let bunPath = relay.appendingPathComponent("bun").path
let entry = relay.appendingPathComponent("dist/index.js").path
guard FileManager.default.isExecutableFile(atPath: bunPath),
FileManager.default.isReadableFile(atPath: entry)
else { return nil }
return [bunPath, entry, subcommand] + extraArgs
private static func relayEntrypoint(in root: URL) -> String? {
let distEntry = root.appendingPathComponent("dist/index.js").path
if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry }
let binEntry = root.appendingPathComponent("bin/clawdis.js").path
if FileManager.default.isReadableFile(atPath: binEntry) { return binEntry }
return nil
}
private static func runtimeResolution() -> Result<RuntimeResolution, RuntimeResolutionError> {
RuntimeLocator.resolve(
preferred: ProcessInfo.processInfo.environment["CLAWDIS_RUNTIME"],
searchPaths: self.preferredPaths())
}
private static func makeRuntimeCommand(
runtime: RuntimeResolution,
entrypoint: String,
subcommand: String,
extraArgs: [String]
) -> [String] {
[runtime.path, entrypoint, subcommand] + extraArgs
}
private static func runtimeErrorCommand(_ error: RuntimeResolutionError) -> [String] {
let message = RuntimeLocator.describeFailure(error)
return self.errorCommand(with: message)
}
private static func errorCommand(with message: String) -> [String] {
let script = """
cat <<'__CLAWDIS_ERR__' >&2
\(message)
__CLAWDIS_ERR__
exit 1
"""
return ["/bin/sh", "-c", script]
}
static func projectRoot() -> URL {
@@ -296,22 +324,35 @@ enum CommandResolver {
{
return ssh
}
if let bundled = self.bundledRelayCommand(subcommand: subcommand, extraArgs: extraArgs) {
return bundled
}
if let clawdisPath = self.clawdisExecutable() {
return [clawdisPath, subcommand] + extraArgs
}
if let node = self.findExecutable(named: "node") {
if let cli = self.nodeCliPath() {
return [node, cli, subcommand] + extraArgs
let runtimeResult = self.runtimeResolution()
switch runtimeResult {
case let .success(runtime):
if let relay = self.bundledRelayRoot(),
let entry = self.relayEntrypoint(in: relay)
{
return self.makeRuntimeCommand(runtime: runtime, entrypoint: entry, subcommand: subcommand, extraArgs: extraArgs)
}
if let entry = self.relayEntrypoint(in: self.projectRoot()) {
return self.makeRuntimeCommand(runtime: runtime, entrypoint: entry, subcommand: subcommand, extraArgs: extraArgs)
}
if let clawdisPath = self.clawdisExecutable() {
return [clawdisPath, subcommand] + extraArgs
}
if let pnpm = self.findExecutable(named: "pnpm") {
// Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs.
return [pnpm, "--silent", "clawdis", subcommand] + extraArgs
}
let missingEntry = "clawdis entrypoint missing (looked for dist/index.js or bin/clawdis.js); run pnpm build."
return self.errorCommand(with: missingEntry)
case let .failure(error):
return self.runtimeErrorCommand(error)
}
if let pnpm = self.findExecutable(named: "pnpm") {
// Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs.
return [pnpm, "--silent", "clawdis", subcommand] + extraArgs
}
return ["clawdis", subcommand] + extraArgs
}
static func clawdisMacCommand(subcommand: String, extraArgs: [String] = []) -> [String] {

View File

@@ -1,30 +1,27 @@
# Bundled Bun runtime (mac app only)
# Host Node/Bun runtime (mac app)
Date: 2025-12-07 · Owner: steipete · Scope: packaged mac app runtime
Date: 2025-12-08 · Owner: steipete · Scope: packaged mac app runtime
## What we ship
- The mac menu-bar app embeds an **arm64 Bun runtime** under `Contents/Resources/Relay/` only for the packaged app. Dev/CI keep using pnpm+node.
- Payload: `bun` binary (defaults to `/opt/homebrew/bin/bun`, override with `BUN_PATH=/path/to/bun`), `dist/` output, production `node_modules/`, and the root `package.json`/`pnpm-lock.yaml` for provenance.
- We prune dev/build tooling (vite, rolldown, biome, vitest, tsc/tsx, @types, etc.) and drop all non-macOS sharp vendors so only `sharp-darwin-arm64` + `sharp-libvips-darwin-arm64` remain.
## What we require
- The mac menu-bar app no longer ships an embedded runtime. We expect **Node ≥22.0.0 or Bun ≥1.3.0** to be present on the host.
- The bundle still carries `dist/` output, production `node_modules/`, and the root `package.json`/`pnpm-lock.yaml` so we avoid on-device installs; we simply reuse the host runtime.
- Launchd jobs export a PATH that includes `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so Homebrew/PNPM installs are found even under the minimal launchd environment.
## Build/packaging flow
- Run `scripts/package-mac-app.sh` (or `BUN_PATH=/custom/bun scripts/package-mac-app.sh`).
- Ensures deps via `pnpm install`, then `pnpm exec tsc`.
- Builds the Swift app and stages `dist/`, Bun, and production `node_modules` into `Contents/Resources/Relay/` using a temp deploy (hoisted layout, no dev deps).
- Prunes optional tooling + extra sharp vendors, then codesigns binaries and native addons.
- Architecture: **arm64 only**. Ship a separate bundle if you need Rosetta/x64.
- Run `scripts/package-mac-app.sh`.
- Ensures deps via `pnpm install`, builds JS with `pnpm exec tsc`, then builds the Swift app.
- Stages `dist/`, production `node_modules`, and metadata into `Contents/Resources/Relay/` (no bundled bun binary).
- Prunes optional tooling and non-macOS sharp vendors; only `sharp-darwin-arm64` + `sharp-libvips-darwin-arm64` remain for size/signing.
- Architecture: **arm64 only**. Host runtime must also be arm64 or Rosetta-compatible.
## Runtime behavior
- `CommandResolver` prefers the bundled `bun dist/index.js <subcommand>` when present; falls back to system `clawdis`/pnpm/node otherwise.
- `RelayProcessManager` runs in the bundled cwd/PATH so native deps (sharp, undici) resolve without installing anything on the host.
- `CommandResolver` picks the runtime via `CLAWDIS_RUNTIME` (`bun`/`node`) or defaults to Bun then Node; it enforces the version gates and prints a clear error (with PATH) if requirements are not met.
- Relay processes run inside the bundled relay directory so native deps resolve, but the runtime itself comes from the host.
## Testing the bundle
- After packaging: `cd dist/Clawdis.app/Contents/Resources/Relay && ./bun dist/index.js --help` should print the CLI help without missing-module errors.
- After packaging: `cd dist/Clawdis.app/Contents/Resources/Relay && bun dist/index.js --help` **or** `node dist/index.js --help` should print CLI help. If you see a runtime error, install/upgrade Node or Bun on the host.
- If sharp fails to load, confirm the remaining `@img/sharp-darwin-arm64` + `@img/sharp-libvips-darwin-arm64` directories exist and are codesigned.
## Notes / limits
- Bundle is mac-app-only; keep using pnpm+node for dev/test.
- Packaging stops early if Bun or `pnpm build` prerequisites are missing.
## FAQ
- **What does `--legacy` do?** When used with `pnpm deploy`, `--legacy` builds a classic flattened `node_modules` layout instead of pnpm's symlinked structure. We no longer need it in the current packaging flow because we create a self-contained hoisted install directly in the temp deploy dir.
- Dev/CI continues to use pnpm + Node; the packaged app simply reuses the host runtime instead of embedding Bun.
- Missing or too-old runtimes will surface as an immediate CLI error with install hints; update the host rather than rebuilding the app.

View File

@@ -29,9 +29,9 @@ Run the Node-based Clawdis/clawdis relay as a direct child of the LSUIElement ap
- Add a small `RelayProcessManager` (Swift) that owns:
- `execution: Execution?` from `Swift Subprocess` to track the child.
- `start(config)` called when “Clawdis Active” flips ON:
- binary: bundled Node or packaged relay CLI under `Clawdis.app/Contents/Resources/Relay/`
- args: current clawdis/clawdis entrypoint and flags
- cwd/env: point to `~/.clawdis` as today; inject `PATH` if the embedded Node isnt on PATH
- binary: host Node or Bun running the bundled relay under `Clawdis.app/Contents/Resources/Relay/`
- args: current clawdis entrypoint and flags
- cwd/env: point to `~/.clawdis` as today; inject the expanded PATH so Homebrew Node/Bun resolve under launchd
- output: stream stdout/stderr to `/tmp/clawdis-relay.log` (cap buffer via Subprocess OutputLimits)
- restart: optional linear/backoff restart if exit was non-zero and Active is still true
- `stop()` called when Active flips OFF or app terminates: cancel the execution and `waitUntilExit`.
@@ -41,11 +41,9 @@ Run the Node-based Clawdis/clawdis relay as a direct child of the LSUIElement ap
- Keep the existing `LaunchdManager` around so we can switch back if needed; the toggle can choose between launchd or child mode with a flag if we want both.
## Packaging and signing
- Bundle the relay runtime inside the app:
- Option A: embed a Node binary + JS entrypoint under `Contents/Resources/Relay/`.
- Option B: build a single binary via `pkg`/`nexe` and embed that.
- Codesign the embedded runtime as nested code with the same team ID; notarization fails if nested code is unsigned.
- If we keep using Homebrew Node, do *not* let it touch TCC; only the app/XPC should.
- Bundle the relay payload (dist + production node_modules) under `Contents/Resources/Relay/`; rely on host Node ≥22 or Bun ≥1.3 instead of embedding a runtime.
- Codesign native addons and dylibs inside the bundle; no nested runtime binary to sign now.
- Host runtime should not call TCC APIs directly; keep privileged work inside the app/XPC.
## Logging and observability
- Stream child stdout/stderr to `/tmp/clawdis-relay.log`; surface the last N lines in the Debug tab.
@@ -59,7 +57,7 @@ Run the Node-based Clawdis/clawdis relay as a direct child of the LSUIElement ap
## Open questions / follow-ups
- Do we need dual-mode (launchd for prod, child for dev)? If yes, gate via a setting or build flag.
- Should we embed Node or keep using the system/Homebrew Node? Embedding improves reproducibility and signing hygiene; Homebrew keeps bundle smaller but risks path/sandbox drift.
- Embedding a runtime is off the table for now; we rely on host Node/Bun for size/simplicity. Revisit only if host PATH drift becomes painful.
- Do we want a tiny signed helper for rare TCC actions that cannot be brokered via XPC?
## Decision snapshot (current recommendation)

View File

@@ -82,9 +82,9 @@ if [ -f "$APP_BUNDLE/Contents/MacOS/ClawdisCLI" ]; then
echo "Signing CLI helper"; sign_item "$APP_BUNDLE/Contents/MacOS/ClawdisCLI"
fi
# Sign bundled relay runtime bits (bun, native addons, libvips dylibs)
# Sign bundled relay payload (native addons, libvips dylibs)
if [ -d "$APP_BUNDLE/Contents/Resources/Relay" ]; then
find "$APP_BUNDLE/Contents/Resources/Relay" -type f \( -name "bun" -o -name "*.node" -o -name "*.dylib" \) -print0 | while IFS= read -r -d '' f; do
find "$APP_BUNDLE/Contents/Resources/Relay" -type f \( -name "*.node" -o -name "*.dylib" \) -print0 | while IFS= read -r -d '' f; do
echo "Signing relay payload: $f"; sign_item "$f"
done
fi

View File

@@ -111,15 +111,8 @@ echo "🧹 Removing unused pdfjs legacy bundle"
rm -rf "$APP_ROOT/Contents/Resources/WebChat/vendor/pdfjs-dist/legacy"
RELAY_DIR="$APP_ROOT/Contents/Resources/Relay"
BUN_SRC="${BUN_PATH:-$(command -v bun || true)}"
if [ -z "$BUN_SRC" ] || [ ! -x "$BUN_SRC" ]; then
echo "bun binary not found (set BUN_PATH to override)" >&2
exit 1
fi
echo "🧰 Staging relay runtime (bun + dist + node_modules)"
cp "$BUN_SRC" "$RELAY_DIR/bun"
chmod +x "$RELAY_DIR/bun"
echo "🧰 Staging relay payload (dist + node_modules; expects system Node ≥22 or Bun ≥1.3)"
rsync -a --delete --exclude "Clawdis.app" "$ROOT_DIR/dist/" "$RELAY_DIR/dist/"
cp "$ROOT_DIR/package.json" "$RELAY_DIR/"
cp "$ROOT_DIR/pnpm-lock.yaml" "$RELAY_DIR/"

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);
}