142 lines
6.7 KiB
JavaScript
142 lines
6.7 KiB
JavaScript
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)
|
|
})
|
|
})
|