Refactor plugin/config loading, add theme-only plugin package support (#20556)
This commit is contained in:
@@ -9,6 +9,8 @@ const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
||||
|
||||
const { Plugin } = await import("../../src/plugin/index")
|
||||
const { PluginLoader } = await import("../../src/plugin/loader")
|
||||
const { readPackageThemes } = await import("../../src/plugin/shared")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
const { Npm } = await import("../../src/npm")
|
||||
const { Bus } = await import("../../src/bus")
|
||||
@@ -833,4 +835,302 @@ export default {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("reads oc-themes from package manifest", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const mod = path.join(dir, "mod")
|
||||
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(mod, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "acme-plugin",
|
||||
version: "1.0.0",
|
||||
"oc-themes": ["themes/one.json", "./themes/one.json", "themes/two.json"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
return { mod }
|
||||
},
|
||||
})
|
||||
|
||||
const file = path.join(tmp.extra.mod, "package.json")
|
||||
const json = await Filesystem.readJson<Record<string, unknown>>(file)
|
||||
const list = readPackageThemes("acme-plugin", {
|
||||
dir: tmp.extra.mod,
|
||||
pkg: file,
|
||||
json,
|
||||
})
|
||||
|
||||
expect(list).toEqual([
|
||||
Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
|
||||
Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
|
||||
])
|
||||
})
|
||||
|
||||
test("handles no-entrypoint tui packages via missing callback", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const mod = path.join(dir, "mods", "acme-plugin")
|
||||
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(mod, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "acme-plugin",
|
||||
version: "1.0.0",
|
||||
"oc-themes": ["themes/night.json"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
|
||||
return { mod }
|
||||
},
|
||||
})
|
||||
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const missing: string[] = []
|
||||
|
||||
try {
|
||||
const loaded = await PluginLoader.loadExternal({
|
||||
items: [
|
||||
{
|
||||
spec: "acme-plugin@1.0.0",
|
||||
scope: "local" as const,
|
||||
source: tmp.path,
|
||||
},
|
||||
],
|
||||
kind: "tui",
|
||||
missing: async (item) => {
|
||||
if (!item.pkg) return
|
||||
const themes = readPackageThemes(item.spec, item.pkg)
|
||||
if (!themes.length) return
|
||||
return {
|
||||
spec: item.spec,
|
||||
target: item.target,
|
||||
themes,
|
||||
}
|
||||
},
|
||||
report: {
|
||||
missing(_candidate, _retry, message) {
|
||||
missing.push(message)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(loaded).toEqual([
|
||||
{
|
||||
spec: "acme-plugin@1.0.0",
|
||||
target: tmp.extra.mod,
|
||||
themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
||||
},
|
||||
])
|
||||
expect(missing).toHaveLength(0)
|
||||
} finally {
|
||||
install.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("passes package metadata for entrypoint tui plugins", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const mod = path.join(dir, "mods", "acme-plugin")
|
||||
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(mod, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "acme-plugin",
|
||||
version: "1.0.0",
|
||||
exports: {
|
||||
"./tui": "./tui.js",
|
||||
},
|
||||
"oc-themes": ["themes/night.json"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
await Bun.write(path.join(mod, "tui.js"), 'export default { id: "demo", tui: async () => {} }\n')
|
||||
await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
|
||||
return { mod }
|
||||
},
|
||||
})
|
||||
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
|
||||
try {
|
||||
const loaded = await PluginLoader.loadExternal({
|
||||
items: [
|
||||
{
|
||||
spec: "acme-plugin@1.0.0",
|
||||
scope: "local" as const,
|
||||
source: tmp.path,
|
||||
},
|
||||
],
|
||||
kind: "tui",
|
||||
finish: async (item) => {
|
||||
if (!item.pkg) return
|
||||
return {
|
||||
spec: item.spec,
|
||||
themes: readPackageThemes(item.spec, item.pkg),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
expect(loaded).toEqual([
|
||||
{
|
||||
spec: "acme-plugin@1.0.0",
|
||||
themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
install.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects oc-themes path traversal", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const mod = path.join(dir, "mod")
|
||||
await fs.mkdir(mod, { recursive: true })
|
||||
const file = path.join(mod, "package.json")
|
||||
await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2))
|
||||
return { mod, file }
|
||||
},
|
||||
})
|
||||
|
||||
const json = await Filesystem.readJson<Record<string, unknown>>(tmp.extra.file)
|
||||
expect(() =>
|
||||
readPackageThemes("acme", {
|
||||
dir: tmp.extra.mod,
|
||||
pkg: tmp.extra.file,
|
||||
json,
|
||||
}),
|
||||
).toThrow("outside plugin directory")
|
||||
})
|
||||
|
||||
test("retries failed file plugins once after wait and keeps order", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const a = path.join(dir, "a")
|
||||
const b = path.join(dir, "b")
|
||||
const aSpec = pathToFileURL(a).href
|
||||
const bSpec = pathToFileURL(b).href
|
||||
await fs.mkdir(a, { recursive: true })
|
||||
await fs.mkdir(b, { recursive: true })
|
||||
return { a, b, aSpec, bSpec }
|
||||
},
|
||||
})
|
||||
|
||||
let wait = 0
|
||||
const calls: Array<[string, boolean]> = []
|
||||
|
||||
const loaded = await PluginLoader.loadExternal({
|
||||
items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({
|
||||
spec,
|
||||
scope: "local" as const,
|
||||
source: tmp.path,
|
||||
})),
|
||||
kind: "tui",
|
||||
wait: async () => {
|
||||
wait += 1
|
||||
await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n")
|
||||
await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n")
|
||||
},
|
||||
report: {
|
||||
start(candidate, retry) {
|
||||
calls.push([candidate.plan.spec, retry])
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wait).toBe(1)
|
||||
expect(calls).toEqual([
|
||||
[tmp.extra.aSpec, false],
|
||||
[tmp.extra.bSpec, false],
|
||||
[tmp.extra.aSpec, true],
|
||||
[tmp.extra.bSpec, true],
|
||||
])
|
||||
expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec])
|
||||
})
|
||||
|
||||
test("retries file plugins when finish returns undefined", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
await Bun.write(file, "export default {}\n")
|
||||
return { spec }
|
||||
},
|
||||
})
|
||||
|
||||
let wait = 0
|
||||
let count = 0
|
||||
|
||||
const loaded = await PluginLoader.loadExternal({
|
||||
items: [
|
||||
{
|
||||
spec: tmp.extra.spec,
|
||||
scope: "local" as const,
|
||||
source: tmp.path,
|
||||
},
|
||||
],
|
||||
kind: "tui",
|
||||
wait: async () => {
|
||||
wait += 1
|
||||
},
|
||||
finish: async (load, _item, retry) => {
|
||||
count += 1
|
||||
if (!retry) return
|
||||
return {
|
||||
retry,
|
||||
spec: load.spec,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
expect(wait).toBe(1)
|
||||
expect(count).toBe(2)
|
||||
expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }])
|
||||
})
|
||||
|
||||
test("does not wait or retry npm plugin failures", async () => {
|
||||
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
||||
let wait = 0
|
||||
const errors: Array<[string, boolean]> = []
|
||||
|
||||
try {
|
||||
const loaded = await PluginLoader.loadExternal({
|
||||
items: [
|
||||
{
|
||||
spec: "acme-plugin@1.0.0",
|
||||
scope: "local" as const,
|
||||
source: "test",
|
||||
},
|
||||
],
|
||||
kind: "tui",
|
||||
wait: async () => {
|
||||
wait += 1
|
||||
},
|
||||
report: {
|
||||
error(_candidate, retry, stage) {
|
||||
errors.push([stage, retry])
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(loaded).toEqual([])
|
||||
expect(wait).toBe(0)
|
||||
expect(errors).toEqual([["install", false]])
|
||||
} finally {
|
||||
install.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user