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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user