diff --git a/README.md b/README.md index 9ac21ad..438e06f 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ opencode-architect is an [OpenCode](https://opencode.ai) plugin and CLI that shi ### Option 1 — Install as an OpenCode plugin -Add the package to the `plugin` array in your OpenCode config — `.opencode/opencode.json` in your project, or `~/.config/opencode/opencode.json` for all projects: +Add the package to the `plugins` array in your OpenCode config — `.opencode/opencode.json` in your project, or `~/.config/opencode/opencode.json` for all projects: ```json { - "plugin": ["opencode-architect@latest"] + "plugins": ["opencode-architect@latest"] } ``` @@ -21,7 +21,7 @@ The plugin registers the full agent suite at startup, with self-contained bundle ### Option 2 — Install with the CLI (bunx or npx) -The CLI registers the package as a plugin: it adds `opencode-architect@latest` to the `plugin` array of your OpenCode config with surgical text editing (comments and formatting elsewhere in the file are preserved), then records the registration in an `opencode-architect.manifest.json` manifest at the scope base. Nothing is copied — the agents, references, and templates all load from the package at startup. +The CLI registers the package as a plugin: it adds `opencode-architect@latest` to the `plugins` array of your OpenCode config with surgical text editing (comments and formatting elsewhere in the file are preserved), then records the registration in an `opencode-architect.manifest.json` manifest at the scope base. Nothing is copied — the agents, references, and templates all load from the package at startup. A legacy v1 `plugin` entry is left untouched and reported with an upgrade advisory. ```bash # Project scope (default): edits the ./.opencode/ or repo-root config @@ -47,11 +47,11 @@ bunx opencode-architect --help # full usage Re-running install when the manifest matches reality is a zero-write no-op. `--mode copy` is refused with an explanatory error: this package is code-backed (it ships agents), and copying cannot express plugin registration. -Every install — including a no-op — also clears this package's stale copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`): `opencode-architect`, `opencode-architect@latest`, and `opencode-architect@`. Pinned copies like `opencode-architect@0.6.0` and other packages' cache dirs are left untouched. This makes OpenCode re-fetch the just-installed version on next start instead of reusing a stale or partial extraction. Removal is best-effort: a failure prints a warning but the install still succeeds. +Every install — including a no-op — also clears this package's stale cache keys from OpenCode's plugin cache (`$XDG_CACHE_HOME/opencode/npm`, falling back to `~/.cache/opencode/npm`): `opencode-architect`, `opencode-architect@latest`, and `opencode-architect@` — each key holding every cached generation beneath it. Pinned keys like `opencode-architect@0.6.0` and other packages' cache keys are left untouched. This makes OpenCode re-fetch the just-installed version on next start instead of reusing a stale or partial extraction. Removal is best-effort: a failure prints a warning but the install still succeeds. **Upgrading from a copy install (pre-0.8):** if a previous version copied agents into your scope base, install detects the old manifest, removes exactly the files it lists, prints a notice, and switches the scope to plugin registration in one step. Locally modified files are tracked by hash; uninstall and migration only remove what the manifest recorded. -`clear-cache` is a manual-only command (never invoked at load time) for removing cached copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`). With no flags it removes `opencode-architect` and every `opencode-architect@*` copy. `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory (`$XDG_CACHE_HOME/opencode`, falling back to `~/.cache/opencode`). `--dry-run` lists what any mode would remove without deleting anything, and previews broad modes without `--yes`. Both broad modes require `--yes` to confirm, are mutually exclusive, and package names containing path separators or `..` are rejected. The command is idempotent — running with nothing cached succeeds — and removal failures warn without changing the exit code. +`clear-cache` is a manual-only command (never invoked at load time) for removing cached package keys from OpenCode's plugin cache (`$XDG_CACHE_HOME/opencode/npm`, falling back to `~/.cache/opencode/npm`), where each key directory holds every cached generation beneath it. With no flags it removes `opencode-architect` and every `opencode-architect@*` key. `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory (`$XDG_CACHE_HOME/opencode`, falling back to `~/.cache/opencode`). `--dry-run` lists what any mode would remove without deleting anything, and previews broad modes without `--yes`. Both broad modes require `--yes` to confirm, are mutually exclusive, and package names containing path separators or `..` are rejected. The command is idempotent — running with nothing cached succeeds — and removal failures warn without changing the exit code. ## What you get: ten specialist OpenCode agents diff --git a/src/cache-cleaner.ts b/src/cache-cleaner.ts index 0f2d2a3..2351428 100644 --- a/src/cache-cleaner.ts +++ b/src/cache-cleaner.ts @@ -1,7 +1,7 @@ import { exists, readdir, rm } from "node:fs/promises"; -import { homedir } from "node:os"; import path from "node:path"; import { ClearCacheUsageError } from "./clear-cache-usage-error"; +import { NpmCache } from "./npm-cache"; export const PACKAGE_NAME = "opencode-architect"; @@ -19,15 +19,7 @@ export interface ClearCacheOutcome { } export class CacheCleaner { - public packagesCacheRoot(): string { - const xdgCacheHome = process.env.XDG_CACHE_HOME; - if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "packages"); - return path.join(homedir(), ".cache", "opencode", "packages"); - } - - public opencodeCacheRoot(): string { - return path.dirname(this.packagesCacheRoot()); - } + private readonly cache = new NpmCache(); public isUnsafePackageName(name: string): boolean { return name.includes("/") || name.includes("\\") || name.includes("..") || name === "."; @@ -54,7 +46,7 @@ export class CacheCleaner { const warnings: string[] = []; if (all) { - const target = this.opencodeCacheRoot(); + const target = this.cache.opencodeRoot(); if (dryRun) { if (await exists(target)) removed.push(target); return dryRunOutcome(removed, warnings); @@ -64,10 +56,10 @@ export class CacheCleaner { } const name = packageName ?? PACKAGE_NAME; - const packagesRoot = this.packagesCacheRoot(); + const npmRoot = this.cache.root(); let entries: string[]; try { - entries = await readdir(packagesRoot); + entries = await readdir(npmRoot); } catch (error) { if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { removed, warnings, dryRun }; throw error; @@ -77,7 +69,7 @@ export class CacheCleaner { const targets = entries .filter((entry) => entry === name || entry.startsWith(prefix)) .sort() - .map((entry) => path.join(packagesRoot, entry)); + .map((entry) => path.join(npmRoot, entry)); if (dryRun) { removed.push(...targets); diff --git a/src/cli.ts b/src/cli.ts index e86b9ab..004c37d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -62,6 +62,9 @@ export async function runCli(argv: string[]): Promise { if (outcome.configPath !== null) { console.log(` Plugin entry: ${outcome.configPath}`); } + if (outcome.configWarning !== null) { + console.warn(` Warning: ${outcome.configWarning}`); + } console.log(` Manifest: ${outcome.manifestPath}`); for (const cachePath of outcome.clearedCache) { console.log(` Cleared cache: ${cachePath}`); @@ -82,6 +85,9 @@ export async function runCli(argv: string[]): Promise { if (outcome.pluginRemoved && outcome.configPath !== null) { console.log(` Removed the plugin entry from ${outcome.configPath}.`); } + if (outcome.configWarning !== null) { + console.warn(` Warning: ${outcome.configWarning}`); + } break; } case "status": { @@ -141,9 +147,10 @@ function printHelp(): void { console.log(` opencode-architect v${VERSION} -Registers the opencode-architect plugin in a config file's plugin array so the +Registers the opencode-architect plugin in a config file's plugins array so the agent suite loads from the package at startup. Nothing is copied: the plugin -registers the agents and resolves reference paths at load time. +registers the agents and resolves reference paths at load time. Legacy v1 +"plugin" entries are detected and left read-only with an upgrade advisory. Commands: install Ensure the plugin entry and write the install manifest diff --git a/src/installer.ts b/src/installer.ts index c69aa3e..713939f 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -5,6 +5,7 @@ import { hashElement } from "folder-hash"; import { AGENT_FILENAMES } from "./agent-loader"; import { BundledAssetsMissingError } from "./bundled-assets-missing-error"; import { CopyModeUnsupportedError } from "./copy-mode-unsupported-error"; +import { NpmCache } from "./npm-cache"; import { PluginConfigEditor } from "./plugin-config"; export type Scope = "local" | "global"; @@ -38,6 +39,7 @@ export interface InstallOutcome { manifestPath: string; configPath: string | null; configAction: "noop" | "updated" | "created" | "blocked"; + configWarning: string | null; removedPayload: string[]; clearedCache: string[]; cacheWarnings: string[]; @@ -49,6 +51,7 @@ export interface UninstallOutcome { removed: string[]; pluginRemoved: boolean; configPath: string | null; + configWarning: string | null; } export interface StatusOutcome { @@ -66,6 +69,7 @@ const LEGACY_MANIFEST_NAME = "opencode-architect.json"; export class Installer { private readonly editor = new PluginConfigEditor(); + private readonly cache = new NpmCache(); private readonly assetsDir: string; constructor(assetsDir: string | null = null) { @@ -140,6 +144,7 @@ export class Installer { manifestPath, configPath: registration.configPath, configAction: registration.action, + configWarning: registration.warning, removedPayload, clearedCache: cache.removed, cacheWarnings: cache.warnings, @@ -149,9 +154,9 @@ export class Installer { private async requireBundledAssets(version: string): Promise { for (const name of BUNDLED_ASSET_DIRS) { const dir = path.join(this.assetsDir, name); - if (!(await exists(dir))) throw new BundledAssetsMissingError(dir, this.packageCacheRoot(), version); + if (!(await exists(dir))) throw new BundledAssetsMissingError(dir, this.cache.root(), version); const contents = await readdir(dir); - if (contents.length === 0) throw new BundledAssetsMissingError(dir, this.packageCacheRoot(), version); + if (contents.length === 0) throw new BundledAssetsMissingError(dir, this.cache.root(), version); } } @@ -162,7 +167,7 @@ export class Installer { PACKAGE_NAME, `${PACKAGE_NAME}@latest`, `${PACKAGE_NAME}@${version}`, - ].map((name) => path.join(this.packageCacheRoot(), name)); + ].map((name) => path.join(this.cache.root(), name)); for (const target of targets) { if (!(await exists(target))) continue; try { @@ -176,12 +181,6 @@ export class Installer { return { removed, warnings }; } - private packageCacheRoot(): string { - const xdgCacheHome = process.env.XDG_CACHE_HOME; - if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "packages"); - return path.join(homedir(), ".cache", "opencode", "packages"); - } - public async uninstall(scope: Scope, projectDir: string): Promise { const base = this.scopeBase(scope, projectDir); const record = await this.readManifestRecord(base); @@ -208,8 +207,19 @@ export class Installer { } const mode: InstallMode = - manifest !== null ? manifest.mode : pluginRemoved ? "plugin" : "none"; - return { scope, mode, removed, pluginRemoved, configPath: removal.configPath }; + manifest !== null + ? manifest.mode + : pluginRemoved || removal.configPath !== null + ? "plugin" + : "none"; + return { + scope, + mode, + removed, + pluginRemoved, + configPath: removal.configPath, + configWarning: removal.warning, + }; } private async removeResidualPayload(base: string): Promise { diff --git a/src/npm-cache.ts b/src/npm-cache.ts new file mode 100644 index 0000000..89e79f0 --- /dev/null +++ b/src/npm-cache.ts @@ -0,0 +1,14 @@ +import { homedir } from "node:os"; +import path from "node:path"; + +export class NpmCache { + public root(): string { + const xdgCacheHome = process.env.XDG_CACHE_HOME; + if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "npm"); + return path.join(homedir(), ".cache", "opencode", "npm"); + } + + public opencodeRoot(): string { + return path.dirname(this.root()); + } +} diff --git a/src/plugin-config.ts b/src/plugin-config.ts index 79c8b54..2b899fa 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import path from "node:path"; export type ConfigScope = "local" | "global"; +export type PluginConfigKey = "plugins" | "plugin"; export interface EnsurePluginEntryOptions { scope: ConfigScope; @@ -25,6 +26,7 @@ interface CandidateRead { candidate: CandidateConfig; text: string; plugins: string[] | null; + legacyPlugins: string[] | null; } export interface RemovePluginEntryOutcome { @@ -43,7 +45,7 @@ interface PluginArrayRange { const DEFAULT_CONFIG_TEMPLATE = `{ // OpenCode configuration "$schema": "https://opencode.ai/config.json", - "plugin": ["__PACKAGE_NAME__"] + "plugins": ["__PACKAGE_NAME__"] } `; @@ -53,7 +55,7 @@ export class PluginConfigEditor { options: EnsurePluginEntryOptions, ): Promise { const canonical = this.canonicalEntry(packageName); - for (const { candidate, text, plugins } of await this.readCandidates(options)) { + for (const { candidate, text, plugins, legacyPlugins } of await this.readCandidates(options)) { if (plugins === null) { return { action: "blocked", @@ -66,6 +68,13 @@ export class PluginConfigEditor { if (this.hasMatchingEntry(plugins, packageName)) { return { action: "noop", configPath: candidate.path, warning: null }; } + if (this.hasLegacyEntry(legacyPlugins, packageName)) { + return { + action: "noop", + configPath: candidate.path, + warning: this.legacyEntryAdvisory(candidate, packageName), + }; + } if (!candidate.writable) continue; const spliced = this.spliceEntry(text, canonical, packageName, candidate.lenient); if (spliced === null) { @@ -107,9 +116,11 @@ export class PluginConfigEditor { packageName: string, options: EnsurePluginEntryOptions, ): Promise { - for (const { candidate, plugins } of await this.readCandidates(options)) { + for (const { candidate, plugins, legacyPlugins } of await this.readCandidates(options)) { if (plugins === null) continue; - if (this.hasMatchingEntry(plugins, packageName)) return candidate.path; + const registered = + this.hasMatchingEntry(plugins, packageName) || this.hasLegacyEntry(legacyPlugins, packageName); + if (registered) return candidate.path; } return null; } @@ -118,7 +129,9 @@ export class PluginConfigEditor { packageName: string, options: EnsurePluginEntryOptions, ): Promise { - for (const { candidate, text, plugins } of await this.readCandidates(options)) { + const reads = await this.readCandidates(options); + let legacyPath: string | null = null; + for (const { candidate, text, plugins, legacyPlugins } of reads) { if (!candidate.writable) continue; if (plugins === null) { return { @@ -129,17 +142,33 @@ export class PluginConfigEditor { `Fix or remove the file and re-run the uninstall.`, }; } - if (!this.hasMatchingEntry(plugins, packageName)) continue; - const spliced = this.spliceOutEntry(text, packageName, candidate.lenient); - if (spliced === null) { + if (this.hasMatchingEntry(plugins, packageName)) { + const spliced = this.spliceOutEntry(text, packageName, candidate.lenient); + if (spliced === null) { + return { + action: "blocked", + configPath: candidate.path, + warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, + }; + } + await writeFile(candidate.path, spliced); + const leftover = reads.find((read) => this.hasLegacyEntry(read.legacyPlugins, packageName)); return { - action: "blocked", + action: "removed", configPath: candidate.path, - warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, + warning: leftover === undefined ? null : this.legacyRemovalAdvisory(leftover.candidate.path, packageName), }; } - await writeFile(candidate.path, spliced); - return { action: "removed", configPath: candidate.path, warning: null }; + if (legacyPath === null && this.hasLegacyEntry(legacyPlugins, packageName)) { + legacyPath = candidate.path; + } + } + if (legacyPath !== null) { + return { + action: "noop", + configPath: legacyPath, + warning: this.legacyRemovalAdvisory(legacyPath, packageName), + }; } return { action: "noop", configPath: null, warning: null }; } @@ -148,6 +177,10 @@ export class PluginConfigEditor { return entries.some((entry) => this.matchesEntry(entry, packageName)); } + private hasLegacyEntry(legacyPlugins: string[] | null, packageName: string): boolean { + return legacyPlugins !== null && this.hasMatchingEntry(legacyPlugins, packageName); + } + private matchesEntry(entry: string, packageName: string): boolean { const specIndex = entry.lastIndexOf("@"); const name = specIndex > 0 ? entry.slice(0, specIndex) : entry; @@ -183,17 +216,58 @@ export class PluginConfigEditor { for (const candidate of this.candidateConfigs(options)) { if (!(await exists(candidate.path))) continue; const text = await readFile(candidate.path, "utf-8"); - const plugins = this.parsePluginArray(text, candidate.lenient); - if (plugins === null) { + const config = this.parseConfig(text, candidate.lenient); + const plugins = config === null ? null : this.keyEntries(config, "plugins"); + const legacyPlugins = config === null ? null : this.keyEntries(config, "plugin"); + if (config === null) { console.warn( `Warning: ${candidate.path} could not be parsed; refusing to treat it as a registration candidate.`, ); } - reads.push({ candidate, text, plugins }); + reads.push({ candidate, text, plugins, legacyPlugins }); } return reads; } + private keyEntries(config: Record, key: PluginConfigKey): string[] { + const value = config[key]; + if (!Array.isArray(value)) return []; + return value.map((entry) => this.normalizeEntry(entry)).filter((entry): entry is string => entry !== null); + } + + private normalizeEntry(entry: unknown): string | null { + if (typeof entry === "string") return entry; + return this.packageOf(entry); + } + + private packageOf(entry: unknown): string | null { + if (entry === null || typeof entry !== "object") return null; + const pkg = (entry as { package: unknown }).package; + return typeof pkg === "string" ? pkg : null; + } + + private legacyEntryAdvisory(candidate: CandidateConfig, packageName: string): string { + if (path.basename(candidate.path) === "config.json") { + return ( + `${candidate.path} registers ${packageName} under the legacy v1 "plugin" key. ` + + `OpenCode v2 no longer reads config.json, so the entry is inert, and this installer never edits it. ` + + `Add ${packageName} to the "plugins" key of an opencode.json or opencode.jsonc config instead.` + ); + } + return ( + `${candidate.path} registers ${packageName} under the legacy v1 "plugin" key. ` + + `OpenCode v2 still loads it through config migration, and this installer leaves legacy entries ` + + `read-only. Move the entry to the v2 "plugins" key to upgrade, then re-run the install.` + ); + } + + private legacyRemovalAdvisory(configPath: string, packageName: string): string { + return ( + `${configPath} registers ${packageName} under the legacy v1 "plugin" key; it was left untouched. ` + + `Remove it by editing the file, or move the entry to the v2 "plugins" key and re-run the uninstall.` + ); + } + private defaultConfigPath(options: EnsurePluginEntryOptions): string { if (options.scope === "global") { return path.join(this.scopeBase("global", options.projectDir), "opencode.jsonc"); @@ -211,9 +285,7 @@ export class PluginConfigEditor { private parsePluginArray(text: string, lenient: boolean): string[] | null { const config = this.parseConfig(text, lenient); if (config === null) return null; - const plugins = config.plugin; - if (!Array.isArray(plugins)) return []; - return plugins.filter((entry): entry is string => typeof entry === "string"); + return this.keyEntries(config, "plugins"); } private parseConfig(text: string, lenient: boolean): Record | null { @@ -306,7 +378,7 @@ export class PluginConfigEditor { const elements = this.arrayElementRanges(navigable, innerStart, innerEnd); const target = elements.find((element) => { const raw = text.slice(element.start, element.end); - return this.matchesEntry(this.unquote(raw), packageName); + return this.matchesEntry(this.elementSpec(raw), packageName); }); if (!target) return null; const withComma = this.dropAdjacentComma(navigable, elements, target, innerStart, innerEnd); @@ -388,6 +460,16 @@ export class PluginConfigEditor { return trimmed; } + private elementSpec(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) return this.unquote(trimmed); + try { + return this.packageOf(JSON.parse(trimmed.replace(/,(\s*[}\]])/g, "$1"))) ?? ""; + } catch { + return ""; + } + } + private spliceArrayEntry( text: string, navigable: string, @@ -413,7 +495,7 @@ export class PluginConfigEditor { if (nextContentOffset === -1) return null; const insertAt = objectStart + 1 + nextContentOffset; const isClosingBrace = rest[nextContentOffset] === "}"; - const entry = isClosingBrace ? `"plugin": ["${packageName}"]` : `"plugin": ["${packageName}"],`; + const entry = isClosingBrace ? `"plugins": ["${packageName}"]` : `"plugins": ["${packageName}"],`; const leadingWhitespace = nextContentOffset > 0 ? rest.slice(0, nextContentOffset) : ""; return text.slice(0, insertAt) + leadingWhitespace + entry + text.slice(insertAt); } @@ -421,10 +503,10 @@ export class PluginConfigEditor { private findPluginArrayRange(navigable: string): PluginArrayRange | null { let searchFrom = 0; while (searchFrom < navigable.length) { - const keyIndex = navigable.indexOf('"plugin"', searchFrom); + const keyIndex = navigable.indexOf('"plugins"', searchFrom); if (keyIndex === -1) return null; if (this.precededByStructuralChar(navigable, keyIndex, ["{", ","])) { - const colonIndex = this.nextOutsideString(navigable, keyIndex + 8, ":"); + const colonIndex = this.nextOutsideString(navigable, keyIndex + '"plugins"'.length, ":"); if (colonIndex !== -1) { const bracketStart = this.nextOutsideString(navigable, colonIndex + 1, "["); if (bracketStart !== -1) { diff --git a/src/plugin.ts b/src/plugin.ts index 17a6c64..70a559b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -6,6 +6,7 @@ import type { AgentEditor } from "@opencode/plugin/effect/agent"; import { AgentLoader, type LoadedAgent } from "./agent-loader"; import { AssetPermissionAdvisor } from "./asset-permission-advisor"; import { Installer } from "./installer"; +import { NpmCache } from "./npm-cache"; const PACKAGE_NAME = "opencode-architect"; const PLUGIN_ID = "opencode-architect"; @@ -111,9 +112,9 @@ export class OpencodeArchitectPlugin { `reinstall and restart OpenCode. The stale cache copy is `; let text: string; try { - text = `${prefix}~/.cache/opencode/packages/${PACKAGE_NAME}@${await this.readVersion()}. Cause: ${message}`; + text = `${prefix}${new NpmCache().root()}${path.sep}${PACKAGE_NAME}@${await this.readVersion()}. Cause: ${message}`; } catch { - text = `${prefix}~/.cache/opencode/packages/${PACKAGE_NAME}@. Cause: ${message}`; + text = `${prefix}${new NpmCache().root()}${path.sep}${PACKAGE_NAME}@. Cause: ${message}`; } try { console.warn(`[${PACKAGE_NAME}] ${text}`); diff --git a/tests/cache-cleaner.test.ts b/tests/cache-cleaner.test.ts index 5dcdfc5..eae16fa 100644 --- a/tests/cache-cleaner.test.ts +++ b/tests/cache-cleaner.test.ts @@ -1,20 +1,23 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { existsSync } from "node:fs"; -import { chmod, mkdtemp, rm } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { CacheCleaner } from "../src/cache-cleaner"; +import { NpmCache } from "../src/npm-cache"; import { seedCachedPackage, expectClearCacheUsageError } from "./test-helpers"; let cacheDir = ""; let originalXdgCache: string | undefined; let cleaner = new CacheCleaner(); +let cache = new NpmCache(); beforeEach(async () => { cacheDir = await mkdtemp(path.join(tmpdir(), "oa-clear-cache-")); originalXdgCache = process.env.XDG_CACHE_HOME; process.env.XDG_CACHE_HOME = cacheDir; cleaner = new CacheCleaner(); + cache = new NpmCache(); }); afterEach(async () => { @@ -24,7 +27,7 @@ afterEach(async () => { }); function cacheRoot(): string { - return cleaner.packagesCacheRoot(); + return cache.root(); } describe("clearCache default mode", () => { @@ -57,6 +60,21 @@ describe("clearCache default mode", () => { expect(outcome.removed).toEqual([]); expect(outcome.warnings).toEqual([]); }); + + test("removes every generation under a key in the v2 generation cache layout", async () => { + const keyDir = path.join(cacheRoot(), "opencode-architect@latest"); + for (const generation of ["1738848000000", "1738851600000"]) { + const pkgDir = path.join(keyDir, generation, "node_modules", "opencode-architect"); + await mkdir(pkgDir, { recursive: true }); + await writeFile(path.join(pkgDir, "index.ts"), "cached"); + } + + const outcome = await cleaner.clear({ packageName: null, all: false, yes: false, dryRun: false }); + + expect(outcome.warnings).toEqual([]); + expect(outcome.removed).toEqual([keyDir]); + expect(existsSync(keyDir)).toBe(false); + }); }); describe("clearCache --package mode", () => { @@ -91,13 +109,13 @@ describe("clearCache --package mode", () => { describe("clearCache --all mode", () => { test("removes the entire opencode cache directory", async () => { await seedCachedPackage(cacheRoot(), "opencode-architect"); - const unrelated = path.join(cleaner.opencodeCacheRoot(), "other-tool"); + const unrelated = path.join(cache.opencodeRoot(), "other-tool"); await seedCachedPackage(unrelated, "data"); const outcome = await cleaner.clear({ packageName: null, all: true, yes: true, dryRun: false }); expect(outcome.warnings).toEqual([]); - expect(existsSync(cleaner.opencodeCacheRoot())).toBe(false); + expect(existsSync(cache.opencodeRoot())).toBe(false); }); test("requires --yes and deletes nothing without it", async () => { @@ -149,8 +167,8 @@ describe("clearCache dry-run", () => { const outcome = await cleaner.clear({ packageName: null, all: true, yes: false, dryRun: true }); expect(outcome.dryRun).toBe(true); - expect(outcome.removed).toEqual([cleaner.opencodeCacheRoot()]); - expect(existsSync(cleaner.opencodeCacheRoot())).toBe(true); + expect(outcome.removed).toEqual([cache.opencodeRoot()]); + expect(existsSync(cache.opencodeRoot())).toBe(true); }); test("with nothing cached lists nothing and succeeds", async () => { @@ -180,7 +198,7 @@ describe("clearCache failure tolerance", () => { expect(outcome.warnings.length).toBe(1); expect(outcome.warnings[0]).toContain(kept); } finally { - await chmod(kept, 0o700); + await chmod(kept, 0o700).catch(() => {}); } }); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index eb62631..137eb68 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { seedCachedPackage } from "./test-helpers"; @@ -96,27 +96,53 @@ describe("cli", () => { if (process.getuid?.() === 0) return; const dir = await mkdtemp(path.join(tmpdir(), "oa-cli-")); const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cache-")); - const blocked = path.join(cacheDir, "opencode", "packages", "opencode-architect@latest", "nested"); + const blocked = path.join( + cacheDir, + "opencode", + "npm", + "opencode-architect@latest", + "1738848000000", + "node_modules", + "opencode-architect", + ); await mkdir(blocked, { recursive: true }); - await writeFile(path.join(blocked, "file.txt"), "cached"); + await writeFile(path.join(blocked, "index.ts"), "cached"); await chmod(blocked, 0o500); try { const run = await runCli(["install"], dir, { XDG_CACHE_HOME: cacheDir }); expect(run.exitCode).toBe(0); expect(run.stdout).toContain("Registered"); - expect(run.stderr).toContain(`Could not clear cached package ${path.join(cacheDir, "opencode", "packages", "opencode-architect@latest")}`); - expect(existsSync(path.join(cacheDir, "opencode", "packages", "opencode-architect"))).toBe(false); + expect(run.stderr).toContain(`Could not clear cached package ${path.join(cacheDir, "opencode", "npm", "opencode-architect@latest")}`); + expect(existsSync(path.join(cacheDir, "opencode", "npm", "opencode-architect"))).toBe(false); } finally { - await chmod(blocked, 0o700); + await chmod(blocked, 0o700).catch(() => {}); await rm(dir, { recursive: true, force: true }); await rm(cacheDir, { recursive: true, force: true }); } }); + + test("install with a legacy v1 plugin entry keeps it read-only and advises upgrading", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-cli-")); + const configPath = path.join(dir, "opencode.json"); + const legacyConfig = '{ "plugin": ["opencode-architect@0.9.0"] }\n'; + await writeFile(configPath, legacyConfig); + try { + const run = await runCli(["install"], dir); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("Registered"); + expect(run.stderr).toContain("legacy v1"); + expect(run.stderr).toContain('"plugins"'); + expect(await readFile(configPath, "utf-8")).toBe(legacyConfig); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); }); describe("cli clear-cache", () => { - const packagesDir = (cacheDir: string) => path.join(cacheDir, "opencode", "packages"); + const packagesDir = (cacheDir: string) => path.join(cacheDir, "opencode", "npm"); const seedCache = seedCachedPackage; test("default mode removes only this package's cache dirs", async () => { @@ -130,7 +156,7 @@ describe("cli clear-cache", () => { expect(run.exitCode).toBe(0); expect(existsSync(ours)).toBe(false); - expect(existsSync(path.join(cacheDir, "opencode", "packages", "opencode-architect@latest"))).toBe(false); + expect(existsSync(path.join(cacheDir, "opencode", "npm", "opencode-architect@latest"))).toBe(false); expect(existsSync(other)).toBe(true); } finally { await rm(cacheDir, { recursive: true, force: true }); diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 0a0d8a3..7613620 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -92,7 +92,7 @@ describe("Installer.install", () => { expect(outcome.manifestPath).toBe(manifestPath("local")); const config = await readJson(configPath); - expect(config.plugin).toEqual(["opencode-architect@latest"]); + expect(config.plugins).toEqual(["opencode-architect@latest"]); expect(config.theme).toBe("dark"); const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; @@ -127,11 +127,48 @@ describe("Installer.install", () => { expect(await readFile(configPath, "utf-8")).toBe(configBefore); }); + test("a legacy v1 entry is left read-only with an advisory and never registered twice", async () => { + const configPath = path.join(scopeBase("local"), "opencode.json"); + await writeText(configPath, '{ "plugin": ["opencode-architect@0.9.0"] }\n'); + const before = await readFile(configPath, "utf-8"); + + const outcome = await install("local"); + + expect(outcome.configAction).toBe("noop"); + expect(outcome.configWarning).toContain("legacy v1"); + expect(outcome.configWarning).toContain('"plugins"'); + expect(await readFile(configPath, "utf-8")).toBe(before); + expect(outcome.action).toBe("installed"); + + const manifestBefore = await readFile(manifestPath("local"), "utf-8"); + const repeat = await install("local"); + + expect(repeat.action).toBe("noop"); + expect(repeat.configAction).toBe("noop"); + expect(await readFile(configPath, "utf-8")).toBe(before); + expect(await readFile(manifestPath("local"), "utf-8")).toBe(manifestBefore); + }); + + test("uninstall leaves a legacy v1 entry untouched and advises", async () => { + const configPath = path.join(scopeBase("local"), "opencode.json"); + const legacyConfig = '{ "plugin": ["opencode-architect"] }\n'; + await writeText(configPath, legacyConfig); + + const outcome = await installer.uninstall("local", projectDir); + + expect(outcome.mode).toBe("plugin"); + expect(outcome.pluginRemoved).toBe(false); + expect(outcome.configPath).toBe(configPath); + expect(outcome.configWarning).toContain("legacy v1"); + expect(outcome.configWarning).toContain(configPath); + expect(await readFile(configPath, "utf-8")).toBe(legacyConfig); + }); + test("an existing entry with a version spec matches semantically without a write", async () => { const configPath = path.join(scopeBase("local"), "opencode.jsonc"); await writeText( configPath, - `{\n // keep this comment\n "plugin": ["other", "opencode-architect@latest"],\n}\n`, + `{\n // keep this comment\n "plugins": ["other", "opencode-architect@latest"],\n}\n`, ); const before = await readFile(configPath, "utf-8"); @@ -149,7 +186,7 @@ describe("Installer.install", () => { "{", " // my precious comment", ' "$schema": "https://opencode.ai/config.json",', - ' "plugin": [', + ' "plugins": [', ' // plugin note', ' "other-extension",', " ],", @@ -186,7 +223,7 @@ describe("Installer.install", () => { expect(outcome.configAction).toBe("created"); expect(outcome.configPath).toBe(path.join(projectDir, "opencode.jsonc")); const text = await readFile(path.join(projectDir, "opencode.jsonc"), "utf-8"); - expect(text).toContain('"plugin": ["opencode-architect@latest"]'); + expect(text).toContain('"plugins": ["opencode-architect@latest"]'); }); test("refuses copy mode with an explanatory error", async () => { @@ -259,7 +296,7 @@ describe("Installer.install", () => { expect(existsSync(path.join(scopeBase("local"), "opencode-architect"))).toBe(false); const config = await readJson(path.join(scopeBase("local"), "opencode.json")); - expect(config.plugin).toEqual(["opencode-architect@latest"]); + expect(config.plugins).toEqual(["opencode-architect@latest"]); const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; expect(manifest.mode).toBe("plugin"); }); @@ -281,7 +318,7 @@ describe("Installer.install", () => { expect(existsSync(manifestPath("local"))).toBe(true); expect(existsSync(configPath)).toBe(true); const config = await readJson(configPath); - expect(config.plugin).toBeUndefined(); + expect(config.plugins).toBeUndefined(); }); test("migration aborts with the payload intact when a config is unparseable", async () => { @@ -324,7 +361,7 @@ describe("Installer.install", () => { expect(outcome.action).toBe("upgraded"); const config = await readJson(configPath); - expect(config.plugin).toEqual(["opencode-architect@latest"]); + expect(config.plugins).toEqual(["opencode-architect@latest"]); const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; expect(manifest.version).toBe(await readPackageVersion()); }); @@ -368,7 +405,7 @@ describe("Installer.install", () => { describe("Installer.install cache pruning", () => { function cacheRoot(): string { - return path.join(cacheDir, "opencode", "packages"); + return path.join(cacheDir, "opencode", "npm"); } async function seedCache(): Promise { @@ -380,12 +417,13 @@ describe("Installer.install cache pruning", () => { "opencode-architect@0.6.0", "other-package", ]) { - await mkdir(path.join(cacheRoot(), name, "nested"), { recursive: true }); - await writeFile(path.join(cacheRoot(), name, "nested", "file.txt"), "cached"); + const generation = path.join(cacheRoot(), name, "1738848000000", "node_modules", name.replace(/@.*$/, "")); + await mkdir(generation, { recursive: true }); + await writeFile(path.join(generation, "index.ts"), "cached"); } } - test("removes stale non-pinned copies and preserves pinned and foreign dirs", async () => { + test("removes stale non-pinned key dirs and preserves pinned and foreign keys", async () => { await seedCache(); const version = await readPackageVersion(); @@ -404,6 +442,14 @@ describe("Installer.install cache pruning", () => { expect(existsSync(path.join(cacheRoot(), "other-package"))).toBe(true); }); + test("a removed key takes every cached generation with it", async () => { + await seedCache(); + + await install("local"); + + expect(existsSync(path.join(cacheRoot(), "opencode-architect@latest", "1738848000000"))).toBe(false); + }); + test("prunes even when the install is a no-op", async () => { await install("local"); await seedCache(); @@ -425,7 +471,13 @@ describe("Installer.install cache pruning", () => { test("removal failure warns but the install still succeeds", async () => { await seedCache(); - const blocked = path.join(cacheRoot(), "opencode-architect@latest", "nested"); + const blocked = path.join( + cacheRoot(), + "opencode-architect@latest", + "1738848000000", + "node_modules", + "opencode-architect", + ); await chmod(blocked, 0o500); try { const outcome = await install("local"); @@ -436,7 +488,7 @@ describe("Installer.install cache pruning", () => { expect(existsSync(path.join(cacheRoot(), "opencode-architect"))).toBe(false); expect(existsSync(path.join(cacheRoot(), "opencode-architect@latest"))).toBe(true); } finally { - await chmod(blocked, 0o700); + await chmod(blocked, 0o700).catch(() => {}); } }); @@ -461,7 +513,7 @@ describe("Installer.uninstall", () => { expect(outcome.configPath).toBe(configPath); expect(existsSync(manifestPath("local"))).toBe(false); const config = await readJson(configPath); - expect(config.plugin).toEqual([]); + expect(config.plugins).toEqual([]); expect((await readFile(configPath, "utf-8")).length).toBeLessThan(before.length); }); @@ -555,6 +607,18 @@ describe("Installer.status", () => { }); test("detects a registration without a manifest", async () => { + await writeJson(path.join(scopeBase("local"), "opencode.json"), { + plugins: ["opencode-architect"], + }); + + const outcome = await installer.status("local", projectDir); + + expect(outcome.mode).toBe("plugin"); + expect(outcome.version).toBeNull(); + expect(outcome.configPath).toBe(path.join(scopeBase("local"), "opencode.json")); + }); + + test("detects a legacy v1 registration without a manifest", async () => { await writeJson(path.join(scopeBase("local"), "opencode.json"), { plugin: ["opencode-architect"], }); diff --git a/tests/load-hook.test.ts b/tests/load-hook.test.ts index 909af1b..92a7902 100644 --- a/tests/load-hook.test.ts +++ b/tests/load-hook.test.ts @@ -135,7 +135,7 @@ describe("plugin activation (startup non-interference)", () => { expect(permission.evaluations.length).toBe(0); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain("bunx opencode-architect clear-cache"); - expect(warnings[0]).toContain(`~/.cache/opencode/packages/opencode-architect@`); + expect(warnings[0]).toContain(path.join(".cache", "opencode", "npm", "opencode-architect@")); expect(warnings[0]).toContain("ENOENT"); }); diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts index c7c5cff..f22905a 100644 --- a/tests/plugin-config.test.ts +++ b/tests/plugin-config.test.ts @@ -90,7 +90,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { " // consumer comment, keep me", ' "$schema": "https://opencode.ai/config.json",', ' "model": "x/y",', - ' "plugin": [', + ' "plugins": [', ' "some-other-plugin",', " ],", "}", @@ -106,22 +106,22 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { const after = await readFile(configPath, "utf-8"); expect(after).toContain('"my-pkg@latest"'); const without = after.replace('"my-pkg@latest",', "").replace(/,\s*,/g, ","); - expect(without.split('"plugin"')[0]).toBe(before.split('"plugin"')[0]); + expect(without.split('"plugins"')[0]).toBe(before.split('"plugins"')[0]); const parsed = parseJsonc(after); - expect(parsed.plugin).toContain("my-pkg@latest"); - expect(parsed.plugin).toContain("some-other-plugin"); + expect(parsed.plugins).toContain("my-pkg@latest"); + expect(parsed.plugins).toContain("some-other-plugin"); expect(parsed.model).toBe("x/y"); }); test("writes a new entry in the canonical name@latest form", async () => { const projectDir = await makeDir("project"); - await write("project/opencode.json", '{ "plugin": ["other"] }\n'); + await write("project/opencode.json", '{ "plugins": ["other"] }\n'); await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); const parsed = parseJsonc(await readFile(path.join(projectDir, "opencode.json"), "utf-8")); - expect(parsed.plugin).toContain("my-pkg@latest"); - expect(parsed.plugin).not.toContain("my-pkg"); + expect(parsed.plugins).toContain("my-pkg@latest"); + expect(parsed.plugins).not.toContain("my-pkg"); }); test("performs no write when a semantically matching entry exists", async () => { @@ -129,7 +129,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { for (const entry of ["my-pkg", "my-pkg@1.2.3", "my-pkg@latest"]) { const configPath = await write( "project/opencode.json", - `{ "plugin": ["other", "${entry}"] }\n`, + `{ "plugins": ["other", "${entry}"] }\n`, ); const before = await readFile(configPath, "utf-8"); const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); @@ -145,7 +145,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { expect(outcome.action).toBe("created"); expect(outcome.configPath).toBe(path.join(projectDir, "opencode.jsonc")); const parsed = parseJsonc(await readFile(path.join(projectDir, "opencode.jsonc"), "utf-8")); - expect(parsed.plugin).toEqual(["my-pkg@latest"]); + expect(parsed.plugins).toEqual(["my-pkg@latest"]); expect(parsed.$schema).toContain("config.json"); }); @@ -163,7 +163,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { test("warns about an unparseable candidate even when an earlier candidate matches", async () => { const projectDir = await makeDir("project"); - await write("project/.opencode/opencode.json", '{ "plugin": ["my-pkg"] }\n'); + await write("project/.opencode/opencode.json", '{ "plugins": ["my-pkg"] }\n'); const brokenRoot = await write("project/opencode.json", "{ broken ]"); const captured = captureConsole("warn"); @@ -180,7 +180,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { test("findRegistration warns about unparseable candidates and still finds later registrations", async () => { const projectDir = await makeDir("project"); await write("project/.opencode/opencode.json", "{ broken ]"); - const rootConfig = await write("project/opencode.json", '{ "plugin": ["my-pkg@1.0.0"] }\n'); + const rootConfig = await write("project/opencode.json", '{ "plugins": ["my-pkg@1.0.0"] }\n'); const captured = captureConsole("warn"); try { @@ -214,7 +214,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { test("strict .json rejects comments and trailing commas", async () => { const projectDir = await makeDir("project"); - await write("project/opencode.json", '{ "plugin": ["a",], }\n'); + await write("project/opencode.json", '{ "plugins": ["a",], }\n'); const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("blocked"); }); @@ -223,7 +223,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { const projectDir = await makeDir("project"); await write( "project/opencode.jsonc", - '{ /* c */ "plugin": ["a",], // trailing\n}\n', + '{ /* c */ "plugins": ["a",], // trailing\n}\n', ); const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("updated"); @@ -231,37 +231,37 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { test("lenient .jsonc tolerates a comment between a trailing comma and its closer", async () => { const projectDir = await makeDir("project"); - await write("project/opencode.jsonc", '{ "plugin": ["other",/* c */], }\n'); + await write("project/opencode.jsonc", '{ "plugins": ["other",/* c */], }\n'); const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("updated"); const parsed = parseJsonc(await readFile(path.join(projectDir, "opencode.jsonc"), "utf-8")); - expect(parsed.plugin).toContain("my-pkg@latest"); - expect(parsed.plugin).toContain("other"); + expect(parsed.plugins).toContain("my-pkg@latest"); + expect(parsed.plugins).toContain("other"); }); test("schema URLs with // and escaped quotes survive splicing and parsing", async () => { const projectDir = await makeDir("project"); const configPath = await write( "project/opencode.jsonc", - '{ "$schema": "https://opencode.ai/config.json", "key": "a \\"quoted\\" // value", "plugin": [] }\n', + '{ "$schema": "https://opencode.ai/config.json", "key": "a \\"quoted\\" // value", "plugins": [] }\n', ); const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("updated"); const parsed = JSON.parse((await readFile(configPath, "utf-8")).replace(/,(\s*[}\]])/g, "$1")); expect(parsed.$schema).toBe("https://opencode.ai/config.json"); expect(parsed.key).toBe('a "quoted" // value'); - expect(parsed.plugin).toEqual(["my-pkg@latest"]); + expect(parsed.plugins).toEqual(["my-pkg@latest"]); }); test("scope base config wins over repo root; existing scope-base file is edited", async () => { const projectDir = await makeDir("project"); const scopeBasePath = await write( "project/.opencode/opencode.json", - '{ "plugin": [] }\n', + '{ "plugins": [] }\n', ); - await write("project/opencode.json", '{ "plugin": [] }\n'); + await write("project/opencode.json", '{ "plugins": [] }\n'); const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("updated"); expect(outcome.configPath).toBe(scopeBasePath); @@ -280,7 +280,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { } }); - test("global config.json with the entry is a read-only no-op", async () => { + test("global config.json with the entry is a read-only no-op with an upgrade advisory", async () => { const projectDir = await makeDir("project"); const xdg = await makeDir("xdg"); const globalConfig = await write( @@ -292,6 +292,8 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "global", projectDir }); expect(outcome.action).toBe("noop"); expect(outcome.configPath).toBe(globalConfig); + expect(outcome.warning).toContain("legacy v1"); + expect(outcome.warning).toContain("no longer reads config.json"); expect(await readFile(globalConfig, "utf-8")).toBe('{ "plugin": ["my-pkg@2.0.0"] }\n'); } finally { delete process.env.XDG_CONFIG_HOME; @@ -308,7 +310,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { expect(outcome.action).toBe("created"); expect(await readFile(globalConfig, "utf-8")).toBe('{ "model": "x/y" }\n'); const created = parseJsonc(await readFile(path.join(xdg, "opencode", "opencode.jsonc"), "utf-8")); - expect(created.plugin).toEqual(["my-pkg@latest"]); + expect(created.plugins).toEqual(["my-pkg@latest"]); } finally { delete process.env.XDG_CONFIG_HOME; } @@ -316,10 +318,10 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { test("splices an inline empty plugin array in a minified config", async () => { const projectDir = await makeDir("project"); - const configPath = await write("project/opencode.json", '{"plugin":[]}\n'); + const configPath = await write("project/opencode.json", '{"plugins":[]}\n'); const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("updated"); - expect(await readFile(configPath, "utf-8")).toBe('{"plugin":["my-pkg@latest"]}\n'); + expect(await readFile(configPath, "utf-8")).toBe('{"plugins":["my-pkg@latest"]}\n'); }); test("config without a plugin key gets one spliced in, rest untouched", async () => { @@ -331,9 +333,110 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("updated"); const after = await readFile(configPath, "utf-8"); - expect(after).toContain('"plugin": ["my-pkg@latest"],'); + expect(after).toContain('"plugins": ["my-pkg@latest"],'); expect(after).toContain('"model": "x/y"'); const parsed = JSON.parse(after.replace(/,(\s*[}\]])/g, "$1")); - expect(parsed.plugin).toEqual(["my-pkg@latest"]); + expect(parsed.plugins).toEqual(["my-pkg@latest"]); + }); + + test("a legacy v1 plugin entry is a zero-write no-op with an upgrade advisory", async () => { + const projectDir = await makeDir("project"); + const configPath = await write( + "project/opencode.json", + '{ "plugin": ["other", "my-pkg@1.0.0"] }\n', + ); + const before = await readFile(configPath, "utf-8"); + + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("noop"); + expect(outcome.configPath).toBe(configPath); + expect(outcome.warning).toContain("legacy v1"); + expect(outcome.warning).toContain(configPath); + expect(outcome.warning).toContain('"plugins"'); + expect(await readFile(configPath, "utf-8")).toBe(before); + }); + + test("writes the v2 plugins key alongside a legacy plugin key holding other packages", async () => { + const projectDir = await makeDir("project"); + const configPath = await write("project/opencode.json", '{ "plugin": ["old-one"] }\n'); + + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("updated"); + const parsed = parseJsonc(await readFile(configPath, "utf-8")); + expect(parsed.plugins).toEqual(["my-pkg@latest"]); + expect(parsed.plugin).toEqual(["old-one"]); + }); + + test("an object-form v2 entry matches semantically without a write", async () => { + const projectDir = await makeDir("project"); + const configPath = await write( + "project/opencode.json", + '{ "plugins": [{ "package": "my-pkg", "options": { "x": 1 } }] }\n', + ); + const before = await readFile(configPath, "utf-8"); + + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("noop"); + expect(await readFile(configPath, "utf-8")).toBe(before); + }); + + test("findRegistration finds a legacy v1 plugin registration", async () => { + const projectDir = await makeDir("project"); + const configPath = await write("project/opencode.json", '{ "plugin": ["my-pkg"] }\n'); + + const found = await editor().findRegistration("my-pkg", { scope: "local", projectDir }); + + expect(found).toBe(configPath); + }); +}); + +describe("PluginConfigEditor.removePluginEntry", () => { + test("leaves a legacy-only entry untouched and advises instead of removing", async () => { + const projectDir = await makeDir("project"); + const configPath = await write("project/opencode.json", '{ "plugin": ["my-pkg"] }\n'); + const before = await readFile(configPath, "utf-8"); + + const outcome = await editor().removePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("noop"); + expect(outcome.configPath).toBe(configPath); + expect(outcome.warning).toContain("legacy v1"); + expect(outcome.warning).toContain(configPath); + expect(await readFile(configPath, "utf-8")).toBe(before); + }); + + test("removes an object-form v2 entry and keeps the rest of the array", async () => { + const projectDir = await makeDir("project"); + const configPath = await write( + "project/opencode.json", + '{ "plugins": [{ "package": "my-pkg", "options": {} }, "other"] }\n', + ); + + const outcome = await editor().removePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("removed"); + expect(outcome.configPath).toBe(configPath); + const parsed = JSON.parse(await readFile(configPath, "utf-8")); + expect(parsed.plugins).toEqual(["other"]); + }); + + test("removing the v2 entry warns about a leftover legacy entry in the same config", async () => { + const projectDir = await makeDir("project"); + const configPath = await write( + "project/opencode.json", + '{ "plugins": ["my-pkg"], "plugin": ["my-pkg"] }\n', + ); + + const outcome = await editor().removePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("removed"); + expect(outcome.warning).toContain("legacy v1"); + expect(outcome.warning).toContain(configPath); + const parsed = JSON.parse(await readFile(configPath, "utf-8")); + expect(parsed.plugins).toEqual([]); + expect(parsed.plugin).toEqual(["my-pkg"]); }); }); diff --git a/tests/readme.test.ts b/tests/readme.test.ts index 4a804dc..9330d47 100644 --- a/tests/readme.test.ts +++ b/tests/readme.test.ts @@ -54,11 +54,11 @@ function headingSlugExists(readme: string, slug: string): boolean { } describe("shipped config snippets (D8)", () => { - test("README snippets use the plugin key with canonical entries, never plugins", async () => { + test("README snippets use the v2 plugins key with canonical entries, never the v1 plugin key", async () => { const readme = await readFile(README_PATH, "utf-8"); - expect(readme).toContain('"plugin": ["opencode-architect@latest"]'); - expect(readme).not.toContain('"plugins"'); + expect(readme).toContain('"plugins": ["opencode-architect@latest"]'); + expect(readme).not.toContain('"plugin": ["opencode-architect'); expect(readme).toContain("opencode-architect@latest"); }); }); diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts index e10f653..320487f 100644 --- a/tests/test-helpers.ts +++ b/tests/test-helpers.ts @@ -18,8 +18,9 @@ export function captureConsole(method: "log" | "warn"): CapturedConsole { export async function seedCachedPackage(dir: string, name: string): Promise { const target = path.join(dir, name); - await mkdir(path.join(target, "nested"), { recursive: true }); - await writeFile(path.join(target, "nested", "file.txt"), "cached"); + const pkgDir = path.join(target, "1738848000000", "node_modules", name.replace(/@.*$/, "")); + await mkdir(pkgDir, { recursive: true }); + await writeFile(path.join(pkgDir, "index.ts"), "cached"); return target; }