Files
Kilo-Org_kilocode/packages/kilo-vscode/script/launch.ts
2026-05-28 17:01:56 +02:00

376 lines
12 KiB
TypeScript

#!/usr/bin/env bun
/**
* Build the Kilo VS Code extension and launch it in a development host.
*
* Usage:
* bun script/launch.ts [options]
*
* Options:
* --no-build Skip the build step (reuse last build)
* --workspace PATH Folder to open in VS Code (default: repo root)
* --mode dev|vsix "dev" uses --extensionDevelopmentPath, "vsix" packages a VSIX (default: dev)
* --app-path PATH Explicit path to the VS Code executable (auto-detected if omitted)
* --insiders Prefer VS Code Insiders over stable
* --wait Block until the VS Code window is closed
* --clean Wipe the user-data and extensions dirs before launching
* --preserve-settings Merge defaults into existing VS Code user settings
* --accessible Enable VS Code accessibility support for assistive-technology testing
*
* Environment:
* VSCODE_EXEC_PATH Path to VS Code executable (same as --app-path)
*
* Cross-platform: macOS, Linux, and Windows are all supported.
*
* The script uses a stable directory per repo checkout under the OS temp dir
* so nothing accumulates — the same dirs are reused on every launch.
*/
import { $ } from "bun"
import { createHash } from "node:crypto"
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
import { homedir, tmpdir } from "node:os"
import { delimiter, join, resolve } from "node:path"
import { spawn } from "node:child_process"
const win = process.platform === "win32"
const root = join(import.meta.dir, "..")
const repo = resolve(root, "..", "..")
// Stable per-repo directory under OS temp — no accumulation
const hash = createHash("sha256").update(repo).digest("hex").slice(0, 12)
const base = join(tmpdir(), `kilo-vscode-dev-${hash}`)
const userDir = join(base, "user-data")
const extDir = join(base, "extensions")
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
function parse(argv: string[]) {
const result: Record<string, string | boolean> = {}
for (let i = 0; i < argv.length; i++) {
const item = argv[i]!
if (!item.startsWith("--")) continue
if (item.startsWith("--no-")) {
result[item.slice(5)] = false
continue
}
const parts = item.slice(2).split("=", 2)
const key = parts[0]!
const raw = parts[1]
if (raw !== undefined) {
result[key] = raw
continue
}
const next = argv[i + 1]
if (!next || next.startsWith("--")) {
result[key] = true
continue
}
result[key] = next
i++
}
return result
}
const opts = parse(process.argv.slice(2))
const shouldBuild = opts["build"] !== false
const mode = (opts["mode"] as string) ?? "dev"
const workspace = opts["workspace"] ? resolve(opts["workspace"] as string) : repo
const insiders = opts["insiders"] === true
const explicit = opts["app-path"] as string | undefined
const blocking = opts["wait"] === true
const clean = opts["clean"] === true
const preserve = opts["preserve-settings"] === true
const accessible = opts["accessible"] === true
// ---------------------------------------------------------------------------
// VS Code executable detection
// ---------------------------------------------------------------------------
function which(name: string): string | null {
const paths = (process.env.PATH ?? "").split(delimiter).filter(Boolean)
const exts = win ? [".cmd", ".exe", ".bat", ""] : [""]
for (const dir of paths) {
for (const ext of exts) {
const full = join(dir, name.endsWith(ext) ? name : `${name}${ext}`)
if (existsSync(full)) return full
}
}
return null
}
function detect(): string {
const env = explicit ?? process.env["VSCODE_EXEC_PATH"]
if (env && existsSync(env)) return env
const candidates: string[] = []
const prefer = insiders ? "insiders" : "stable"
if (process.platform === "darwin") {
const order =
prefer === "insiders"
? [
"/Applications/Visual Studio Code - Insiders.app/Contents/MacOS/Code - Insiders",
"/Applications/Visual Studio Code.app/Contents/MacOS/Code",
]
: [
"/Applications/Visual Studio Code.app/Contents/MacOS/Code",
"/Applications/Visual Studio Code - Insiders.app/Contents/MacOS/Code - Insiders",
]
candidates.push(...order)
}
if (process.platform === "linux") {
const stable = [
"/usr/share/code/code",
"/usr/bin/code",
"/snap/code/current/usr/share/code/code",
"/var/lib/flatpak/exports/bin/com.visualstudio.code",
]
const ins = [
"/usr/share/code-insiders/code-insiders",
"/usr/bin/code-insiders",
"/snap/code-insiders/current/usr/share/code-insiders/code-insiders",
"/var/lib/flatpak/exports/bin/com.visualstudio.code.insiders",
]
candidates.push(...(prefer === "insiders" ? [...ins, ...stable] : [...stable, ...ins]))
}
if (win) {
const local = process.env["LOCALAPPDATA"] ?? ""
const program = process.env["PROGRAMFILES"] ?? "C:\\Program Files"
const stable = [
join(local, "Programs", "Microsoft VS Code", "Code.exe"),
join(program, "Microsoft VS Code", "Code.exe"),
]
const ins = [
join(local, "Programs", "Microsoft VS Code Insiders", "Code - Insiders.exe"),
join(program, "Microsoft VS Code Insiders", "Code - Insiders.exe"),
]
candidates.push(...(prefer === "insiders" ? [...ins, ...stable] : [...stable, ...ins]))
}
const found = candidates.find((c) => existsSync(c))
if (found) return found
// Last resort: PATH lookup
const path = insiders ? (which("code-insiders") ?? which("code")) : (which("code") ?? which("code-insiders"))
if (path) return path
console.error(
`Could not find VS Code. Set VSCODE_EXEC_PATH or pass --app-path.\n` +
`Searched:\n${candidates.map((c) => ` ${c}`).join("\n")}`,
)
process.exit(1)
}
// ---------------------------------------------------------------------------
// CLI path detection (needed for --mode vsix)
// ---------------------------------------------------------------------------
function codeCli(app: string): string {
const name = app.toLowerCase().includes("insiders") ? "code-insiders" : "code"
const direct = which(name)
if (direct) return direct
if (process.platform === "darwin") {
const bundled = app.includes("Insiders")
? "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code"
: "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"
if (existsSync(bundled)) return bundled
}
console.error(`VS Code CLI (${name}) not found on PATH. Install the shell command or use --mode dev instead.`)
process.exit(1)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function newest(paths: string[]) {
return [...paths].sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
async function compile() {
if (!shouldBuild) {
console.log("[launch] Skipping build (--no-build)")
return
}
console.log("[launch] Building extension...")
await $`bun run package`.cwd(root).env(cleanEnv(process.env))
console.log("[launch] Build complete")
}
function cleanEnv(input: NodeJS.ProcessEnv) {
const env = { ...input, HOME: homedir().trim() }
for (const key of ["XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "KILO_TEST_HOME"]) {
const value = env[key]
if (value !== undefined) env[key] = value.trim()
}
return env
}
// ---------------------------------------------------------------------------
// VSIX packaging (only for --mode vsix)
// ---------------------------------------------------------------------------
async function packageVsix(out: string): Promise<string> {
await $`bunx vsce package --no-dependencies --skip-license -o ${out}/`.cwd(root)
const files = newest(
readdirSync(out)
.filter((f) => f.endsWith(".vsix"))
.map((f) => join(out, f)),
)
const vsix = files.at(0)
if (!vsix) {
console.error(`No VSIX was created in ${out}`)
process.exit(1)
}
return vsix
}
async function installVsix(path: string, app: string) {
const cmd = codeCli(app)
await $`${cmd} --extensions-dir ${extDir} --user-data-dir ${userDir} --install-extension ${path} --force`.cwd(root)
}
// ---------------------------------------------------------------------------
// Settings for isolated instance
// ---------------------------------------------------------------------------
function settings(keep: boolean, enabled: boolean) {
const dir = join(userDir, "User")
const file = join(dir, "settings.json")
const defaults = {
"editor.accessibilitySupport": enabled ? "on" : "off",
"extensions.autoCheckUpdates": false,
"extensions.autoUpdate": false,
"extensions.ignoreRecommendations": true,
"security.workspace.trust.enabled": false,
"task.allowAutomaticTasks": "off",
"telemetry.telemetryLevel": "off",
"update.mode": "none",
"workbench.startupEditor": "none",
"workbench.tips.enabled": false,
"window.commandCenter": false,
}
mkdirSync(dir, { recursive: true })
const cfg =
keep && existsSync(file)
? { ...defaults, ...load(file), ...(enabled ? { "editor.accessibilitySupport": "on" } : {}) }
: defaults
writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n")
}
function load(file: string) {
try {
const cfg = JSON.parse(readFileSync(file, "utf8"))
if (cfg && typeof cfg === "object" && !Array.isArray(cfg)) return cfg as Record<string, unknown>
} catch (err) {
console.warn(
`[launch] Could not parse existing settings.json, rewriting defaults: ${err instanceof Error ? err.message : String(err)}`,
)
return {}
}
console.warn("[launch] Existing settings.json root is not an object, rewriting defaults")
return {}
}
// ---------------------------------------------------------------------------
// Launch
// ---------------------------------------------------------------------------
async function launch() {
await compile()
if (clean) {
console.log("[launch] Cleaning previous state...")
rmSync(base, { recursive: true, force: true })
}
mkdirSync(userDir, { recursive: true })
mkdirSync(extDir, { recursive: true })
const app = detect()
settings(preserve, accessible)
const args = [workspace, `--extensions-dir=${extDir}`, `--user-data-dir=${userDir}`, "--skip-release-notes"]
if (mode === "dev") {
args.push(`--extensionDevelopmentPath=${root}`)
args.push("--disable-extension=kilocode.kilo-code")
}
if (mode === "vsix") {
const out = join(base, "vsix")
mkdirSync(out, { recursive: true })
const vsix = await packageVsix(out)
await installVsix(vsix, app)
console.log(`[launch] Installed VSIX: ${vsix}`)
}
if (blocking) {
args.push("--wait")
}
// Strip Electron/VS Code env vars so the spawned instance doesn't attach
// to the current Electron process (e.g. when launched from a VS Code task).
const env = cleanEnv(process.env)
for (const key of Object.keys(env)) {
if (key.startsWith("ELECTRON_") || key.startsWith("VSCODE_")) delete env[key]
}
console.log(`[launch] Starting VS Code (${mode} mode)`)
console.log(`[launch] Executable: ${app}`)
console.log(`[launch] Workspace: ${workspace}`)
console.log(`[launch] State: ${base}`)
console.log(`[launch] Accessibility support: ${accessible ? "on" : "off"}`)
if (blocking) {
const result = Bun.spawnSync([app, ...args], {
cwd: workspace,
env,
stdio: ["ignore", "inherit", "inherit"],
})
console.log(`[launch] VS Code exited (code ${result.exitCode})`)
return
}
const child = spawn(app, args, {
cwd: workspace,
detached: !win,
env,
stdio: "ignore",
...(win ? { shell: true } : {}),
})
child.unref()
console.log(`[launch] VS Code launched (pid ${child.pid})`)
}
try {
await launch()
} catch (err) {
console.error(`[launch] ERROR: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
}