feat: scaffold OpenCode V2 toolkit with safe project installer
This commit is contained in:
commit
aac5627e98
30 files changed
+7533
No files matched your search
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user