mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-22 14:22:22 +00:00
## Summary - Add narrow CodeNomad routes for native OpenCode V2 plugin inventory and activation-rule mutations. - Present one row per user/configured plugin with exactly two compact **Global** and **Project** switches, sharing Yolo/MCP switch geometry. Builtins are hidden; exact configured disabled IDs remain available. - Keep complete names, source paths, failures and configuration targets readable on narrow keyboard/touch layouts. Retain focused rows across refreshes and guard repeated pending activation without removing keyboard focus. - Provide translated feedback in all ten locales and suppress stale feedback after the owning surface is disposed. ## Authority and refresh behavior - Discover roots through the authenticated daemon's `config.get`, after native-location ownership and connection-generation checks. - Append only exact activation rules while preserving JSONC comments, source options, unknown keys, formatting, BOM and native permissions. Bound reads, check optimistic conflicts, serialize by write target and use atomic replacement with nonce-owned locks. - Require proven raw/normalized alignment before acknowledging a no-op. Watcher lag cannot report success for a rule that is no longer saved on disk. - Account for inherited `.opencode` precedence by choosing a higher-precedence target in the active project location; preserve ancestor files and reject an escaping fallback target. - Use native WSL filesystem operations. Retain canonical invalidation and one trailing reconciliation when host/native cache records converge. - Cache display snapshots by instance/worktree, share them across sessions in that worktree, and refresh only on visible demand. Global writes invalidate sibling snapshots without background reads. ## Compatibility The current server/UI/bundled-plugin dependency pin and recommended runtime are **2.0.16**, aligned with `dev`. The demonstrated technical minimum remains **2.0.7**. Activation writes use the narrow local JSONC operation rather than a runtime-specific write endpoint. ## Validation status and provenance Final reviewed head: **`5204d97694ca8ced954bddc9cc94ea464e46119d`**, aligned with `dev@9d7ea5d6`. Both independent resumed review loops reached **ZERO actionable findings**: UX R6 and quality/optimization R5. Local validation on the final head: **100 plugin/server/cache/WSL tests passed**, **eight real-component browser regressions passed**, **14 restoration/metadata integration tests passed**, server/UI typechecks and UI production build passed. Translation keys/placeholders match across all ten locales. The current-dev baseline and PR browser comparison passed the same 34 shared cases with one optional native Electron skip; detailed revision provenance is in the final review comment. **CI qualification is excluded at the user's explicit request.** No final CI success is claimed, and CI completion is not being awaited. The earlier head `38845060` failed typechecking because `noticeId` was undeclared; the earlier historical UX R4 zero-finding claim was also incorrect and has been [explicitly corrected](https://github.com/NeuralNomadsAI/CodeNomad/pull/726#issuecomment-5816956417). Historical test results and the installer built from `bd31dcd0` are not validation or artifacts of the final corrected head. See the final review comment for completed counts, review evidence and revision boundaries. The PR remains open. ## Scope note Implemented independently; no code was copied from PR #593. This separate V2 implementation supersedes PR #593.
784 lines
36 KiB
TypeScript
784 lines
36 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import fs from "node:fs"
|
|
import os from "node:os"
|
|
import path from "node:path"
|
|
import { pathToFileURL } from "node:url"
|
|
import { afterEach, describe, it } from "node:test"
|
|
import { parse } from "jsonc-parser"
|
|
import { PluginControls, PluginControlsError } from "./plugin-controls"
|
|
|
|
const temporaryDirectories = new Set<string>()
|
|
|
|
afterEach(() => {
|
|
for (const directory of temporaryDirectories) fs.rmSync(directory, { recursive: true, force: true })
|
|
temporaryDirectories.clear()
|
|
})
|
|
|
|
describe("OpenCode V2 plugin activation controls", () => {
|
|
it("keeps runtime inventory separate from configured sources and ordered rules", async () => {
|
|
const fixture = createFixture({
|
|
globalPlugins: [{ package: "@acme/reviewer", options: { strict: true } }, "-acme.reviewer"],
|
|
projectPlugins: ["acme.reviewer", "-opencode.provider.*"],
|
|
runtime: [
|
|
{ id: "acme.reviewer", source: { type: "package", target: "@acme/reviewer", version: "1.2.0" }, features: { server: true }, state: { status: "active" } },
|
|
{ id: "broken", source: { type: "local", path: "/plugins/broken" }, features: { rpc: true }, state: { status: "failed", error: "boom", ref: "err_1" } },
|
|
],
|
|
})
|
|
|
|
const snapshot = await fixture.controls.read("workspace", fixture.location)
|
|
|
|
assert.deepEqual(snapshot.runtime.map((entry) => [entry.id, entry.state.status]), [
|
|
["acme.reviewer", "active"], ["broken", "failed"],
|
|
])
|
|
assert.deepEqual(snapshot.configured.sources.map((entry) => [entry.target, entry.scope, entry.hasOptions]), [
|
|
["@acme/reviewer", "global", true],
|
|
])
|
|
assert.deepEqual(snapshot.configured.rules.map((rule) => [rule.selector, rule.enabled, rule.scope]), [
|
|
["acme.reviewer", false, "global"],
|
|
["acme.reviewer", true, "project"],
|
|
["opencode.provider.*", false, "project"],
|
|
])
|
|
const reviewer = snapshot.controls.find((entry) => entry.id === "acme.reviewer")!
|
|
assert.equal(reviewer.global, "disabled")
|
|
assert.equal(reviewer.project, "enabled")
|
|
assert.equal(reviewer.effective, "enabled")
|
|
assert.equal(reviewer.runtime?.source.type, "package")
|
|
})
|
|
|
|
it("keeps an exact disabled plugin visible when it is absent from runtime", async () => {
|
|
const fixture = createFixture({ globalPlugins: ["-acme.reviewer"], runtime: [] })
|
|
|
|
const snapshot = await fixture.controls.read("workspace", fixture.location)
|
|
|
|
assert.deepEqual(snapshot.runtime, [])
|
|
assert.deepEqual(snapshot.controls.map((entry) => ({ id: entry.id, effective: entry.effective })), [
|
|
{ id: "acme.reviewer", effective: "disabled" },
|
|
])
|
|
})
|
|
|
|
it("distinguishes a package target from later exact activation rules when target and ID match", async () => {
|
|
const fixture = createFixture({
|
|
globalPlugins: ["same.id", "-same.id", "same.id"],
|
|
runtime: [activePlugin("same.id", "same.id")],
|
|
})
|
|
|
|
const snapshot = await fixture.controls.read("workspace", fixture.location)
|
|
|
|
assert.deepEqual(snapshot.configured.sources.map((entry) => entry.target), ["same.id"])
|
|
assert.deepEqual(snapshot.configured.rules.map((rule) => [rule.selector, rule.enabled]), [
|
|
["same.id", false],
|
|
["same.id", true],
|
|
])
|
|
assert.equal(snapshot.controls.find((entry) => entry.id === "same.id")?.effective, "enabled")
|
|
})
|
|
|
|
it("replays object-form sources as ordered add operations", async () => {
|
|
const fixture = createFixture({
|
|
globalPlugins: ["same.id", "-same.id", { package: "same.id", options: {} }],
|
|
runtime: [activePlugin("same.id", "same.id")],
|
|
})
|
|
|
|
const snapshot = await fixture.controls.read("workspace", fixture.location)
|
|
|
|
assert.deepEqual(snapshot.configured.rules.map((rule) => [rule.selector, rule.enabled]), [
|
|
["same.id", false],
|
|
["same.id", true],
|
|
])
|
|
assert.equal(snapshot.controls.find((entry) => entry.id === "same.id")?.effective, "enabled")
|
|
})
|
|
|
|
it("correlates relative and file URL local sources with their runtime entrypoint", async () => {
|
|
for (const kind of ["relative", "file"] as const) {
|
|
const fixture = createFixture({ runtime: [] })
|
|
const configuredTarget = kind === "file"
|
|
? pathToFileURL(path.join(fixture.projectDirectory, "plugins", "reviewer")).href
|
|
: "./plugins/reviewer"
|
|
const sourceDirectory = kind === "file"
|
|
? path.join(fixture.projectDirectory, "plugins", "reviewer")
|
|
: path.join(fixture.projectDirectory, ".opencode", "plugins", "reviewer")
|
|
const entrypoint = path.join(sourceDirectory, "server.js")
|
|
fixture.runtime.push({
|
|
id: "acme.reviewer",
|
|
source: { type: "local", path: entrypoint },
|
|
features: { server: true },
|
|
state: { status: "active" },
|
|
})
|
|
fixture.setProjectPlugins([
|
|
configuredTarget,
|
|
"-acme.reviewer",
|
|
{ package: configuredTarget, options: {} },
|
|
])
|
|
|
|
const snapshot = await fixture.controls.read("workspace", fixture.location)
|
|
assert.equal(snapshot.controls.find((entry) => entry.id === "acme.reviewer")?.effective, "enabled")
|
|
}
|
|
})
|
|
|
|
it("marks an exact disabled OpenCode builtin even when runtime inventory omits it", async () => {
|
|
const fixture = createFixture({ globalPlugins: ["-opencode.prompt.identity"], runtime: [] })
|
|
|
|
const control = (await fixture.controls.read("workspace", fixture.location)).controls
|
|
.find((entry) => entry.id === "opencode.prompt.identity")
|
|
|
|
assert.equal(control?.builtin, true)
|
|
assert.equal(control?.effective, "disabled")
|
|
})
|
|
|
|
it("serializes mutations, writes only the selected project layer, and preserves prior rules", async () => {
|
|
const fixture = createFixture({
|
|
globalPlugins: [
|
|
{ package: "@acme/reviewer", options: { strict: true } },
|
|
{ package: "@acme/linter", options: { level: "strict" } },
|
|
],
|
|
runtime: [activePlugin("acme.reviewer", "@acme/reviewer"), activePlugin("acme.linter", "@acme/linter")],
|
|
omitProjectDocument: true,
|
|
})
|
|
const globalBefore = fs.readFileSync(fixture.globalFile, "utf8")
|
|
|
|
const [reviewer, linter] = await Promise.all([
|
|
fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "acme.reviewer", scope: "project", enabled: false }),
|
|
fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "acme.linter", scope: "project", enabled: false }),
|
|
])
|
|
|
|
assert.equal(reviewer.changed, true)
|
|
assert.equal(linter.changed, true)
|
|
assert.equal(reviewer.target.scope, "project")
|
|
assert.equal(fs.readFileSync(fixture.globalFile, "utf8"), globalBefore)
|
|
const projectFile = path.join(fixture.projectDirectory, ".opencode", "opencode.jsonc")
|
|
assert.deepEqual((parse(fs.readFileSync(projectFile, "utf8")) as any).plugins, ["-acme.reviewer", "-acme.linter"])
|
|
assert.equal(linter.snapshot.controls.find((entry) => entry.id === "acme.reviewer")?.project, "disabled")
|
|
assert.equal(linter.snapshot.controls.find((entry) => entry.id === "acme.linter")?.project, "disabled")
|
|
})
|
|
|
|
it("serializes same-target mutations while letting other scopes proceed", async () => {
|
|
const fixture = createFixture({
|
|
globalPlugins: [{ package: "@acme/reviewer", options: { strict: true } }],
|
|
projectPlugins: [],
|
|
runtime: [activePlugin("acme.reviewer", "@acme/reviewer")],
|
|
})
|
|
const manager = (fixture.controls as any).options.workspaceManager
|
|
const innerIdentity = manager.getWorktreeIdentityForPath.bind(manager)
|
|
const gate = deferred<void>()
|
|
let identityCalls = 0
|
|
let projectSettled = false
|
|
manager.getWorktreeIdentityForPath = async (...args: [string, string]) => {
|
|
identityCalls += 1
|
|
// The first mutation (global scope) is held here; a per-target queue
|
|
// lets the project-scope mutation overtake it instead of blocking.
|
|
if (identityCalls === 1) await gate.promise
|
|
return innerIdentity(...args)
|
|
}
|
|
|
|
const global = fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "acme.reviewer", scope: "global", enabled: false })
|
|
const project = fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "acme.reviewer", scope: "project", enabled: false })
|
|
void project.then(() => { projectSettled = true })
|
|
const deadline = Date.now() + 5_000
|
|
while (!projectSettled && Date.now() < deadline) await tick()
|
|
assert.equal(projectSettled, true)
|
|
gate.resolve()
|
|
const [globalResponse, projectResponse] = await Promise.all([global, project])
|
|
|
|
assert.equal(globalResponse.changed, true)
|
|
assert.equal(projectResponse.changed, true)
|
|
assert.deepEqual((parse(fs.readFileSync(fixture.globalFile, "utf8")) as any).plugins, [
|
|
{ package: "@acme/reviewer", options: { strict: true } },
|
|
"-acme.reviewer",
|
|
])
|
|
assert.deepEqual((parse(fs.readFileSync(fixture.projectFile, "utf8")) as any).plugins, ["-acme.reviewer"])
|
|
})
|
|
|
|
it("serializes global writes from different worktree directories to the same file", async () => {
|
|
const fixture = createFixture({
|
|
globalPlugins: [],
|
|
runtime: [activePlugin("acme.reviewer", "@acme/reviewer"), activePlugin("acme.linter", "@acme/linter")],
|
|
})
|
|
const otherLocation = { directory: path.join(path.dirname(fixture.projectDirectory), "other-worktree") }
|
|
|
|
const [reviewer, linter] = await Promise.all([
|
|
fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "acme.reviewer", scope: "global", enabled: false }),
|
|
fixture.controls.mutate("workspace", { location: otherLocation, pluginId: "acme.linter", scope: "global", enabled: false }),
|
|
])
|
|
|
|
assert.equal(reviewer.changed, true)
|
|
assert.equal(linter.changed, true)
|
|
assert.deepEqual((parse(fs.readFileSync(fixture.globalFile, "utf8")) as any).plugins, ["-acme.reviewer", "-acme.linter"])
|
|
})
|
|
|
|
it("appends a later exact rule to re-enable without deleting the disabling rule", async () => {
|
|
const fixture = createFixture({
|
|
globalPlugins: [{ package: "@acme/reviewer", options: { strict: true } }],
|
|
projectPlugins: ["-acme.reviewer"],
|
|
runtime: [],
|
|
})
|
|
|
|
const response = await fixture.controls.mutate("workspace", {
|
|
location: fixture.location,
|
|
pluginId: "acme.reviewer",
|
|
scope: "project",
|
|
enabled: true,
|
|
})
|
|
|
|
assert.equal(response.rule, "acme.reviewer")
|
|
assert.equal(response.snapshot.controls.find((entry) => entry.id === "acme.reviewer")?.effective, "enabled")
|
|
assert.deepEqual((parse(fs.readFileSync(fixture.projectFile, "utf8")) as any).plugins, ["-acme.reviewer", "acme.reviewer"])
|
|
})
|
|
|
|
it("uses daemon-reported roots and host translation instead of process configuration", async () => {
|
|
const host = temporaryDirectory()
|
|
const globalHost = path.join(host, "daemon-global")
|
|
const projectHost = path.join(host, "daemon-project")
|
|
fs.mkdirSync(globalHost, { recursive: true })
|
|
fs.mkdirSync(projectHost, { recursive: true })
|
|
fs.writeFileSync(path.join(globalHost, "opencode.jsonc"), `{ "plugins": [] }\n`)
|
|
const location = { directory: "/srv/project" }
|
|
const entries = [
|
|
{ type: "document", path: "/daemon/config/opencode.jsonc", info: { plugins: [] } },
|
|
{ type: "directory", path: "/daemon/config" },
|
|
]
|
|
const manager = fakeManager({
|
|
entries,
|
|
runtime: [activePlugin("acme.reviewer", "@acme/reviewer")],
|
|
style: "posix",
|
|
translate: (servicePath) => servicePath === "/daemon/config" ? globalHost : servicePath === "/srv/project" ? projectHost : undefined,
|
|
})
|
|
const controls = new PluginControls({ workspaceManager: manager as any, worktreeDeletionFence: openFence(), logger: logger() })
|
|
|
|
const response = await controls.mutate("workspace", { location, pluginId: "acme.reviewer", scope: "global", enabled: false })
|
|
|
|
assert.equal(response.target.path, "/daemon/config/opencode.jsonc")
|
|
assert.deepEqual((parse(fs.readFileSync(path.join(globalHost, "opencode.jsonc"), "utf8")) as any).plugins, ["-acme.reviewer"])
|
|
})
|
|
|
|
it("uses the canonical service directory for WSL requests and snapshot identity", async () => {
|
|
const host = temporaryDirectory()
|
|
const globalHost = path.join(host, "daemon-global")
|
|
const projectHost = path.join(host, "daemon-project")
|
|
fs.mkdirSync(globalHost, { recursive: true })
|
|
fs.mkdirSync(projectHost, { recursive: true })
|
|
fs.writeFileSync(path.join(globalHost, "opencode.jsonc"), `{ "plugins": [] }\n`)
|
|
const entries = [{ type: "directory", path: "/daemon/config" }]
|
|
const manager = fakeManager({
|
|
entries,
|
|
runtime: [activePlugin("acme.reviewer", "@acme/reviewer")],
|
|
style: "posix",
|
|
serviceDirectory: "/srv/project",
|
|
translate: (servicePath) => servicePath === "/daemon/config" ? globalHost : servicePath === "/srv/project" ? projectHost : undefined,
|
|
})
|
|
const controls = new PluginControls({ workspaceManager: manager as any, worktreeDeletionFence: openFence(), logger: logger() })
|
|
|
|
const snapshot = await controls.read("workspace", { directory: "\\\\wsl.localhost\\Ubuntu\\srv\\project" })
|
|
|
|
assert.equal(snapshot.location.directory, "/srv/project")
|
|
assert.deepEqual(manager.calls.configLocations, ["/srv/project"])
|
|
assert.deepEqual(manager.calls.pluginLocations, ["/srv/project"])
|
|
})
|
|
|
|
it("does not expose Project when it resolves to the Global configuration file", async () => {
|
|
const directory = temporaryDirectory()
|
|
const configFile = path.join(directory, "opencode.jsonc")
|
|
fs.writeFileSync(configFile, `{ "plugins": [] }\n`)
|
|
const manager = fakeManager({
|
|
entries: [
|
|
{ type: "document", path: configFile, info: { plugins: [] } },
|
|
{ type: "directory", path: directory },
|
|
],
|
|
runtime: [activePlugin("known", "known-package")],
|
|
})
|
|
const controls = new PluginControls({ workspaceManager: manager as any, worktreeDeletionFence: openFence(), logger: logger() })
|
|
|
|
const snapshot = await controls.read("workspace", { directory })
|
|
assert.deepEqual(snapshot.targets.map((target) => target.scope), ["global"])
|
|
await assert.rejects(
|
|
controls.mutate("workspace", { location: { directory }, pluginId: "known", scope: "project", enabled: false }),
|
|
(error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable",
|
|
)
|
|
assert.deepEqual((parse(fs.readFileSync(configFile, "utf8")) as any).plugins, [])
|
|
})
|
|
|
|
it("does not expose Project through a symlink to the Global configuration", async () => {
|
|
const root = temporaryDirectory()
|
|
const globalDirectory = path.join(root, "global")
|
|
const projectDirectory = path.join(root, "project")
|
|
const globalFile = path.join(globalDirectory, "opencode.jsonc")
|
|
fs.mkdirSync(globalDirectory, { recursive: true })
|
|
fs.mkdirSync(projectDirectory, { recursive: true })
|
|
fs.writeFileSync(globalFile, `{ "plugins": [] }\n`)
|
|
fs.symlinkSync(globalDirectory, path.join(projectDirectory, ".opencode"), process.platform === "win32" ? "junction" : "dir")
|
|
const manager = fakeManager({
|
|
entries: [
|
|
{ type: "document", path: globalFile, info: { plugins: [] } },
|
|
{ type: "directory", path: globalDirectory },
|
|
],
|
|
runtime: [activePlugin("known", "known-package")],
|
|
})
|
|
const controls = new PluginControls({ workspaceManager: manager as any, worktreeDeletionFence: openFence(), logger: logger() })
|
|
|
|
const snapshot = await controls.read("workspace", { directory: projectDirectory })
|
|
assert.deepEqual(snapshot.targets.map((target) => target.scope), ["global"])
|
|
await assert.rejects(
|
|
controls.mutate("workspace", { location: { directory: projectDirectory }, pluginId: "known", scope: "project", enabled: false }),
|
|
(error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable",
|
|
)
|
|
assert.deepEqual((parse(fs.readFileSync(globalFile, "utf8")) as any).plugins, [])
|
|
})
|
|
|
|
it("does not expose Project at the global configuration root when no document exists", async () => {
|
|
const directory = temporaryDirectory()
|
|
const manager = fakeManager({
|
|
entries: [{ type: "directory", path: directory }],
|
|
runtime: [activePlugin("known", "known-package")],
|
|
})
|
|
const controls = new PluginControls({ workspaceManager: manager as any, worktreeDeletionFence: openFence(), logger: logger() })
|
|
|
|
const snapshot = await controls.read("workspace", { directory })
|
|
assert.deepEqual(snapshot.targets.map((target) => target.scope), ["global"])
|
|
await assert.rejects(
|
|
controls.mutate("workspace", { location: { directory }, pluginId: "known", scope: "project", enabled: false }),
|
|
(error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable",
|
|
)
|
|
assert.equal(fs.existsSync(path.join(directory, ".opencode", "opencode.jsonc")), false)
|
|
})
|
|
|
|
it("uses normalized daemon entries for authorization while preserving raw placeholders", async () => {
|
|
const fixture = createFixture({
|
|
globalPlugins: ["{env:PLUGIN_RULE}"],
|
|
reportedGlobalPlugins: ["-known"],
|
|
runtime: [],
|
|
})
|
|
|
|
const response = await fixture.controls.mutate("workspace", {
|
|
location: fixture.location,
|
|
pluginId: "known",
|
|
scope: "global",
|
|
enabled: true,
|
|
})
|
|
|
|
assert.equal(response.snapshot.controls.find((entry) => entry.id === "known")?.effective, "enabled")
|
|
assert.deepEqual((parse(fs.readFileSync(fixture.globalFile, "utf8")) as any).plugins, ["{env:PLUGIN_RULE}", "known"])
|
|
})
|
|
|
|
it("persists the requested rule when a raw replacement or deletion has overtaken daemon normalization", async () => {
|
|
for (const rawPlugins of [["known"], [], ["known", "-known"]]) {
|
|
const fixture = createFixture({
|
|
globalPlugins: rawPlugins,
|
|
reportedGlobalPlugins: ["-known"],
|
|
runtime: [activePlugin("known", "known-package")],
|
|
})
|
|
|
|
const response = await fixture.controls.mutate("workspace", {
|
|
location: fixture.location, pluginId: "known", scope: "global", enabled: false,
|
|
})
|
|
|
|
assert.equal(response.changed, true, "divergent raw documents cannot prove a no-op")
|
|
assert.equal(response.snapshot.controls.find((entry) => entry.id === "known")?.global, "disabled")
|
|
assert.deepEqual((parse(fs.readFileSync(fixture.globalFile, "utf8")) as any).plugins, [...rawPlugins, "-known"])
|
|
}
|
|
})
|
|
|
|
it("retains no-op detection for an aligned document and a concrete pending rule", async () => {
|
|
for (const reportedGlobalPlugins of [["-known"], []]) {
|
|
const fixture = createFixture({
|
|
globalPlugins: ["-known"], reportedGlobalPlugins,
|
|
runtime: [activePlugin("known", "known-package")],
|
|
})
|
|
const before = fs.readFileSync(fixture.globalFile, "utf8")
|
|
|
|
const response = await fixture.controls.mutate("workspace", {
|
|
location: fixture.location, pluginId: "known", scope: "global", enabled: false,
|
|
})
|
|
|
|
assert.equal(response.changed, false)
|
|
assert.equal(fs.readFileSync(fixture.globalFile, "utf8"), before)
|
|
}
|
|
})
|
|
|
|
it("creates a location-local Project layer above inherited .opencode rules without editing the direct or ancestor file", async () => {
|
|
for (const trailingVirtualRule of [false, true]) {
|
|
const root = temporaryDirectory()
|
|
const globalDirectory = path.join(root, "global")
|
|
const projectDirectory = path.join(root, "project")
|
|
const ancestorDirectory = path.join(root, ".opencode")
|
|
const directFile = path.join(projectDirectory, "opencode.jsonc")
|
|
const ancestorFile = path.join(ancestorDirectory, "opencode.jsonc")
|
|
const targetFile = path.join(projectDirectory, ".opencode", "opencode.jsonc")
|
|
for (const directory of [globalDirectory, projectDirectory, ancestorDirectory]) fs.mkdirSync(directory)
|
|
if (trailingVirtualRule) fs.mkdirSync(path.dirname(targetFile))
|
|
fs.writeFileSync(directFile, `{ "plugins": [], "model": "unchanged" }\n`)
|
|
fs.writeFileSync(ancestorFile, `{ "plugins": ["-known"] }\n`)
|
|
const directBefore = fs.readFileSync(directFile, "utf8")
|
|
const ancestorBefore = fs.readFileSync(ancestorFile, "utf8")
|
|
const entries = [
|
|
{ type: "directory", path: globalDirectory },
|
|
{ type: "document", path: directFile, info: { plugins: [] } },
|
|
{ type: "document", path: ancestorFile, info: { plugins: ["-known"] } },
|
|
{ type: "directory", path: ancestorDirectory },
|
|
...(trailingVirtualRule ? [{ type: "directory", path: path.dirname(targetFile) }] : []),
|
|
...(trailingVirtualRule ? [{ type: "document", info: { plugins: ["-known"] } }] : []),
|
|
]
|
|
const controls = new PluginControls({
|
|
workspaceManager: fakeManager({ entries, runtime: [activePlugin("known", "known-package")] }) as any,
|
|
worktreeDeletionFence: openFence(), logger: logger(),
|
|
})
|
|
|
|
const before = await controls.read("workspace", { directory: projectDirectory })
|
|
assert.deepEqual(before.targets.find((target) => target.scope === "project"), { scope: "project", path: targetFile, exists: false })
|
|
assert.equal(fs.existsSync(targetFile), false, "display reads do not create the fallback")
|
|
const response = await controls.mutate("workspace", {
|
|
location: { directory: projectDirectory }, pluginId: "known", scope: "project", enabled: true,
|
|
})
|
|
|
|
assert.equal(response.changed, true)
|
|
assert.equal(response.target.path, targetFile)
|
|
const control = response.snapshot.controls.find((entry) => entry.id === "known")!
|
|
assert.equal(control.project, "enabled")
|
|
assert.equal(control.effective, trailingVirtualRule ? "disabled" : "enabled")
|
|
assert.deepEqual((parse(fs.readFileSync(targetFile, "utf8")) as any).plugins, ["known"])
|
|
assert.equal(fs.readFileSync(directFile, "utf8"), directBefore)
|
|
assert.equal(fs.readFileSync(ancestorFile, "utf8"), ancestorBefore)
|
|
}
|
|
})
|
|
|
|
it("makes Project unavailable when its higher-precedence fallback aliases an ancestor configuration", async () => {
|
|
const root = temporaryDirectory()
|
|
const globalDirectory = path.join(root, "global")
|
|
const projectDirectory = path.join(root, "project")
|
|
const ancestorDirectory = path.join(root, ".opencode")
|
|
const ancestorFile = path.join(ancestorDirectory, "opencode.jsonc")
|
|
for (const directory of [globalDirectory, projectDirectory, ancestorDirectory]) fs.mkdirSync(directory)
|
|
fs.writeFileSync(ancestorFile, `{ "plugins": ["-known"] }\n`)
|
|
fs.symlinkSync(ancestorDirectory, path.join(projectDirectory, ".opencode"), process.platform === "win32" ? "junction" : "dir")
|
|
const controls = new PluginControls({
|
|
workspaceManager: fakeManager({
|
|
entries: [
|
|
{ type: "directory", path: globalDirectory },
|
|
{ type: "document", path: ancestorFile, info: { plugins: ["-known"] } },
|
|
{ type: "directory", path: ancestorDirectory },
|
|
],
|
|
runtime: [activePlugin("known", "known-package")],
|
|
}) as any,
|
|
worktreeDeletionFence: openFence(), logger: logger(),
|
|
})
|
|
const before = fs.readFileSync(ancestorFile, "utf8")
|
|
|
|
const snapshot = await controls.read("workspace", { directory: projectDirectory })
|
|
assert.deepEqual(snapshot.targets.map((target) => target.scope), ["global"])
|
|
await assert.rejects(controls.mutate("workspace", {
|
|
location: { directory: projectDirectory }, pluginId: "known", scope: "project", enabled: true,
|
|
}), (error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable")
|
|
assert.equal(fs.readFileSync(ancestorFile, "utf8"), before)
|
|
})
|
|
|
|
it("never treats a concrete pending rule as the normalized form of a placeholder", async () => {
|
|
const fixture = createFixture({
|
|
globalPlugins: ["{env:PLUGIN_RULE}", "-known", "known"],
|
|
reportedGlobalPlugins: ["known"],
|
|
runtime: [activePlugin("known", "known-package")],
|
|
})
|
|
|
|
const response = await fixture.controls.mutate("workspace", {
|
|
location: fixture.location,
|
|
pluginId: "known",
|
|
scope: "global",
|
|
enabled: false,
|
|
})
|
|
|
|
assert.equal(response.changed, true)
|
|
assert.equal(response.snapshot.controls.find((entry) => entry.id === "known")?.effective, "disabled")
|
|
assert.deepEqual((parse(fs.readFileSync(fixture.globalFile, "utf8")) as any).plugins, [
|
|
"{env:PLUGIN_RULE}", "-known", "known", "-known",
|
|
])
|
|
})
|
|
|
|
it("preserves normalized ordering when a placeholder follows a concrete rule", async () => {
|
|
const fixture = createFixture({
|
|
globalPlugins: ["-known", "{env:PLUGIN_RULE}"],
|
|
reportedGlobalPlugins: ["-known", "known"],
|
|
runtime: [activePlugin("known", "known-package")],
|
|
})
|
|
|
|
const response = await fixture.controls.mutate("workspace", {
|
|
location: fixture.location,
|
|
pluginId: "known",
|
|
scope: "global",
|
|
enabled: false,
|
|
})
|
|
|
|
assert.equal(response.changed, true)
|
|
assert.equal(response.snapshot.controls.find((entry) => entry.id === "known")?.effective, "disabled")
|
|
assert.deepEqual((parse(fs.readFileSync(fixture.globalFile, "utf8")) as any).plugins, [
|
|
"-known", "{env:PLUGIN_RULE}", "-known",
|
|
])
|
|
})
|
|
|
|
it("keeps daemon-reported virtual precedence when adding a missing global document", async () => {
|
|
const root = temporaryDirectory()
|
|
const globalDirectory = path.join(root, "global")
|
|
const projectDirectory = path.join(root, "project")
|
|
const projectConfigDirectory = path.join(projectDirectory, ".opencode")
|
|
fs.mkdirSync(globalDirectory, { recursive: true })
|
|
fs.mkdirSync(projectConfigDirectory, { recursive: true })
|
|
const entries = [
|
|
{ type: "document", info: { plugins: ["known"] } },
|
|
{ type: "directory", path: globalDirectory },
|
|
{ type: "document", path: path.join(projectConfigDirectory, "opencode.jsonc"), info: { plugins: [] } },
|
|
{ type: "directory", path: projectConfigDirectory },
|
|
{ type: "document", info: { plugins: ["-known"] } },
|
|
]
|
|
const manager = fakeManager({ entries, runtime: [activePlugin("known", "known-package")] })
|
|
const controls = new PluginControls({
|
|
workspaceManager: manager as any,
|
|
worktreeDeletionFence: openFence(),
|
|
logger: logger(),
|
|
})
|
|
|
|
const response = await controls.mutate("workspace", {
|
|
location: { directory: projectDirectory },
|
|
pluginId: "known",
|
|
scope: "global",
|
|
enabled: true,
|
|
})
|
|
|
|
const control = response.snapshot.controls.find((entry) => entry.id === "known")
|
|
assert.equal(control?.global, "enabled")
|
|
assert.equal(control?.effective, "disabled", "the trailing virtual rule remains higher precedence")
|
|
assert.deepEqual((parse(fs.readFileSync(path.join(globalDirectory, "opencode.jsonc"), "utf8")) as any).plugins, ["known"])
|
|
})
|
|
|
|
it("rejects foreign locations and unknown plugin IDs before any write", async () => {
|
|
const fixture = createFixture({ runtime: [activePlugin("known", "known-package")], ownsLocation: false })
|
|
await assert.rejects(
|
|
fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "known", scope: "global", enabled: false }),
|
|
(error: unknown) => error instanceof PluginControlsError && error.kind === "forbidden",
|
|
)
|
|
assert.equal(fixture.calls.config, 0)
|
|
assert.equal(fixture.calls.plugins, 0)
|
|
|
|
const owned = createFixture({ runtime: [activePlugin("known", "known-package")] })
|
|
await assert.rejects(
|
|
owned.controls.mutate("workspace", { location: owned.location, pluginId: "invented", scope: "global", enabled: false }),
|
|
(error: unknown) => error instanceof PluginControlsError && error.kind === "forbidden",
|
|
)
|
|
assert.deepEqual((parse(fs.readFileSync(owned.globalFile, "utf8")) as any).plugins, [])
|
|
})
|
|
|
|
it("fails closed when the selected configuration document is malformed", async () => {
|
|
const fixture = createFixture({ runtime: [activePlugin("known", "known-package")] })
|
|
const malformed = `{ "plugins": ["known", } broken }`
|
|
fs.writeFileSync(fixture.globalFile, malformed)
|
|
|
|
await assert.rejects(
|
|
fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "known", scope: "global", enabled: false }),
|
|
(error: unknown) => error instanceof PluginControlsError && error.kind === "invalid",
|
|
)
|
|
assert.equal(fs.readFileSync(fixture.globalFile, "utf8"), malformed)
|
|
})
|
|
|
|
it("refuses a mutation while worktree deletion blocks the location", async () => {
|
|
const fixture = createFixture({
|
|
runtime: [activePlugin("known", "known-package")],
|
|
worktreeDeletionFence: { enter: () => undefined },
|
|
})
|
|
const before = fs.readFileSync(fixture.globalFile, "utf8")
|
|
|
|
await assert.rejects(
|
|
fixture.controls.mutate("workspace", { location: fixture.location, pluginId: "known", scope: "global", enabled: false }),
|
|
(error: unknown) => error instanceof PluginControlsError && error.kind === "conflict",
|
|
)
|
|
|
|
assert.equal(fs.readFileSync(fixture.globalFile, "utf8"), before)
|
|
})
|
|
|
|
it("fails closed instead of presenting malformed runtime inventory as active", async () => {
|
|
const fixture = createFixture({
|
|
runtime: [{ id: "known", source: { type: "unexpected" }, features: {}, state: { status: "pending" } }],
|
|
})
|
|
|
|
await assert.rejects(
|
|
fixture.controls.read("workspace", fixture.location),
|
|
(error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable",
|
|
)
|
|
})
|
|
|
|
it("fences a read if the authenticated connection changes during target discovery", async () => {
|
|
const root = temporaryDirectory()
|
|
const globalDirectory = path.join(root, "global")
|
|
const projectDirectory = path.join(root, "project")
|
|
fs.mkdirSync(globalDirectory, { recursive: true })
|
|
fs.mkdirSync(projectDirectory, { recursive: true })
|
|
const manager = fakeManager({
|
|
entries: [{ type: "directory", path: globalDirectory }],
|
|
runtime: [activePlugin("known", "known-package")],
|
|
assertCurrent: (count) => { if (count === 2) throw new Error("stale connection") },
|
|
})
|
|
const controls = new PluginControls({
|
|
workspaceManager: manager as any,
|
|
worktreeDeletionFence: openFence(),
|
|
logger: logger(),
|
|
})
|
|
|
|
await assert.rejects(
|
|
controls.read("workspace", { directory: projectDirectory }),
|
|
(error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable",
|
|
)
|
|
})
|
|
|
|
it("fences a mutation again immediately before the atomic rename", async () => {
|
|
const fixture = createFixture({
|
|
runtime: [activePlugin("known", "known-package")],
|
|
assertCurrent: (count) => { if (count === 4) throw new Error("stale connection") },
|
|
})
|
|
const before = fs.readFileSync(fixture.globalFile, "utf8")
|
|
|
|
await assert.rejects(
|
|
fixture.controls.mutate("workspace", {
|
|
location: fixture.location,
|
|
pluginId: "known",
|
|
scope: "global",
|
|
enabled: false,
|
|
}),
|
|
(error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable",
|
|
)
|
|
|
|
assert.equal(fixture.calls.assertCurrent, 4)
|
|
assert.equal(fs.readFileSync(fixture.globalFile, "utf8"), before)
|
|
})
|
|
|
|
it("fences a no-op mutation after reading the target document", async () => {
|
|
const fixture = createFixture({
|
|
globalPlugins: ["-known"],
|
|
runtime: [],
|
|
assertCurrent: (count) => { if (count === 3) throw new Error("stale connection") },
|
|
})
|
|
|
|
await assert.rejects(
|
|
fixture.controls.mutate("workspace", {
|
|
location: fixture.location,
|
|
pluginId: "known",
|
|
scope: "global",
|
|
enabled: false,
|
|
}),
|
|
(error: unknown) => error instanceof PluginControlsError && error.kind === "unavailable",
|
|
)
|
|
assert.equal(fixture.calls.assertCurrent, 3)
|
|
})
|
|
})
|
|
|
|
function createFixture(options: {
|
|
globalPlugins?: unknown[]
|
|
reportedGlobalPlugins?: unknown[]
|
|
projectPlugins?: unknown[]
|
|
reportedProjectPlugins?: unknown[]
|
|
runtime?: any[]
|
|
omitProjectDocument?: boolean
|
|
ownsLocation?: boolean
|
|
assertCurrent?: (count: number) => void
|
|
worktreeDeletionFence?: { enter(identities: string[]): (() => void) | undefined }
|
|
} = {}) {
|
|
const root = temporaryDirectory()
|
|
const globalDirectory = path.join(root, "daemon-config")
|
|
const projectDirectory = path.join(root, "project")
|
|
const globalFile = path.join(globalDirectory, "opencode.jsonc")
|
|
const projectFile = path.join(projectDirectory, ".opencode", "opencode.jsonc")
|
|
fs.mkdirSync(globalDirectory, { recursive: true })
|
|
fs.mkdirSync(path.dirname(projectFile), { recursive: true })
|
|
fs.writeFileSync(globalFile, `${JSON.stringify({ plugins: options.globalPlugins ?? [] }, null, 2)}\n`)
|
|
if (!options.omitProjectDocument) fs.writeFileSync(projectFile, `${JSON.stringify({ plugins: options.projectPlugins ?? [] }, null, 2)}\n`)
|
|
const entries: any[] = [
|
|
{ type: "document", path: globalFile, info: { plugins: options.reportedGlobalPlugins ?? options.globalPlugins ?? [] } },
|
|
{ type: "directory", path: globalDirectory },
|
|
]
|
|
let projectEntry: any
|
|
if (!options.omitProjectDocument) {
|
|
projectEntry = { type: "document", path: projectFile, info: { plugins: options.reportedProjectPlugins ?? options.projectPlugins ?? [] } }
|
|
entries.push(projectEntry)
|
|
entries.push({ type: "directory", path: path.dirname(projectFile) })
|
|
}
|
|
const runtime = options.runtime ?? []
|
|
const manager = fakeManager({
|
|
entries,
|
|
runtime,
|
|
ownsLocation: options.ownsLocation,
|
|
assertCurrent: options.assertCurrent,
|
|
})
|
|
return {
|
|
controls: new PluginControls({
|
|
workspaceManager: manager as any,
|
|
worktreeDeletionFence: options.worktreeDeletionFence ?? openFence(),
|
|
logger: logger(),
|
|
}),
|
|
calls: manager.calls,
|
|
location: { directory: projectDirectory },
|
|
globalDirectory,
|
|
projectDirectory,
|
|
globalFile,
|
|
projectFile,
|
|
runtime,
|
|
setProjectPlugins: (plugins: unknown[]) => {
|
|
if (!projectEntry) throw new Error("Project document is omitted")
|
|
projectEntry.info.plugins = plugins
|
|
fs.writeFileSync(projectFile, `${JSON.stringify({ plugins }, null, 2)}\n`)
|
|
},
|
|
}
|
|
}
|
|
|
|
function fakeManager(options: {
|
|
entries: any[]
|
|
runtime: any[]
|
|
ownsLocation?: boolean
|
|
style?: "win32" | "posix"
|
|
translate?: (servicePath: string) => string | undefined
|
|
serviceDirectory?: string
|
|
wslDistro?: string
|
|
assertCurrent?: (count: number) => void
|
|
}) {
|
|
const calls = { config: 0, plugins: 0, assertCurrent: 0, configLocations: [] as string[], pluginLocations: [] as string[] }
|
|
const client = {
|
|
config: { get: async (input: any) => { calls.config++; calls.configLocations.push(input.location.directory); return options.entries } },
|
|
plugin: { list: async (input: any) => { calls.plugins++; calls.pluginLocations.push(input.location.directory); return { location: { directory: "unused" }, data: options.runtime } } },
|
|
}
|
|
return {
|
|
calls,
|
|
get: (id: string) => id === "workspace" ? { id } : undefined,
|
|
getSharedServiceConnection: async () => ({
|
|
client,
|
|
assertCurrent: () => {
|
|
calls.assertCurrent++
|
|
options.assertCurrent?.(calls.assertCurrent)
|
|
},
|
|
}),
|
|
ownsLocation: async () => options.ownsLocation !== false,
|
|
getServiceDirectoryForPath: async (_id: string, directory: string) => options.serviceDirectory ?? directory,
|
|
getWorktreeIdentityForPath: async () => "fixture-worktree",
|
|
getServicePathStyle: () => options.style ?? (process.platform === "win32" ? "win32" : "posix"),
|
|
getServiceWslDistro: () => options.wslDistro,
|
|
getHostPathForServicePath: async (_id: string, servicePath: string) => options.translate?.(servicePath) ?? servicePath,
|
|
}
|
|
}
|
|
|
|
function activePlugin(id: string, target: string) {
|
|
return { id, source: { type: "package", target }, features: { server: true }, state: { status: "active" } }
|
|
}
|
|
|
|
function logger() {
|
|
return { info() {}, warn() {}, error() {}, debug() {} } as any
|
|
}
|
|
|
|
function openFence() {
|
|
return { enter: () => () => {} }
|
|
}
|
|
|
|
function temporaryDirectory(): string {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-plugin-controls-"))
|
|
temporaryDirectories.add(directory)
|
|
return directory
|
|
}
|
|
|
|
function deferred<T = void>(): { promise: Promise<T>; resolve: (value: T | PromiseLike<T>) => void } {
|
|
let resolve!: (value: T | PromiseLike<T>) => void
|
|
const promise = new Promise<T>((accept) => { resolve = accept })
|
|
return { promise, resolve }
|
|
}
|
|
|
|
function tick(): Promise<void> {
|
|
return new Promise((resolve) => setImmediate(resolve))
|
|
}
|