From 01b3ecb58de7d68cd22dd25893918525be47d628 Mon Sep 17 00:00:00 2001 From: Ondrej Rafaj Date: Sat, 13 Jun 2026 10:40:00 +0100 Subject: [PATCH] fix(cli): honor explicit alternate device ports --- docs/cli.md | 2 +- packages/cli/package.json | 2 +- packages/cli/src/commands/helpers.ts | 13 ++++- packages/cli/src/commands/ping.ts | 5 +- packages/cli/src/discovery/registry.ts | 54 +++++++++++++++---- packages/cli/tests/commands/program.test.ts | 24 +++++++++ packages/cli/tests/discovery/registry.test.ts | 22 ++++++++ 7 files changed, 109 insertions(+), 13 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index f1ea6e5..46404bb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -25,7 +25,7 @@ kelpie [options] # compatibility shorthand for `kelpie navigate `, `--tab-id ` | Target a specific tab for macOS commands that support per-tab control | | `--format ` | Output format: `json` (default), `table`, `text` | | `--timeout ` | CLI-level command timeout for a single device request in milliseconds (default: 10000). Overrides per-method API defaults (typically 5000ms). Not the same as `--scan-timeout` on `kelpie discover`, which controls mDNS scan duration. | -| `--port ` | Override default port 8420 | +| `--port ` | Override default port 8420. With `--device `, targets the matching `:` device before any stale same-IP entry. | | `--help` | Show help for any command | | `--version` | Show CLI version | | `--llm-help` | Show detailed LLM-oriented help with schemas and examples | diff --git a/packages/cli/package.json b/packages/cli/package.json index 7aeee08..fa77674 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@unlikeotherai/kelpie", - "version": "0.1.9", + "version": "0.1.10", "license": "Apache-2.0", "type": "module", "bin": { diff --git a/packages/cli/src/commands/helpers.ts b/packages/cli/src/commands/helpers.ts index b0f5ba3..83da7e6 100644 --- a/packages/cli/src/commands/helpers.ts +++ b/packages/cli/src/commands/helpers.ts @@ -8,6 +8,15 @@ export function getGlobals(program: Command): GlobalOptions { return program.opts(); } +export function explicitGlobalPort(program: Command, globals: GlobalOptions): number | undefined { + if (program.getOptionValueSource("port") !== "cli") { + return undefined; + } + const rawPort = globals.port as unknown; + const port = typeof rawPort === "number" ? rawPort : Number.parseInt(String(rawPort), 10); + return Number.isInteger(port) && port > 0 ? port : undefined; +} + export function withGlobalTabId( globals: GlobalOptions, body?: Record, @@ -20,7 +29,9 @@ export function withGlobalTabId( export async function requireDevice(program: Command): Promise { const globals = getGlobals(program); if (globals.device) { - const device = await getDevice(globals.device); + const device = await getDevice(globals.device, { + port: explicitGlobalPort(program, globals), + }); if (!device) { print({ success: false, error: { code: "DEVICE_NOT_FOUND", message: `No device matching "${globals.device}"` } }, globals.format); process.exitCode = 4; diff --git a/packages/cli/src/commands/ping.ts b/packages/cli/src/commands/ping.ts index c2df84b..0738ce8 100644 --- a/packages/cli/src/commands/ping.ts +++ b/packages/cli/src/commands/ping.ts @@ -3,6 +3,7 @@ import { getDevice, getAllDevices } from "../discovery/registry.js"; import { sendCommand } from "../client/http-client.js"; import { print } from "../output/formatter.js"; import type { GlobalOptions, DiscoveredDevice } from "../types.js"; +import { explicitGlobalPort } from "./helpers.js"; export function registerPing(program: Command): void { program @@ -13,7 +14,9 @@ export function registerPing(program: Command): void { const timeout = globals.timeout; if (globals.device) { - const device = await getDevice(globals.device); + const device = await getDevice(globals.device, { + port: explicitGlobalPort(program, globals), + }); if (!device) { print( { success: false, error: { code: "DEVICE_NOT_FOUND", message: `No device matching "${globals.device}"` } }, diff --git a/packages/cli/src/discovery/registry.ts b/packages/cli/src/discovery/registry.ts index a33ca39..20c7f43 100644 --- a/packages/cli/src/discovery/registry.ts +++ b/packages/cli/src/discovery/registry.ts @@ -49,7 +49,14 @@ export function getAllDevices(): DiscoveredDevice[] { return Array.from(devices.values()); } -export async function getDevice(query: string): Promise { +export interface DeviceLookupOptions { + port?: number; +} + +export async function getDevice( + query: string, + options: DeviceLookupOptions = {}, +): Promise { evictExpired(); // Auto-scan on first use so --device works without a prior `discover` call if (!autoScanned && devices.size === 0) { @@ -72,6 +79,12 @@ export async function getDevice(query: string): Promise d.ip === addressQuery.host && d.port === addressQuery.port); + if (byAddress) return byAddress; + } const byNameExact = all.find((d) => d.name.toLowerCase() === lowerQuery); if (byNameExact) return byNameExact; @@ -81,10 +94,12 @@ export async function getDevice(query: string): Promise d.ip === query); - if (byIp) return byIp; + if (!addressQuery?.explicitPort) { + const byIp = all.find((d) => d.ip === query); + if (byIp) return byIp; + } - const directAddress = getDirectAddressDevice(query); + const directAddress = getDirectAddressDevice(addressQuery); if (directAddress) return directAddress; return undefined; @@ -112,16 +127,37 @@ async function getBrowserAliasDevice(query: string): Promise { expect(capturedBody()).toEqual({ mode: "readable", tabId: "tab-123" }); }); + + it("uses --port when resolving a device by IP", async () => { + mockFetch({ success: true, url: "https://example.com/app", title: "Example", loadTime: 10 }); + addDevice(device); + addDevice({ + ...device, + id: "admin-device", + port: 8422, + }); + vi.spyOn(console, "log").mockImplementation(() => undefined); + + await makeProgram().parseAsync([ + "node", + "kelpie", + "--device", + "192.168.1.42", + "--port", + "8422", + "navigate", + "https://example.com/app", + ]); + + expect(capturedUrl()).toBe("http://192.168.1.42:8422/v1/navigate"); + }); }); diff --git a/packages/cli/tests/discovery/registry.test.ts b/packages/cli/tests/discovery/registry.test.ts index 66fab14..423fa20 100644 --- a/packages/cli/tests/discovery/registry.test.ts +++ b/packages/cli/tests/discovery/registry.test.ts @@ -80,6 +80,28 @@ describe("device registry", () => { expect((await getDevice("192.168.1.42"))?.id).toBe("test-uuid-1234"); }); + it("prefers an explicit host port over stale same-IP entries", async () => { + addDevices([ + makeDevice({ id: "old", ip: "192.168.1.42", port: 8420 }), + makeDevice({ id: "admin", ip: "192.168.1.42", port: 8422 }), + ]); + + expect((await getDevice("192.168.1.42:8422"))?.id).toBe("admin"); + expect((await getDevice("192.168.1.42", { port: 8422 }))?.id).toBe("admin"); + }); + + it("uses the requested direct port when no discovered address matches", async () => { + addDevice(makeDevice({ id: "old", ip: "192.168.1.42", port: 8420 })); + + const device = await getDevice("192.168.1.42", { port: 8422 }); + + expect(device).toMatchObject({ + id: "direct:192.168.1.42:8422", + ip: "192.168.1.42", + port: 8422, + }); + }); + it("returns undefined for unknown device", async () => { expect(await getDevice("nonexistent")).toBeUndefined(); });