diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..4fe5b91 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,110 @@ +name: Bug report +description: Something in bytebell-server or the bytebell CLI is broken or behaving unexpectedly. +title: "[bug] " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug! A few quick tips: + - Please redact API keys, tokens, and private repo URLs from anything you paste. + - Server and CLI logs live in `~/.bytebell/logs/`. + - Only the first four fields are required — fill in the rest if you have it handy. + + - type: textarea + id: summary + attributes: + label: What happened? + description: A short description of the bug. What did you do, and what went wrong? + placeholder: I ran `bytebell index https://github.com/...` and the worker stalled at PROCESSING. + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: The minimum sequence of commands or clicks needed to see the bug. + placeholder: | + 1. `bytebell boot` + 2. `bytebell index ` + 3. Wait ~2 minutes + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + validations: + required: true + + - type: input + id: version + attributes: + label: Bytebell version + description: Output of `bytebell --version`. + placeholder: e.g. 0.4.2 + validations: + required: true + + - type: dropdown + id: component + attributes: + label: Which part of Bytebell is affected? + description: Pick whatever feels closest — "Not sure" is fine. + multiple: true + options: + - "Not sure" + - "bytebell-server (HTTP / MCP / workers)" + - "bytebell CLI / TUI" + - "Ingestion (@bb/ingest-github)" + - "MCP surface (@bb/mcp)" + - "Adapter (@bb/mongo / @bb/neo4j / @bb/redis)" + - "LLM layer (@bb/llm)" + - "Config / first-run setup (@bb/config)" + - "Docs / README" + + - type: dropdown + id: llm + attributes: + label: LLM provider + options: + - "Not applicable / don't know" + - "OpenRouter" + - "Ollama (local)" + + - type: input + id: os + attributes: + label: OS and architecture + placeholder: e.g. macOS 14.5 arm64, Ubuntu 22.04 x86_64 + + - type: input + id: bun + attributes: + label: Bun version + description: Output of `bun --version`. + + - type: textarea + id: logs + attributes: + label: Logs or error output + description: Paste relevant lines from `~/.bytebell/logs/server-YYYY-MM-DD.log` or `cli-YYYY-MM-DD.log`. Redact secrets. + render: shell + + - type: textarea + id: context + attributes: + label: Anything else? + description: Repo you were ingesting, recent config changes, screenshots — whatever might help. + + - type: checkboxes + id: preflight + attributes: + label: Before you submit + options: + - label: I searched existing issues and didn't find a duplicate. + required: true + - label: I've redacted any secrets from the information above. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..776d071 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/ByteBell/bytebell-oss/blob/main/SECURITY.md + about: Please do NOT open a public issue. Email team@bytebell.ai — see SECURITY.md for details and PGP info. + - name: Question, help, or discussion + url: https://github.com/ByteBell/bytebell-oss/discussions + about: For "how do I…", design discussions, or sharing what you built — use Discussions, not issues. + - name: Read the contributing guide + url: https://github.com/ByteBell/bytebell-oss/blob/main/contributing.md + about: New here? The contributing guide explains the architecture, package layout, and how to get a local dev loop running. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..0a8dcf4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,46 @@ +name: Feature request +description: Suggest a new capability or improvement for Bytebell. +title: "[feat] " +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Have an idea? Great — describe the problem first, then your proposal. Don't worry + about getting the architecture right; maintainers will help shape it during triage. + + - type: textarea + id: problem + attributes: + label: What problem are you trying to solve? + description: Be concrete — a real workflow, a repo you couldn't ingest, a query you couldn't run. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What would you like to see? + description: Your proposed solution. Rough sketches are fine. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives you considered + description: Workarounds you tried, other tools, or different shapes for the same idea. + + - type: textarea + id: context + attributes: + label: Additional context + description: Links, screenshots, prior art in other projects, related issues. + + - type: checkboxes + id: preflight + attributes: + label: Before you submit + options: + - label: I searched existing issues and discussions and didn't find a duplicate. + required: true diff --git a/README.md b/README.md index aa61afe..e499532 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,5 @@ # Bytebell [bytebell.ai] -## What is this and why does it exist - -If you've ever worked on a codebase that spans multiple repositories, you already know the pain. You open up your copilot or your coding agent, you ask it something like "how does this authentication service in Repo A affect the user management flow in Repo B" and it just goes blank. - -It either hallucinates an answer or tells you it doesn't have enough context. And the thing is, its not the model's fault. The model is smart enough. The problem is that no tool in the current ecosystem gives it the right context to work with. - -Every code intelligence tool today, whether its vector search based like **claude-context** and **Cody**, or AST based like **Serena** and **code-graph-mcp**, or even production grade tools like **Sourcegraph** with **SCIP** indexing, they all do fundamentally the same thing. - -They read your code structurally. They parse syntax trees, they map function calls, they build embedding vectors. And all of that is useful to some degree but it completely misses the question that actually matters which is what is this code for. Not what it looks like syntactically, not what functions it calls, but what is its purpose, what business logic does it encode, why does it exist in the first place, and how does it connect to code in entirely different repositories that were written by entirely different teams. - -**ByteBell** takes a fundamentally different approach. Instead of parsing structure and hoping the model figures out meaning at query time, we run an LLM once at index time across your entire codebase. The LLM reads every file and extracts its **semantic purpose**, its **business role**, its **cross-repo dependencies**, what it does and why it does it. All of that understanding gets stored in a **persistent semantic graph** that lives across sessions, across models, across every copilot and agent you use. You pay the LLM cost once during indexing and then every tool in your stack benefits from that understanding forever. - -The key insight here is that every other tool in this space persists an index. ByteBell persists meaning. This is an architectural difference that changes everything downstream. - -## How it actually works - -When you point ByteBell at your repositories, it does a one time semantic indexing pass. For every file, it sends the code to an LLM with a carefully designed prompt that asks it to extract several things: what does this file do in plain english, what business domain does it belong to, how does it relate to other files in this repo and in other repos, what would break if you changed it, and what is the intent behind the key functions and classes. -All of these semantic annotations get stored in a graph database where the nodes are files, functions, and concepts, and the edges are semantic relationships like "this service depends on that authentication module for JWT validation" rather than just "this file imports that file." The difference is massive because when a model later queries this graph it gets back actual understanding, not just a list of files that look syntactically similar to the query. -The graph is persistent and shared. It doesn't disappear when your session ends. It doesn't get rebuilt every time you switch from one copilot to another. Whether you're using Claude, or GPT, or an open source model, or switching between Cursor and Windsurf and Claude Code, they all read from the same semantic graph. Your codebase understanding is decoupled from any single tool or model. - -## The cost problem and how we solved it - -The obvious objection to using LLMs for indexing is cost. If you're running Claude Opus on every file in a large codebase you'll burn through thousands of dollars before you even start working. So we did something that nobody else has done properly, we ran a systematic benchmark across 14 different models to find the sweet spot between accuracy and cost. -We tested on 30 Kubernetes ecosystem files with roughly 33,800 average input tokens per file and 3,200 output tokens per file. We scored each model across 7 categories including search accuracy, graph quality, semantic understanding, cross-repo integration, section mapping, business context extraction, and JSON formatting. Any model that scored below 70 points was dropped as unusable regardless of how cheap it was. -The results were surprising. -DeepSeek V4 Flash can index 1,000 files at just $7.01 with an accuracy score of 71.13. Thats 100x cheaper than Claude Opus 4.7 at $752.70 and only about 2.3 points behind it in quality. GLM 5.1 sits in a nice balanced spot at $23.24 with 72.22 accuracy. Claude Sonnet 4.6 is the premium quality option at $149.40 with the highest accuracy at 73.56 if you need the absolute best analysis and dont mind paying for it. - -Models like GPT 5.4 scored 55.65 which is just completely unusable, and Step 3.5 Flash came in at 69.71 which is cheap but falls below our quality floor. - -So the default recommendation is DeepSeek V4 Flash for most use cases because it gives you production quality semantic indexing at a cost thats genuinely negligible even for very large codebases. You can always run a premium model like Sonnet on your most critical repositories if you want that extra 2 points of accuracy. - -## What we tested and what we found - -We ran ByteBell on the SWE-bench Verified benchmark, specifically on Astropy and OpenTelemetry which are the two most important repositories in that dataset, roughly 8 GB of code combined. We compared task performance with ByteBell's semantic context layer (MCP) versus raw model performance without it. - -The per-task results across 55 tasks show something that seems counterintuitive at first. ByteBell feeds the model 22 less context, the average cost per task is actually lower with ByteBell at $0.52 versus $0.73 without it. -The reason is simple, when the model has real semantic understanding of the codebase it stops wasting tokens exploring dead ends, searching through irrelevant files, and guessing at relationships. It knows exactly where to look and what things mean. The result is 60% less cost and 80% faster responses with the same or better accuracy. - -But the really important finding was about cross-repository performance. We tested on 150,000+ files across 46 Kubernetes ecosystem repositories and in cross-repo scenarios, even SOTA models with their full prompt caching and claude.md configurations dont just perform poorly, they fail to complete the task entirely. They literally cannot finish. The model runs out of context, gets confused about which repo it's looking at, hallucinates connections that dont exist, and eventually gives up or produces garbage. ByteBell's persistent semantic graph is the only approach we've seen that gives the model enough cross-repo understanding to actually solve these problems end to end. - -## How ByteBell compares to existing tools - -We did a detailed comparison against the major tools in this space and the differences are architectural, not incremental. -Vector search tools like claude-context and Cody embed your code into vector space and retrieve chunks that are semantically similar to your query. This works okay for simple "find code that looks like this" queries but it fundamentally treats code like english text which it is not. - -Code is logic with complex dependency chains, side effects, and implicit contracts that dont show up in embedding similarity. These tools also dont persist understanding across sessions and dont share context across different copilots. - -AST and LSP based tools like **Serena** and **code-graph-mcp** parse your code into abstract syntax trees and use language server protocols to map structural relationships. They know what calls what and what imports what but they have zero understanding of business intent. They can tell you that function A calls function B but they cannot tell you why that call exists or what business rule it implements. They also work within a single repository boundary and have no concept of cross-repo semantic connections. - -**GitNexus** builds a static AST graph which is essentially a more sophisticated version of the AST approach. It maps out structural relationships across your codebase in a graph format which is useful but again, its purely structural. It knows syntax, not semantics. And it doesn't persist any understanding across sessions. - -**Graphify** combines AST parsing with multimodal analysis so it tries to understand code through multiple representations beyond just the syntax tree. Its a step in the right direction but its still fundamentally building a structural graph enriched with pattern matching rather than extracting actual semantic intent. No cross-repo graph, no persistent meaning across sessions. - -**Sourcegraph** with **SCIP** indexing is probably the most production grade tool in this list and it does excellent structural code intelligence at scale. But SCIP is a structural indexing format, it gives you precise code navigation and cross-references but not semantic understanding. It also only partially supports cross-repo connections and doesn't share context across different copilots and agents. -Augment is a cloud SaaS approach that does provide some cost reduction per query but it doesn't persist meaning across sessions, doesn't share across copilots, doesn't build a cross-repo semantic graph, and critically it cannot run on-prem or air-gapped which is a dealbreaker for many enterprises. - -**ByteBell** is the only tool that checks every box. Persistent semantic understanding across sessions, shared across every copilot and agent, one graph that works with every model, cross-repo semantic connections, business context per commit, fully on-prem and air-gapped capable, 80%+ cost reduction per query, and 20 to 40% accuracy improvement on cross-repo tasks. - -Based on what we've seen so far, we believe that open source models with access to ByteBell's semantic context layer can improve their performance by at least 10%-40% compared to current SOTA models running without it. The early results already point strongly in that direction but we need to prove it across the full dataset to make that claim definitively. - -If you can sponsor API credits on OpenRouter, OpenAI, or Anthropic, or if you know someone who can, please reach out. Every dollar goes directly into running benchmarks on the complete SWE-bench Verified dataset and we will publish all results openly. This is an open source project and the benchmark results will be open too. - ## Quickstart > Looking for the full CLI reference? Every `bytebell` subcommand, flag, and option lives in **[commands.md](commands.md)**. The Quickstart below is the minimum sequence from zero to a queryable graph. diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index 3fdc22d..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:27117: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: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: @@ -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"] diff --git a/packages/cli/README.md b/packages/cli/README.md index d1b0fd6..c6ef0d2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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`. 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/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/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"; +} 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); + }, + }), + ); + }); +}