Skip to content

Commit 75a22ef

Browse files
committed
fix(opencode): expand env-prefixed plugin path specs
Support plugin specs in config files that use home/environment prefixes (like C:\Users\ghima, C:\Users\ghima, ~, and %USERPROFILE%) so local plugin paths resolve correctly instead of being treated as npm packages. Adds regression tests for C:\Users\ghima and %USERPROFILE% forms.
1 parent becf57e commit 75a22ef

2 files changed

Lines changed: 86 additions & 2 deletions

File tree

packages/opencode/src/config/plugin.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { pathToFileURL } from "url"
44
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
55
import { zod } from "@/util/effect-zod"
66
import { withStatics } from "@/util/schema"
7+
import os from "os"
78
import path from "path"
89

910
export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -49,11 +50,52 @@ export function pluginOptions(plugin: Spec): Options | undefined {
4950
return Array.isArray(plugin) ? plugin[1] : undefined
5051
}
5152

53+
function expandPathVariablePrefix(spec: string) {
54+
if (spec === "~") return os.homedir()
55+
if (spec.startsWith("~/") || spec.startsWith("~\\")) return path.join(os.homedir(), spec.slice(2))
56+
57+
const posix = spec.match(/^\$([A-Za-z_][A-Za-z0-9_]*)(?=$|[\\/])/)
58+
if (posix) {
59+
const value = process.env[posix[1]]
60+
if (value) return value + spec.slice(posix[0].length)
61+
}
62+
63+
const braced = spec.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}(?=$|[\\/])/)
64+
if (braced) {
65+
const value = process.env[braced[1]]
66+
if (value) return value + spec.slice(braced[0].length)
67+
}
68+
69+
const windows = spec.match(/^%([^%]+)%(?=$|[\\/])/)
70+
if (windows) {
71+
const value = process.env[windows[1]]
72+
if (value) return value + spec.slice(windows[0].length)
73+
}
74+
75+
return spec
76+
}
77+
78+
function hasPathVariablePrefix(spec: string) {
79+
if (spec === "~" || spec.startsWith("~/") || spec.startsWith("~\\")) return true
80+
81+
const posix = spec.match(/^\$([A-Za-z_][A-Za-z0-9_]*)(?=$|[\\/])/)
82+
if (posix) return !!process.env[posix[1]]
83+
84+
const braced = spec.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}(?=$|[\\/])/)
85+
if (braced) return !!process.env[braced[1]]
86+
87+
const windows = spec.match(/^%([^%]+)%(?=$|[\\/])/)
88+
if (windows) return !!process.env[windows[1]]
89+
90+
return false
91+
}
92+
5293
// Path-like specs are resolved relative to the config file that declared them so merges later on do not
5394
// accidentally reinterpret `./plugin.ts` relative to some other directory.
5495
export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise<Spec> {
55-
const spec = pluginSpecifier(plugin)
56-
if (!isPathPluginSpec(spec)) return plugin
96+
const raw = pluginSpecifier(plugin)
97+
const spec = expandPathVariablePrefix(raw)
98+
if (!isPathPluginSpec(raw) && !hasPathVariablePrefix(raw) && !isPathPluginSpec(spec)) return plugin
5799

58100
const base = path.dirname(configFilepath)
59101
const file = (() => {

packages/opencode/test/config/config.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1968,6 +1968,48 @@ describe("resolvePluginSpec", () => {
19681968
expect(await ConfigPlugin.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
19691969
})
19701970

1971+
test("resolves $HOME-prefixed plugin directory specs", async () => {
1972+
await using tmp = await tmpdir({
1973+
init: async (dir) => {
1974+
const plugin = path.join(dir, "plugin")
1975+
await fs.mkdir(plugin, { recursive: true })
1976+
await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
1977+
},
1978+
})
1979+
1980+
const previous = process.env.HOME
1981+
process.env.HOME = tmp.path
1982+
try {
1983+
const file = path.join(tmp.path, "opencode.json")
1984+
const hit = await ConfigPlugin.resolvePluginSpec("$HOME/plugin", file)
1985+
expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
1986+
} finally {
1987+
if (previous === undefined) delete process.env.HOME
1988+
else process.env.HOME = previous
1989+
}
1990+
})
1991+
1992+
test("resolves %USERPROFILE%-prefixed plugin directory specs", async () => {
1993+
await using tmp = await tmpdir({
1994+
init: async (dir) => {
1995+
const plugin = path.join(dir, "plugin")
1996+
await fs.mkdir(plugin, { recursive: true })
1997+
await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
1998+
},
1999+
})
2000+
2001+
const previous = process.env.USERPROFILE
2002+
process.env.USERPROFILE = tmp.path
2003+
try {
2004+
const file = path.join(tmp.path, "opencode.json")
2005+
const hit = await ConfigPlugin.resolvePluginSpec("%USERPROFILE%/plugin", file)
2006+
expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
2007+
} finally {
2008+
if (previous === undefined) delete process.env.USERPROFILE
2009+
else process.env.USERPROFILE = previous
2010+
}
2011+
})
2012+
19712013
test("resolves windows-style relative plugin directory specs", async () => {
19722014
if (process.platform !== "win32") return
19732015

0 commit comments

Comments
 (0)