feat: scaffold OpenCode V2 toolkit with safe project installer

This commit is contained in:
sargatxet committed 2026-10-08 12:14:43 +02:00
commit aac5627e98
30 files changed
+7533

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict"
import test from "node:test"
import { addPlugin, removePlugin } from "../dist/installer/config.js"
const pluginName = "@sargatxet/opencode-toolkit"
test("adds a package plugin while preserving unrelated JSONC and comments", () => {
const original = `{
// Keep the user's settings intact.
"model": "provider/model",
"plugins": ["existing-plugin"]
}`
const result = addPlugin(original, pluginName)
const parsed = JSON.parse(result.content.replace("// Keep the user's settings intact.\n ", ""))
assert.equal(result.added, true)
assert.equal(parsed.model, "provider/model")
assert.deepEqual(parsed.plugins, ["existing-plugin", pluginName])
assert.match(result.content, /Keep the user's settings intact/)
})
test("does not duplicate an already configured package plugin", () => {
const original = JSON.stringify({ plugins: [pluginName] }, null, 2)
const result = addPlugin(original, pluginName)
assert.equal(result.added, false)
assert.equal(result.content, original)
})
test("removes only the requested package plugin", () => {
const original = JSON.stringify({ plugins: ["other-plugin", pluginName] }, null, 2)
const result = removePlugin(original, pluginName)
assert.equal(result.removed, true)
assert.deepEqual(JSON.parse(result.content).plugins, ["other-plugin"])
})
test("rejects malformed or incompatible plugin configuration", () => {
assert.throws(() => addPlugin('{ "plugins": "not-an-array" }', pluginName), /plugins.*array/i)
assert.throws(() => addPlugin('{ broken', pluginName), /invalid JSONC/i)
})
+141
View File
@@ -0,0 +1,141 @@
import assert from "node:assert/strict"
import { mkdtemp, readFile, readdir, rm, writeFile, mkdir, symlink, lstat, access } from "node:fs/promises"
import { createHash } from "node:crypto"
import os from "node:os"
import path from "node:path"
import test from "node:test"
import { installProject, uninstallProject } from "../dist/installer/project.js"
const PACKAGE_NAME = "@sargatxet/opencode-toolkit"
async function withProject(run) {
const project = await mkdtemp(path.join(os.tmpdir(), "opencode-toolkit-test-"))
try {
await run(project)
} finally {
await rm(project, { recursive: true, force: true })
}
}
test("installs curated content and package registration idempotently", async () => {
await withProject(async (project) => {
await writeFile(path.join(project, "opencode.json"), '{\n // keep this comment\n "model": "provider/model"\n}\n')
const first = await installProject(project)
const second = await installProject(project)
const config = await readFile(path.join(project, "opencode.json"), "utf8")
const installedSkills = await readdir(path.join(project, ".opencode", "skills"))
assert.equal(first.installed.length, 9)
assert.equal(first.pluginConfigured, true)
assert.equal(second.installed.length, 0)
assert.equal(second.updated.length, 0)
assert.deepEqual(installedSkills, [
"toolkit-coding-standards",
"toolkit-security-review",
"toolkit-tdd-workflow",
])
assert.match(config, new RegExp(PACKAGE_NAME.replaceAll("/", "\\/")))
assert.match(await readFile(path.join(project, "opencode.json"), "utf8"), /keep this comment/)
})
})
test("refuses to overwrite unowned files and leaves installation unchanged", async () => {
await withProject(async (project) => {
const target = path.join(project, ".opencode", "skills", "toolkit-tdd-workflow", "SKILL.md")
await mkdir(path.dirname(target), { recursive: true })
await writeFile(target, "user-authored skill")
await assert.rejects(installProject(project), /collision.*not managed/i)
assert.equal(await readFile(target, "utf8"), "user-authored skill")
await assert.rejects(lstat(path.join(project, ".opencode", ".opencode-toolkit-install.json")), { code: "ENOENT" })
})
})
test("refuses ambiguous projects with both OpenCode config locations", async () => {
await withProject(async (project) => {
await writeFile(path.join(project, "opencode.json"), '{"model":"provider/model"}')
await mkdir(path.join(project, ".opencode"), { recursive: true })
await writeFile(path.join(project, ".opencode", "opencode.json"), '{"plugins":[]}')
await assert.rejects(installProject(project), /both opencode\.json.*\.opencode\/opencode\.json/i)
await assert.rejects(lstat(path.join(project, ".opencode", ".opencode-toolkit-install.json")), { code: "ENOENT" })
assert.equal(await readFile(path.join(project, "opencode.json"), "utf8"), '{"model":"provider/model"}')
})
})
test("uninstall removes owned files and preserves locally modified content", async () => {
await withProject(async (project) => {
await installProject(project)
const preservedPath = path.join(project, ".opencode", "agents", "toolkit-code-reviewer.md")
await writeFile(preservedPath, "local edits")
const result = await uninstallProject(project)
const config = JSON.parse(await readFile(path.join(project, ".opencode", "opencode.json"), "utf8"))
assert.equal(result.removed.length, 8)
assert.deepEqual(result.preserved, [path.join(".opencode", "agents", "toolkit-code-reviewer.md")])
assert.equal(result.pluginRemoved, true)
assert.deepEqual(config.plugins, [])
assert.equal(await readFile(preservedPath, "utf8"), "local edits")
await assert.rejects(lstat(path.join(project, ".opencode", ".opencode-toolkit-install.json")), { code: "ENOENT" })
})
})
test("update removes obsolete unmodified files and keeps edited obsolete files tracked", async () => {
await withProject(async (project) => {
await installProject(project)
const manifestPath = path.join(project, ".opencode", ".opencode-toolkit-install.json")
const manifest = JSON.parse(await readFile(manifestPath, "utf8"))
const removable = path.join(".opencode", "skills", "toolkit-obsolete", "SKILL.md")
const edited = path.join(".opencode", "agents", "toolkit-obsolete.md")
const removableContent = "obsolete but unchanged"
const originalEditedContent = "upstream obsolete content"
await mkdir(path.dirname(path.join(project, removable)), { recursive: true })
await mkdir(path.dirname(path.join(project, edited)), { recursive: true })
await writeFile(path.join(project, removable), removableContent)
await writeFile(path.join(project, edited), "locally edited obsolete content")
const digest = (value) => createHash("sha256").update(value).digest("hex")
manifest.files.push(
{ path: removable, hash: digest(removableContent) },
{ path: edited, hash: digest(originalEditedContent) },
)
await writeFile(manifestPath, JSON.stringify(manifest))
const result = await installProject(project)
assert.deepEqual(result.removed, [removable])
assert.deepEqual(result.preserved, [edited])
await assert.rejects(access(path.join(project, removable)), { code: "ENOENT" })
assert.equal(await readFile(path.join(project, edited), "utf8"), "locally edited obsolete content")
const updatedManifest = JSON.parse(await readFile(manifestPath, "utf8"))
assert.ok(updatedManifest.files.some((entry) => entry.path === edited))
})
})
test("rejects symlinked install directories without writing outside the project", async () => {
await withProject(async (project) => {
const external = await mkdtemp(path.join(os.tmpdir(), "opencode-toolkit-external-"))
try {
await mkdir(path.join(project, ".opencode", "skills"), { recursive: true })
await symlink(external, path.join(project, ".opencode", "skills", "toolkit-coding-standards"), "dir")
await assert.rejects(installProject(project), /symlink/i)
assert.deepEqual(await readdir(external), [])
} finally {
await rm(external, { recursive: true, force: true })
}
})
})
test("rejects a tampered ownership manifest before following paths", async () => {
await withProject(async (project) => {
await installProject(project)
const manifestPath = path.join(project, ".opencode", ".opencode-toolkit-install.json")
const manifest = JSON.parse(await readFile(manifestPath, "utf8"))
manifest.configPath = "../../outside/opencode.json"
await writeFile(manifestPath, JSON.stringify(manifest))
await assert.rejects(uninstallProject(project), /invalid opencode config path/i)
assert.equal((await readdir(path.join(project, ".opencode", "skills"))).length, 3)
})
})
+77
View File
@@ -0,0 +1,77 @@
import assert from "node:assert/strict"
import test from "node:test"
import toolkit from "../dist/plugin.js"
function createContext() {
const registered = []
const vcs = {
get: async () => ({ branch: { current: "feature/toolkit", default: "main" } }),
status: async () => [{ path: "src/app.ts", status: "modified" }],
}
const context = {
location: {
directory: "/workspace/demo",
project: { id: "project-1", directory: "/workspace/demo", canonical: "/workspace/demo" },
},
vcs,
tool: {
transform: async (register) => {
register({
namespace: () => {},
add: (definition) => registered.push(definition),
list: () => [],
get: () => undefined,
update: () => {},
remove: () => {},
})
return { dispose: async () => {} }
},
},
}
return { context, registered, vcs }
}
test("exports a stable OpenCode V2 plugin definition", () => {
assert.equal(toolkit.id, "sargatxet.opencode-toolkit")
assert.equal(typeof toolkit.setup, "function")
})
test("registers namespaced read-only project status tool", async () => {
const { context, registered } = createContext()
await toolkit.setup(context)
assert.equal(registered.length, 1)
assert.equal(registered[0].name, "project_status")
assert.equal(typeof registered[0].execute, "function")
assert.deepEqual(registered[0].input, {
type: "object",
properties: {},
additionalProperties: false,
})
})
test("status tool reports VCS context without mutating it", async () => {
const { context, registered, vcs } = createContext()
const originalGet = vcs.get
const originalStatus = vcs.status
await toolkit.setup(context)
const result = await registered[0].execute({}, { signal: new AbortController().signal })
assert.deepEqual(JSON.parse(result.content), {
project: {
id: "project-1",
directory: "/workspace/demo",
canonical: "/workspace/demo",
},
vcs: {
branch: { current: "feature/toolkit", default: "main" },
},
changes: [{ path: "src/app.ts", status: "modified" }],
})
assert.equal(vcs.get, originalGet)
assert.equal(vcs.status, originalStatus)
})