feat(KB-073): add interactive port selection for dashboard command

- Add --interactive flag to CLI parser for kb dashboard\n- Implement promptForPort helper with port availability validation\n- Add comprehensive tests for interactive port prompting\n- Update help text, README, and STANDALONE.md documentation\n- Add changeset for the interactive port feature
This commit is contained in:
gsxdsm
2026-03-29 21:36:10 -07:00
parent a2c4127da0
commit 03424127e7
6 changed files with 273 additions and 5 deletions

View File

@@ -23,6 +23,7 @@ Launch the web UI and AI engine:
```bash
kb dashboard
kb dashboard --port 8080
kb dashboard --interactive # Interactive port selection (prompts for port)
kb dashboard --paused # Start with automation paused (review before work begins)
kb dashboard --dev # Start web UI only (no AI engine)
```

View File

@@ -48,6 +48,7 @@ Usage:
kb dashboard Start the board web UI
kb dashboard --paused Start with automation paused
kb dashboard --dev Start web UI only (no AI engine)
kb dashboard --interactive Start with interactive port selection
kb task create [desc] [opts] Create a new task (goes to triage)
kb task list List all tasks
kb task show <id> Show task details, steps, log
@@ -66,6 +67,7 @@ Usage:
Options:
--port, -p <port> Dashboard port (default: 4040)
--interactive Interactive mode (port selection for dashboard, issue selection for import)
--paused Start with engine paused (automation disabled)
--dev Start dashboard only (no AI engine)
--attach <file> Attach file(s) on task create (repeatable)
@@ -103,7 +105,8 @@ async function main() {
const open = !args.includes("--no-open");
const paused = args.includes("--paused");
const dev = args.includes("--dev");
await runDashboard(port, { open, paused, dev });
const interactive = args.includes("--interactive");
await runDashboard(port, { open, paused, dev, interactive });
break;
}

View File

@@ -61,6 +61,12 @@ vi.mock("@kb/dashboard", () => ({
createServer: vi.fn(() => ({ listen: mockListen })),
}));
// ── Mock node:readline ──────────────────────────────────────────────
vi.mock("node:readline", () => ({
createInterface: vi.fn(),
}));
// ── Mock @kb/engine ────────────────────────────────────────────────
// We need the real WorktreePool class so we can assert `instanceof`.
@@ -1037,3 +1043,175 @@ describe("runDashboard — merge conflict retry logic", () => {
);
});
});
// ── promptForPort tests ───────────────────────────────────────────────
import { promptForPort } from "./dashboard.js";
describe("promptForPort", () => {
let mockRl: {
question: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockRl = {
question: vi.fn(),
close: vi.fn(),
};
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns default port on empty input", async () => {
const { createInterface } = await import("node:readline");
vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType<typeof createInterface>);
// Simulate user pressing Enter (empty input)
mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => {
callback("");
});
const result = await promptForPort(4040);
expect(result).toBe(4040);
expect(mockRl.close).toHaveBeenCalled();
});
it("returns valid custom port", async () => {
const { createInterface } = await import("node:readline");
vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType<typeof createInterface>);
mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => {
callback("8080");
});
const result = await promptForPort(4040);
expect(result).toBe(8080);
expect(mockRl.close).toHaveBeenCalled();
});
it("re-prompts on invalid (non-numeric) input", async () => {
const { createInterface } = await import("node:readline");
vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType<typeof createInterface>);
// First call returns invalid input, second call returns valid
let callCount = 0;
mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => {
callCount++;
if (callCount === 1) {
callback("abc");
} else {
callback("3000");
}
});
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const result = await promptForPort(4040);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("not a number"));
expect(result).toBe(3000);
expect(mockRl.question).toHaveBeenCalledTimes(2);
consoleSpy.mockRestore();
});
it("re-prompts on out-of-range port (too low)", async () => {
const { createInterface } = await import("node:readline");
vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType<typeof createInterface>);
let callCount = 0;
mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => {
callCount++;
if (callCount === 1) {
callback("0");
} else {
callback("5000");
}
});
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const result = await promptForPort(4040);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("must be between 1 and 65535"));
expect(result).toBe(5000);
expect(mockRl.question).toHaveBeenCalledTimes(2);
consoleSpy.mockRestore();
});
it("re-prompts on out-of-range port (too high)", async () => {
const { createInterface } = await import("node:readline");
vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType<typeof createInterface>);
let callCount = 0;
mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => {
callCount++;
if (callCount === 1) {
callback("70000");
} else {
callback("9000");
}
});
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const result = await promptForPort(4040);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("must be between 1 and 65535"));
expect(result).toBe(9000);
expect(mockRl.question).toHaveBeenCalledTimes(2);
consoleSpy.mockRestore();
});
it("accepts minimum valid port (1)", async () => {
const { createInterface } = await import("node:readline");
vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType<typeof createInterface>);
mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => {
callback("1");
});
const result = await promptForPort(4040);
expect(result).toBe(1);
});
it("accepts maximum valid port (65535)", async () => {
const { createInterface } = await import("node:readline");
vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType<typeof createInterface>);
mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => {
callback("65535");
});
const result = await promptForPort(4040);
expect(result).toBe(65535);
});
it("rejects on SIGINT (Ctrl+C)", async () => {
const { createInterface } = await import("node:readline");
vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType<typeof createInterface>);
// Simulate that the promise rejects when SIGINT is triggered
const removeListenerSpy = vi.spyOn(process, "removeListener").mockImplementation(() => {});
// Trigger SIGINT handler immediately to test rejection
let sigintHandler: (() => void) | null = null;
const onSpy = vi.spyOn(process, "on").mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
if (event === "SIGINT") {
sigintHandler = handler as () => void;
}
return process;
});
mockRl.question.mockImplementation(() => {
// Simulate SIGINT during prompt
setTimeout(() => {
if (sigintHandler) sigintHandler();
}, 10);
});
await expect(promptForPort(4040)).rejects.toThrow("Interactive prompt cancelled");
onSpy.mockRestore();
removeListenerSpy.mockRestore();
});
});

