Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions infra/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ services:
container_name: bytebell-mongo
restart: unless-stopped
ports:
- "127.0.0.1:27117:27017"
- "127.0.0.1:${MONGO_HOST_PORT:-27017}:27017"
volumes:
- mongo_data:/data/db
environment:
Expand All @@ -25,8 +25,8 @@ services:
container_name: bytebell-neo4j
restart: unless-stopped
ports:
- "127.0.0.1:7474:7474"
- "127.0.0.1:7787: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:
Expand All @@ -47,7 +47,7 @@ services:
container_name: bytebell-redis
restart: unless-stopped
ports:
- "127.0.0.1:6479:6379"
- "127.0.0.1:${REDIS_HOST_PORT:-6379}:6379"
volumes:
- redis_data:/data
command: ["redis-server", "--appendonly", "yes"]
Expand Down
24 changes: 19 additions & 5 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,30 @@ 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
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`.
Expand Down
199 changes: 169 additions & 30 deletions packages/cli/src/BootCommand.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -43,54 +59,177 @@ async function runBoot(): Promise<void> {
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<ReturnType<typeof ensureServerRunning>>;
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 <git-url> or bytebell ingest [path]\n");
}

async function bringInfraUp(neo4jPassword: string): Promise<UpResult | null> {
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<boolean> {
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<void> {
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<boolean> {
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 <git-url> or bytebell ingest [path]\n");
}

function enforcePreflight(): boolean {
Expand Down
Loading
Loading