mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Merge pull request #11028 from IamCoder18/cli-server-hostname-url-display
feat(server,daemon): display local and network URLs when binding to 0.0.0.0
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
---
|
||||
|
||||
Display local and network URLs when the server binds to 0.0.0.0
|
||||
@@ -18,7 +18,16 @@ export const ServeCommand = effectCmd({
|
||||
}
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
console.log(`kilo server listening on http://${server.hostname}:${server.port}`) // kilocode_change
|
||||
|
||||
// kilocode_change start
|
||||
const urls = server.urls
|
||||
|
||||
console.log(`kilo server listening on ${urls.bind}`)
|
||||
if (urls.network) {
|
||||
console.log(` Local: ${urls.local}`)
|
||||
console.log(` Network: ${urls.network}`)
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - graceful signal shutdown
|
||||
// yield* Effect.never
|
||||
|
||||
@@ -6,29 +6,6 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstanceRuntime } from "../../project/instance-runtime" // kilocode_change
|
||||
import open from "open"
|
||||
import { networkInterfaces } from "os"
|
||||
|
||||
function getNetworkIPs() {
|
||||
const nets = networkInterfaces()
|
||||
const results: string[] = []
|
||||
|
||||
for (const name of Object.keys(nets)) {
|
||||
const net = nets[name]
|
||||
if (!net) continue
|
||||
|
||||
for (const netInfo of net) {
|
||||
// Skip internal and non-IPv4 addresses
|
||||
if (netInfo.internal || netInfo.family !== "IPv4") continue
|
||||
|
||||
// Skip Docker bridge networks (typically 172.x.x.x)
|
||||
if (netInfo.address.startsWith("172.")) continue
|
||||
|
||||
results.push(netInfo.address)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export const WebCommand = effectCmd({
|
||||
command: "web",
|
||||
@@ -47,39 +24,25 @@ export const WebCommand = effectCmd({
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
|
||||
if (opts.hostname === "0.0.0.0") {
|
||||
// Show localhost for local access
|
||||
const localhostUrl = `http://localhost:${server.port}`
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl)
|
||||
// kilocode_change start
|
||||
const urls = server.urls
|
||||
|
||||
// Show network IPs for remote access
|
||||
const networkIPs = getNetworkIPs()
|
||||
if (networkIPs.length > 0) {
|
||||
for (const ip of networkIPs) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD + " Network access: ",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`http://${ip}:${server.port}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.mdns) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`${opts.mdnsDomain}:${server.port}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Open localhost in browser
|
||||
open(localhostUrl).catch(() => {})
|
||||
} else {
|
||||
const displayUrl = server.url.toString()
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl)
|
||||
open(displayUrl).catch(() => {})
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + " Local: ", UI.Style.TEXT_NORMAL, urls.local)
|
||||
if (urls.network) {
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + " Network: ", UI.Style.TEXT_NORMAL, urls.network)
|
||||
}
|
||||
|
||||
if (opts.mdns) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`${opts.mdnsDomain}:${server.port}`,
|
||||
)
|
||||
}
|
||||
|
||||
open(urls.local).catch(() => {})
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - graceful signal shutdown
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import open from "open"
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { serverUrls } from "@/kilocode/cli/server-urls"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Daemon } from "@/kilocode/daemon/daemon"
|
||||
import { warnPort } from "@/kilocode/cli/port-warning"
|
||||
@@ -48,10 +49,15 @@ export const KiloConsoleCommand = cmd({
|
||||
const state = daemon.result.state
|
||||
if (!state) throw new Error("Kilo daemon did not provide connection state")
|
||||
|
||||
const url = publicUrl(state)
|
||||
const urls = state.urls ?? serverUrls(state.hostname, state.port)
|
||||
const consoleLocal = `${urls.local}/console`
|
||||
const consoleNetwork = urls.network ? `${urls.network}/console` : undefined
|
||||
|
||||
await launch(browserUrl(state)).catch((err) => {
|
||||
console.warn(`Could not open browser automatically: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
console.log(`Kilo Console: ${url}`)
|
||||
console.log("Kilo Console:")
|
||||
console.log(` Local: ${consoleLocal}`)
|
||||
if (consoleNetwork) console.log(` Network: ${consoleNetwork}`)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { serverUrls } from "@/kilocode/cli/server-urls"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Daemon } from "@/kilocode/daemon/daemon"
|
||||
import { warnPort } from "@/kilocode/cli/port-warning"
|
||||
@@ -19,6 +20,7 @@ function safe(input: Daemon.State | undefined) {
|
||||
hostname: input.hostname,
|
||||
port: input.port,
|
||||
url: input.url,
|
||||
urls: input.urls,
|
||||
username: input.username,
|
||||
version: input.version,
|
||||
startedAt: input.startedAt,
|
||||
@@ -47,7 +49,13 @@ function print(input: Daemon.Status, json?: boolean) {
|
||||
return
|
||||
}
|
||||
console.log(`kilo daemon running`)
|
||||
console.log(`url: ${input.state?.url}`)
|
||||
if (input.state?.urls) {
|
||||
const urls = input.state.urls
|
||||
console.log(`local: ${urls.local}`)
|
||||
if (urls.network) console.log(`network: ${urls.network}`)
|
||||
} else {
|
||||
console.log(`url: ${input.state?.url}`)
|
||||
}
|
||||
console.log(`pid: ${input.state?.pid}`)
|
||||
console.log(`version: ${input.health?.version ?? input.state?.version}`)
|
||||
console.log(`auth: enabled`)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// kilocode_change - new file
|
||||
import { isIP } from "net"
|
||||
import { networkInterfaces } from "os"
|
||||
|
||||
export function getNetworkIPs() {
|
||||
const nets = networkInterfaces()
|
||||
const results: string[] = []
|
||||
|
||||
for (const name of Object.keys(nets)) {
|
||||
const net = nets[name]
|
||||
if (!net) continue
|
||||
for (const netInfo of net) {
|
||||
if (netInfo.internal || netInfo.family !== "IPv4") continue
|
||||
results.push(netInfo.address)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function format(hostname: string, port: number) {
|
||||
const url = new URL("http://localhost")
|
||||
url.hostname = isIP(hostname) === 6 ? `[${hostname}]` : hostname
|
||||
url.port = String(port)
|
||||
return url.origin
|
||||
}
|
||||
|
||||
export function serverUrls(hostname: string, port: number) {
|
||||
const bind = format(hostname, port)
|
||||
const local = hostname === "0.0.0.0" ? format("localhost", port) : hostname === "::" ? format("::1", port) : bind
|
||||
const ip = hostname === "0.0.0.0" ? getNetworkIPs()[0] : undefined
|
||||
|
||||
return {
|
||||
local,
|
||||
network: ip ? format(ip, port) : undefined,
|
||||
bind,
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Flock } from "@opencode-ai/core/util/flock"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { serverUrls } from "@/kilocode/cli/server-urls"
|
||||
|
||||
export namespace Daemon {
|
||||
const username = "kilo"
|
||||
@@ -30,6 +31,13 @@ export namespace Daemon {
|
||||
hostname: z.string(),
|
||||
port: z.number().int().positive(),
|
||||
url: z.string(),
|
||||
urls: z
|
||||
.object({
|
||||
local: z.string(),
|
||||
network: z.string().optional(),
|
||||
bind: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
token: z.string(),
|
||||
@@ -205,6 +213,7 @@ export namespace Daemon {
|
||||
hostname: ready.hostname,
|
||||
port: ready.port,
|
||||
url: `http://${host(ready.hostname)}:${ready.port}`,
|
||||
urls: serverUrls(ready.hostname, ready.port),
|
||||
username,
|
||||
password,
|
||||
token,
|
||||
|
||||
@@ -2,6 +2,7 @@ import "./init-projectors"
|
||||
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { serverUrls } from "@/kilocode/cli/server-urls" // kilocode_change
|
||||
import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
@@ -24,6 +25,13 @@ export type Listener = {
|
||||
hostname: string
|
||||
port: number
|
||||
url: URL
|
||||
// kilocode_change start
|
||||
urls: {
|
||||
local: string
|
||||
network?: string
|
||||
bind: string
|
||||
}
|
||||
// kilocode_change end
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -79,6 +87,7 @@ export async function listen(opts: ListenOptions): Promise<Listener> {
|
||||
hostname: listener.hostname,
|
||||
port: listener.port,
|
||||
url: listener.url,
|
||||
urls: listener.urls, // kilocode_change
|
||||
stop: (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined),
|
||||
}
|
||||
}
|
||||
@@ -96,6 +105,7 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unkno
|
||||
hostname: opts.hostname,
|
||||
port: address.port,
|
||||
url: listenerUrl,
|
||||
urls: serverUrls(opts.hostname, address.port), // kilocode_change
|
||||
stop: yield* makeStop(state, unpublishMdns),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,9 +8,12 @@ import { Effect } from "effect"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
|
||||
describe("opencode run (non-interactive subprocess)", () => {
|
||||
// kilocode_change start
|
||||
// Keep full CLI subprocesses serial within this file; the test runner already
|
||||
// executes files in parallel, and nested concurrency exhausts Windows CI.
|
||||
// Happy path: prompt completes, output reaches stdout, process exits 0.
|
||||
// If this fails, all the others likely will too — debug here first.
|
||||
cliIt.concurrent(
|
||||
cliIt.live(
|
||||
"exits 0 and writes the response to stdout on a successful prompt",
|
||||
({ llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -21,33 +24,36 @@ describe("opencode run (non-interactive subprocess)", () => {
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start
|
||||
// Regression for #27371: an unknown model used to hang the process forever
|
||||
// waiting on a session.status === idle event that never arrived. The fix
|
||||
// makes the SDK call surface an error promptly so the process exits nonzero.
|
||||
// We assert nonzero exit AND wall-clock under the harness timeout — a hang
|
||||
// would expire the timeout and produce a different (signal-killed) failure.
|
||||
cliIt.concurrent(
|
||||
// makes the SDK call surface an error promptly so the process exits 1.
|
||||
// A harness timeout produces synthetic exit code -1, so the exact assertion
|
||||
// distinguishes the intended failure from a signal-killed process.
|
||||
cliIt.live(
|
||||
"exits nonzero promptly when the model is unknown (regression for #27371)",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* opencode.run("say hi", {
|
||||
model: "test/nonexistent-model",
|
||||
timeoutMs: 15_000,
|
||||
timeoutMs: 30_000,
|
||||
})
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.durationMs).toBeLessThan(15_000)
|
||||
opencode.expectExit(result, 1)
|
||||
}),
|
||||
30_000,
|
||||
60_000,
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start
|
||||
// Locks in the current behavior: when the LLM stream errors mid-response
|
||||
// (the prompt was accepted, then the upstream provider failed), opencode
|
||||
// emits a session.error event and the process exits 0 today.
|
||||
//
|
||||
// This is debatable — a future cleanup might flip it to exit 1. If you're
|
||||
// changing this expectation, do it deliberately and say so in the PR.
|
||||
cliIt.concurrent(
|
||||
cliIt.live(
|
||||
"mid-stream LLM error still exits 0 today (contract lock-in)",
|
||||
({ llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -57,11 +63,13 @@ describe("opencode run (non-interactive subprocess)", () => {
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start
|
||||
// --format json puts one JSON object per line on stdout for each emitted
|
||||
// event. Consumers (CI scripts, tooling) parse this stream. Asserts the
|
||||
// shape so a future event-emit change has to update this expectation.
|
||||
cliIt.concurrent(
|
||||
cliIt.live(
|
||||
"--format json emits parseable line-delimited JSON to stdout",
|
||||
({ llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -81,4 +89,5 @@ describe("opencode run (non-interactive subprocess)", () => {
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { explicitNetworkOptions } from "../../../../src/cli/network"
|
||||
import { getNetworkIPs, serverUrls } from "../../../../src/kilocode/cli/server-urls"
|
||||
import { Daemon } from "../../../../src/kilocode/daemon/daemon"
|
||||
|
||||
function opts(input: Partial<Daemon.Network> = {}): Daemon.Options {
|
||||
@@ -30,6 +31,41 @@ function state(input: Partial<Daemon.Network> = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
describe("server URL display", () => {
|
||||
test("advertises a network URL only for wildcard binds", () => {
|
||||
expect(serverUrls("127.0.0.1", 4096)).toStrictEqual({
|
||||
local: "http://127.0.0.1:4096",
|
||||
network: undefined,
|
||||
bind: "http://127.0.0.1:4096",
|
||||
})
|
||||
expect(serverUrls("192.168.1.50", 4096)).toStrictEqual({
|
||||
local: "http://192.168.1.50:4096",
|
||||
network: undefined,
|
||||
bind: "http://192.168.1.50:4096",
|
||||
})
|
||||
|
||||
const ip = getNetworkIPs()[0]
|
||||
expect(serverUrls("0.0.0.0", 4096)).toStrictEqual({
|
||||
local: "http://localhost:4096",
|
||||
network: ip ? `http://${ip}:4096` : undefined,
|
||||
bind: "http://0.0.0.0:4096",
|
||||
})
|
||||
})
|
||||
|
||||
test("formats IPv6 bind URLs without advertising IPv4 interfaces", () => {
|
||||
expect(serverUrls("::1", 4096)).toStrictEqual({
|
||||
local: "http://[::1]:4096",
|
||||
network: undefined,
|
||||
bind: "http://[::1]:4096",
|
||||
})
|
||||
expect(serverUrls("::", 4096)).toStrictEqual({
|
||||
local: "http://[::1]:4096",
|
||||
network: undefined,
|
||||
bind: "http://[::]:4096",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("console daemon startup", () => {
|
||||
test("detects every explicit network option form", () => {
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user