fix(cli): make dev-setup work from local builds and hide it in releases

The `Installation.isLocal()` gate in dev-setup rejected locally-built
binaries because KILO_CHANNEL bakes the git branch name, not "local".
`detectRepo()` also failed inside a Bun single-file executable where
`import.meta.url` resolves to a `/$bunfs/` virtual path.

Introduce a build-time `KILO_BUILD_KIND` flag (source/release) derived
from `Script.release`, guard dev-setup/dev-alias registration on it,
and rewrite `detectRepo()` to try KILO_DEV_REPO, `import.meta.url`
(skipping bunfs), `process.execPath`, then `process.cwd()`.
This commit is contained in:
Alex Alecu
2026-04-24 19:02:05 +03:00
parent 8f3d899892
commit e04dd779e6
6 changed files with 157 additions and 26 deletions
+1
View File
@@ -235,6 +235,7 @@ for (const item of targets) {
KILO_RIPGREP_WORKER_PATH: rgPath,
KILO_CHANNEL: `'${Script.channel}'`,
KILO_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "",
KILO_BUILD_KIND: Script.release ? `'release'` : `'source'`, // kilocode_change
},
})
+7 -3
View File
@@ -15,7 +15,7 @@ import { UninstallCommand } from "./cli/cmd/uninstall"
import { ModelsCommand } from "./cli/cmd/models"
import { UI } from "./cli/ui"
import { Installation } from "./installation"
import { InstallationVersion } from "./installation/version"
import { InstallationBuildKind, InstallationVersion } from "./installation/version" // kilocode_change - add InstallationBuildKind
import { NamedError } from "@opencode-ai/shared/util/error"
import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
@@ -237,11 +237,15 @@ let cli = yargs(args) // kilocode_change
.command(SessionCommand)
.command(RemoteCommand) // kilocode_change
.command(ConfigCLICommand) // kilocode_change
.command(DevSetupCommand) // kilocode_change
.command(DevAliasCommand) // kilocode_change
.command(PluginCommand)
.command(DbCommand)
// kilocode_change start - dev-only commands are hidden from release builds
if (InstallationBuildKind !== "release") {
cli = cli.command(DevSetupCommand).command(DevAliasCommand)
}
// kilocode_change end
// kilocode_change start - registered after initial chain to avoid self-referential type error
cli = cli.command(createHelpCommand(() => cli))
@@ -1,8 +1,13 @@
declare global {
const KILO_VERSION: string
const KILO_CHANNEL: string
const KILO_BUILD_KIND: string // kilocode_change
}
export const InstallationVersion = typeof KILO_VERSION === "string" ? KILO_VERSION : "local"
export const InstallationChannel = typeof KILO_CHANNEL === "string" ? KILO_CHANNEL : "local"
export const InstallationLocal = InstallationChannel === "local"
// kilocode_change start - distinguish release builds from source / local builds
export const InstallationBuildKind: "source" | "release" =
typeof KILO_BUILD_KIND === "string" && KILO_BUILD_KIND === "release" ? "release" : "source"
// kilocode_change end
+62 -21
View File
@@ -4,7 +4,6 @@ import { existsSync } from "fs"
import { mkdir } from "fs/promises"
import { fileURLToPath } from "url"
import { cmd } from "@/cli/cmd/cmd"
import { Installation } from "@/installation"
import { UI } from "@/cli/ui"
type Shell = "zsh" | "bash" | "fish" | "powershell"
@@ -43,13 +42,8 @@ export const DevSetupCommand = cmd({
default: false,
}),
handler: async (args) => {
if (!Installation.isLocal()) {
UI.error("dev-setup only works when running from a source checkout (./bin/kilodev)")
process.exitCode = 1
return
}
const repo = await detectRepo()
const repo = await safeDetectRepo()
if (!repo) return
const shell = args.shell ? parse(args.shell) : detectShell()
const snippet = aliasLine(shell, repo)
@@ -137,16 +131,22 @@ export const DevAliasCommand = cmd({
default: "zsh",
}),
handler: async (args) => {
if (!Installation.isLocal()) {
UI.error("dev-alias only works when running from a source checkout (./bin/kilodev)")
process.exitCode = 1
return
}
const repo = await detectRepo()
const repo = await safeDetectRepo()
if (!repo) return
process.stdout.write(aliasLine(parse(args.shell), repo) + "\n")
},
})
async function safeDetectRepo(): Promise<string | undefined> {
try {
return await detectRepo()
} catch (err) {
UI.error(err instanceof Error ? err.message : String(err))
process.exitCode = 1
return undefined
}
}
const { TEXT_HIGHLIGHT: H, TEXT_NORMAL: N, TEXT_DIM: D, TEXT_SUCCESS: S, TEXT_NORMAL_BOLD: B } = UI.Style
const Style = UI.Style
@@ -262,15 +262,56 @@ function stamp(): string {
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
}
async function detectRepo(): Promise<string> {
const hint = process.env.KILO_DEV_REPO
if (hint) return hint
const walk = async (dir: string): Promise<string> => {
// Walk up from `start` until we find a directory containing
// packages/opencode/package.json. Exported for test coverage.
export async function findRepoFrom(start: string): Promise<string | undefined> {
let dir = start
while (true) {
const candidate = path.join(dir, "packages", "opencode", "package.json")
if (await Bun.file(candidate).exists()) return dir
const parent = path.dirname(dir)
if (parent === dir) throw new Error("cannot locate repo root")
return walk(parent)
if (parent === dir) return undefined
dir = parent
}
return walk(path.dirname(fileURLToPath(import.meta.url)))
}
// bunfs single-file executable virtual roots — `import.meta.url` points here,
// which is not a real on-disk path and must be skipped.
function isBunfsPath(p: string): boolean {
if (p.startsWith("/$bunfs/")) return true
// Windows bunfs root, e.g. `B:/~BUN/root/...`. Drive letter may be any case.
if (/^[A-Za-z]:[\\/]~BUN[\\/]/.test(p)) return true
return false
}
// Exported for test coverage.
export async function detectRepo(): Promise<string> {
const hint = process.env.KILO_DEV_REPO
if (hint) return hint
const candidates: string[] = []
const meta = (() => {
try {
return fileURLToPath(import.meta.url)
} catch {
return undefined
}
})()
if (meta && !isBunfsPath(meta)) candidates.push(path.dirname(meta))
// process.execPath points at the binary itself; walk up from its directory.
// For a local build it's <repo>/packages/opencode/dist/<target>/bin/kilo, so
// findRepoFrom eventually hits the repo's packages/opencode/package.json.
if (process.execPath) candidates.push(path.dirname(process.execPath))
candidates.push(process.cwd())
for (const start of candidates) {
const found = await findRepoFrom(start)
if (found) return found
}
throw new Error(
"cannot locate kilocode source checkout; set KILO_DEV_REPO=/path/to/kilocode or run ./bin/kilodev dev-setup from the repo",
)
}
+8 -2
View File
@@ -25,6 +25,7 @@ import { ConfigCommand as ConfigCLICommand } from "../cli/cmd/config"
import { PluginCommand } from "../cli/cmd/plug"
import { DevSetupCommand, DevAliasCommand } from "./cli/dev-setup"
import { HelpCommand } from "./help-command"
import { InstallationBuildKind } from "../installation/version"
// Synthetic entry for the yargs built-in .completion() command so that
// generateHelp --all and cli-reference.md include it automatically.
@@ -34,6 +35,12 @@ const CompletionCommand = {
handler: () => {},
}
// Dev-only commands are spread in conditionally so release builds omit them
// from `kilo help --all` and the docs table. They're also guarded the same way
// at the yargs registration site in src/index.ts, so the commands-in-sync
// regex in test/kilocode/help.test.ts sees DevSetup/DevAlias on neither side.
const dev = InstallationBuildKind === "release" ? [] : [DevSetupCommand, DevAliasCommand]
export const commands = [
AcpCommand,
McpCommand,
@@ -56,8 +63,7 @@ export const commands = [
RemoteCommand,
DbCommand,
ConfigCLICommand,
DevSetupCommand,
DevAliasCommand,
...dev,
PluginCommand,
HelpCommand,
CompletionCommand,
@@ -0,0 +1,74 @@
import { describe, test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../../fixture/fixture"
import { detectRepo, findRepoFrom } from "../../../src/kilocode/cli/dev-setup"
// Simulate a repo root by writing the sentinel file detectRepo looks for.
async function makeSynthRepo(dir: string) {
const pkg = path.join(dir, "packages", "opencode")
await fs.mkdir(pkg, { recursive: true })
await Bun.write(path.join(pkg, "package.json"), JSON.stringify({ name: "synth" }))
return dir
}
async function withEnv<T>(name: string, value: string | undefined, fn: () => Promise<T>): Promise<T> {
const prev = process.env[name]
if (value === undefined) delete process.env[name]
else process.env[name] = value
try {
return await fn()
} finally {
if (prev === undefined) delete process.env[name]
else process.env[name] = prev
}
}
describe("findRepoFrom", () => {
test("returns the repo root when started from a nested path", async () => {
await using tmp = await tmpdir()
await makeSynthRepo(tmp.path)
const nested = path.join(tmp.path, "packages", "opencode", "src", "kilocode", "cli")
await fs.mkdir(nested, { recursive: true })
const found = await findRepoFrom(nested)
expect(found).toBe(tmp.path)
})
test("returns the repo root when started from the root itself", async () => {
await using tmp = await tmpdir()
await makeSynthRepo(tmp.path)
const found = await findRepoFrom(tmp.path)
expect(found).toBe(tmp.path)
})
test("returns undefined when no sentinel is found up to filesystem root", async () => {
await using tmp = await tmpdir()
// No packages/opencode/package.json anywhere under tmp, and the filesystem
// above tmp does not contain a kilocode repo at its own root.
const found = await findRepoFrom(tmp.path)
expect(found).toBeUndefined()
})
})
describe("detectRepo", () => {
test("honours KILO_DEV_REPO without touching the filesystem", async () => {
// Pointing at a path that does not exist — the hint must be returned as-is
// (this proves no walk happens when KILO_DEV_REPO is set).
const hint = "/definitely/does/not/exist/kilo-repo"
const got = await withEnv("KILO_DEV_REPO", hint, () => detectRepo())
expect(got).toBe(hint)
})
test("KILO_DEV_REPO wins over the filesystem walk", async () => {
// Build a synthetic repo, point KILO_DEV_REPO at it, and run from an
// unrelated cwd. Even though the walk from cwd (inside the real kilo
// checkout) would succeed, the hint must take priority.
await using tmp = await tmpdir()
await makeSynthRepo(tmp.path)
const got = await withEnv("KILO_DEV_REPO", tmp.path, () => detectRepo())
expect(got).toBe(tmp.path)
})
})