From f5e49246a00e0a857bb7bf7551dc2f1f979464cb Mon Sep 17 00:00:00 2001 From: Dead-Bytes <143434285+Dead-Bytes@users.noreply.github.com> Date: Wed, 20 May 2026 09:09:10 +0530 Subject: [PATCH 1/2] feat: implement dynamic port handling to resolve the port conflicts --- infra/docker/docker-compose.yml | 8 +- packages/cli/README.md | 12 +- packages/cli/src/BootCommand.ts | 199 ++++++++++++++++++---- packages/cli/src/PortConflictSelector.tsx | 193 +++++++++++++++++++++ packages/cli/src/dockerInfra.ts | 77 ++++++--- packages/cli/src/dockerPortDiagnostics.ts | 126 ++++++++++++++ packages/cli/src/infraPorts.ts | 133 +++++++++++++++ packages/cli/src/portConflictPrompt.ts | 61 +++++++ 8 files changed, 748 insertions(+), 61 deletions(-) create mode 100644 packages/cli/src/PortConflictSelector.tsx create mode 100644 packages/cli/src/dockerPortDiagnostics.ts create mode 100644 packages/cli/src/infraPorts.ts create mode 100644 packages/cli/src/portConflictPrompt.ts diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index c73be99..afd6cb8 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -4,7 +4,7 @@ services: container_name: bytebell-mongo restart: unless-stopped ports: - - "127.0.0.1:27017:27017" + - "127.0.0.1:${MONGO_HOST_PORT:-27017}:27017" volumes: - mongo_data:/data/db environment: @@ -25,8 +25,8 @@ services: container_name: bytebell-neo4j restart: unless-stopped ports: - - "127.0.0.1:7474:7474" - - "127.0.0.1:7687:7687" + - "127.0.0.1:${NEO4J_HTTP_HOST_PORT:-7474}:7474" + - "127.0.0.1:${NEO4J_BOLT_HOST_PORT:-7687}:7687" volumes: - neo4j_data:/data environment: @@ -47,7 +47,7 @@ services: container_name: bytebell-redis restart: unless-stopped ports: - - "127.0.0.1:6379:6379" + - "127.0.0.1:${REDIS_HOST_PORT:-6379}:6379" volumes: - redis_data:/data command: ["redis-server", "--appendonly", "yes"] diff --git a/packages/cli/README.md b/packages/cli/README.md index 619a054..2abcde1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -36,12 +36,20 @@ indexing, configuration, server lifecycle, and inspection. matching `bytebell set …` hint). Auto-fills blank infra config keys with local-docker defaults (mongo / neo4j / neo4j-user / redis) and generates a random Neo4j password if one isn't already set. Writes - `infra/docker/.env`, runs `docker compose -f + `infra/docker/.env` (Neo4j password + host ports derived from the + configured URIs), runs `docker compose -f infra/docker/docker-compose.yml up -d`, polls `docker compose ps --format json` until all three services report `healthy`, then invokes `ensureServerRunning()` (existing helper) to spawn `bytebell-server`. Idempotent — re-running on an already-up - stack is a fast no-op. + stack is a fast no-op. When a compose host port is already taken, + boot drops into an Ink picker (`PortConflictSelector.tsx`) offering + three choices: reuse the existing service on that port (compose + starts only the unconflicted services), stop the conflicting + container and reuse the port, or change bytebell's host port for + the affected service (mongo / neo4j-bolt / redis URI gets rewritten + via `setConfigValue`, compose env is regenerated, retry). Up to + four conflict rounds before giving up. - `bytebell shutdown` — sends SIGTERM to the server PID, polls until the PID file vanishes (≤ 30 s), and prints the `docker compose down` hint. Docker infra is **left running** by design — warm re-boots are diff --git a/packages/cli/src/BootCommand.ts b/packages/cli/src/BootCommand.ts index 2e522da..02d7ab7 100644 --- a/packages/cli/src/BootCommand.ts +++ b/packages/cli/src/BootCommand.ts @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: AGPL-3.0-only WITH non-commercial-clause import { Command } from "commander"; import { Config } from "@bb/types"; import { HINTS, getConfigValue, isDevMode } from "@bb/config"; @@ -6,11 +7,26 @@ import { DockerComposeError, DockerHealthTimeoutError, DockerNotFoundError, + DockerPortConflictError, composeFilePath, + down, up, + type UpResult, } from "./dockerInfra.ts"; import { ServerStartTimeoutError, ensureServerRunning } from "./serverSpawn.ts"; import { createSpinner, error, info, success } from "./output.ts"; +import { + labelForService, + readInfraPorts, + serviceForPort, + setInfraPort, + type InfraPorts, + type InfraService, +} from "./infraPorts.ts"; +import { diagnosePortConflict, promptPortConflict } from "./portConflictPrompt.ts"; +import { removeContainer } from "./dockerPortDiagnostics.ts"; + +const MAX_CONFLICT_ROUNDS = 4; export function buildBootCommand(): Command { const cmd = new Command("boot"); @@ -43,54 +59,177 @@ async function runBoot(): Promise { return; } - const dockerSpinner = createSpinner("Starting Docker infrastructure..."); - try { - const result = await up({ - neo4jPassword: defaults.neo4jPassword, - onProgress: (line) => { - dockerSpinner.update(`Docker: ${line}`); - }, - }); - dockerSpinner.stop(true, `Docker infra is up (${composeFilePath()})`); - success(`mongo → ${result.services.mongo}`); - success(`neo4j → ${result.services.neo4j}`); - success(`redis → ${result.services.redis}`); - } catch (cause: unknown) { - dockerSpinner.stop(false, "Docker startup failed"); - handleDockerError(cause); + const upResult = await bringInfraUp(defaults.neo4jPassword); + if (upResult === null) { return; } + success(`mongo → ${upResult.services.mongo}`); + success(`neo4j → ${upResult.services.neo4j}`); + success(`redis → ${upResult.services.redis}`); - let serverContext: Awaited>; - const serverSpinner = createSpinner("Starting ByteBell server..."); + if (!(await startServer())) { + return; + } + + const port = getConfigValue(Config.ServerPort); + success(`MCP endpoint: http://127.0.0.1:${port}/mcp`); + process.stdout.write("\nNext: bytebell index or bytebell ingest [path]\n"); +} + +async function bringInfraUp(neo4jPassword: string): Promise { + const skipServices = new Set<"mongo" | "neo4j" | "redis">(); + for (let round = 0; round < MAX_CONFLICT_ROUNDS; round += 1) { + const ports = readInfraPorts(); + const watched = composeServicesToStart(skipServices); + const spinner = createSpinner("Starting Docker infrastructure..."); + try { + const result = await up({ + neo4jPassword, + ports, + servicesToStart: watched, + onProgress: (line) => spinner.update(`Docker: ${line}`), + }); + spinner.stop(true, `Docker infra is up (${composeFilePath()})`); + for (const svc of skipServices) { + info(`reusing existing service on port ${portFor(svc, ports)} for ${svc} (not managed by bytebell)`); + } + return result; + } catch (cause: unknown) { + spinner.stop(false, "Docker startup failed"); + if (cause instanceof DockerPortConflictError) { + const handled = await handlePortConflict(cause, ports, skipServices); + if (handled) { + continue; + } + process.exitCode = 1; + return null; + } + handleDockerError(cause); + return null; + } + } + error(`Gave up after ${MAX_CONFLICT_ROUNDS} attempts to resolve port conflicts.`); + process.exitCode = 1; + return null; +} + +async function handlePortConflict( + cause: DockerPortConflictError, + ports: InfraPorts, + skipServices: Set<"mongo" | "neo4j" | "redis">, +): Promise { + const infraService = serviceForPort(cause.port, ports); + if (infraService === null) { + error(`Port ${cause.port} conflict, but it doesn't match a known bytebell service. Aborting.`); + info(cause.stderr.trim()); + return false; + } + const composeService = composeServiceFor(infraService); + const serviceLabel = labelForService(infraService); + const ctx = await diagnosePortConflict(cause.port, serviceLabel); + const resolution = await promptPortConflict(ctx); + + if (resolution.action === "cancel") { + error("Boot cancelled."); + return false; + } + + // Tear down the half-created compose state so the retry starts clean. + await safeComposeDown(); + + if (resolution.action === "reuse") { + skipServices.add(composeService); + success(`will reuse existing ${serviceLabel} on port ${cause.port}.`); + return true; + } + if (resolution.action === "kill") { + if (ctx.container === null) { + error("Nothing to remove — the conflicting process isn't a docker container. Stop it manually and retry."); + return false; + } + try { + await removeContainer(ctx.container.id); + success(`removed conflicting container ${ctx.container.name}.`); + } catch (e: unknown) { + error(`docker rm -f ${ctx.container.name} failed: ${e instanceof Error ? e.message : String(e)}`); + return false; + } + return true; + } + if (resolution.action === "change") { + const newPort = resolution.newPort; + if (newPort === undefined) { + error("internal: change selected without a new port."); + return false; + } + setInfraPort(infraService, newPort); + success(`updated bytebell ${serviceLabel} → port ${newPort}.`); + skipServices.delete(composeService); + return true; + } + return false; +} + +async function safeComposeDown(): Promise { + try { + await down(); + } catch { + // best-effort cleanup — ignore failures + } +} + +function composeServicesToStart(skip: Set<"mongo" | "neo4j" | "redis">): readonly ("mongo" | "neo4j" | "redis")[] { + if (skip.size === 0) { + return ["mongo", "neo4j", "redis"]; + } + return (["mongo", "neo4j", "redis"] as const).filter((s) => !skip.has(s)); +} + +function composeServiceFor(service: InfraService): "mongo" | "neo4j" | "redis" { + if (service === "mongo") { + return "mongo"; + } + if (service === "redis") { + return "redis"; + } + return "neo4j"; +} + +function portFor(service: "mongo" | "neo4j" | "redis", ports: InfraPorts): number { + if (service === "mongo") { + return ports.mongo; + } + if (service === "redis") { + return ports.redis; + } + return ports.neo4jBolt; +} + +async function startServer(): Promise { + const spinner = createSpinner("Starting ByteBell server..."); try { - serverContext = await ensureServerRunning((line) => { - serverSpinner.update(`Server: ${line}`); - }); - if (serverContext.alreadyRunning) { - serverSpinner.stop(true, `Server already running`); - if (serverContext.devModeMismatch === true) { + const ctx = await ensureServerRunning((line) => spinner.update(`Server: ${line}`)); + if (ctx.alreadyRunning) { + spinner.stop(true, "Server already running"); + if (ctx.devModeMismatch === true) { info( "BYTEBELL_DEV=1 set but server is already running. Run `bytebell shutdown && BYTEBELL_DEV=1 bytebell boot` to apply.", ); } } else { - serverSpinner.stop(true, `Server started (logs: ${serverContext.logPath ?? "n/a"})`); + spinner.stop(true, `Server started (logs: ${ctx.logPath ?? "n/a"})`); } + return true; } catch (cause: unknown) { - serverSpinner.stop(false, "Server startup failed"); + spinner.stop(false, "Server startup failed"); if (cause instanceof ServerStartTimeoutError) { error(cause.message); } else { error(cause instanceof Error ? cause.message : String(cause)); } process.exitCode = 1; - return; + return false; } - - const port = getConfigValue(Config.ServerPort); - success(`MCP endpoint: http://127.0.0.1:${port}/mcp`); - process.stdout.write("\nNext: bytebell index or bytebell ingest [path]\n"); } function enforcePreflight(): boolean { diff --git a/packages/cli/src/PortConflictSelector.tsx b/packages/cli/src/PortConflictSelector.tsx new file mode 100644 index 0000000..4d22c1e --- /dev/null +++ b/packages/cli/src/PortConflictSelector.tsx @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: AGPL-3.0-only WITH non-commercial-clause +import { useState } from "react"; +import type { ReactElement } from "react"; +import { Box, Text, useApp, useInput } from "ink"; +import TextInput from "ink-text-input"; + +export type PortConflictAction = "reuse" | "kill" | "change" | "cancel"; + +export interface PortConflictResolution { + action: PortConflictAction; + newPort?: number; +} + +export interface PortConflictSelectorProps { + port: number; + serviceLabel: string; + occupantLabel: string; + canKill: boolean; + onDone: (result: PortConflictResolution) => void; +} + +interface Choice { + action: Exclude; + label: string; + hint: string; + disabled?: boolean; + disabledHint?: string; +} + +export function PortConflictSelector(props: PortConflictSelectorProps): ReactElement { + const { exit } = useApp(); + const [index, setIndex] = useState(0); + const [stage, setStage] = useState<"choose" | "port-input">("choose"); + const [portInput, setPortInput] = useState(String(props.port + 1)); + const [error, setError] = useState(null); + + const choices: Choice[] = [ + { + action: "reuse", + label: `Use the service already running on ${props.port}`, + hint: "skip starting bytebell's container for this service", + }, + killChoice(props), + { + action: "change", + label: `Change bytebell's host port for ${props.serviceLabel}`, + hint: "pick a new free port; bytebell config + compose env are updated", + }, + ]; + + useInput((input, key) => { + if (stage === "choose") { + if (key.escape) { + exit(); + props.onDone({ action: "cancel" }); + return; + } + if (key.upArrow || input === "k") { + setIndex((i) => nextEnabledIndex(choices, i, -1)); + return; + } + if (key.downArrow || input === "j") { + setIndex((i) => nextEnabledIndex(choices, i, 1)); + return; + } + if (key.return) { + const chosen = choices[index]; + if (chosen === undefined || chosen.disabled === true) { + return; + } + if (chosen.action === "change") { + setStage("port-input"); + return; + } + exit(); + props.onDone({ action: chosen.action }); + } + return; + } + if (key.escape) { + setStage("choose"); + setError(null); + } + }); + + if (stage === "port-input") { + return ( + + New host port for {props.serviceLabel} + current: {props.port} — Enter to confirm, Esc to go back + + Port: + { + setPortInput(value); + if (error !== null) { + setError(null); + } + }} + onSubmit={(value) => { + const parsed = parsePort(value, props.port); + if (typeof parsed === "string") { + setError(parsed); + return; + } + exit(); + props.onDone({ action: "change", newPort: parsed }); + }} + /> + + {error !== null ? ( + + {error} + + ) : null} + + ); + } + + return ( + + + + Port {props.port} is already in use ({props.serviceLabel}). + + occupant: {props.occupantLabel} + + {choices.map((choice, i) => { + const selected = i === index; + const colorProp = choice.disabled === true ? { color: "gray" } : selected ? { color: "cyan" } : {}; + return ( + + + {selected ? "❯ " : " "} + {choice.label} + + {choice.disabled === true ? (choice.disabledHint ?? choice.hint) : choice.hint} + + ); + })} + + ↑/↓ to choose, Enter to confirm, Esc to cancel + + + ); +} + +function killChoice(props: PortConflictSelectorProps): Choice { + if (props.canKill) { + return { + action: "kill", + label: `Stop the conflicting container and reuse port ${props.port}`, + hint: `docker rm -f ${props.occupantLabel}`, + }; + } + return { + action: "kill", + label: `Stop the conflicting container and reuse port ${props.port}`, + hint: "no removable container found", + disabled: true, + disabledHint: "occupant is not a docker container — stop it manually", + }; +} + +function nextEnabledIndex(choices: Choice[], from: number, step: number): number { + const n = choices.length; + for (let i = 1; i <= n; i += 1) { + const candidate = (from + step * i + n) % n; + if (choices[candidate]?.disabled !== true) { + return candidate; + } + } + return from; +} + +function parsePort(raw: string, current: number): number | string { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return "Enter a port number."; + } + if (!/^\d+$/u.test(trimmed)) { + return "Port must be a positive integer."; + } + const n = Number.parseInt(trimmed, 10); + if (n < 1 || n > 65535) { + return "Port must be between 1 and 65535."; + } + if (n === current) { + return "Pick a different port than the conflicting one."; + } + return n; +} diff --git a/packages/cli/src/dockerInfra.ts b/packages/cli/src/dockerInfra.ts index 0b39b53..b18801c 100644 --- a/packages/cli/src/dockerInfra.ts +++ b/packages/cli/src/dockerInfra.ts @@ -2,6 +2,8 @@ import { spawn } from "node:child_process"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { envFileBody, type InfraPorts } from "./infraPorts.ts"; +import { parsePortFromComposeError } from "./dockerPortDiagnostics.ts"; const COMPOSE_HEALTH_POLL_MS = 2_000; const COMPOSE_HEALTH_TIMEOUT_MS = 90_000; @@ -18,8 +20,21 @@ export class DockerNotFoundError extends Error { export class DockerComposeError extends Error { override readonly name = "DockerComposeError"; + readonly stderr: string; constructor(stage: string, exitCode: number, stderr: string) { super(`docker compose ${stage} failed (exit ${exitCode}): ${stderr.trim() || "no stderr"}`); + this.stderr = stderr; + } +} + +export class DockerPortConflictError extends Error { + override readonly name = "DockerPortConflictError"; + readonly port: number; + readonly stderr: string; + constructor(port: number, stderr: string) { + super(`Host port ${port} is already in use.`); + this.port = port; + this.stderr = stderr; } } @@ -40,6 +55,8 @@ interface ComposePsRow { interface UpOptions { neo4jPassword: string; + ports: InfraPorts; + servicesToStart?: readonly ServiceName[]; onProgress?: (line: string) => void; } @@ -58,36 +75,43 @@ function envFilePath(): string { } export async function up(opts: UpOptions): Promise { - await writeEnvFile(opts.neo4jPassword); - await runDocker(["compose", "-f", composeFilePath(), "up", "-d"], "up", true); + await writeEnvFile(opts.ports, opts.neo4jPassword); + const upArgs = ["compose", "-f", composeFilePath(), "up", "-d", ...(opts.servicesToStart ?? [])]; + await runDocker(upArgs, "up"); - const unhealthy = await waitUntilHealthy(opts.onProgress); + const watched = opts.servicesToStart ?? SERVICES; + const unhealthy = await waitUntilHealthy(watched, opts.onProgress); if (unhealthy.length > 0) { throw new DockerHealthTimeoutError(unhealthy); } return { composeFile: composeFilePath(), services: { - mongo: "127.0.0.1:27017", - neo4j: "127.0.0.1:7687 (HTTP 7474)", - redis: "127.0.0.1:6379", + mongo: `127.0.0.1:${opts.ports.mongo}`, + neo4j: `127.0.0.1:${opts.ports.neo4jBolt} (HTTP ${opts.ports.neo4jHttp})`, + redis: `127.0.0.1:${opts.ports.redis}`, }, }; } -async function writeEnvFile(neo4jPassword: string): Promise { - const target = envFilePath(); - const body = `NEO4J_PASSWORD=${neo4jPassword}\n`; - await writeFile(target, body, { mode: 0o600 }); +export async function down(): Promise { + await runDocker(["compose", "-f", composeFilePath(), "down", "--remove-orphans"], "down"); +} + +async function writeEnvFile(ports: InfraPorts, neo4jPassword: string): Promise { + await writeFile(envFilePath(), envFileBody(ports, neo4jPassword), { mode: 0o600 }); } -async function waitUntilHealthy(onProgress?: (line: string) => void): Promise { +async function waitUntilHealthy( + watched: readonly ServiceName[], + onProgress?: (line: string) => void, +): Promise { const start = Date.now(); while (Date.now() - start < COMPOSE_HEALTH_TIMEOUT_MS) { const rows = await psSnapshot(); - const status = summarize(rows); + const status = summarize(rows, watched); if (onProgress !== undefined) { - onProgress(formatProgress(status)); + onProgress(formatProgress(status, watched)); } if (status.unhealthy.length === 0) { return []; @@ -95,7 +119,7 @@ async function waitUntilHealthy(onProgress?: (line: string) => void): Promise r.Service === service); if (row !== undefined && row.Health === "healthy") { healthy.push(service); @@ -117,13 +141,13 @@ function summarize(rows: ComposePsRow[]): StatusSummary { return { healthy, unhealthy }; } -function formatProgress(status: StatusSummary): string { +function formatProgress(status: StatusSummary, watched: readonly ServiceName[]): string { const tag = (name: ServiceName): string => (status.healthy.includes(name) ? `${name} ✓` : `${name} …`); - return SERVICES.map(tag).join(" "); + return watched.map(tag).join(" "); } async function psSnapshot(): Promise { - const { stdout } = await runDocker(["compose", "-f", composeFilePath(), "ps", "--format", "json"], "ps", false); + const { stdout } = await runDocker(["compose", "-f", composeFilePath(), "ps", "--format", "json"], "ps"); return parsePsOutput(stdout); } @@ -171,17 +195,15 @@ interface DockerRunResult { stderr: string; } -async function runDocker(args: string[], stage: string, inheritStdout: boolean): Promise { +async function runDocker(args: string[], stage: string): Promise { return new Promise((resolve, reject) => { - const child = spawn("docker", args, { - stdio: ["ignore", inheritStdout ? "inherit" : "pipe", "pipe"], - }); + const child = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; - child.stdout?.on("data", (chunk: Buffer) => { + child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); }); - child.stderr?.on("data", (chunk: Buffer) => { + child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); }); child.on("error", (cause: Error & { code?: string }) => { @@ -197,6 +219,11 @@ async function runDocker(args: string[], stage: string, inheritStdout: boolean): resolve({ stdout, stderr }); return; } + const port = parsePortFromComposeError(stderr); + if (port !== null) { + reject(new DockerPortConflictError(port, stderr)); + return; + } reject(new DockerComposeError(stage, exit, stderr)); }); }); diff --git a/packages/cli/src/dockerPortDiagnostics.ts b/packages/cli/src/dockerPortDiagnostics.ts new file mode 100644 index 0000000..7e9a125 --- /dev/null +++ b/packages/cli/src/dockerPortDiagnostics.ts @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: AGPL-3.0-only WITH non-commercial-clause +import { spawn } from "node:child_process"; + +const PORT_ALLOCATION_RE = /Bind for (?:[\d.]+:)?(\d+) failed: port is already allocated/iu; +const ADDRESS_IN_USE_RE = + /(?:listen tcp(?:[46])?\s+[\d.]+:|listen tcp \[[^\]]+\]:)(\d+):\s*bind: address already in use/iu; + +export interface ConflictingContainer { + id: string; + name: string; + image: string; + isBytebell: boolean; +} + +export interface ConflictingHostProcess { + pid: number; + command: string; +} + +export function parsePortFromComposeError(stderr: string): number | null { + const m1 = PORT_ALLOCATION_RE.exec(stderr); + if (m1?.[1] !== undefined) { + return parsePort(m1[1]); + } + const m2 = ADDRESS_IN_USE_RE.exec(stderr); + if (m2?.[1] !== undefined) { + return parsePort(m2[1]); + } + return null; +} + +export async function findContainerOnPort(port: number): Promise { + const args = ["ps", "--filter", `publish=${port}`, "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}"]; + const { stdout } = await runDockerCapture(args); + const line = stdout + .split("\n") + .map((l) => l.trim()) + .find((l) => l.length > 0); + if (line === undefined) { + return null; + } + const [id, name, image] = line.split("\t"); + if (id === undefined || name === undefined || image === undefined) { + return null; + } + return { + id, + name, + image, + isBytebell: name.startsWith("bytebell-"), + }; +} + +export async function removeContainer(id: string): Promise { + await runDockerCapture(["rm", "-f", id]); +} + +export async function findHostProcessOnPort(port: number): Promise { + if (process.platform !== "darwin" && process.platform !== "linux") { + return null; + } + const result = await runCapture("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-F", "pc"]); + if (result === null) { + return null; + } + const lines = result.stdout.split("\n"); + let pid: number | null = null; + let command = ""; + for (const line of lines) { + if (line.startsWith("p")) { + const n = Number.parseInt(line.slice(1), 10); + if (Number.isInteger(n) && n > 0) { + pid = n; + } + } else if (line.startsWith("c")) { + command = line.slice(1); + } + if (pid !== null && command.length > 0) { + break; + } + } + if (pid === null) { + return null; + } + return { pid, command }; +} + +function parsePort(raw: string): number | null { + const n = Number.parseInt(raw, 10); + if (!Number.isInteger(n) || n <= 0 || n > 65535) { + return null; + } + return n; +} + +async function runDockerCapture(args: string[]): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", (cause: Error) => reject(cause)); + child.on("exit", () => resolve({ stdout, stderr })); + }); +} + +async function runCapture(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string } | null> { + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", () => resolve(null)); + child.on("exit", () => resolve({ stdout, stderr })); + }); +} diff --git a/packages/cli/src/infraPorts.ts b/packages/cli/src/infraPorts.ts new file mode 100644 index 0000000..5b6787f --- /dev/null +++ b/packages/cli/src/infraPorts.ts @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: AGPL-3.0-only WITH non-commercial-clause +import { Config } from "@bb/types"; +import { getConfigValue, setConfigValue } from "@bb/config"; + +export type InfraService = "mongo" | "neo4j-bolt" | "neo4j-http" | "redis"; + +export const NEO4J_HTTP_BOLT_OFFSET = 213; + +const DEFAULTS: Record = { + mongo: 27017, + "neo4j-bolt": 7687, + "neo4j-http": 7474, + redis: 6379, +}; + +export interface InfraPorts { + mongo: number; + neo4jBolt: number; + neo4jHttp: number; + redis: number; +} + +export function readInfraPorts(): InfraPorts { + const boltPort = portFromUri(readString(Config.Neo4jUri), DEFAULTS["neo4j-bolt"]); + return { + mongo: portFromUri(readString(Config.MongoUri), DEFAULTS.mongo), + neo4jBolt: boltPort, + neo4jHttp: deriveHttpPort(boltPort), + redis: portFromUri(readString(Config.RedisUrl), DEFAULTS.redis), + }; +} + +export function serviceForPort(port: number, ports: InfraPorts): InfraService | null { + if (port === ports.mongo) { + return "mongo"; + } + if (port === ports.neo4jBolt) { + return "neo4j-bolt"; + } + if (port === ports.neo4jHttp) { + return "neo4j-http"; + } + if (port === ports.redis) { + return "redis"; + } + return null; +} + +export function setInfraPort(service: InfraService, newPort: number): void { + switch (service) { + case "mongo": + setConfigValue(Config.MongoUri, replacePort(readString(Config.MongoUri), newPort)); + return; + case "neo4j-bolt": + case "neo4j-http": + setConfigValue(Config.Neo4jUri, replacePort(readString(Config.Neo4jUri), boltPortForService(service, newPort))); + return; + case "redis": + setConfigValue(Config.RedisUrl, replacePort(readString(Config.RedisUrl), newPort)); + return; + } +} + +export function envFileBody(ports: InfraPorts, neo4jPassword: string): string { + return [ + `NEO4J_PASSWORD=${neo4jPassword}`, + `MONGO_HOST_PORT=${ports.mongo}`, + `NEO4J_BOLT_HOST_PORT=${ports.neo4jBolt}`, + `NEO4J_HTTP_HOST_PORT=${ports.neo4jHttp}`, + `REDIS_HOST_PORT=${ports.redis}`, + "", + ].join("\n"); +} + +export function labelForService(service: InfraService): string { + switch (service) { + case "mongo": + return "mongo"; + case "neo4j-bolt": + return "neo4j (bolt)"; + case "neo4j-http": + return "neo4j (http UI)"; + case "redis": + return "redis"; + } +} + +function deriveHttpPort(boltPort: number): number { + const candidate = boltPort - NEO4J_HTTP_BOLT_OFFSET; + if (candidate > 0 && candidate <= 65535) { + return candidate; + } + return DEFAULTS["neo4j-http"]; +} + +function boltPortForService(service: "neo4j-bolt" | "neo4j-http", newPort: number): number { + if (service === "neo4j-bolt") { + return newPort; + } + return newPort + NEO4J_HTTP_BOLT_OFFSET; +} + +function portFromUri(uri: string, fallback: number): number { + if (uri.length === 0) { + return fallback; + } + try { + const parsed = new URL(uri); + if (parsed.port.length > 0) { + const n = Number.parseInt(parsed.port, 10); + if (Number.isInteger(n) && n > 0 && n <= 65535) { + return n; + } + } + } catch { + // fall through + } + return fallback; +} + +function replacePort(uri: string, newPort: number): string { + if (uri.length === 0) { + throw new Error("internal: cannot replace port on empty URI"); + } + const parsed = new URL(uri); + parsed.port = String(newPort); + return parsed.toString(); +} + +function readString(key: Config): string { + const value = getConfigValue(key); + return typeof value === "string" ? value : ""; +} diff --git a/packages/cli/src/portConflictPrompt.ts b/packages/cli/src/portConflictPrompt.ts new file mode 100644 index 0000000..70c3285 --- /dev/null +++ b/packages/cli/src/portConflictPrompt.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: AGPL-3.0-only WITH non-commercial-clause +import React from "react"; +import { render } from "ink"; +import { + PortConflictSelector, + type PortConflictResolution, + type PortConflictSelectorProps, +} from "./PortConflictSelector.tsx"; +import { + findContainerOnPort, + findHostProcessOnPort, + type ConflictingContainer, + type ConflictingHostProcess, +} from "./dockerPortDiagnostics.ts"; + +export interface PortConflictContext { + port: number; + serviceLabel: string; + container: ConflictingContainer | null; + hostProcess: ConflictingHostProcess | null; +} + +export async function diagnosePortConflict(port: number, serviceLabel: string): Promise { + const container = await findContainerOnPort(port).catch(() => null); + const hostProcess = container === null ? await findHostProcessOnPort(port) : null; + return { port, serviceLabel, container, hostProcess }; +} + +export async function promptPortConflict(ctx: PortConflictContext): Promise { + const props: PortConflictSelectorProps = { + port: ctx.port, + serviceLabel: ctx.serviceLabel, + occupantLabel: describeOccupant(ctx), + canKill: ctx.container !== null, + onDone: () => { + // overridden below + }, + }; + return new Promise((resolve) => { + const app = render( + React.createElement(PortConflictSelector, { + ...props, + onDone: (result) => { + app.unmount(); + resolve(result); + }, + }), + ); + }); +} + +function describeOccupant(ctx: PortConflictContext): string { + if (ctx.container !== null) { + const flag = ctx.container.isBytebell ? " [bytebell]" : ""; + return `container ${ctx.container.name} (${ctx.container.image})${flag}`; + } + if (ctx.hostProcess !== null) { + return `host process ${ctx.hostProcess.command} (pid ${ctx.hostProcess.pid})`; + } + return "unknown process"; +} From 6230b52c2ea286f93d3f22bc9bb1c2224d872470 Mon Sep 17 00:00:00 2001 From: Dead-Bytes <143434285+Dead-Bytes@users.noreply.github.com> Date: Thu, 21 May 2026 16:31:43 +0530 Subject: [PATCH 2/2] feat: enhance shutdown command with Docker infra management options --- packages/cli/README.md | 12 +++-- packages/cli/src/ShutdownCommand.ts | 70 +++++++++++++++++++++++++--- packages/cli/src/StopInfraPrompt.tsx | 63 +++++++++++++++++++++++++ packages/cli/src/shutdownPrompts.ts | 17 +++++++ 4 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/StopInfraPrompt.tsx create mode 100644 packages/cli/src/shutdownPrompts.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 2abcde1..49f42e7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -51,9 +51,15 @@ infra/docker/docker-compose.yml up -d`, polls via `setConfigValue`, compose env is regenerated, retry). Up to four conflict rounds before giving up. - `bytebell shutdown` — sends SIGTERM to the server PID, polls until - the PID file vanishes (≤ 30 s), and prints the `docker compose down` - hint. Docker infra is **left running** by design — warm re-boots are - fast. + the PID file vanishes (≤ 30 s), then asks (Ink prompt + `StopInfraPrompt.tsx`) whether to stop Docker infra too. Default + answer is **Yes** (Enter tears down `mongo + neo4j + redis` via + `docker compose down --remove-orphans`); pressing `n` / Esc keeps the + containers running for fast warm re-boots and prints the manual + `docker compose down` hint. The prompt is skipped when stdin isn't a + TTY (CI-safe — falls back to keeping infra up). Two flags override + the prompt deterministically: `--with-docker` always stops infra, + `--keep-docker` always leaves it running; passing both is rejected. - `bytebell server start` — low-level wrapper that spawns the server in the foreground (Ctrl+C to stop). Used during dev; everyday users prefer `bytebell boot`. diff --git a/packages/cli/src/ShutdownCommand.ts b/packages/cli/src/ShutdownCommand.ts index e8c00f3..5b1e32d 100644 --- a/packages/cli/src/ShutdownCommand.ts +++ b/packages/cli/src/ShutdownCommand.ts @@ -1,20 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-only WITH non-commercial-clause import { Command } from "commander"; import { readFile, stat } from "node:fs/promises"; import path from "node:path"; import { getBytebellHome } from "@bb/config"; -import { composeFilePath } from "./dockerInfra.ts"; +import { DockerComposeError, DockerNotFoundError, composeFilePath, down } from "./dockerInfra.ts"; import { createSpinner, error, success } from "./output.ts"; +import { promptStopDocker } from "./shutdownPrompts.ts"; const POLL_INTERVAL_MS = 500; const POLL_TIMEOUT_MS = 30_000; +interface ShutdownOptions { + withDocker?: boolean; + keepDocker?: boolean; +} + export function buildShutdownCommand(): Command { const cmd = new Command("shutdown"); - cmd.description("Stop the bytebell-server (docker infra is left running).").action(runShutdown); + cmd + .description("Stop the bytebell-server (and optionally Docker infra).") + .option("--with-docker", "also stop Docker infra without prompting") + .option("--keep-docker", "leave Docker infra running without prompting") + .action((opts: ShutdownOptions) => runShutdown(opts)); return cmd; } -async function runShutdown(): Promise { +async function runShutdown(opts: ShutdownOptions): Promise { + if (opts.withDocker === true && opts.keepDocker === true) { + error("--with-docker and --keep-docker are mutually exclusive."); + process.exitCode = 1; + return; + } + const pidFile = path.join(getBytebellHome(), "pid"); const pid = await readPid(pidFile); if (pid === null) { @@ -40,16 +57,55 @@ async function runShutdown(): Promise { } const drained = await waitForPidFileGone(pidFile); - if (drained) { - spinner.stop(true, `server (pid ${pid}) shut down gracefully.`); - } else { + if (!drained) { spinner.stop( false, `server (pid ${pid}) did not exit within ${POLL_TIMEOUT_MS / 1000}s; not escalating to SIGKILL.`, ); process.exitCode = 1; + process.stdout.write(dockerHint()); + return; + } + spinner.stop(true, `server (pid ${pid}) shut down gracefully.`); + + const shouldStop = await decideStopDocker(opts); + if (shouldStop) { + await stopDocker(); + } else { + process.stdout.write(dockerHint()); + } +} + +async function decideStopDocker(opts: ShutdownOptions): Promise { + if (opts.withDocker === true) { + return true; + } + if (opts.keepDocker === true) { + return false; + } + if (process.stdin.isTTY !== true) { + return false; + } + return promptStopDocker(); +} + +async function stopDocker(): Promise { + const spinner = createSpinner("Stopping Docker infrastructure..."); + try { + await down(); + spinner.stop(true, "Docker infra stopped."); + } catch (cause: unknown) { + spinner.stop(false, "Docker shutdown failed"); + if (cause instanceof DockerNotFoundError) { + error(cause.message); + } else if (cause instanceof DockerComposeError) { + error(cause.message); + } else { + error(cause instanceof Error ? cause.message : String(cause)); + } + process.exitCode = 1; + process.stdout.write(dockerHint()); } - process.stdout.write(dockerHint()); } async function readPid(pidFile: string): Promise { diff --git a/packages/cli/src/StopInfraPrompt.tsx b/packages/cli/src/StopInfraPrompt.tsx new file mode 100644 index 0000000..e898621 --- /dev/null +++ b/packages/cli/src/StopInfraPrompt.tsx @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: AGPL-3.0-only WITH non-commercial-clause +import { useState } from "react"; +import type { ReactElement } from "react"; +import { Box, Text, useApp, useInput } from "ink"; + +export interface StopInfraPromptProps { + onDone: (stop: boolean) => void; +} + +interface Choice { + value: boolean; + label: string; +} + +const CHOICES: readonly Choice[] = [ + { value: true, label: "Yes — stop Docker too" }, + { value: false, label: "No — keep it running (warm re-boots)" }, +]; + +export function StopInfraPrompt(props: StopInfraPromptProps): ReactElement { + const { exit } = useApp(); + const [index, setIndex] = useState(0); + + useInput((input, key) => { + if (input === "y" || input === "Y") { + exit(); + props.onDone(true); + return; + } + if (input === "n" || input === "N" || key.escape) { + exit(); + props.onDone(false); + return; + } + if (key.upArrow || key.downArrow || input === "j" || input === "k") { + setIndex((i) => (i === 0 ? 1 : 0)); + return; + } + if (key.return) { + const chosen = CHOICES[index]; + exit(); + props.onDone(chosen?.value ?? true); + } + }); + + return ( + + Also stop Docker infra (mongo + neo4j + redis)? + {CHOICES.map((choice, i) => { + const selected = i === index; + return ( + + {selected ? "❯ " : " "} + {choice.label} + + ); + })} + + ↑/↓ or y/n, Enter to confirm, Esc = no + + + ); +} diff --git a/packages/cli/src/shutdownPrompts.ts b/packages/cli/src/shutdownPrompts.ts new file mode 100644 index 0000000..bd95c17 --- /dev/null +++ b/packages/cli/src/shutdownPrompts.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-only WITH non-commercial-clause +import React from "react"; +import { render } from "ink"; +import { StopInfraPrompt } from "./StopInfraPrompt.tsx"; + +export async function promptStopDocker(): Promise { + return new Promise((resolve) => { + const app = render( + React.createElement(StopInfraPrompt, { + onDone: (stop: boolean) => { + app.unmount(); + resolve(stop); + }, + }), + ); + }); +}