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.deepEqual(JSON.parse(config.replace(" // keep this comment\n", "")).plugins, [ "./node_modules/@sargatxet/opencode-toolkit", ]) assert.match(await readFile(path.join(project, "opencode.json"), "utf8"), /keep this comment/) }) }) test("registers a config-relative package path and migrates the previously managed npm entry", async () => { await withProject(async (project) => { await mkdir(path.join(project, ".opencode"), { recursive: true }) const configPath = path.join(project, ".opencode", "opencode.json") await writeFile(configPath, "{}\n") await installProject(project) await writeFile(configPath, `{ "plugins": ["${PACKAGE_NAME}"] }\n`) await installProject(project) const config = JSON.parse(await readFile(configPath, "utf8")) assert.deepEqual(config.plugins, ["../node_modules/@sargatxet/opencode-toolkit"]) const result = await uninstallProject(project) assert.equal(result.pluginRemoved, true) assert.deepEqual(JSON.parse(await readFile(configPath, "utf8")).plugins, []) }) }) 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) }) })