Spaces:
Runtime error
Runtime error
| import { spawn } from "node:child_process"; | |
| import { promisify } from "node:util"; | |
| import { execFile as execFileCb } from "node:child_process"; | |
| import { t } from "../i18n.mjs"; | |
| const execFile = promisify(execFileCb); | |
| const DEFAULT_IMAGE = "docker.io/redis:7-alpine"; | |
| const DEFAULT_NAME = "omniroute-redis"; | |
| const DEFAULT_PORT = "6379"; | |
| const DEFAULT_VOLUME = "omniroute-redis-data"; | |
| const RUNTIME_PREFERENCE = ["podman", "docker"]; | |
| async function detectRuntime() { | |
| for (const candidate of RUNTIME_PREFERENCE) { | |
| try { | |
| await execFile(candidate, ["--version"], { timeout: 3000 }); | |
| return candidate; | |
| } catch { | |
| // try next candidate | |
| } | |
| } | |
| return null; | |
| } | |
| async function containerExists(runtime, name) { | |
| try { | |
| const { stdout } = await execFile(runtime, ["ps", "-a", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]); | |
| return stdout.trim() === name; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| async function containerRunning(runtime, name) { | |
| try { | |
| const { stdout } = await execFile(runtime, ["ps", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]); | |
| return stdout.trim() === name; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| async function pingRedis(port) { | |
| // Minimal TCP probe via /dev/tcp — works in bash/zsh but Node has no | |
| // native equivalent, so spawn a short-lived `redis-cli` if available, | |
| // otherwise fall back to a raw socket connect. | |
| return new Promise((resolve) => { | |
| import("node:net").then(({ createConnection }) => { | |
| const socket = createConnection({ port: Number(port), host: "127.0.0.1" }); | |
| const timeout = setTimeout(() => { | |
| socket.destroy(); | |
| resolve(false); | |
| }, 1500); | |
| socket.once("connect", () => { | |
| clearTimeout(timeout); | |
| socket.end(); | |
| resolve(true); | |
| }); | |
| socket.once("error", () => { | |
| clearTimeout(timeout); | |
| resolve(false); | |
| }); | |
| }); | |
| }); | |
| } | |
| function colorize(text, code) { | |
| if (process.stdout.isTTY === false) return text; | |
| return `\x1b[${code}m${text}\x1b[0m`; | |
| } | |
| function info(msg) { | |
| console.log(colorize("•", "36") + " " + msg); | |
| } | |
| function success(msg) { | |
| console.log(colorize("✓", "32") + " " + msg); | |
| } | |
| function warn(msg) { | |
| console.error(colorize("!", "33") + " " + msg); | |
| } | |
| function fail(msg) { | |
| console.error(colorize("✗", "31") + " " + msg); | |
| } | |
| export function registerRedis(program) { | |
| const redis = program | |
| .command("redis") | |
| .description( | |
| t("redis.description") || | |
| "Launch a 1-click local Redis container (Podman or Docker) for OmniRoute caching and quota tracking" | |
| ); | |
| redis | |
| .command("up") | |
| .description("Start the local Redis container") | |
| .option("-p, --port <port>", "Host port to expose", DEFAULT_PORT) | |
| .option("-n, --name <name>", "Container name", DEFAULT_NAME) | |
| .option("-i, --image <image>", "Container image", DEFAULT_IMAGE) | |
| .option("--no-pull", "Skip pulling the image if it is missing") | |
| .option("--runtime <runtime>", "Force a specific runtime (podman|docker)") | |
| .option("--password <password>", "Set a Redis password (AUTH)") | |
| .action(async (opts, cmd) => { | |
| const globalOpts = cmd.parent.optsWithGlobals(); | |
| const exitCode = await runRedisUpCommand({ ...opts, output: globalOpts.output }); | |
| if (exitCode !== 0) process.exit(exitCode); | |
| }); | |
| redis | |
| .command("down") | |
| .description("Stop and remove the local Redis container") | |
| .option("-n, --name <name>", "Container name", DEFAULT_NAME) | |
| .option("--keep-data", "Keep the named volume for next start") | |
| .option("--runtime <runtime>", "Force a specific runtime (podman|docker)") | |
| .action(async (opts, cmd) => { | |
| const globalOpts = cmd.parent.optsWithGlobals(); | |
| const exitCode = await runRedisDownCommand({ ...opts, output: globalOpts.output }); | |
| if (exitCode !== 0) process.exit(exitCode); | |
| }); | |
| redis | |
| .command("status") | |
| .description("Show status of the local Redis container") | |
| .option("-n, --name <name>", "Container name", DEFAULT_NAME) | |
| .option("-p, --port <port>", "Host port", DEFAULT_PORT) | |
| .option("--runtime <runtime>", "Force a specific runtime (podman|docker)") | |
| .action(async (opts, cmd) => { | |
| const globalOpts = cmd.parent.optsWithGlobals(); | |
| const exitCode = await runRedisStatusCommand({ ...opts, output: globalOpts.output }); | |
| if (exitCode !== 0) process.exit(exitCode); | |
| }); | |
| } | |
| async function pickRuntime(forced) { | |
| if (forced) { | |
| try { | |
| await execFile(forced, ["--version"], { timeout: 3000 }); | |
| return forced; | |
| } catch (err) { | |
| fail(`Forced runtime '${forced}' not available: ${err.message}`); | |
| return null; | |
| } | |
| } | |
| const detected = await detectRuntime(); | |
| if (!detected) { | |
| fail("Neither podman nor docker found on PATH. Install one or pass --runtime."); | |
| return null; | |
| } | |
| return detected; | |
| } | |
| export async function runRedisUpCommand(opts = {}) { | |
| const runtime = await pickRuntime(opts.runtime); | |
| if (!runtime) return 1; | |
| const name = opts.name || DEFAULT_NAME; | |
| const port = opts.port || DEFAULT_PORT; | |
| const image = opts.image || DEFAULT_IMAGE; | |
| const exists = await containerExists(runtime, name); | |
| const running = exists && (await containerRunning(runtime, name)); | |
| if (running) { | |
| success(`Container '${name}' is already running on port ${port}.`); | |
| return 0; | |
| } | |
| if (exists && !opts.pull) { | |
| info(`Starting existing container '${name}'…`); | |
| try { | |
| await execFile(runtime, ["start", name]); | |
| success(`Container '${name}' started on port ${port}.`); | |
| return 0; | |
| } catch (err) { | |
| fail(`Failed to start existing container: ${err.message}`); | |
| return 1; | |
| } | |
| } | |
| if (!opts.pull) { | |
| info(`Checking if image '${image}' is present locally…`); | |
| let present = false; | |
| try { | |
| const { stdout } = await execFile(runtime, ["images", "--format", "{{.Repository}}:{{.Tag}}"]); | |
| present = stdout.split("\n").some((line) => line.trim() === image); | |
| } catch { | |
| // ignore — fall through to pull | |
| } | |
| if (!present) { | |
| info(`Image not found locally — pulling '${image}'…`); | |
| try { | |
| await execFile(runtime, ["pull", image]); | |
| } catch (err) { | |
| fail(`Failed to pull image: ${err.message}`); | |
| return 1; | |
| } | |
| } | |
| } | |
| const args = [ | |
| "run", | |
| "-d", | |
| "--name", name, | |
| "--restart", "unless-stopped", | |
| "-p", `${port}:6379`, | |
| "-v", `${DEFAULT_VOLUME}:/data`, | |
| ]; | |
| if (opts.password) { | |
| args.push("-e", `REDIS_PASSWORD=${opts.password}`); | |
| } | |
| args.push(image, "redis-server", "--appendonly", "yes"); | |
| if (opts.password) args.push("--requirepass", opts.password); | |
| info(`Launching ${runtime} run ${args.join(" ")}`); | |
| try { | |
| await execFile(runtime, args); | |
| success(`Container '${name}' is now running on redis://127.0.0.1:${port}`); | |
| info(`Set OMNIROUTE_REDIS_URL=redis://127.0.0.1:${port} in your .env to wire OmniRoute to it.`); | |
| return 0; | |
| } catch (err) { | |
| fail(`Failed to launch container: ${err.message}`); | |
| return 1; | |
| } | |
| } | |
| export async function runRedisDownCommand(opts = {}) { | |
| const runtime = await pickRuntime(opts.runtime); | |
| if (!runtime) return 1; | |
| const name = opts.name || DEFAULT_NAME; | |
| if (!(await containerExists(runtime, name))) { | |
| info(`Container '${name}' does not exist — nothing to do.`); | |
| return 0; | |
| } | |
| try { | |
| await execFile(runtime, ["rm", "-f", name]); | |
| success(`Removed container '${name}'.`); | |
| } catch (err) { | |
| fail(`Failed to remove container: ${err.message}`); | |
| return 1; | |
| } | |
| if (!opts.keepData) { | |
| try { | |
| await execFile(runtime, ["volume", "rm", DEFAULT_VOLUME]); | |
| success(`Removed volume '${DEFAULT_VOLUME}'.`); | |
| } catch (err) { | |
| warn(`Could not remove volume '${DEFAULT_VOLUME}': ${err.message}`); | |
| } | |
| } | |
| return 0; | |
| } | |
| export async function runRedisStatusCommand(opts = {}) { | |
| const runtime = await pickRuntime(opts.runtime); | |
| if (!runtime) return 1; | |
| const name = opts.name || DEFAULT_NAME; | |
| const port = opts.port || DEFAULT_PORT; | |
| const exists = await containerExists(runtime, name); | |
| if (!exists) { | |
| console.log(JSON.stringify({ runtime, name, port, exists: false, running: false, reachable: false }, null, 2)); | |
| return 0; | |
| } | |
| const running = await containerRunning(runtime, name); | |
| const reachable = running ? await pingRedis(port) : false; | |
| if (opts.json || opts.output === "json") { | |
| console.log(JSON.stringify({ runtime, name, port, exists, running, reachable }, null, 2)); | |
| return 0; | |
| } | |
| console.log(`\n\x1b[1m\x1b[36mRedis (${runtime})\x1b[0m\n`); | |
| console.log(` Container: ${name}`); | |
| console.log(` Exists: ${exists ? "yes" : "no"}`); | |
| console.log(` Running: ${running ? "yes" : "no"}`); | |
| console.log(` Reachable: ${reachable ? "yes" : "no"} (port ${port})`); | |
| if (running && !reachable) { | |
| warn("Container is running but the port is not reachable. Is REDIS_PASSWORD set or another process bound?"); | |
| } | |
| if (!running) { | |
| info(`Run 'omniroute redis up' to launch it.`); | |
| } | |
| return 0; | |
| } |