diff --git a/README.md b/README.md index aae063bd2..2c57280c9 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/apps/macos/Sources/Clawdis/RuntimeLocator.swift b/apps/macos/Sources/Clawdis/RuntimeLocator.swift new file mode 100644 index 000000000..64c236b85 --- /dev/null +++ b/apps/macos/Sources/Clawdis/RuntimeLocator.swift @@ -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 { + 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" } +} + diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index 7c2a52e99..19ebd80b4 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -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 { + 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] { diff --git a/docs/mac/bun.md b/docs/mac/bun.md index 1824a1c19..703920a8c 100644 --- a/docs/mac/bun.md +++ b/docs/mac/bun.md @@ -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 ` 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. diff --git a/docs/mac/child-process.md b/docs/mac/child-process.md index 0b03d2ec5..544174169 100644 --- a/docs/mac/child-process.md +++ b/docs/mac/child-process.md @@ -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 isn’t 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) diff --git a/scripts/codesign-mac-app.sh b/scripts/codesign-mac-app.sh index baf5c0e1e..18c59bc98 100755 --- a/scripts/codesign-mac-app.sh +++ b/scripts/codesign-mac-app.sh @@ -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 diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 23a5bce7e..3270212b4 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -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/" diff --git a/src/index.ts b/src/index.ts index d7c7a1827..65d7ec932 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(); diff --git a/src/infra/runtime-guard.test.ts b/src/infra/runtime-guard.test.ts new file mode 100644 index 000000000..f34b53ac2 --- /dev/null +++ b/src/infra/runtime-guard.test.ts @@ -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(); + }); +}); + diff --git a/src/infra/runtime-guard.ts b/src/infra/runtime-guard.ts new file mode 100644 index 000000000..167fda868 --- /dev/null +++ b/src/infra/runtime-guard.ts @@ -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); +} +