mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 14:11:49 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d2e75ef49 |
@@ -1,6 +1,8 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Option, Schema } from "effect"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import type { PromptInfo } from "../component/prompt/history"
|
||||
import { tryJsonConfig } from "@/util/json"
|
||||
|
||||
export type HomeRoute = {
|
||||
type: "home"
|
||||
@@ -21,17 +23,25 @@ export type PluginRoute = {
|
||||
|
||||
export type Route = HomeRoute | SessionRoute | PluginRoute
|
||||
|
||||
const HOME_ROUTE: Route = { type: "home" }
|
||||
|
||||
// Schema covers only the env-fed shape — `prompt` is set programmatically and
|
||||
// not expected from OPENCODE_ROUTE, so we keep it out of validation.
|
||||
export const RouteSchema = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("home") }),
|
||||
Schema.Struct({ type: Schema.Literal("session"), sessionID: Schema.String }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("plugin"),
|
||||
id: Schema.String,
|
||||
data: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}),
|
||||
])
|
||||
|
||||
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
name: "Route",
|
||||
init: (props: { initialRoute?: Route }) => {
|
||||
const [store, setStore] = createStore<Route>(
|
||||
props.initialRoute ??
|
||||
(process.env["OPENCODE_ROUTE"]
|
||||
? JSON.parse(process.env["OPENCODE_ROUTE"])
|
||||
: {
|
||||
type: "home",
|
||||
}),
|
||||
)
|
||||
const fromEnv = tryJsonConfig(process.env["OPENCODE_ROUTE"], RouteSchema, "OPENCODE_ROUTE")
|
||||
const [store, setStore] = createStore<Route>(props.initialRoute ?? Option.getOrElse(fromEnv, () => HOME_ROUTE))
|
||||
|
||||
return {
|
||||
get data() {
|
||||
|
||||
@@ -106,6 +106,21 @@ export function FormatError(input: unknown): string | undefined {
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
// InvalidConfigError: malformed env-var JSON at a safety boundary
|
||||
if (isTaggedError(input, "InvalidConfigError")) {
|
||||
const source = stringField(input, "source") ?? ""
|
||||
const value = stringField(input, "value") ?? ""
|
||||
const reason = stringField(input, "reason") ?? ""
|
||||
return [
|
||||
`Error: ${source} is invalid and cannot be applied.`,
|
||||
` Value: '${value}'`,
|
||||
` Reason: ${reason}`,
|
||||
"",
|
||||
`opencode will not start with a malformed ${source}. Set ${source}`,
|
||||
`correctly or unset it to use defaults.`,
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
// UICancelledError: user cancelled an interactive CLI prompt
|
||||
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
|
||||
return ""
|
||||
|
||||
@@ -14,6 +14,7 @@ import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/instal
|
||||
import { existsSync } from "fs"
|
||||
import { Account } from "@/account/account"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { requireJsonConfig } from "@/util/json"
|
||||
import type { ConsoleState } from "./console-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
@@ -703,7 +704,8 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_PERMISSION) {
|
||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||
const decoded = requireJsonConfig(Flag.OPENCODE_PERMISSION, ConfigPermission.Info, "OPENCODE_PERMISSION")
|
||||
result.permission = mergeDeep(result.permission ?? {}, decoded)
|
||||
}
|
||||
|
||||
if (result.tools) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Option, Result, Schema } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
const decodeJsonUnknown = Schema.decodeUnknownResult(Schema.UnknownFromJsonString)
|
||||
|
||||
export class InvalidConfigError extends Schema.TaggedErrorClass<InvalidConfigError>()("InvalidConfigError", {
|
||||
source: Schema.String,
|
||||
value: Schema.String,
|
||||
reason: Schema.String,
|
||||
}) {}
|
||||
|
||||
// Decode JSON config at a safety boundary. On malformed JSON or schema
|
||||
// mismatch, throws `InvalidConfigError`. On success, returns the typed value.
|
||||
// Use when defaulting silently is dangerous (e.g. permissions).
|
||||
export function requireJsonConfig<S extends Schema.Decoder<unknown>>(raw: string, schema: S, source: string): S["Type"] {
|
||||
const decoded = decode(raw, schema)
|
||||
if (Result.isFailure(decoded)) {
|
||||
throw new InvalidConfigError({ source, value: raw, reason: decoded.failure })
|
||||
}
|
||||
return decoded.success
|
||||
}
|
||||
|
||||
// Decode JSON config at a UX-preference boundary. Returns `None` for absent,
|
||||
// empty, malformed, or schema-mismatched input, logging a warn so the user
|
||||
// can debug. Use when defaulting silently is benign (e.g. theme, route).
|
||||
export function tryJsonConfig<S extends Schema.Decoder<unknown>>(
|
||||
raw: string | undefined,
|
||||
schema: S,
|
||||
source: string,
|
||||
): Option.Option<S["Type"]> {
|
||||
if (!raw) return Option.none()
|
||||
const decoded = decode(raw, schema)
|
||||
if (Result.isFailure(decoded)) {
|
||||
log.warn(`ignoring invalid ${source}`, { value: raw, reason: decoded.failure })
|
||||
return Option.none()
|
||||
}
|
||||
return Option.some(decoded.success)
|
||||
}
|
||||
|
||||
function decode<S extends Schema.Decoder<unknown>>(raw: string, schema: S): Result.Result<S["Type"], string> {
|
||||
const decodeSchema = Schema.decodeUnknownResult(schema)
|
||||
const parsed = decodeJsonUnknown(raw)
|
||||
if (Result.isFailure(parsed)) return Result.fail(parsed.failure.toString())
|
||||
const decoded = decodeSchema(parsed.success)
|
||||
if (Result.isFailure(decoded)) return Result.fail(decoded.failure.toString())
|
||||
return Result.succeed(decoded.success)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Option } from "effect"
|
||||
import { RouteSchema } from "@/cli/cmd/tui/context/route"
|
||||
import { tryJsonConfig } from "@/util/json"
|
||||
|
||||
const parse = (raw: string | undefined) => tryJsonConfig(raw, RouteSchema, "OPENCODE_ROUTE")
|
||||
|
||||
describe("tryJsonConfig with RouteSchema", () => {
|
||||
test("returns None for undefined input", () => {
|
||||
expect(Option.isNone(parse(undefined))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns None for empty string", () => {
|
||||
expect(Option.isNone(parse(""))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns None for malformed JSON", () => {
|
||||
expect(Option.isNone(parse("{not json"))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns None when JSON does not match route schema", () => {
|
||||
expect(Option.isNone(parse(`{"type":"unknown"}`))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns Some for valid home route", () => {
|
||||
expect(Option.getOrThrow(parse(`{"type":"home"}`))).toEqual({ type: "home" })
|
||||
})
|
||||
|
||||
test("returns Some for valid session route", () => {
|
||||
expect(Option.getOrThrow(parse(`{"type":"session","sessionID":"abc123"}`))).toEqual({
|
||||
type: "session",
|
||||
sessionID: "abc123",
|
||||
})
|
||||
})
|
||||
|
||||
test("returns Some for valid plugin route", () => {
|
||||
expect(Option.getOrThrow(parse(`{"type":"plugin","id":"foo","data":{"x":1}}`))).toEqual({
|
||||
type: "plugin",
|
||||
id: "foo",
|
||||
data: { x: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigPermission } from "@/config/permission"
|
||||
import { InvalidConfigError, requireJsonConfig } from "@/util/json"
|
||||
|
||||
const decode = (raw: string) => requireJsonConfig(raw, ConfigPermission.Info, "OPENCODE_PERMISSION")
|
||||
|
||||
describe("requireJsonConfig with ConfigPermission.Info", () => {
|
||||
test("throws InvalidConfigError on malformed JSON", () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
decode("{not json")
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(InvalidConfigError)
|
||||
const data = caught as InvalidConfigError
|
||||
expect(data.source).toBe("OPENCODE_PERMISSION")
|
||||
expect(data.value).toBe("{not json")
|
||||
expect(typeof data.reason).toBe("string")
|
||||
expect(data.reason.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("throws InvalidConfigError on shape mismatch", () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
// `banana` is not a valid Action ("ask" | "allow" | "deny")
|
||||
decode(`{"bash":"banana"}`)
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(InvalidConfigError)
|
||||
const data = caught as InvalidConfigError
|
||||
expect(data.source).toBe("OPENCODE_PERMISSION")
|
||||
expect(data.value).toBe(`{"bash":"banana"}`)
|
||||
expect(data.reason.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("returns decoded value for Action shorthand", () => {
|
||||
expect(decode(`"ask"`)).toEqual({ "*": "ask" })
|
||||
})
|
||||
|
||||
test("returns decoded value for object of per-target rules", () => {
|
||||
expect(decode(`{"bash":"ask","edit":"allow"}`)).toEqual({
|
||||
bash: "ask",
|
||||
edit: "allow",
|
||||
})
|
||||
})
|
||||
|
||||
test("returns decoded value for nested Object rule", () => {
|
||||
expect(decode(`{"bash":{"rm *":"deny","ls":"allow"}}`)).toEqual({
|
||||
bash: { "rm *": "deny", ls: "allow" },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Option, Schema } from "effect"
|
||||
import { InvalidConfigError, requireJsonConfig, tryJsonConfig } from "@/util/json"
|
||||
|
||||
const PointSchema = Schema.Struct({
|
||||
x: Schema.Number,
|
||||
y: Schema.Number,
|
||||
})
|
||||
|
||||
describe("requireJsonConfig", () => {
|
||||
test("returns decoded value on success", () => {
|
||||
expect(requireJsonConfig(`{"x":1,"y":2}`, PointSchema, "TEST")).toEqual({ x: 1, y: 2 })
|
||||
})
|
||||
|
||||
test("throws InvalidConfigError on malformed JSON", () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
requireJsonConfig("not json", PointSchema, "TEST")
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(InvalidConfigError)
|
||||
const err = caught as InvalidConfigError
|
||||
expect(err.source).toBe("TEST")
|
||||
expect(err.value).toBe("not json")
|
||||
expect(err.reason.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("throws InvalidConfigError on schema mismatch", () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
requireJsonConfig(`{"x":"oops"}`, PointSchema, "TEST")
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(InvalidConfigError)
|
||||
const err = caught as InvalidConfigError
|
||||
expect(err.source).toBe("TEST")
|
||||
expect(err.value).toBe(`{"x":"oops"}`)
|
||||
expect(err.reason.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("InvalidConfigError carries _tag for downstream matching", () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
requireJsonConfig("not json", PointSchema, "TEST")
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect((caught as { _tag: string })._tag).toBe("InvalidConfigError")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tryJsonConfig", () => {
|
||||
test("returns Some on success", () => {
|
||||
const decoded = tryJsonConfig(`{"x":1,"y":2}`, PointSchema, "TEST")
|
||||
expect(Option.isSome(decoded)).toBe(true)
|
||||
expect(Option.getOrThrow(decoded)).toEqual({ x: 1, y: 2 })
|
||||
})
|
||||
|
||||
test("returns None for undefined input", () => {
|
||||
expect(Option.isNone(tryJsonConfig(undefined, PointSchema, "TEST"))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns None for empty string", () => {
|
||||
expect(Option.isNone(tryJsonConfig("", PointSchema, "TEST"))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns None for malformed JSON", () => {
|
||||
expect(Option.isNone(tryJsonConfig("not json", PointSchema, "TEST"))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns None on schema mismatch", () => {
|
||||
expect(Option.isNone(tryJsonConfig(`{"x":"oops"}`, PointSchema, "TEST"))).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user