View File

@@ -1,5 +1,6 @@
import { exec } from "node:child_process";
import type { AddressInfo } from "node:net";
import { createInterface } from "node:readline";
import { TaskStore } from "@kb/core";
import { createServer } from "@kb/dashboard";
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier } from "@kb/engine";
@@ -13,7 +14,79 @@ function openBrowser(url: string): void {
exec(cmd, () => {});
}
export async function runDashboard(port: number, opts: { open?: boolean; paused?: boolean; dev?: boolean } = {}) {
/**
* Prompt the user for a port number interactively.
* Shows "Port [4040]: " and accepts user input or Enter for default.
* Validates input is a valid port number (1-65535).
* Re-prompts on invalid input.
* Handles SIGINT (Ctrl+C) gracefully.
*/
export function promptForPort(defaultPort: number = 4040, input: NodeJS.ReadableStream = process.stdin): Promise<number> {
return new Promise((resolve, reject) => {
const rl = createInterface({
input,
output: process.stdout,
});
// Handle Ctrl+C during prompt
const sigintHandler = () => {
rl.close();
console.log("\n");
reject(new Error("Interactive prompt cancelled"));
};
process.on("SIGINT", sigintHandler);
const ask = () => {
rl.question(`Port [${defaultPort}]: `, (answer) => {
const trimmed = answer.trim();
// Empty input: use default
if (trimmed === "") {
process.removeListener("SIGINT", sigintHandler);
rl.close();
resolve(defaultPort);
return;
}
// Validate as number
const port = parseInt(trimmed, 10);
if (isNaN(port)) {
console.log(`Invalid input: "${trimmed}" is not a number`);
ask();
return;
}
// Validate port range
if (port < 1 || port > 65535) {
console.log(`Invalid port: ${port} (must be between 1 and 65535)`);
ask();
return;
}
process.removeListener("SIGINT", sigintHandler);
rl.close();
resolve(port);
});
};
ask();
});
}
export async function runDashboard(port: number, opts: { open?: boolean; paused?: boolean; dev?: boolean; interactive?: boolean } = {}) {
// Handle interactive port selection
let selectedPort = port;
if (opts.interactive) {
try {
selectedPort = await promptForPort(port);
} catch (err: any) {
if (err.message === "Interactive prompt cancelled") {
console.log("Cancelled — exiting");
process.exit(0);
}
throw err;
}
}
const cwd = process.cwd();
const store = new TaskStore(cwd);
await store.init();
@@ -395,7 +468,7 @@ export async function runDashboard(port: number, opts: { open?: boolean; paused?
});
}
const server = app.listen(port);
const server = app.listen(selectedPort);
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
@@ -409,8 +482,8 @@ export async function runDashboard(port: number, opts: { open?: boolean; paused?
server.on("listening", () => {
const actualPort = (server.address() as AddressInfo).port;
if (actualPort !== port) {
console.log(`⚠ Port ${port} in use, using ${actualPort} instead`);
if (actualPort !== selectedPort) {
console.log(`⚠ Port ${selectedPort} in use, using ${actualPort} instead`);
}
console.log();