feat: configurable shell selection + desktop settings UI (#20602)
This commit is contained in:
@@ -55,6 +55,8 @@ const it = testEffect(layer)
|
||||
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const save = (config: Config.Info) =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const saveGlobal = (config: Config.Info) =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const clear = (wait = false) =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const listDirs = () =>
|
||||
@@ -142,6 +144,106 @@ test("loads JSON config file", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("loads shell config field", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
shell: "bash",
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(config.shell).toBe("bash")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("updates config and preserves empty shell sentinel", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(
|
||||
dir,
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
shell: "bash",
|
||||
},
|
||||
"config.json",
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await save({ shell: "" })
|
||||
|
||||
const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "config.json"))
|
||||
expect(writtenConfig.shell).toBe("")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("updates global config and omits empty shell key in json", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
shell: "bash",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const prev = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = tmp.path
|
||||
await clear(true)
|
||||
|
||||
try {
|
||||
await saveGlobal({ shell: "" })
|
||||
|
||||
const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "opencode.json"))
|
||||
expect("shell" in writtenConfig).toBe(false)
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = prev
|
||||
await clear(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("updates global config and omits empty shell key in jsonc", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
shell: "bash",
|
||||
model: "test/model",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const prev = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = tmp.path
|
||||
await clear(true)
|
||||
|
||||
try {
|
||||
await saveGlobal({ shell: "" })
|
||||
|
||||
const file = path.join(tmp.path, "opencode.jsonc")
|
||||
const writtenConfig = await Filesystem.readText(file)
|
||||
const parsed = ConfigParse.schema(Config.Info.zod, ConfigParse.jsonc(writtenConfig, file), file)
|
||||
expect(writtenConfig).not.toContain('"shell"')
|
||||
expect(parsed.shell).toBeUndefined()
|
||||
expect(parsed.model).toBe("test/model")
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = prev
|
||||
await clear(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("loads formatter boolean config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -67,3 +67,38 @@ describe("pty shell args", () => {
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe("pty configured shell", () => {
|
||||
test(
|
||||
"uses configured shell for default PTY command",
|
||||
async () => {
|
||||
const configured = process.platform === "win32" ? Bun.which("pwsh") || Bun.which("powershell") : Bun.which("bash")
|
||||
if (!configured) return
|
||||
|
||||
await using dir = await tmpdir({
|
||||
config: { shell: Shell.name(configured) },
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* pty.create({ title: "configured" })
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
expect(info.command.toLowerCase()).toBe(configured.toLowerCase())
|
||||
} else {
|
||||
expect(info.command).toBe(configured)
|
||||
}
|
||||
expect(info.args).toEqual(process.platform === "win32" ? [] : ["-l"])
|
||||
} finally {
|
||||
yield* pty.remove(info.id)
|
||||
}
|
||||
}),
|
||||
),
|
||||
})
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -316,9 +316,11 @@ const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) =>
|
||||
})
|
||||
|
||||
const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
|
||||
const config = yield* Config.Service
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const run = yield* SessionRunState.Service
|
||||
const sessions = yield* Session.Service
|
||||
yield* config.get()
|
||||
const chat = yield* sessions.create(input ?? { title: "Pinned" })
|
||||
return { prompt, run, sessions, chat }
|
||||
})
|
||||
@@ -1078,6 +1080,32 @@ unix("shell completes a fast command on the preferred shell", () =>
|
||||
),
|
||||
)
|
||||
|
||||
unix(
|
||||
"shell uses configured shell over env shell",
|
||||
() =>
|
||||
withSh(() =>
|
||||
provideTmpdirInstance(
|
||||
(_dir) =>
|
||||
Effect.gen(function* () {
|
||||
if (!Bun.which("bash")) return
|
||||
|
||||
const { prompt, chat } = yield* boot()
|
||||
const result = yield* prompt.shell({
|
||||
sessionID: chat.id,
|
||||
agent: "build",
|
||||
command: "[[ 1 -eq 1 ]] && printf configured",
|
||||
})
|
||||
|
||||
const tool = completedTool(result.parts)
|
||||
if (!tool) return
|
||||
expect(tool.state.output).toContain("configured")
|
||||
}),
|
||||
{ git: true, config: { ...cfg, shell: "bash" } },
|
||||
),
|
||||
),
|
||||
30_000,
|
||||
)
|
||||
|
||||
unix("shell commands can change directory after startup", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
@@ -1263,6 +1291,45 @@ it.live(
|
||||
3_000,
|
||||
)
|
||||
|
||||
unix(
|
||||
"command ! expansion uses configured shell over env shell",
|
||||
() =>
|
||||
withSh(() =>
|
||||
provideTmpdirServer(
|
||||
({ llm }) =>
|
||||
Effect.gen(function* () {
|
||||
if (!Bun.which("bash")) return
|
||||
|
||||
const { prompt, chat } = yield* boot()
|
||||
yield* llm.text("done")
|
||||
|
||||
const result = yield* prompt.command({
|
||||
sessionID: chat.id,
|
||||
command: "probe",
|
||||
arguments: "",
|
||||
})
|
||||
|
||||
expect(result.info.role).toBe("assistant")
|
||||
const inputs = yield* llm.inputs
|
||||
expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("configured")
|
||||
}),
|
||||
{
|
||||
git: true,
|
||||
config: (url) => ({
|
||||
...providerCfg(url),
|
||||
shell: "bash",
|
||||
command: {
|
||||
probe: {
|
||||
template: "Probe: !`[[ 1 -eq 1 ]] && printf configured`",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
),
|
||||
),
|
||||
30_000,
|
||||
)
|
||||
|
||||
unix(
|
||||
"cancel interrupts shell and resolves cleanly",
|
||||
() =>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { Filesystem } from "../../src/util"
|
||||
import { which } from "../../src/util/which"
|
||||
|
||||
const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
|
||||
const prev = process.env.SHELL
|
||||
@@ -39,6 +40,20 @@ describe("shell", () => {
|
||||
expect(Shell.posix("C:/tools/pwsh.exe")).toBe(false)
|
||||
})
|
||||
|
||||
test("falls back when configured shell cannot be resolved", async () => {
|
||||
await withShell(undefined, async () => {
|
||||
const preferred = Shell.preferred()
|
||||
const acceptable = Shell.acceptable()
|
||||
expect(Shell.preferred("opencode-missing-shell")).toBe(preferred)
|
||||
expect(Shell.acceptable("opencode-missing-shell")).toBe(acceptable)
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back for terminal-only acceptable shells", () => {
|
||||
expect(Shell.name(Shell.acceptable("fish"))).not.toBe("fish")
|
||||
expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu")
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
test("rejects blacklisted shells case-insensitively", async () => {
|
||||
await withShell("NU.EXE", async () => {
|
||||
@@ -62,8 +77,19 @@ describe("shell", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("resolves bare bash to Git Bash before PATH", async () => {
|
||||
const bash = Shell.gitbash()
|
||||
if (!bash) return
|
||||
expect(Shell.acceptable("bash")).toBe(bash)
|
||||
expect(Shell.preferred("bash")).toBe(bash)
|
||||
await withShell("bash", async () => {
|
||||
expect(Shell.acceptable()).toBe(bash)
|
||||
expect(Shell.preferred()).toBe(bash)
|
||||
})
|
||||
})
|
||||
|
||||
test("resolves bare PowerShell shells", async () => {
|
||||
const shell = Bun.which("pwsh") || Bun.which("powershell")
|
||||
const shell = which("pwsh") || which("powershell")
|
||||
if (!shell) return
|
||||
await withShell(path.win32.basename(shell), async () => {
|
||||
expect(Shell.preferred()).toBe(shell)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Config } from "../../src/config"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { BashTool } from "../../src/tool/bash"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
@@ -21,6 +22,7 @@ const runtime = ManagedRuntime.make(
|
||||
AppFileSystem.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
),
|
||||
)
|
||||
@@ -153,6 +155,33 @@ describe("tool.bash", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back from terminal-only configured shell", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: { shell: "fish" },
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const fallback = Shell.name(Shell.acceptable("fish"))
|
||||
expect(fallback).not.toBe("fish")
|
||||
expect(bash.description).toContain(fallback)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo fallback",
|
||||
description: "Echo fallback text",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
expect(result.metadata.exit).toBe(0)
|
||||
expect(result.output).toContain("fallback")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool.bash permissions", () => {
|
||||
|
||||
Reference in New Issue
Block a user