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

@@ -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();