diff --git a/dev/cli/src/commands/status.ts b/dev/cli/src/commands/status.ts index ec681e2a4e..3fcbad8d02 100644 --- a/dev/cli/src/commands/status.ts +++ b/dev/cli/src/commands/status.ts @@ -16,11 +16,45 @@ export async function status(root: string) { ); const portServices = services.filter(s => s.port && s.type !== 'infra'); + + // Group services by port to detect shared-port conflicts + const portGroups = new Map(); for (const svc of portServices) { - const listening = await isPortListening(svc.port!); - console.log( - ` ${listening ? ui.green('●') : ui.dim('○')} ${svc.name.padEnd(12)} ${listening ? `port ${svc.port}` : ui.dim('not running')}` - ); + const group = portGroups.get(svc.port!) ?? []; + group.push(svc); + portGroups.set(svc.port!, group); + } + + // Check each unique port once + const portStatus = new Map(); + for (const port of portGroups.keys()) { + portStatus.set(port, await isPortListening(port)); + } + + for (const [port, group] of portGroups) { + const listening = portStatus.get(port)!; + + if (group.length === 1) { + // Unique port — show definitive status + const svc = group[0]; + console.log( + ` ${listening ? ui.green('●') : ui.dim('○')} ${svc.name.padEnd(12)} ${listening ? `port ${port}` : ui.dim('not running')}` + ); + } else { + // Shared port — cannot determine which service is actually running + const names = group.map(s => s.name); + if (listening) { + console.log( + ` ${ui.yellow('●')} ${names.join(', ').padEnd(12)} port ${port} ${ui.yellow('(shared — cannot distinguish)')}` + ); + } else { + for (const svc of group) { + console.log( + ` ${ui.dim('○')} ${svc.name.padEnd(12)} ${ui.dim('not running')}` + ); + } + } + } } console.log(); diff --git a/dev/cli/test/status.test.ts b/dev/cli/test/status.test.ts new file mode 100644 index 0000000000..1e2d7a7edf --- /dev/null +++ b/dev/cli/test/status.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test'; +import { services } from '../src/services/registry'; + +describe('status: shared-port detection', () => { + test('identifies services that share a port', () => { + const portServices = services.filter(s => s.port && s.type !== 'infra'); + const portGroups = new Map(); + for (const svc of portServices) { + const group = portGroups.get(svc.port!) ?? []; + group.push(svc.name); + portGroups.set(svc.port!, group); + } + const sharedPorts = [...portGroups.entries()].filter(([, names]) => names.length > 1); + + // Verify the known shared ports are detected + expect(sharedPorts.length).toBeGreaterThanOrEqual(2); + + const port8792 = portGroups.get(8792); + expect(port8792).toBeDefined(); + expect(port8792).toContain('auto-fix'); + expect(port8792).toContain('db-proxy'); + + const port8795 = portGroups.get(8795); + expect(port8795).toBeDefined(); + expect(port8795).toContain('kiloclaw'); + expect(port8795).toContain('git-token'); + }); + + test('unique-port services are not grouped', () => { + const portServices = services.filter(s => s.port && s.type !== 'infra'); + const portGroups = new Map(); + for (const svc of portServices) { + const group = portGroups.get(svc.port!) ?? []; + group.push(svc.name); + portGroups.set(svc.port!, group); + } + + // Port 3000 belongs only to nextjs + const port3000 = portGroups.get(3000); + expect(port3000).toEqual(['nextjs']); + }); + + test('all non-infra port services are accounted for in port groups', () => { + const portServices = services.filter(s => s.port && s.type !== 'infra'); + const portGroups = new Map(); + for (const svc of portServices) { + const group = portGroups.get(svc.port!) ?? []; + group.push(svc.name); + portGroups.set(svc.port!, group); + } + + // Every port-having service should be in exactly one group + const allGroupedNames = [...portGroups.values()].flat(); + const serviceNames = portServices.map(s => s.name); + expect(allGroupedNames.sort()).toEqual(serviceNames.sort()); + }); +});