mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge pull request #11138 from IamCoder18/cli-console-restart-on-port-mismatch
fix(cli): restart daemon when console requested host/port don't match
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Restart the daemon when `kilo console` or `kilo daemon start` receives explicit network options that don't match the running daemon, instead of silently ignoring the requested settings.
|
||||
@@ -33,6 +33,27 @@ const options = {
|
||||
|
||||
export type NetworkOptions = InferredOptionTypes<typeof options>
|
||||
|
||||
// kilocode_change start
|
||||
const flags = [
|
||||
["port", "--port"],
|
||||
["hostname", "--hostname"],
|
||||
["mdns", "--mdns"],
|
||||
["mdnsDomain", "--mdns-domain"],
|
||||
["cors", "--cors"],
|
||||
] as const
|
||||
export type NetworkOption = (typeof flags)[number][0]
|
||||
|
||||
export function explicitNetworkOptions(argv = process.argv) {
|
||||
const index = argv.indexOf("--")
|
||||
const args = index === -1 ? argv : argv.slice(0, index)
|
||||
return flags.flatMap(([name, flag]) =>
|
||||
args.some((arg) => arg === flag || arg.startsWith(`${flag}=`) || (name === "mdns" && arg === "--no-mdns"))
|
||||
? [name]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
export function withNetworkOptions<T>(yargs: Argv<T>) {
|
||||
return yargs.options(options)
|
||||
}
|
||||
@@ -42,10 +63,13 @@ export const resolveNetworkOptions = Effect.fn("Cli.resolveNetworkOptions")(func
|
||||
})
|
||||
|
||||
export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Config.Info) {
|
||||
const portExplicitlySet = process.argv.includes("--port")
|
||||
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
||||
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
||||
const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain")
|
||||
// kilocode_change start
|
||||
const explicit = explicitNetworkOptions()
|
||||
const portExplicitlySet = explicit.includes("port")
|
||||
const hostnameExplicitlySet = explicit.includes("hostname")
|
||||
const mdnsExplicitlySet = explicit.includes("mdns")
|
||||
const mdnsDomainExplicitlySet = explicit.includes("mdnsDomain")
|
||||
// kilocode_change end
|
||||
const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
|
||||
const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"])
|
||||
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import open from "open"
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Daemon } from "@/kilocode/daemon/daemon"
|
||||
import { warnPort } from "@/kilocode/cli/port-warning"
|
||||
@@ -43,8 +43,9 @@ export const KiloConsoleCommand = cmd({
|
||||
handler: async (args) => {
|
||||
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
|
||||
warnPort(opts.port)
|
||||
const result = await Daemon.start(opts)
|
||||
const state = result.state
|
||||
const daemon = await Daemon.ensure(opts, explicitNetworkOptions())
|
||||
if (daemon.restarted) console.warn("Restarted the Kilo daemon to apply the requested network options")
|
||||
const state = daemon.result.state
|
||||
if (!state) throw new Error("Kilo daemon did not provide connection state")
|
||||
|
||||
const url = publicUrl(state)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Daemon } from "@/kilocode/daemon/daemon"
|
||||
import { warnPort } from "@/kilocode/cli/port-warning"
|
||||
@@ -62,12 +62,19 @@ const StartCommand = cmd({
|
||||
handler: async (args) => {
|
||||
const opts = await AppRuntime.runPromise(resolveNetworkOptions(args))
|
||||
warnPort(opts.port)
|
||||
const result = await Daemon.start(opts)
|
||||
const daemon = await Daemon.ensure(opts, explicitNetworkOptions())
|
||||
const result = daemon.result
|
||||
if (args.json) {
|
||||
print(result, true)
|
||||
return
|
||||
}
|
||||
console.log(result.reused ? "kilo daemon already running" : "kilo daemon started")
|
||||
console.log(
|
||||
result.reused
|
||||
? "kilo daemon already running"
|
||||
: daemon.restarted
|
||||
? "kilo daemon restarted"
|
||||
: "kilo daemon started",
|
||||
)
|
||||
print(result)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -15,6 +15,16 @@ export namespace Daemon {
|
||||
const lock = "kilocode-daemon"
|
||||
export const PortRange = { start: 4097, end: 4116 } as const
|
||||
|
||||
export const Network = z.object({
|
||||
hostname: z.string(),
|
||||
port: z.number().int().nonnegative(),
|
||||
mdns: z.boolean(),
|
||||
mdnsDomain: z.string(),
|
||||
cors: z.array(z.string()).transform((items) => [...new Set(items)].sort()),
|
||||
})
|
||||
export type Network = z.infer<typeof Network>
|
||||
export type NetworkOption = keyof Network
|
||||
|
||||
export const State = z.object({
|
||||
pid: z.number().int().positive(),
|
||||
hostname: z.string(),
|
||||
@@ -26,6 +36,7 @@ export namespace Daemon {
|
||||
version: z.string(),
|
||||
startedAt: z.string(),
|
||||
log: z.string(),
|
||||
options: Network.optional(),
|
||||
})
|
||||
export type State = z.infer<typeof State>
|
||||
|
||||
@@ -44,12 +55,7 @@ export namespace Daemon {
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
export type Options = {
|
||||
hostname: string
|
||||
port: number
|
||||
mdns?: boolean
|
||||
mdnsDomain?: string
|
||||
cors?: string[]
|
||||
export type Options = Network & {
|
||||
command?: string[]
|
||||
env?: NodeJS.ProcessEnv
|
||||
timeout?: number
|
||||
@@ -60,6 +66,11 @@ export namespace Daemon {
|
||||
reused: boolean
|
||||
}
|
||||
|
||||
export type Ensure = {
|
||||
result: Start
|
||||
restarted: boolean
|
||||
}
|
||||
|
||||
export type Stop = Status & {
|
||||
stopped: boolean
|
||||
}
|
||||
@@ -157,13 +168,31 @@ export namespace Daemon {
|
||||
return { running: true, stale: false, state, health: probe, file: file() }
|
||||
}
|
||||
|
||||
export async function start(input: Options): Promise<Start> {
|
||||
export function matches(state: State, input: Options, explicit: readonly NetworkOption[]) {
|
||||
const options = Network.parse(input)
|
||||
return explicit.every((name) => {
|
||||
if (name === "hostname") return state.hostname === options.hostname
|
||||
if (name === "port") return options.port === 0 || state.port === options.port
|
||||
if (name === "mdns" && state.hostname !== options.hostname) return false
|
||||
if (!state.options) return false
|
||||
if (name === "cors") return state.options.cors.join("\n") === options.cors.join("\n")
|
||||
return state.options[name] === options[name]
|
||||
})
|
||||
}
|
||||
|
||||
async function run(input: Options, explicit: readonly NetworkOption[] = [], force = false): Promise<Ensure> {
|
||||
return await Flock.withLock(
|
||||
lock,
|
||||
async () => {
|
||||
const current = await status()
|
||||
if (current.running) return { ...current, started: false, reused: true }
|
||||
if (current.stale && current.state) await terminate(current.state.pid, true)
|
||||
const restarted = current.running && !!current.state && (force || !matches(current.state, input, explicit))
|
||||
if (current.running && !restarted) {
|
||||
return { result: { ...current, started: false, reused: true }, restarted: false }
|
||||
}
|
||||
if (current.state && (current.stale || restarted)) {
|
||||
await terminate(current.state.pid, current.stale)
|
||||
if (alive(current.state.pid)) await terminate(current.state.pid, true)
|
||||
}
|
||||
await clear()
|
||||
const password = "kilo"
|
||||
const token = auth(password)
|
||||
@@ -182,15 +211,24 @@ export namespace Daemon {
|
||||
version: InstallationVersion,
|
||||
startedAt: new Date().toISOString(),
|
||||
log: out,
|
||||
options: Network.parse(input),
|
||||
}
|
||||
await write(state)
|
||||
const next = await status()
|
||||
return { ...next, started: true, reused: false, state }
|
||||
return { result: { ...next, started: true, reused: false, state }, restarted }
|
||||
},
|
||||
{ dir: path.join(root(), "locks"), timeoutMs: 15_000, staleMs: 30_000 },
|
||||
)
|
||||
}
|
||||
|
||||
export async function start(input: Options): Promise<Start> {
|
||||
return (await run(input)).result
|
||||
}
|
||||
|
||||
export async function ensure(input: Options, explicit: readonly NetworkOption[]): Promise<Ensure> {
|
||||
return await run(input, explicit)
|
||||
}
|
||||
|
||||
export async function stop(): Promise<Stop> {
|
||||
return await Flock.withLock(
|
||||
lock,
|
||||
@@ -209,8 +247,7 @@ export namespace Daemon {
|
||||
}
|
||||
|
||||
export async function restart(input: Options): Promise<Start> {
|
||||
await stop()
|
||||
return await start(input)
|
||||
return (await run(input, [], true)).result
|
||||
}
|
||||
|
||||
export function command(
|
||||
@@ -248,6 +285,7 @@ export namespace Daemon {
|
||||
|
||||
async function port(input: Options) {
|
||||
if (input.port !== 0) return input.port
|
||||
if (input.env?.KILO_TEST_DAEMON_EPHEMERAL_PORT) return 0
|
||||
const ports = Array.from({ length: PortRange.end - PortRange.start + 1 }, (_, index) => PortRange.start + index)
|
||||
const free = await Promise.any(
|
||||
ports.map((item) =>
|
||||
@@ -289,7 +327,10 @@ export namespace Daemon {
|
||||
})
|
||||
const failure = new Promise<never>((_, reject) => child.once("error", reject))
|
||||
child.unref()
|
||||
return await Promise.race([wait(out, child.pid, input.timeout ?? 10_000), failure])
|
||||
return await Promise.race([wait(out, child.pid, input.timeout ?? 10_000), failure]).catch(async (err) => {
|
||||
if (child.pid && alive(child.pid)) await terminate(child.pid, true)
|
||||
throw err
|
||||
})
|
||||
} finally {
|
||||
await Promise.all([stdout.close(), stderr.close()])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { explicitNetworkOptions } from "../../../../src/cli/network"
|
||||
import { Daemon } from "../../../../src/kilocode/daemon/daemon"
|
||||
|
||||
function opts(input: Partial<Daemon.Network> = {}): Daemon.Options {
|
||||
return {
|
||||
hostname: "127.0.0.1",
|
||||
port: 4097,
|
||||
mdns: false,
|
||||
mdnsDomain: "kilo.local",
|
||||
cors: [],
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
function state(input: Partial<Daemon.Network> = {}) {
|
||||
const options = Daemon.Network.parse(opts(input))
|
||||
return Daemon.State.parse({
|
||||
pid: 1,
|
||||
hostname: options.hostname,
|
||||
port: options.port,
|
||||
url: `http://${options.hostname}:${options.port}`,
|
||||
username: "kilo",
|
||||
password: "kilo",
|
||||
token: "token",
|
||||
version: "test",
|
||||
startedAt: new Date(0).toISOString(),
|
||||
log: "/tmp/daemon.log",
|
||||
options,
|
||||
})
|
||||
}
|
||||
|
||||
describe("console daemon startup", () => {
|
||||
test("detects every explicit network option form", () => {
|
||||
expect(
|
||||
explicitNetworkOptions([
|
||||
"kilo",
|
||||
"console",
|
||||
"--port=4321",
|
||||
"--hostname",
|
||||
"0.0.0.0",
|
||||
"--no-mdns",
|
||||
"--mdns-domain=test.local",
|
||||
"--cors",
|
||||
"https://example.com",
|
||||
]),
|
||||
).toStrictEqual(["port", "hostname", "mdns", "mdnsDomain", "cors"])
|
||||
expect(explicitNetworkOptions(["kilo", "console", "--", "--port=4321"])).toStrictEqual([])
|
||||
})
|
||||
|
||||
test("matches every explicit network option", () => {
|
||||
const current = state({ mdns: true, cors: ["https://b.example", "https://a.example"] })
|
||||
const input = opts({
|
||||
port: current.port,
|
||||
mdns: true,
|
||||
cors: ["https://a.example", "https://b.example", "https://a.example"],
|
||||
})
|
||||
|
||||
expect(Daemon.matches(current, input, ["port", "hostname", "mdns", "mdnsDomain", "cors"])).toBe(true)
|
||||
})
|
||||
|
||||
test("treats an explicit auto port as compatible", () => {
|
||||
expect(Daemon.matches(state(), opts({ port: 0 }), ["port"])).toBe(true)
|
||||
})
|
||||
|
||||
test("supports daemon state written before network options were persisted", () => {
|
||||
const current = { ...state(), options: undefined }
|
||||
|
||||
expect(Daemon.matches(current, opts(), ["hostname", "port"])).toBe(true)
|
||||
expect(Daemon.matches(current, opts(), ["mdns"])).toBe(false)
|
||||
expect(Daemon.matches(current, opts(), ["mdnsDomain"])).toBe(false)
|
||||
expect(Daemon.matches(current, opts(), ["cors"])).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects each mismatched explicit network option", () => {
|
||||
const current = state()
|
||||
|
||||
expect(Daemon.matches(current, opts({ hostname: "0.0.0.0" }), ["hostname"])).toBe(false)
|
||||
expect(Daemon.matches(current, opts({ port: current.port + 1 }), ["port"])).toBe(false)
|
||||
expect(Daemon.matches(current, opts({ mdns: true }), ["mdns"])).toBe(false)
|
||||
expect(Daemon.matches(current, opts({ mdnsDomain: "test.local" }), ["mdnsDomain"])).toBe(false)
|
||||
expect(Daemon.matches(current, opts({ cors: ["https://example.com"] }), ["cors"])).toBe(false)
|
||||
|
||||
const mdns = state({ mdns: true })
|
||||
expect(Daemon.matches(mdns, opts({ hostname: "0.0.0.0", mdns: true }), ["mdns"])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,7 @@ function dirs(root: string) {
|
||||
XDG_CONFIG_HOME: path.join(root, "xdg-config"),
|
||||
XDG_STATE_HOME: path.join(root, "xdg-state"),
|
||||
XDG_CACHE_HOME: path.join(root, "xdg-cache"),
|
||||
KILO_TEST_DAEMON_EPHEMERAL_PORT: "1",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +125,7 @@ describe("daemon manager", () => {
|
||||
expect(started.running).toBe(true)
|
||||
expect(started.state?.pid).toBeGreaterThan(0)
|
||||
expect(started.state?.token).toBeTruthy()
|
||||
expect(started.state?.port).toBeGreaterThanOrEqual(Daemon.PortRange.start)
|
||||
expect(started.state?.port).toBeLessThanOrEqual(Daemon.PortRange.end)
|
||||
expect(started.state?.port).toBeGreaterThan(0)
|
||||
|
||||
const blocked = await fetch(`${started.state!.url}/config?directory=${encodeURIComponent(tmp.path)}`)
|
||||
expect(blocked.status).toBe(401)
|
||||
|
||||
Reference in New Issue
Block a user