diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d01dc7..dece95e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `status` now resolves the **effective version** a session would load, per scope, instead of echoing the manifest version. For each scope it reports the config file holding the registration, the raw entry as written (`name@latest`, a pinned spec, a `{ package }` object, or a path/`file://` form), and the resolved source on disk — an npm spec to OpenCode's package cache (`$XDG_CACHE_HOME/opencode/npm`, falling back to `~/.cache/opencode/npm`) with the version read from that copy's `package.json` and the copy's age as a staleness hint, a path entry to the package checkout via `PluginEntryResolver.packageRoot` — and closes with an effective verdict that annotates a double-scope registration (`should load: 1.0.0 (cache copy, local + global; both scopes register — double-load)`) plus warnings for that double-load, a manifest/resolved version mismatch, a missing or incomplete cached copy, and an unparseable config. The verdict answers from the strongest source available: a resolved copy on disk, else the registration spec (a pinned exact version answers directly; `@latest` answers with the published version under `--online`; ranges stay `unresolved`), else the manifest version. A manifest without a live registration reports unregistered: the report answers what would load, not what was once installed. New `--online` queries the npm registry for every registered package — the name comes from the entry, or from the checkout's `package.json` for path-form registrations — and prints the published latest per scope (`npm-latest=`, also `, latest ` on the verdict), flagging staleness with warn-and-continue on network failure. New `--path ` resolves against another project directory; both flags are status-only and rejected elsewhere. `status` remains read-only. Resolution lives in the new `StatusReporter` (`src/status-reporter.ts`) over `src/loaded-version.ts`, `src/config-entries.ts`, and `src/registry-version-checker.ts`, with entry classification unified in `src/entry-spec.ts`; `Installer` is install/uninstall/cache only and exposes `manifestAt()` in place of the retired `status()`/`StatusOutcome`. The generated-package templates (`registration`, `plugin-entry`, `installer`, `cli`) gain the same resolution so packaged extensions inherit it. + - Path-form plugin-entry recognition (the `aurelia-expert@db0736e` lesson, issue #3): the new `src/plugin-entry.ts` and `templates/plugin-entry.template.txt` resolve a `file://` URL or filesystem config entry to a package by `package.json` name. Registration-scope detection (`templates/registration.template.txt`) and the surgical config editor (`src/plugin-config.ts`, `templates/plugin-config.template.txt`) now count a live path-form entry as registered, so installation never adds a duplicate `name@latest` entry beside it. The packager and publisher template maps gained `src/plugin-entry.ts`, and conformance-checklist items B2 and C1 gained the rule. ### Changed diff --git a/README.md b/README.md index 26e844b..102aa9f 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,11 @@ bunx opencode-architect install --scope global Useful flags and commands: ```bash -bunx opencode-architect status # show mode, version, and the config file holding the entry +bunx opencode-architect status # resolve what a session would load, per scope, plus an effective verdict +bunx opencode-architect status --scope global # narrow the resolution to one scope +bunx opencode-architect status --online # also query the npm registry and flag a stale resolved copy +bunx opencode-architect status --path ../other # resolve against another project directory +bunx opencode-architect status --package other # resolve another installed package by name bunx opencode-architect uninstall # remove the plugin entry, the manifest, and any residual payload bunx opencode-architect clear-cache # remove cached copies of this package from OpenCode's package cache bunx opencode-architect clear-cache --all --yes # remove the whole OpenCode cache directory (destructive) @@ -45,6 +49,8 @@ bunx opencode-architect install --force # re-register and rewrite the ma bunx opencode-architect --help # full usage ``` +`status` does not report the manifest version alone: for each scope it resolves what a session would actually load. It prints the config file that holds the registration, the raw entry as written (`name@latest`, a pinned spec, a `{ package }` object, or a path/`file://` form), and the resolved source on disk — an npm spec resolves to OpenCode's package cache (`$XDG_CACHE_HOME/opencode/npm`, falling back to `~/.cache/opencode/npm`), a path entry to the package checkout — with the version read from that copy's `package.json` and the copy's age as a staleness hint (`resolved=1.0.0 (cache copy, 2h old)`). It closes with an effective verdict and warns when a package is registered in both scopes — the verdict annotates the double-load (`should load: 1.0.0 (cache copy, local + global; both scopes register — double-load)`) — when the manifest version disagrees with the resolved copy, when the cached copy for a spec is missing or incomplete, or when a config file cannot be parsed. The verdict answers from the strongest source it can: a resolved copy on disk (`cache copy` / `checkout copy`), else the registration spec itself — a pinned exact version answers directly (`should load: 1.0.0 (pinned spec, local)`, since the host fetches exactly that at next start) and `@latest` answers with the published version under `--online` (`npm latest`); a range like `^1.7.0` stays `unresolved` until a copy exists, and only then falls back to the manifest version. `status` is read-only. Without `--online` it never touches the network; `--online` queries the npm registry for every registered package — the name comes from the entry, or from the checkout's `package.json` for path-form registrations — and prints it per scope (`npm-latest=1.0.0`) plus `, latest ` on the verdict, flagging staleness; network failures warn rather than fail. `--path ` resolves against another project directory (the directory must exist), so a package installed elsewhere can be checked without `cd`-ing into it. `--package ` resolves `` instead of `opencode-architect`: it matches a plugins entry or a path checkout whose `package.json` name is ``, reads that scope's `.manifest.json` (or the legacy `.json`), and, under `--online`, queries the registry for `` — so a package installed from another repo can be inspected in place. `--online` and `--path` are status-only; `--package` applies to `status` and `clear-cache` only; any other command rejects them with an explanatory error. + 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 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. diff --git a/agents/opencode-packager.md b/agents/opencode-packager.md index 6457380..b39a93b 100644 --- a/agents/opencode-packager.md +++ b/agents/opencode-packager.md @@ -45,7 +45,7 @@ opencode-myextension/ └── tsconfig.json ``` -6. **Create src/plugin.ts** from `../templates/plugin-local.template.txt`, plus `src/plugin-name.ts` from `../templates/plugin-name.template.txt`, `src/manifest.ts` from `../templates/manifest.template.txt`, `src/registration.ts` from `../templates/registration.template.txt`, `src/plugin-config.ts` from `../templates/plugin-config.template.txt`, `src/plugin-entry.ts` from `../templates/plugin-entry.template.txt`, `src/installer.ts` from `../templates/installer.template.txt` (src/plugin.ts imports it), and `src/cli.ts` from `../templates/cli.template.txt`: the load hook performs read-only registration-scope detection, then ensures skills, commands, and agents only for the scopes where the plugin is registered, gated by the per-scope install manifest (version, mode, registration, per-file sha256). The plugin-entry module resolves path-form config entries (`file://` URL or filesystem path) to this package, so a path-registered package counts as registered and installation never adds a duplicate `name@latest` entry beside it (aurelia-expert issue #3). Never write outside the detected scopes, never edit `plugins` arrays or root configs at load, and never rewrite a config that fails to parse. The install logic is content-based (ADR-0008): the same installer serves both the global scope base and the project scope base with identical behavior — copy install touches no config, plugin registration goes through the surgical editor, and code-backed packages refuse `--mode copy`. Cache hygiene is self-scoped (checklist A6): the installer template prunes this package's own cache copies on every install (warn-and-continue, other packages untouched), and the hook's failure advisory tells consumers to run `bunx clear-cache` — the hook itself never deletes cache entries. +6. **Create src/plugin.ts** from `../templates/plugin-local.template.txt`, plus `src/plugin-name.ts` from `../templates/plugin-name.template.txt`, `src/manifest.ts` from `../templates/manifest.template.txt`, `src/registration.ts` from `../templates/registration.template.txt`, `src/plugin-config.ts` from `../templates/plugin-config.template.txt`, `src/plugin-entry.ts` from `../templates/plugin-entry.template.txt`, `src/installer.ts` from `../templates/installer.template.txt` (src/plugin.ts imports it), and `src/cli.ts` from `../templates/cli.template.txt`: the load hook performs read-only registration-scope detection, then ensures skills, commands, and agents only for the scopes where the plugin is registered, gated by the per-scope install manifest (version, mode, registration, per-file sha256). The plugin-entry module resolves path-form config entries (`file://` URL or filesystem path) to this package, so a path-registered package counts as registered and installation never adds a duplicate `name@latest` entry beside it (aurelia-expert issue #3). Never write outside the detected scopes, never edit `plugins` arrays or root configs at load, and never rewrite a config that fails to parse. The install logic is content-based (ADR-0008): the same installer serves both the global scope base and the project scope base with identical behavior — copy install touches no config, plugin registration goes through the surgical editor, and code-backed packages refuse `--mode copy`. Cache hygiene is self-scoped (checklist A6): the installer template prunes this package's own cache copies on every install (warn-and-continue, other packages untouched), and the hook's failure advisory tells consumers to run `bunx clear-cache` — the hook itself never deletes cache entries. The generated `status()` resolves the effective load per scope — registration, target config file, raw entry as written, resolved source on disk (npm package cache copy or path checkout, with the copy's age as a staleness hint), and an effective verdict that annotates a double-scope registration — with `--online` registry staleness (warn-and-continue, injectable fetch) and `--path `, both rejected outside status, so packaged extensions inherit the suite's status behavior. 7. **Create package.json** from `../templates/package-basics.template.json` and tsconfig.json from `../templates/tsconfig.template.json`. The template declares `exports` with both `.` and `"./server": "./index.ts"` — keep the "./server" export: v2 resolves the plugin entrypoint through it on every observed runtime, and the root-index fallback is runtime-dependent and must not be relied on (per opencode-v2-facts §7, §13 row 9, §14.6). Keep `"@opencode/plugin": "latest"` and `"effect": "latest"` — never pin consumer dependency versions (per opencode-v2-facts §15). The template ships with `"content": "assets"`; the asset inventory is the source of truth for the declaration (ADR-0008): keep `"assets"` only when the package ships skills and/or commands alone; set `"code"` when the source contained any agent, tool, plugin, hook, or other plugin integration — code-backed packages may also ship skills and commands. The `files` list names the content directories actually shipped at the package root (`skills`, `commands`, plus `agents`, `plugins`, `tools`, `src` as present) — never an `assets` entry. The `bin` entry (`"": "src/cli.ts"`) and the `check`/`test` scripts ship at packager time: every load-hook advisory and README instruction says `bunx ...`, so the entry point must resolve before any publishing step. Report the decision: "Content declaration: code (1 agent, 2 tools found)" or "Content declaration: assets (skills and commands only)". diff --git a/agents/opencode-publisher.md b/agents/opencode-publisher.md index 41b9ff4..d585433 100644 --- a/agents/opencode-publisher.md +++ b/agents/opencode-publisher.md @@ -21,7 +21,7 @@ You are an OpenCode extension publisher: you transform locally-packaged extensio 2. **Extract install logic to src/installer.ts.** Move install(), uninstall(), status(), scope detection, path resolution, and config management out of src/plugin.ts, keeping the manifest module (src/manifest.ts), plugin-name normalizer (src/plugin-name.ts), registration detector (src/registration.ts), and surgical config editor (src/plugin-config.ts) as separate files; update src/plugin.ts to call install() from src/installer.ts. Preserve the content-based deployment plan (ADR-0008): the installer reads the `content` declaration — assets-only packages copy-install by default (copy touches no config file) with `--mode plugin` as the opt-in; code-backed packages always register and `--mode copy` is a hard error. Preserve the invariants: manifest-gated idempotency (no `.version` markers; the plugin-mode no-op includes a present entry), semantic `@latest` plugin dedup written canonically as `name@latest` via the surgical editor only, abort-with-warning on unparseable config (never rewrite from `{}`), skip consumer-modified files unless `--force`, and root-config migration CLI-only behind explicit consent. Preserve the self-scoped cache hygiene (checklist A6): install() prunes the package's own cache copies (``, `@latest`, `@`) on every invocation — including no-ops — best-effort with warn-and-continue, and clearPackageCache() backs the CLI's `clear-cache` subcommand. -3. **Create the CLI entry point.** Build src/cli.ts from `../templates/cli.template.txt`: install command calls install(scope, projectDir, { mode, force }) — it resolves the mode from the content declaration and surfaces `CopyModeUnsupportedError` as an explanatory exit-1 error — uninstall calls uninstall(scope, projectDir), status calls status(projectDir) and reports mode, version, entry, and the target config file per scope, migrate calls migrateRootConfig only behind `--force` consent, and clear-cache calls clearPackageCache() — strictly self-only (this package's own cache copies, warn-and-continue, idempotent); it must not offer `--package` or `--all` modes, which exist only in the suite's own CLI. +3. **Create the CLI entry point.** Build src/cli.ts from `../templates/cli.template.txt`: install command calls install(scope, projectDir, { mode, force }) — it resolves the mode from the content declaration and surfaces `CopyModeUnsupportedError` as an explanatory exit-1 error — uninstall calls uninstall(scope, projectDir), status calls status(projectDir, online) and resolves the effective load per scope — the registration, the target config file, the raw entry as written, the resolved source on disk (npm package cache copy or path checkout, with the copy's age as a staleness hint), and an effective verdict that annotates a double-scope registration — with `--online` registry staleness (warn-and-continue, injectable fetch) and `--path ` for another project directory, both rejected outside status — migrate calls migrateRootConfig only behind `--force` consent, and clear-cache calls clearPackageCache() — strictly self-only (this package's own cache copies, warn-and-continue, idempotent); it must not offer `--package` or `--all` modes, which exist only in the suite's own CLI. 4. **Expand package.json** from `../templates/package-full.template.json`: bin field for the CLI, scripts (check, test), expanded dependencies, npm fields (repository, bugs, license, author). The `exports` map keeps `"./server": "./index.ts"` — every distributed package must declare it; the root-index fallback is runtime-dependent and must not be relied on (per opencode-v2-facts §7, §13 row 9, §14.6). `"@opencode/plugin": "latest"` and `"effect": "latest"` stay unpinned (per opencode-v2-facts §15). Carry the packager's `content` declaration through unchanged — expansion adds npm fields, never alters the declaration. diff --git a/src/cli.ts b/src/cli.ts index 004c37d..b219b9f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,11 @@ #!/usr/bin/env bun +import type { Stats } from "node:fs"; +import { stat } from "node:fs/promises"; +import path from "node:path"; import { parseArgs } from "node:util"; import { Installer, type Scope } from "./installer"; +import { StatusReporter, type EffectiveVersion, type StatusReport } from "./status-reporter"; +import type { ResolvedSource } from "./loaded-version"; import { CacheCleaner } from "./cache-cleaner"; import { ClearCacheUsageError } from "./clear-cache-usage-error"; @@ -17,6 +22,8 @@ export async function runCli(argv: string[]): Promise { all: { type: "boolean", default: false }, yes: { type: "boolean", default: false }, "dry-run": { type: "boolean", default: false }, + online: { type: "boolean", default: false }, + path: { type: "string" }, help: { type: "boolean", short: "h", default: false }, version: { type: "boolean", short: "v", default: false }, }, @@ -34,6 +41,18 @@ export async function runCli(argv: string[]): Promise { } const command = positionals[0]; + if (command !== "status" && (values.path !== undefined || values.online)) { + console.error("--path and --online only apply to the status command."); + return 1; + } + if (command !== "status" && command !== "clear-cache" && values.package !== undefined) { + console.error("--package only applies to the status and clear-cache commands."); + return 1; + } + if (command === "status" && values.package !== undefined && !isSafeLookupName(values.package)) { + console.error(`Invalid package name: ${values.package}`); + return 1; + } const scopeInput = values.scope; if (scopeInput !== undefined && scopeInput !== "local" && scopeInput !== "global") { console.error(`Invalid scope: ${scopeInput}. Must be "local" or "global".`); @@ -91,10 +110,14 @@ export async function runCli(argv: string[]): Promise { break; } case "status": { - const outcome = await installer.status(scope, process.cwd()); - const version = outcome.version ?? "-"; - const configPath = outcome.configPath ?? "-"; - console.log(`${scope} scope: mode=${outcome.mode} version=${version} config=${configPath}`); + const projectDir = await resolveProjectDir(values.path); + const scopes: Scope[] | null = scopeInput === undefined ? null : [scope]; + const report = await new StatusReporter(installer).report(projectDir, { + scopes, + online: values.online, + packageName: values.package, + }); + printStatusReport(report); break; } case "clear-cache": { @@ -143,6 +166,86 @@ if (import.meta.main) { process.exitCode = await runCli(process.argv.slice(2)); } +function printStatusReport(report: StatusReport): void { + console.log(`package: ${report.packageName}`); + for (const scopeReport of report.scopes) { + const entry = scopeReport.entryText ?? "-"; + const config = scopeReport.configPath ?? "-"; + const npm = + scopeReport.publishedVersion !== null ? ` npm-latest=${scopeReport.publishedVersion}` : ""; + console.log( + `${scopeReport.scope} scope: mode=${scopeReport.mode} config=${config} entry=${entry} resolved=${resolvedLabel(scopeReport.resolved)}${npm}`, + ); + for (const warning of scopeReport.warnings) { + console.warn(`Warning: ${warning}`); + } + } + if (report.effective.scope !== null) { + console.log(`should load: ${verdictLabel(report.effective)}`); + } else { + console.log("should load: nothing (not registered in any scope)"); + } + for (const warning of report.warnings) { + console.warn(`Warning: ${warning}`); + } +} + +function resolvedLabel(source: ResolvedSource | null): string { + if (source === null) return "-"; + const age = StatusReporter.formatAge(source.modifiedMs); + const detail = age === null ? sourceLabel(source.source) : `${sourceLabel(source.source)}, ${age}`; + return `${source.version ?? "unknown"} (${detail})`; +} + +function verdictLabel(effective: EffectiveVersion): string { + const latest = + effective.latestVersion !== null && effective.versionKind !== "npm" ? `, latest ${effective.latestVersion}` : ""; + const scopeLabel = + effective.registeredScopes.length > 1 + ? `${effective.registeredScopes.join(" + ")}; both scopes register — double-load` + : effective.scope; + return `${effective.version ?? "unknown"} (${kindLabel(effective.versionKind)}, ${scopeLabel}${latest})`; +} + +function kindLabel(kind: EffectiveVersion["versionKind"]): string { + if (kind === "cache") return "cache copy"; + if (kind === "checkout") return "checkout copy"; + if (kind === "spec") return "pinned spec"; + if (kind === "npm") return "npm latest"; + if (kind === "manifest") return "manifest"; + return "unresolved"; +} + +function sourceLabel(source: "cache" | "checkout"): string { + return source === "cache" ? "cache copy" : "checkout copy"; +} + +async function resolveProjectDir(input: string | undefined): Promise { + if (input === undefined) return process.cwd(); + const cleaned = stripQuotes(input.trim()); + const resolved = path.resolve(cleaned); + let info: Stats | null = null; + try { + info = await stat(resolved); + } catch { + throw new Error(`--path does not exist: ${input}`); + } + if (!info.isDirectory()) throw new Error(`--path is not a directory: ${input}`); + return resolved; +} + +function stripQuotes(value: string): string { + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) return value.slice(1, -1); + if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1); + return value; +} + +function isSafeLookupName(value: string): boolean { + const trimmed = value.trim(); + if (trimmed.length === 0) return false; + return !trimmed.split(/[\\/]/).includes(".."); +} + function printHelp(): void { console.log(` opencode-architect v${VERSION} @@ -155,18 +258,28 @@ registers the agents and resolves reference paths at load time. Legacy v1 Commands: install Ensure the plugin entry and write the install manifest uninstall Remove the plugin entry, the manifest, and any residual payload - status Show install mode, version, and the config file holding the entry + status Resolve what a session would load: per-scope registration, the + config file holding it, the raw entry, the resolved source on + disk, and an effective verdict; --package resolves another + installed package instead of this one clear-cache Remove cached copies from OpenCode's package cache; default targets this package only Options: - -s, --scope "local" (project) or "global" (XDG/home config); default local + -s, --scope status: narrow to "local" or "global"; by default both + scopes are resolved. install/uninstall: default local -m, --mode "plugin" (default) or "copy"; copy is refused for this code-backed package -f, --force re-register and rewrite the manifest even when it is up to date; consent to migrating a legacy copy install (removes its copied payload) - --package clear-cache: remove and every @* instead; - requires --yes + --online status: also query the npm registry for the latest published + version and flag staleness; network failures warn and continue + --path status: resolve against another project directory instead of + the current working directory; the directory must exist + --package status: resolve instead of this package, so another + installed package can be inspected; also selects the + .manifest.json lookup. clear-cache: remove and + every @* instead; requires --yes --all clear-cache: remove the whole OpenCode cache directory; requires --yes --yes clear-cache: confirm a destructive broad mode @@ -179,6 +292,10 @@ Examples: opencode-architect install --scope global opencode-architect uninstall opencode-architect status + opencode-architect status --scope global + opencode-architect status --online + opencode-architect status --path ../other-project + opencode-architect status --online --path ../other-project --package some-pkg opencode-architect clear-cache opencode-architect clear-cache --package some-pkg --yes opencode-architect clear-cache --all --yes diff --git a/src/config-entries.ts b/src/config-entries.ts new file mode 100644 index 0000000..51c62e9 --- /dev/null +++ b/src/config-entries.ts @@ -0,0 +1,159 @@ +import { exists, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +export type ConfigScope = "local" | "global"; + +export interface ConfigCandidate { + path: string; + lenient: boolean; + writable: boolean; +} + +export interface ConfigEntries { + configPath: string; + rawEntries: unknown[]; + parseError: string | null; +} + +export class ConfigEntriesReader { + public candidates(scope: ConfigScope, projectDir: string): ConfigCandidate[] { + const scopeBase = this.scopeBase(scope, projectDir); + const configs: ConfigCandidate[] = []; + if (scope === "local") { + for (const root of [scopeBase, projectDir]) { + configs.push({ path: path.join(root, "opencode.json"), lenient: false, writable: true }); + configs.push({ path: path.join(root, "opencode.jsonc"), lenient: true, writable: true }); + } + } else { + for (const file of ["opencode.json", "opencode.jsonc"]) { + configs.push({ path: path.join(scopeBase, file), lenient: file.endsWith(".jsonc"), writable: true }); + } + } + configs.push({ path: path.join(scopeBase, "config.json"), lenient: false, writable: false }); + return configs; + } + + public async read(scope: ConfigScope, projectDir: string, warn: boolean): Promise { + const results: ConfigEntries[] = []; + for (const candidate of this.candidates(scope, projectDir)) { + if (!(await exists(candidate.path))) continue; + const text = await readFile(candidate.path, "utf-8"); + const config = this.parseConfig(text, candidate.lenient); + if (config === null) { + if (warn) { + console.warn(`Warning: ${candidate.path} could not be parsed; its plugin entries are ignored.`); + } + results.push({ + configPath: candidate.path, + rawEntries: [], + parseError: `config file ${candidate.path} could not be parsed`, + }); + continue; + } + results.push({ configPath: candidate.path, rawEntries: this.pluginEntries(config), parseError: null }); + } + return results; + } + + private pluginEntries(config: Record): unknown[] { + return [...this.arrayEntries(config, "plugins"), ...this.arrayEntries(config, "plugin")]; + } + + private arrayEntries(config: Record, key: string): unknown[] { + const value = config[key]; + return Array.isArray(value) ? value : []; + } + + private scopeBase(scope: ConfigScope, projectDir: string): string { + if (scope === "local") return path.join(projectDir, ".opencode"); + const xdgConfigHome = process.env.XDG_CONFIG_HOME; + if (xdgConfigHome) return path.join(xdgConfigHome, "opencode"); + return path.join(homedir(), ".config", "opencode"); + } + + private parseConfig(text: string, lenient: boolean): Record | null { + const parseable = lenient ? stripTrailingCommas(stripJsoncComments(text)) : text; + try { + const parsed = JSON.parse(parseable) as unknown; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } + } +} + +function stripJsoncComments(text: string): string { + let out = ""; + let i = 0; + let inString = false; + while (i < text.length) { + const ch = text[i]; + if (inString) { + out += ch; + if (ch === "\\" && i + 1 < text.length) { + out += text[i + 1]; + i += 2; + continue; + } + if (ch === '"') inString = false; + i++; + continue; + } + if (ch === '"') { + inString = true; + out += ch; + i++; + continue; + } + if (ch === "/" && text[i + 1] === "/") { + while (i < text.length && text[i] !== "\n") i++; + continue; + } + if (ch === "/" && text[i + 1] === "*") { + i += 2; + while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; + i += 2; + continue; + } + out += ch; + i++; + } + return out; +} + +function stripTrailingCommas(text: string): string { + let out = ""; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (ch === '"') { + out += ch; + i++; + while (i < text.length) { + if (text[i] === "\\") { + out += text[i] + (text[i + 1] ?? ""); + i += 2; + continue; + } + out += text[i]; + const closed = text[i] === '"'; + i++; + if (closed) break; + } + continue; + } + if (ch === ",") { + let j = i + 1; + while (j < text.length && /\s/.test(text[j] ?? "")) j++; + if (text[j] === "]" || text[j] === "}") { + i++; + continue; + } + } + out += ch; + i++; + } + return out; +} diff --git a/src/entry-spec.ts b/src/entry-spec.ts new file mode 100644 index 0000000..f561e9b --- /dev/null +++ b/src/entry-spec.ts @@ -0,0 +1,58 @@ +import { isAbsolute } from "node:path"; + +export type EntryForm = "npm" | "path"; + +export interface ClassifiedEntry { + form: EntryForm; + display: string; + spec: string; + name: string | null; + version: string | null; +} + +export class EntrySpec { + public static classify(entry: unknown): ClassifiedEntry | null { + const spec = EntrySpec.specOf(entry); + if (spec === null) return null; + const display = typeof entry === "string" ? entry : JSON.stringify(entry); + if (EntrySpec.isPath(spec)) { + return { form: "path", display, spec, name: null, version: null }; + } + const parsed = EntrySpec.parseNpm(spec); + if (parsed === null) return null; + return { form: "npm", display, spec, name: parsed.name, version: parsed.version }; + } + + public static specOf(entry: unknown): string | null { + if (typeof entry === "string") return entry.trim(); + if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) { + const spec = (entry as { package?: unknown }).package; + if (typeof spec === "string") return spec.trim(); + } + return null; + } + + public static isPath(spec: string): boolean { + if (spec.startsWith("file://")) return true; + if (spec.startsWith(".")) return true; + if (isAbsolute(spec)) return true; + if (spec.includes("@")) return false; + if (spec.includes("/") || spec.includes("\\")) return true; + return false; + } + + public static baseName(raw: string): string { + const trimmed = raw.trim(); + const specIndex = trimmed.lastIndexOf("@"); + if (specIndex > 0) return trimmed.slice(0, specIndex); + return trimmed; + } + + private static parseNpm(spec: string): { name: string; version: string } | null { + const at = spec.lastIndexOf("@"); + if (at <= 0) return { name: spec, version: "latest" }; + const version = spec.slice(at + 1); + if (version.includes("/") || version.includes("\\")) return null; + return { name: spec.slice(0, at), version: version.length === 0 ? "latest" : version }; + } +} diff --git a/src/installer.ts b/src/installer.ts index de9fa94..79654f8 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -7,6 +7,7 @@ import { BundledAssetsMissingError } from "./bundled-assets-missing-error"; import { CopyModeUnsupportedError } from "./copy-mode-unsupported-error"; import { NpmCache } from "./npm-cache"; import { PluginConfigEditor } from "./plugin-config"; +import type { ManifestState } from "./status-reporter"; export type Scope = "local" | "global"; export type InstallMode = "none" | "copy" | "plugin"; @@ -54,13 +55,6 @@ export interface UninstallOutcome { configWarning: string | null; } -export interface StatusOutcome { - scope: Scope; - mode: InstallMode; - version: string | null; - configPath: string | null; -} - const PACKAGE_NAME = "opencode-architect"; const BUNDLED_ASSET_DIRS = ["agents", "references", "templates"]; const PLUGIN_ENTRY = "opencode-architect@latest"; @@ -244,17 +238,13 @@ export class Installer { return removed; } - public async status(scope: Scope, projectDir: string): Promise { + public async manifestAt(scope: Scope, projectDir: string, packageName: string = PACKAGE_NAME): Promise { const base = this.scopeBase(scope, projectDir); - const { manifest } = await this.readManifestRecord(base); - if (manifest !== null) { - return { scope, mode: manifest.mode, version: manifest.version, configPath: manifest.configPath }; - } - const registrationPath = await this.editor.findRegistration(PACKAGE_NAME, { scope, projectDir }); - if (registrationPath !== null) { - return { scope, mode: "plugin", version: null, configPath: registrationPath }; + for (const candidate of this.manifestCandidatesFor(base, packageName)) { + const manifest = await this.readManifest(candidate); + if (manifest !== null) return { mode: manifest.mode, version: manifest.version }; } - return { scope, mode: "none", version: null, configPath: null }; + return null; } public async hasManifestAnywhere(projectDir: string): Promise { @@ -300,6 +290,10 @@ export class Installer { return [path.join(base, MANIFEST_NAME), path.join(base, LEGACY_MANIFEST_NAME)]; } + private manifestCandidatesFor(base: string, packageName: string): string[] { + return [path.join(base, `${packageName}.manifest.json`), path.join(base, `${packageName}.json`)]; + } + private async readManifestRecord(base: string): Promise<{ manifest: Manifest | null; foundPath: string | null }> { for (const candidate of this.manifestCandidates(base)) { const manifest = await this.readManifest(candidate); diff --git a/src/loaded-version.ts b/src/loaded-version.ts new file mode 100644 index 0000000..2433d83 --- /dev/null +++ b/src/loaded-version.ts @@ -0,0 +1,91 @@ +import { exists, readFile, readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { EntrySpec, type ClassifiedEntry } from "./entry-spec"; +import { NpmCache } from "./npm-cache"; +import { PluginEntryResolver } from "./plugin-entry"; + +export interface ResolvedSource { + version: string | null; + name: string | null; + source: "cache" | "checkout"; + path: string; + modifiedMs: number | null; +} + +export type EntryResolution = + | { status: "resolved"; source: ResolvedSource } + | { status: "missing"; attempted: string } + | { status: "partial"; attempted: string } + | { status: "unhandled" }; + +export class LoadedVersionResolver { + constructor(private readonly cache: NpmCache = new NpmCache()) {} + + public classify(entry: unknown): ClassifiedEntry | null { + return EntrySpec.classify(entry); + } + + public async resolve(entry: unknown, baseDir?: string): Promise { + const classified = this.classify(entry); + if (classified === null) return { status: "unhandled" }; + if (classified.form === "path") return this.resolvePath(classified, baseDir); + return this.resolveNpm(classified); + } + + private async resolvePath(classified: ClassifiedEntry, baseDir: string | undefined): Promise { + const root = await PluginEntryResolver.packageRoot(classified.spec, baseDir); + if (root === null) return { status: "missing", attempted: classified.spec }; + const copy = await this.readCheckout(root); + if (copy === null) return { status: "partial", attempted: root }; + return { status: "resolved", source: copy }; + } + + private async resolveNpm(classified: ClassifiedEntry): Promise { + if (classified.name === null || classified.version === null) return { status: "unhandled" }; + const keyDir = join(this.cache.root(), `${classified.name}@${classified.version}`); + const copy = await this.findCacheCopy(keyDir, classified.name); + if (copy !== null) return { status: "resolved", source: copy }; + return (await exists(keyDir)) ? { status: "partial", attempted: keyDir } : { status: "missing", attempted: keyDir }; + } + + private async findCacheCopy(keyDir: string, name: string): Promise { + const direct = await this.readCacheCopy(keyDir); + if (direct !== null) return direct; + let entries; + try { + entries = await readdir(keyDir, { withFileTypes: true }); + } catch { + return null; + } + let best: ResolvedSource | null = null; + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue; + const copy = await this.readCacheCopy(join(keyDir, entry.name, "node_modules", name)); + if (copy === null) continue; + if (best === null || (copy.modifiedMs ?? 0) > (best.modifiedMs ?? 0)) best = copy; + } + return best; + } + + private async readCacheCopy(dir: string): Promise { + return this.readCopy(dir, "cache"); + } + + private async readCheckout(dir: string): Promise { + return this.readCopy(dir, "checkout"); + } + + private async readCopy(dir: string, source: "cache" | "checkout"): Promise { + const manifestPath = join(dir, "package.json"); + if (!(await exists(manifestPath))) return null; + try { + const parsed = JSON.parse(await readFile(manifestPath, "utf-8")) as { name?: unknown; version?: unknown }; + const version = typeof parsed.version === "string" ? parsed.version : null; + const name = typeof parsed.name === "string" ? parsed.name : null; + const info = await stat(dir); + return { version, name, source, path: dir, modifiedMs: info.mtimeMs }; + } catch { + return null; + } + } +} diff --git a/src/plugin-entry.ts b/src/plugin-entry.ts index ea76ae4..6667fb6 100644 --- a/src/plugin-entry.ts +++ b/src/plugin-entry.ts @@ -25,6 +25,15 @@ export class PluginEntryResolver { return false; } + public static async packageRoot(entry: unknown, baseDir?: string): Promise { + if (typeof entry === "string") return PluginEntryResolver.resolveToPackageRoot(entry, baseDir); + if (PluginEntryResolver.isRecord(entry)) { + const spec = entry.package; + if (typeof spec === "string") return PluginEntryResolver.resolveToPackageRoot(spec, baseDir); + } + return null; + } + private static hasExplicitPathForm(entry: string): boolean { if (entry.startsWith("file://")) return true; if (entry.startsWith(".")) return true; diff --git a/src/registry-version-checker.ts b/src/registry-version-checker.ts new file mode 100644 index 0000000..c7bd345 --- /dev/null +++ b/src/registry-version-checker.ts @@ -0,0 +1,26 @@ +export interface RegistryLookup { + version: string | null; + warning: string | null; +} + +export class RegistryVersionChecker { + constructor(private readonly fetchFn: typeof fetch = fetch) {} + + public async latest(name: string, timeoutMs: number = 3000): Promise { + const url = `https://registry.npmjs.org/${name}/latest`; + try { + const response = await this.fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) { + return { version: null, warning: `npm registry returned ${response.status} for ${name}` }; + } + const parsed = (await response.json()) as { version?: unknown }; + if (typeof parsed.version !== "string") { + return { version: null, warning: `npm registry response for ${name} carried no version` }; + } + return { version: parsed.version, warning: null }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { version: null, warning: `could not query npm registry for ${name}: ${message}` }; + } + } +} diff --git a/src/status-reporter.ts b/src/status-reporter.ts new file mode 100644 index 0000000..e8b1166 --- /dev/null +++ b/src/status-reporter.ts @@ -0,0 +1,289 @@ +import { PACKAGE_NAME } from "./cache-cleaner"; +import path from "node:path"; +import { ConfigEntriesReader, type ConfigEntries } from "./config-entries"; +import { LoadedVersionResolver, type ResolvedSource } from "./loaded-version"; +import { PluginEntryResolver } from "./plugin-entry"; +import { RegistryVersionChecker } from "./registry-version-checker"; +import { EntrySpec } from "./entry-spec"; +import type { InstallMode, Scope } from "./installer"; + +export interface ManifestState { + mode: InstallMode; + version: string; +} + +export interface ManifestLookup { + manifestAt(scope: Scope, projectDir: string, packageName?: string): Promise; +} + +export interface ScopeStatusReport { + scope: Scope; + registered: boolean; + mode: InstallMode; + manifestVersion: string | null; + configPath: string | null; + entryText: string | null; + entryName: string | null; + entryVersion: string | null; + entryForm: "npm" | "path" | null; + resolved: ResolvedSource | null; + publishedVersion: string | null; + warnings: string[]; +} + +export type VersionKind = "cache" | "checkout" | "spec" | "npm" | "manifest" | null; + +export interface EffectiveVersion { + version: string | null; + versionKind: VersionKind; + scope: Scope | null; + latestVersion: string | null; + registeredScopes: Scope[]; +} + +export interface StatusReport { + projectDir: string; + packageName: string; + scopes: ScopeStatusReport[]; + effective: EffectiveVersion; + warnings: string[]; +} + +export interface StatusReportOptions { + scopes: Scope[] | null; + online: boolean; + packageName?: string; +} + +interface MatchedEntry { + configPath: string; + entry: unknown; + display: string; + name: string | null; + version: string | null; + form: "npm" | "path"; +} + +export class StatusReporter { + private readonly entries = new ConfigEntriesReader(); + private readonly resolver = new LoadedVersionResolver(); + + constructor( + private readonly manifests: ManifestLookup, + private readonly fetchFn: typeof fetch = fetch, + ) {} + + public async report(projectDir: string, options: StatusReportOptions): Promise { + const scopes = options.scopes ?? (["local", "global"] as Scope[]); + const packageName = options.packageName ?? PACKAGE_NAME; + let reports: ScopeStatusReport[] = []; + for (const scope of scopes) { + reports.push(await this.resolveScope(scope, projectDir, packageName)); + } + const warnings = this.crossScopeWarnings(reports); + if (options.online) { + const online = await this.onlinePass(reports); + warnings.push(...online.warnings); + reports = online.reports; + } + const effective = this.effectiveVersion(reports); + return { projectDir, packageName, scopes: reports, effective, warnings }; + } + + public static formatAge(modifiedMs: number | null): string | null { + if (modifiedMs === null) return null; + const minutes = Math.floor((Date.now() - modifiedMs) / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m old`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h old`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d old`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo old`; + return `${Math.floor(days / 365)}y old`; + } + + private async resolveScope(scope: Scope, projectDir: string, packageName: string): Promise { + const warnings: string[] = []; + const entries = await this.entries.read(scope, projectDir, false); + for (const candidate of entries) { + if (candidate.parseError !== null) warnings.push(candidate.parseError); + } + const manifestState = await this.manifests.manifestAt(scope, projectDir, packageName); + const matched = await this.findMatchedEntry(entries, packageName); + if (matched === null) { + return { + scope, + registered: false, + mode: manifestState?.mode ?? "none", + manifestVersion: manifestState?.version ?? null, + configPath: null, + entryText: null, + entryName: null, + entryVersion: null, + entryForm: null, + resolved: null, + publishedVersion: null, + warnings, + }; + } + const resolution = await this.resolver.resolve(matched.entry, path.dirname(matched.configPath)); + let resolved: ResolvedSource | null = null; + if (resolution.status === "resolved") { + resolved = resolution.source; + } else if (resolution.status === "missing") { + warnings.push(`${scope}: no cached copy for ${matched.display} (${resolution.attempted})`); + } else if (resolution.status === "partial") { + warnings.push(`${scope}: cached copy for ${matched.display} is incomplete (${resolution.attempted})`); + } + const manifestVersion = manifestState?.version ?? null; + if (manifestVersion !== null && resolved !== null && resolved.version !== null && manifestVersion !== resolved.version) { + warnings.push(`${scope}: manifest records ${manifestVersion} but the resolved copy is ${resolved.version}`); + } + return { + scope, + registered: true, + mode: manifestState?.mode ?? "plugin", + manifestVersion, + configPath: matched.configPath, + entryText: matched.display, + entryName: matched.name, + entryVersion: matched.version, + entryForm: matched.form, + resolved, + publishedVersion: null, + warnings, + }; + } + + private async findMatchedEntry(entries: ConfigEntries[], packageName: string): Promise { + for (const candidate of entries) { + for (const raw of candidate.rawEntries) { + const classified = EntrySpec.classify(raw); + if (classified === null) continue; + if (classified.form === "npm") { + if (classified.name === packageName) { + return { + configPath: candidate.configPath, + entry: raw, + display: classified.display, + name: classified.name, + version: classified.version, + form: "npm", + }; + } + continue; + } + if (await PluginEntryResolver.resolvesToPackage(raw, packageName, path.dirname(candidate.configPath))) { + return { + configPath: candidate.configPath, + entry: raw, + display: classified.display, + name: null, + version: null, + form: "path", + }; + } + } + } + return null; + } + + private crossScopeWarnings(reports: ScopeStatusReport[]): string[] { + const registered = reports.filter((report) => report.registered); + if (registered.length > 1) { + const paths = registered.map((report) => report.configPath).join(" and "); + return [`registered in both scopes (double-load risk): ${paths}`]; + } + return []; + } + + private effectiveVersion(reports: ScopeStatusReport[]): EffectiveVersion { + const registeredScopes = reports.filter((report) => report.registered).map((report) => report.scope); + for (const report of reports) { + if (!report.registered) continue; + const fallback = this.specFallback(report); + if (report.resolved !== null) { + return { + version: report.resolved.version, + versionKind: report.resolved.source, + scope: report.scope, + latestVersion: report.publishedVersion, + registeredScopes, + }; + } + if (fallback !== null) { + return { + version: fallback.version, + versionKind: fallback.kind, + scope: report.scope, + latestVersion: report.publishedVersion, + registeredScopes, + }; + } + return { + version: report.manifestVersion, + versionKind: report.manifestVersion !== null ? "manifest" : null, + scope: report.scope, + latestVersion: report.publishedVersion, + registeredScopes, + }; + } + return { version: null, versionKind: null, scope: null, latestVersion: null, registeredScopes }; + } + + private specFallback(report: ScopeStatusReport): { version: string; kind: "spec" | "npm" } | null { + if (report.entryVersion === null || report.entryVersion === "latest") { + if (report.entryVersion === "latest" && report.publishedVersion !== null) { + return { version: report.publishedVersion, kind: "npm" }; + } + return null; + } + if (isExactSemver(report.entryVersion)) return { version: report.entryVersion, kind: "spec" }; + return null; + } + + private async onlinePass(reports: ScopeStatusReport[]): Promise<{ warnings: string[]; reports: ScopeStatusReport[] }> { + const warnings: string[] = []; + const checker = new RegistryVersionChecker(this.fetchFn); + const latest = new Map(); + const failures = new Map(); + for (const report of reports) { + const name = this.lookupName(report); + if (name === null || latest.has(name)) continue; + const lookup = await checker.latest(name); + if (lookup.warning !== null) { + failures.set(name, lookup.warning); + latest.set(name, null); + } else { + latest.set(name, lookup.version); + } + } + const updated: ScopeStatusReport[] = []; + for (const report of reports) { + const name = this.lookupName(report); + const published = name !== null ? latest.get(name) ?? null : null; + if (name !== null) { + const failure = failures.get(name); + if (failure !== undefined) { + warnings.push(`${report.scope}: ${failure}`); + } else if (published !== null && report.resolved?.version != null && published !== report.resolved.version) { + warnings.push(`${report.scope}: resolves ${report.resolved.version} but npm publishes ${published} (stale)`); + } + } + updated.push({ ...report, publishedVersion: published }); + } + return { warnings, reports: updated }; + } + + private lookupName(report: ScopeStatusReport): string | null { + if (!report.registered) return null; + if (report.entryName !== null) return report.entryName; + return report.resolved?.name ?? null; + } +} + +function isExactSemver(version: string): boolean { + return /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(version); +} diff --git a/templates/cli.template.txt b/templates/cli.template.txt index 855c319..567a41e 100644 --- a/templates/cli.template.txt +++ b/templates/cli.template.txt @@ -9,12 +9,15 @@ The module exports `runCli(argv)` and runs standalone under its shebang via the `import.meta.main` guard — both `bunx ` (through the package.json `bin` entry) and the `index.ts` dispatch shim resolve to the same `runCli`. -**Load-bearing — do not simplify:** the deployment plan is content-based, so `install` resolves the mode through `resolveMode` and never forces one: assets-only packages default to copy and accept `--mode plugin`; code-backed packages always register and `--mode copy` surfaces the `CopyModeUnsupportedError` as a hard, explanatory exit-1 error; `install` forwards `force` so consumer-modified files are skipped without it; `status` reports mode, version, entry, entry config file, and registration presence per scope; `migrate` refuses to run without `--force` (root-config deletion is consent-gated and CLI-only); `clear-cache` is self-only — it removes this package's own cache copies (`` and every `@*`) from OpenCode's package cache, is idempotent (nothing cached is a success), warns and continues on per-target removal failures, and must never grow `--package` or `--all` modes (other packages' cache and the shared OpenCode cache dir are out of scope for a generated package); the module lives at `src/cli.ts` and imports `./installer.ts`. +**Load-bearing — do not simplify:** the deployment plan is content-based, so `install` resolves the mode through `resolveMode` and never forces one: assets-only packages default to copy and accept `--mode plugin`; code-backed packages always register and `--mode copy` surfaces the `CopyModeUnsupportedError` as a hard, explanatory exit-1 error; `install` forwards `force` so consumer-modified files are skipped without it; `status` resolves the effective load per scope — registration, target config file, raw entry as written, resolved source on disk with a relative-age staleness hint, the published npm latest per scope, and an effective verdict that annotates a double-scope registration — and the status-only `--online` (registry staleness, warn-and-continue, injectable fetch) and `--path` flags are rejected with an explanatory exit-1 on every other command; `migrate` refuses to run without `--force` (root-config deletion is consent-gated and CLI-only); `clear-cache` is self-only — it removes this package's own cache copies (`` and every `@*`) from OpenCode's package cache, is idempotent (nothing cached is a success), warns and continues on per-target removal failures, and must never grow `--package` or `--all` modes (other packages' cache and the shared OpenCode cache dir are out of scope for a generated package); the module lives at `src/cli.ts` and imports `./installer.ts`. --- #!/usr/bin/env bun +import type { Stats } from "node:fs"; +import { stat } from "node:fs/promises"; +import { resolve } from "node:path"; import { parseArgs } from "node:util"; -import { install, uninstall, status, migrateRootConfig, clearPackageCache, CopyModeUnsupportedError, type Scope, type InstallMode } from "./installer.ts"; +import { install, uninstall, status, migrateRootConfig, clearPackageCache, CopyModeUnsupportedError, type Scope, type InstallMode, type EffectiveStatus, type ScopeStatus } from "./installer.ts"; const VERSION = JSON.parse( await Bun.file(`${import.meta.dirname}/../package.json`).text() @@ -27,7 +30,8 @@ opencode-myextension v${VERSION} Commands: install Install myextension (copy by default; plugin registration with --mode plugin) uninstall Remove myextension (payload files and/or plugin registration, per manifest) - status Check installation status per scope + status Resolve what a session would load: registration, config file, raw entry, + the resolved source on disk, and an effective verdict per scope migrate Migrate root opencode.json into .opencode/ (asks for --force consent) clear-cache Remove cached copies of this package from OpenCode's package cache @@ -35,6 +39,10 @@ Options: -s, --scope Installation scope: "local" or "global" (default: local) -m, --mode Install mode: "copy" (default for assets-only) or "plugin" (mandatory for code-backed) -f, --force Overwrite consumer-modified files / consent to migration + --online status: also query the npm registry for the latest published + version and flag staleness; network failures warn and continue + --path status: resolve against another project directory instead of + the current working directory; the directory must exist -h, --help Show this help message -v, --version Show version @@ -44,6 +52,8 @@ Examples: opencode-myextension install --mode plugin opencode-myextension uninstall --scope local opencode-myextension status + opencode-myextension status --online + opencode-myextension status --path ../other-project opencode-myextension migrate --force opencode-myextension clear-cache `); @@ -60,6 +70,8 @@ export async function runCli(argv: string[]): Promise { scope: { type: "string", short: "s" }, mode: { type: "string", short: "m" }, force: { type: "boolean", short: "f", default: false }, + online: { type: "boolean", default: false }, + path: { type: "string" }, help: { type: "boolean", short: "h", default: false }, version: { type: "boolean", short: "v", default: false }, }, @@ -71,10 +83,15 @@ export async function runCli(argv: string[]): Promise { if (values.help || positionals.length === 0) { printHelp(); return 0; } const command = positionals[0]; + if (command !== "status" && (values.path !== undefined || values.online)) { + console.error("--path and --online only apply to the status command."); + return 1; + } const scope: Scope | undefined = values.scope as Scope | undefined; const mode: InstallMode | undefined = values.mode === undefined ? undefined : isInstallMode(values.mode) ? values.mode : ("invalid" as InstallMode); const force: boolean = values.force; + const online: boolean = values.online; if (scope && scope !== "local" && scope !== "global") { console.error(`Invalid scope: ${scope}. Must be "local" or "global".`); @@ -114,17 +131,26 @@ export async function runCli(argv: string[]): Promise { break; } case "status": { - const result = await status(process.cwd()); + const projectDir = await resolveProjectDir(values.path); + const result = await status(projectDir, online); console.log("Status:"); for (const [label, scopeStatus] of [["Local", result.local], ["Global", result.global]] as const) { if (!scopeStatus) continue; + const npm = scopeStatus.publishedVersion !== null ? ` npm-latest=${scopeStatus.publishedVersion}` : ""; console.log( - ` ${label}: installed=${scopeStatus.installed}, version=${scopeStatus.version ?? "unknown"}, ` + - `mode=${scopeStatus.mode ?? "unknown"}, ` + - `entry=${scopeStatus.entry ?? "none"} (${scopeStatus.entryConfigPath ?? "no config file"}), ` + - `pluginInConfig=${scopeStatus.pluginInConfig}` + ` ${label}: mode=${scopeStatus.mode ?? "unknown"}, ` + + `config=${scopeStatus.entryConfigPath ?? "-"}, ` + + `entry=${scopeStatus.entry ?? "-"}, ` + + `resolved=${resolvedLabel(scopeStatus)}${npm}` ); + for (const warning of scopeStatus.warnings) console.warn(`Warning: ${warning}`); } + if (result.effective.scope !== null) { + console.log(` should load: ${verdictLabel(result.effective)}`); + } else { + console.log(" should load: nothing (not registered in any scope)"); + } + for (const warning of result.warnings) console.warn(`Warning: ${warning}`); break; } case "migrate": { @@ -167,6 +193,66 @@ export async function runCli(argv: string[]): Promise { } } +async function resolveProjectDir(input: string | undefined): Promise { + if (input === undefined) return process.cwd(); + const cleaned = stripQuotes(input.trim()); + const resolved = resolve(cleaned); + let info: Stats | null = null; + try { + info = await stat(resolved); + } catch { + throw new Error(`--path does not exist: ${input}`); + } + if (!info.isDirectory()) throw new Error(`--path is not a directory: ${input}`); + return resolved; +} + +function stripQuotes(value: string): string { + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) return value.slice(1, -1); + if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1); + return value; +} + +function formatAge(modifiedMs: number | null): string | null { + if (modifiedMs === null) return null; + const minutes = Math.floor((Date.now() - modifiedMs) / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m old`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h old`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d old`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo old`; + return `${Math.floor(days / 365)}y old`; +} + +function resolvedLabel(scopeStatus: ScopeStatus): string { + if (scopeStatus.resolvedVersion === null) return "-"; + const source = scopeStatus.resolvedSource === "cache" ? "cache copy" : "checkout copy"; + const age = formatAge(scopeStatus.resolvedModifiedMs); + return `${scopeStatus.resolvedVersion} (${age === null ? source : `${source}, ${age}`})`; +} + +function verdictLabel(effective: EffectiveStatus): string { + const latest = + effective.latestVersion !== null && effective.versionKind !== "npm" ? `, latest ${effective.latestVersion}` : ""; + const scopeLabel = + effective.registeredScopes.length > 1 + ? `${effective.registeredScopes.join(" + ")}; both scopes register — double-load` + : effective.scope; + return `${effective.version ?? "unknown"} (${kindLabel(effective.versionKind)}, ${scopeLabel}${latest})`; +} + +function kindLabel(kind: EffectiveStatus["versionKind"]): string { + if (kind === "cache") return "cache copy"; + if (kind === "checkout") return "checkout copy"; + if (kind === "spec") return "pinned spec"; + if (kind === "npm") return "npm latest"; + if (kind === "manifest") return "manifest"; + return "unresolved"; +} + if (import.meta.main) { process.exitCode = await runCli(process.argv.slice(2)); } diff --git a/templates/installer.template.txt b/templates/installer.template.txt index db3ba95..7dd3e7f 100644 --- a/templates/installer.template.txt +++ b/templates/installer.template.txt @@ -7,15 +7,16 @@ | `COMMAND NAME` → "my-command.md" | Yes | Command file name in commands/ at the package root | — | | `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; also the manifest file base | — | -**Load-bearing — do not simplify:** the deployment plan is content-based (ADR-0008): the content declaration (`"content"` in package.json, `"assets"` or `"code"`) decides the install modes — assets-only packages copy-install by default with `--mode plugin` as the opt-in, code-backed packages always plugin-register and `--mode copy` throws `CopyModeUnsupportedError`; copy install touches no config file at all — no permission blocks, no MCP entries, no `plugins` array edits; the `plugins` array is edited only by the surgical `PluginConfigEditor` (text splice, every other byte untouched, never a parse-then-reserialize, never rewrite config from `{}`, existing entry is a zero-write no-op); manifest-gated idempotency (never `.version` markers), the plugin-mode zero-write no-op includes a present entry (version match alone is not enough), and payload idempotency requires a recorded, hash-matching payload — a plugin-mode manifest with an empty file map means the load-time hook has not ensured assets yet, so the ensure path runs and records the payload hashes while preserving the recorded mode, entry, and target config file; config reading consults both `opencode.json` and `opencode.jsonc` per base, with `.jsonc` parsed leniently and `.json` strict, and every unparseable candidate is warned about; registration detection through the surgical `PluginConfigEditor` (which delegates to `PluginEntryResolver` from `src/plugin-entry.ts`) counts a path-form entry — a `file://` URL or filesystem path, string or `{ package }` object form — as registered when it resolves to this package, so `install` never adds a duplicate `name@latest` entry beside a live path entry; bundled content directories sit at the package root (`skills/`, `commands/`, `agents/` — no `assets/` intermediary) and resolve through `ASSET_LAYOUT_DIR`, which is `"."` by default; only when adapting a legacy package that wraps them in `assets/` is the constant overridden to `"assets"`, and a missing or empty asset source is a hard error naming the path, package name+version, cache dir, and install command — never a silent skip, and a manifest is never written when zero files were written; the manifest hashes the whole payload relative to the config base (skills and commands), and `migrateRootConfig` deletes nothing without a consent callback and is never called from `install()`. Cache hygiene is self-scoped (ADR-0007): every install — including a no-op — prunes this package's own cache copies (``, `@latest`, `@`) from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/npm`, falling back to `~/.cache/opencode/npm`), best-effort warn-and-continue, never touching other packages' cache dirs or pinned versions, and the load-time hook never prunes — only the CLI's install and `clear-cache` paths do. Every function here is scope-parameterized: the exact same logic serves the local scope base (`/.opencode/`) and the global scope base (`~/.config/opencode/` or `$XDG_CONFIG_HOME/opencode/`), so a global install and a project install differ only in the base path — never in behavior. +**Load-bearing — do not simplify:** the deployment plan is content-based (ADR-0008): the content declaration (`"content"` in package.json, `"assets"` or `"code"`) decides the install modes — assets-only packages copy-install by default with `--mode plugin` as the opt-in, code-backed packages always plugin-register and `--mode copy` throws `CopyModeUnsupportedError`; copy install touches no config file at all — no permission blocks, no MCP entries, no `plugins` array edits; the `plugins` array is edited only by the surgical `PluginConfigEditor` (text splice, every other byte untouched, never a parse-then-reserialize, never rewrite config from `{}`, existing entry is a zero-write no-op); manifest-gated idempotency (never `.version` markers), the plugin-mode zero-write no-op includes a present entry (version match alone is not enough), and payload idempotency requires a recorded, hash-matching payload — a plugin-mode manifest with an empty file map means the load-time hook has not ensured assets yet, so the ensure path runs and records the payload hashes while preserving the recorded mode, entry, and target config file; config reading consults both `opencode.json` and `opencode.jsonc` per base, with `.jsonc` parsed leniently and `.json` strict, and every unparseable candidate is warned about; registration detection through the surgical `PluginConfigEditor` (which delegates to `PluginEntryResolver` from `src/plugin-entry.ts`) counts a path-form entry — a `file://` URL or filesystem path, string or `{ package }` object form — as registered when it resolves to this package, so `install` never adds a duplicate `name@latest` entry beside a live path entry; bundled content directories sit at the package root (`skills/`, `commands/`, `agents/` — no `assets/` intermediary) and resolve through `ASSET_LAYOUT_DIR`, which is `"."` by default; only when adapting a legacy package that wraps them in `assets/` is the constant overridden to `"assets"`, and a missing or empty asset source is a hard error naming the path, package name+version, cache dir, and install command — never a silent skip, and a manifest is never written when zero files were written; the manifest hashes the whole payload relative to the config base (skills and commands), and `migrateRootConfig` deletes nothing without a consent callback and is never called from `install()`. Cache hygiene is self-scoped (ADR-0007): every install — including a no-op — prunes this package's own cache copies (``, `@latest`, `@`) from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/npm`, falling back to `~/.cache/opencode/npm`), best-effort warn-and-continue, never touching other packages' cache dirs or pinned versions, and the load-time hook never prunes — only the CLI's install and `clear-cache` paths do. Every function here is scope-parameterized: the exact same logic serves the local scope base (`/.opencode/`) and the global scope base (`~/.config/opencode/` or `$XDG_CONFIG_HOME/opencode/`), so a global install and a project install differ only in the base path — never in behavior. `status()` resolves the effective load per scope — the registration's config file, the raw entry as written, and the resolved source on disk (an npm spec against the package-cache key `@` with the version read from the cached copy's `package.json`, newest generation first and mtime carried as a staleness hint; a path entry through `PluginEntryResolver.packageRoot`) — closing with an effective verdict plus warnings for a double-scope registration, a manifest/resolved version mismatch, a missing or incomplete cached copy, and `--online` registry staleness with warn-and-continue on an injectable fetch; status is read-only. --- -import { exists, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { exists, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { join, relative } from "node:path"; +import { dirname, isAbsolute, join, relative } from "node:path"; import { PluginNameNormalizer } from "./plugin-name.ts"; -import { parseJsonc } from "./registration.ts"; +import { RegistrationDetector, parseJsonc, type RawConfigEntries } from "./registration.ts"; import { PluginConfigEditor } from "./plugin-config.ts"; +import { PluginEntryResolver } from "./plugin-entry.ts"; import { InstallManifest, listFilesRecursive, sha256, type InstallMode, type ManifestData } from "./manifest.ts"; export type { InstallMode } from "./manifest.ts"; @@ -47,17 +48,52 @@ export interface UninstallResult { } export interface ScopeStatus { + scope: Scope; installed: boolean; version: string | null; mode: InstallMode | null; entry: string | null; + entryName: string | null; + entryVersion: string | null; entryConfigPath: string | null; pluginInConfig: boolean; + resolvedVersion: string | null; + resolvedSource: "cache" | "checkout" | null; + resolvedModifiedMs: number | null; + resolvedName: string | null; + publishedVersion: string | null; + registered: boolean; + warnings: string[]; +} + +export interface EffectiveStatus { + version: string | null; + versionKind: "cache" | "checkout" | "spec" | "npm" | "manifest" | null; + scope: Scope | null; + latestVersion: string | null; + registeredScopes: Scope[]; } export interface StatusResult { local: ScopeStatus | null; global: ScopeStatus | null; + effective: EffectiveStatus; + warnings: string[]; +} + +interface ResolvedEntrySource { + name: string | null; + version: string | null; + source: "cache" | "checkout"; + path: string; + modifiedMs: number | null; +} + +interface MatchedRegistration { + configPath: string; + spec: string; + name: string | null; + form: "npm" | "path"; } const SKILL_NAME = "myextension"; @@ -370,32 +406,272 @@ export async function uninstall(scope: Scope, projectDir: string = process.cwd() return { scope, mode: manifestData?.mode ?? null, removed, pluginRemoved }; } -async function isPluginInConfigScope(scope: Scope, projectDir: string): Promise { - return (await editor.findRegistration(PLUGIN_NAME, { scope, projectDir })) !== null; -} - -export async function status(projectDir: string = process.cwd()): Promise { - const scopes: Array<[Scope, string]> = [ - ["local", getLocalConfigPath(projectDir)], - ["global", getGlobalConfigPath()], - ]; - const result: StatusResult = { local: null, global: null }; - for (const [scope, configBase] of scopes) { - const { skillPath } = await payloadPaths(configBase); - const manifest = new InstallManifest(configBase, PLUGIN_NAME); - const manifestData: ManifestData | null = await manifest.read(); - const pluginInConfig = await isPluginInConfigScope(scope, projectDir); - if (manifestData === null && !pluginInConfig) continue; - result[scope] = { - installed: true, - version: manifestData?.version ?? null, - mode: manifestData?.mode ?? null, - entry: manifestData?.entry ?? null, - entryConfigPath: manifestData?.entryConfigPath ?? null, - pluginInConfig, +export async function status(projectDir: string = process.cwd(), online: boolean = false, fetchFn: typeof fetch = fetch): Promise { + const detector = new RegistrationDetector(PLUGIN_NAME); + const inventory = await detector.inventory(projectDir); + let local = await scopeStatus("local", projectDir, inventory.local); + let global = await scopeStatus("global", projectDir, inventory.global); + const warnings: string[] = []; + if (local !== null && global !== null && local.registered && global.registered) { + warnings.push(`registered in both scopes (double-load risk): ${local.entryConfigPath} and ${global.entryConfigPath}`); + } + let effective: EffectiveStatus; + if (online) { + const outcome = await applyOnline([local, global], fetchFn); + warnings.push(...outcome.warnings); + local = outcome.scopes[0] ?? null; + global = outcome.scopes[1] ?? null; + } + effective = effectiveStatus(local, global); + return { local, global, effective, warnings }; +} + +async function scopeStatus(scope: Scope, projectDir: string, entries: RawConfigEntries[]): Promise { + const configBase = scopeBase(scope, projectDir); + const manifest = new InstallManifest(configBase, PLUGIN_NAME); + const manifestData: ManifestData | null = await manifest.read(); + const matched = await findMatchedRegistration(entries); + if (manifestData === null && matched === null) return null; + const parsedEntry = matched !== null && !isPathSpec(matched.spec) ? parseNpmSpec(matched.spec) : null; + const warnings: string[] = []; + let resolvedVersion: string | null = null; + let resolvedSource: "cache" | "checkout" | null = null; + let resolvedModifiedMs: number | null = null; + let resolvedName: string | null = null; + if (matched !== null) { + const outcome = await resolveEntrySource(matched.spec, dirname(matched.configPath)); + if (outcome.resolved !== null) { + resolvedVersion = outcome.resolved.version; + resolvedSource = outcome.resolved.source; + resolvedModifiedMs = outcome.resolved.modifiedMs; + resolvedName = outcome.resolved.name; + } else if (outcome.status === "missing") { + warnings.push(`${scope}: no cached copy for ${matched.spec}`); + } else { + warnings.push(`${scope}: cached copy for ${matched.spec} is incomplete`); + } + if (manifestData !== null && manifestData.version !== null && resolvedVersion !== null && manifestData.version !== resolvedVersion) { + warnings.push(`${scope}: manifest records ${manifestData.version} but the resolved copy is ${resolvedVersion}`); + } + } + return { + scope, + installed: true, + version: manifestData?.version ?? null, + mode: manifestData?.mode ?? (matched !== null ? "plugin" : null), + entry: matched !== null ? matched.spec : manifestData?.entry ?? null, + entryName: matched?.name ?? null, + entryVersion: parsedEntry?.version ?? null, + entryConfigPath: matched !== null ? matched.configPath : manifestData?.entryConfigPath ?? null, + pluginInConfig: matched !== null, + resolvedVersion, + resolvedSource, + resolvedModifiedMs: resolvedModifiedMs, + resolvedName, + publishedVersion: null, + registered: matched !== null, + warnings, + }; +} + +async function findMatchedRegistration(entries: RawConfigEntries[]): Promise { + for (const candidate of entries) { + for (const raw of candidate.rawEntries) { + const spec = entrySpec(raw); + if (spec === null) continue; + if (isPathSpec(spec)) { + if (await PluginEntryResolver.resolvesToPackage(raw, PLUGIN_NAME, dirname(candidate.configPath))) { + return { configPath: candidate.configPath, spec, name: null, form: "path" }; + } + continue; + } + const parsed = parseNpmSpec(spec); + if (parsed === null) continue; + if (parsed.name === PLUGIN_NAME) { + return { configPath: candidate.configPath, spec, name: parsed.name, form: "npm" }; + } + } + } + return null; +} + +async function resolveEntrySource(spec: string, baseDir: string): Promise<{ resolved: ResolvedEntrySource | null; status: "resolved" | "missing" | "partial" }> { + if (isPathSpec(spec)) { + const root = await PluginEntryResolver.packageRoot(spec, baseDir); + if (root === null) return { resolved: null, status: "missing" }; + const copy = await readCopyManifest(root, "checkout"); + if (copy === null) return { resolved: null, status: "partial" }; + return { resolved: copy, status: "resolved" }; + } + const parsed = parseNpmSpec(spec); + if (parsed === null) return { resolved: null, status: "missing" }; + const keyDir = join(packageCacheRoot(), `${parsed.name}@${parsed.version}`); + const copy = await findCacheCopy(keyDir, parsed.name); + if (copy !== null) return { resolved: copy, status: "resolved" }; + return { resolved: null, status: (await exists(keyDir)) ? "partial" : "missing" }; +} + +async function findCacheCopy(keyDir: string, name: string): Promise { + const direct = await readCopyManifest(keyDir, "cache"); + if (direct !== null) return direct; + let entries; + try { + entries = await readdir(keyDir, { withFileTypes: true }); + } catch { + return null; + } + let best: ResolvedEntrySource | null = null; + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue; + const copy = await readCopyManifest(join(keyDir, entry.name, "node_modules", name), "cache"); + if (copy === null) continue; + if (best === null || (copy.modifiedMs ?? 0) > (best.modifiedMs ?? 0)) best = copy; + } + return best; +} + +async function readCopyManifest(dir: string, source: "cache" | "checkout"): Promise { + const manifestPath = join(dir, "package.json"); + if (!(await exists(manifestPath))) return null; + try { + const parsed = JSON.parse(await readFile(manifestPath, "utf-8")) as { name?: unknown; version?: unknown }; + const version = typeof parsed.version === "string" ? parsed.version : null; + const name = typeof parsed.name === "string" ? parsed.name : null; + const info = await stat(dir); + return { version, name, source, path: dir, modifiedMs: info.mtimeMs }; + } catch { + return null; + } +} + +function entrySpec(entry: unknown): string | null { + if (typeof entry === "string") return entry.trim(); + if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) { + const pkg = (entry as { package?: unknown }).package; + if (typeof pkg === "string") return pkg.trim(); + } + return null; +} + +function isPathSpec(spec: string): boolean { + if (spec.startsWith("file://")) return true; + if (spec.startsWith(".")) return true; + if (isAbsolute(spec)) return true; + if (spec.includes("@")) return false; + if (spec.includes("/") || spec.includes("\\")) return true; + return false; +} + +function parseNpmSpec(spec: string): { name: string; version: string } | null { + const at = spec.lastIndexOf("@"); + if (at > 0) { + const version = spec.slice(at + 1); + if (version.includes("/") || version.includes("\\")) return null; + return { name: spec.slice(0, at), version: version.length === 0 ? "latest" : version }; + } + return { name: spec, version: "latest" }; +} + +function effectiveStatus(local: ScopeStatus | null, global: ScopeStatus | null): EffectiveStatus { + const registeredScopes: Scope[] = []; + if (local?.registered) registeredScopes.push("local"); + if (global?.registered) registeredScopes.push("global"); + for (const scopeStatusValue of [local, global]) { + if (scopeStatusValue === null || !scopeStatusValue.registered) continue; + if (scopeStatusValue.resolvedVersion !== null) { + return { + version: scopeStatusValue.resolvedVersion, + versionKind: scopeStatusValue.resolvedSource, + scope: scopeStatusValue.scope, + latestVersion: scopeStatusValue.publishedVersion, + registeredScopes, + }; + } + const fallback = specFallback(scopeStatusValue); + if (fallback !== null) { + return { + version: fallback.version, + versionKind: fallback.kind, + scope: scopeStatusValue.scope, + latestVersion: scopeStatusValue.publishedVersion, + registeredScopes, + }; + } + return { + version: scopeStatusValue.version, + versionKind: scopeStatusValue.version !== null ? "manifest" : null, + scope: scopeStatusValue.scope, + latestVersion: scopeStatusValue.publishedVersion, + registeredScopes, }; } - return result; + return { version: null, versionKind: null, scope: null, latestVersion: null, registeredScopes }; +} + +function specFallback(scopeStatusValue: ScopeStatus): { version: string; kind: "spec" | "npm" } | null { + if (scopeStatusValue.entryVersion === null || scopeStatusValue.entryVersion === "latest") { + if (scopeStatusValue.entryVersion === "latest" && scopeStatusValue.publishedVersion !== null) { + return { version: scopeStatusValue.publishedVersion, kind: "npm" }; + } + return null; + } + return isExactSemver(scopeStatusValue.entryVersion) ? { version: scopeStatusValue.entryVersion, kind: "spec" } : null; +} + +function isExactSemver(version: string): boolean { + return /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(version); +} + +async function applyOnline( + scopes: Array, + fetchFn: typeof fetch, +): Promise<{ warnings: string[]; scopes: Array }> { + const warnings: string[] = []; + const lookups = new Map(); + const lookupName = (scopeStatusValue: ScopeStatus): string | null => + scopeStatusValue.entryName ?? scopeStatusValue.resolvedName; + for (const scopeStatusValue of scopes) { + if (scopeStatusValue === null || !scopeStatusValue.registered) continue; + const name = lookupName(scopeStatusValue); + if (name === null || lookups.has(name)) continue; + lookups.set(name, await fetchLatestVersion(name, fetchFn)); + } + const updated: Array = []; + for (const scopeStatusValue of scopes) { + if (scopeStatusValue === null || !scopeStatusValue.registered) { + updated.push(scopeStatusValue); + continue; + } + const name = lookupName(scopeStatusValue); + const lookup = name !== null ? lookups.get(name) : undefined; + if (lookup === undefined) { + updated.push(scopeStatusValue); + continue; + } + if (lookup.warning !== null) { + warnings.push(lookup.warning); + updated.push({ ...scopeStatusValue, publishedVersion: null }); + continue; + } + if (lookup.version !== null && scopeStatusValue.resolvedVersion !== null && lookup.version !== scopeStatusValue.resolvedVersion) { + warnings.push(`${lookup.version} is the latest published version but ${scopeStatusValue.resolvedVersion} resolves (stale)`); + } + updated.push({ ...scopeStatusValue, publishedVersion: lookup.version }); + } + return { warnings, scopes: updated }; +} + +async function fetchLatestVersion(name: string, fetchFn: typeof fetch): Promise<{ version: string | null; warning: string | null }> { + try { + const response = await fetchFn(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(3000) }); + if (!response.ok) return { version: null, warning: `npm registry returned ${response.status} for ${name}` }; + const parsed = (await response.json()) as { version?: unknown }; + if (typeof parsed.version !== "string") return { version: null, warning: `npm registry response for ${name} carried no version` }; + return { version: parsed.version, warning: null }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { version: null, warning: `could not query npm registry for ${name}: ${message}` }; + } } export async function isInstalledAnywhere(projectDir: string = process.cwd()): Promise { diff --git a/templates/plugin-entry.template.txt b/templates/plugin-entry.template.txt index dcf47ca..46226bb 100644 --- a/templates/plugin-entry.template.txt +++ b/templates/plugin-entry.template.txt @@ -2,7 +2,7 @@ None — emit as-is as src/plugin-entry.ts. -**Load-bearing — do not simplify:** `PluginEntryResolver.resolvesToPackage(entry, packageName, baseDir?)` answers whether a config entry names this package even when written as a path. An entry counts when its path form (`file://` URL, absolute, relative, or a symlink) resolves to a package root, entrypoint file, or directory whose nearest `package.json` declares a `name` that normalized-matches the package. Relative paths resolve against `baseDir` — the config file's directory — and fall back to the process working directory when no base is passed. Resolution is best-effort: an unroutable or missing path, or a `package.json` with no string `name`, never matches and never throws. Resolution memoizes per resolved path in a module-level map; that mutable module state is safe here because npm-loaded packages are not watched directories. Resolution follows symlinks and compares `package.json` names case-insensitively on Windows. A bare npm spec (`name`, `name@latest`, `@scope/name`) never counts as a path. Both the string form and the `{ "package": "..." }` object form count. +**Load-bearing — do not simplify:** `PluginEntryResolver.resolvesToPackage(entry, packageName, baseDir?)` answers whether a config entry names this package even when written as a path. An entry counts when its path form (`file://` URL, absolute, relative, or a symlink) resolves to a package root, entrypoint file, or directory whose nearest `package.json` declares a `name` that normalized-matches the package. Relative paths resolve against `baseDir` — the config file's directory — and fall back to the process working directory when no base is passed. `PluginEntryResolver.packageRoot(entry, baseDir?)` exposes the same resolution as a path lookup, so a caller can read the resolved checkout's `package.json` (name, version). Resolution is best-effort: an unroutable or missing path, or a `package.json` with no string `name`, never matches and never throws. Resolution memoizes per resolved path in a module-level map; that mutable module state is safe here because npm-loaded packages are not watched directories. Resolution follows symlinks and compares `package.json` names case-insensitively on Windows. A bare npm spec (`name`, `name@latest`, `@scope/name`) never counts as a path. Both the string form and the `{ "package": "..." }` object form count. --- @@ -33,6 +33,15 @@ export class PluginEntryResolver { return false; } + public static async packageRoot(entry: unknown, baseDir?: string): Promise { + if (typeof entry === "string") return PluginEntryResolver.resolveToPackageRoot(entry, baseDir); + if (PluginEntryResolver.isRecord(entry)) { + const spec = entry.package; + if (typeof spec === "string") return PluginEntryResolver.resolveToPackageRoot(spec, baseDir); + } + return null; + } + private static hasExplicitPathForm(entry: string): boolean { if (entry.startsWith("file://")) return true; if (entry.startsWith(".")) return true; diff --git a/templates/registration.template.txt b/templates/registration.template.txt index 13e44ea..fe67fcd 100644 --- a/templates/registration.template.txt +++ b/templates/registration.template.txt @@ -2,7 +2,7 @@ None — emit as-is as src/registration.ts. -**Load-bearing — do not simplify:** detection is read-only (no writes, ever); it checks the global config base (`opencode.json`, `opencode.jsonc`, and the legacy `config.json`), the repo's `.opencode/opencode.json(c)`, and a repo-root `opencode.json(c)` — both extensions at every base plus global `config.json` — with `@latest`-aware name matching, never the launch directory, which opencode always sets to the consumer repo, and never any directory-identity comparison (plugin dir vs `directory`, `import.meta.dirname`, `process.cwd()`, realpath); a maintainer's own checkout is served by that repo's own config registration, not by a directory check. The v2 `plugins` key is authoritative and the legacy v1 `plugin` key is tolerated read-only — an entry under either key counts as registered, and entries match by package name whether written as strings or `{ "package": ... }` objects. A path-form entry (`file://` URL or filesystem path, string or `{ package }` object form) counts as registered too when it resolves to this package — `PluginEntryResolver.resolvesToPackage` (src/plugin-entry.ts) supplies that best-effort, memoized resolution, resolving a relative entry against the directory of the config file that declares it (never the launch directory, which is not a resolution base). `.jsonc` parses leniently (string-aware `//` and `/* */` comment stripping plus trailing-comma removal, so a `$schema` URL's `//` and escaped quotes survive); `.json` stays strict. Every unparseable candidate is warned about before any short-circuit. Nested and root configs combine with OR — a present-but-unregistered nested file never masks a repo-root registration. +**Load-bearing — do not simplify:** detection is read-only (no writes, ever); it checks the global config base (`opencode.json`, `opencode.jsonc`, and the legacy `config.json`), the repo's `.opencode/opencode.json(c)`, and a repo-root `opencode.json(c)` — both extensions at every base plus global `config.json` — with `@latest`-aware name matching, never the launch directory, which opencode always sets to the consumer repo, and never any directory-identity comparison (plugin dir vs `directory`, `import.meta.dirname`, `process.cwd()`, realpath); a maintainer's own checkout is served by that repo's own config registration, not by a directory check. The v2 `plugins` key is authoritative and the legacy v1 `plugin` key is tolerated read-only — an entry under either key counts as registered, and entries match by package name whether written as strings or `{ "package": ... }` objects. A path-form entry (`file://` URL or filesystem path, string or `{ package }` object form) counts as registered too when it resolves to this package — `PluginEntryResolver.resolvesToPackage` (src/plugin-entry.ts) supplies that best-effort, memoized resolution, resolving a relative entry against the directory of the config file that declares it (never the launch directory, which is not a resolution base). `.jsonc` parses leniently (string-aware `//` and `/* */` comment stripping plus trailing-comma removal, so a `$schema` URL's `//` and escaped quotes survive); `.json` stays strict. Every unparseable candidate is warned about before any short-circuit. Nested and root configs combine with OR — a present-but-unregistered nested file never masks a repo-root registration. The detector also exposes `inventory(projectDir)`: one read-only pass returning the raw plugin entries per config file for both bases (global including the legacy `config.json`, local nested plus repo root) for the installer's status resolution; `detect()` behavior is unchanged. --- import { exists, readFile } from "node:fs/promises"; @@ -88,6 +88,16 @@ interface PluginEntries { rawLegacyPlugins: unknown[]; } +export interface RawConfigEntries { + configPath: string; + rawEntries: unknown[]; +} + +export interface RegistrationInventory { + global: RawConfigEntries[]; + local: RawConfigEntries[]; +} + function entryNames(value: unknown): string[] { if (!Array.isArray(value)) return []; return value @@ -133,6 +143,16 @@ async function readGlobalEntries(): Promise { return readPluginEntries(join(getGlobalConfigPath(), LEGACY_READ_ONLY_GLOBAL)); } +async function readAllEntries(paths: string[]): Promise { + const out: RawConfigEntries[] = []; + for (const file of paths) { + const entries = await readPluginEntries(file); + if (entries === null) continue; + out.push({ configPath: file, rawEntries: [...entries.rawPlugins, ...entries.rawLegacyPlugins] }); + } + return out; +} + export class RegistrationDetector { private readonly normalizer = new PluginNameNormalizer(); @@ -150,6 +170,24 @@ export class RegistrationDetector { return "none"; } + async inventory(projectDir: string): Promise { + const globalBase = getGlobalConfigPath(); + const localBase = join(projectDir, ".opencode"); + return { + global: await readAllEntries([ + join(globalBase, "opencode.json"), + join(globalBase, "opencode.jsonc"), + join(globalBase, LEGACY_READ_ONLY_GLOBAL), + ]), + local: await readAllEntries([ + join(localBase, "opencode.json"), + join(localBase, "opencode.jsonc"), + join(projectDir, "opencode.json"), + join(projectDir, "opencode.jsonc"), + ]), + }; + } + private async matchesAny(entries: PluginEntries | null): Promise { if (entries === null) return false; const names = [...entries.plugins, ...entries.legacyPlugins]; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index df6f228..9055fbb 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import path from "node:path"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; import { seedCachedPackage } from "./test-helpers"; const PACKAGE_ROOT = path.resolve(import.meta.dirname, ".."); @@ -145,6 +146,166 @@ describe("cli", () => { }); }); +describe("cli status", () => { + test("defaults to both scopes and emits an effective verdict", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-cli-status-")); + try { + const run = await runCli(["status"], dir); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("local scope:"); + expect(run.stdout).toContain("global scope:"); + expect(run.stdout).toContain("should load:"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("--scope narrows status to one scope", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-cli-status-")); + try { + const run = await runCli(["status", "--scope", "global"], dir); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("global scope:"); + expect(run.stdout).not.toContain("local scope:"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("--path resolves another project directory", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-cli-status-path-")); + try { + await mkdir(path.join(dir, ".opencode"), { recursive: true }); + await writeFile( + path.join(dir, ".opencode", "opencode.jsonc"), + '{\n // other project\n "plugins": ["opencode-architect@latest"],\n}\n', + ); + + const run = await runCli(["status", "--path", dir]); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("local scope:"); + expect(run.stdout).toContain("opencode-architect@latest"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("--path rejects a directory that does not exist", async () => { + const run = await runCli(["status", "--path", path.join(tmpdir(), "oa-cli-status-missing-xyz")]); + + expect(run.exitCode).toBe(1); + expect(run.stderr).toContain("does not exist"); + }); + + test("--path and --online are rejected outside the status command", async () => { + const pathRun = await runCli(["install", "--path", "somewhere"]); + + expect(pathRun.exitCode).toBe(1); + expect(pathRun.stderr).toContain("--path"); + + const onlineRun = await runCli(["clear-cache", "--online"]); + + expect(onlineRun.exitCode).toBe(1); + expect(onlineRun.stderr).toContain("--online"); + }); + + test("--package is rejected outside the status and clear-cache commands", async () => { + const installRun = await runCli(["install", "--package", "some-pkg"]); + + expect(installRun.exitCode).toBe(1); + expect(installRun.stderr).toContain("--package"); + + const uninstallRun = await runCli(["uninstall", "--package", "some-pkg"]); + + expect(uninstallRun.exitCode).toBe(1); + expect(uninstallRun.stderr).toContain("--package"); + + const traversalRun = await runCli(["status", "--package", "../escape"]); + + expect(traversalRun.exitCode).toBe(1); + expect(traversalRun.stderr).toContain("Invalid package name"); + }); + + test("--package resolves another registered package", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-cli-status-pkg-")); + try { + const checkout = path.join(dir, "aurelia-checkout"); + await mkdir(checkout, { recursive: true }); + await writeFile( + path.join(checkout, "package.json"), + JSON.stringify({ name: "aurelia-expert", version: "3.0.0" }), + ); + await mkdir(path.join(dir, ".opencode"), { recursive: true }); + await writeFile( + path.join(dir, ".opencode", "opencode.json"), + `{ "plugins": [{ "package": "${pathToFileURL(checkout).href}" }] }\n`, + ); + + const run = await runCli(["status", "--package", "aurelia-expert"], dir); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("package: aurelia-expert"); + expect(run.stdout).toContain("should load: 3.0.0 (checkout copy, local)"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("a pinned spec answers the verdict without any cached copy", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-cli-status-pinned-")); + const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-status-pinned-cache-")); + try { + await mkdir(path.join(dir, ".opencode"), { recursive: true }); + await writeFile(path.join(dir, ".opencode", "opencode.json"), '{ "plugins": ["opencode-architect@1.0.0"] }\n'); + + const run = await runCli(["status"], dir, { XDG_CACHE_HOME: cacheDir }); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("should load: 1.0.0 (pinned spec, local)"); + } finally { + await rm(dir, { recursive: true, force: true }); + await rm(cacheDir, { recursive: true, force: true }); + } + }); + + test("status annotates the verdict and shows the copy age when both scopes register", async () => { const dir = await mkdtemp(path.join(tmpdir(), "oa-cli-status-both-")); + const configDir = await mkdtemp(path.join(tmpdir(), "oa-cli-status-cfg-")); + const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-status-cache-")); + try { + await mkdir(path.join(dir, ".opencode"), { recursive: true }); + await writeFile(path.join(dir, ".opencode", "opencode.json"), '{ "plugins": ["opencode-architect@latest"] }\n'); + await mkdir(path.join(configDir, "opencode"), { recursive: true }); + await writeFile(path.join(configDir, "opencode", "opencode.json"), '{ "plugins": ["opencode-architect@latest"] }\n'); + const copy = path.join( + cacheDir, + "opencode", + "npm", + "opencode-architect@latest", + "1738848000000", + "node_modules", + "opencode-architect", + ); + await mkdir(copy, { recursive: true }); + await writeFile(path.join(copy, "package.json"), '{ "name": "opencode-architect", "version": "1.0.0" }\n'); + + const run = await runCli(["status"], dir, { XDG_CACHE_HOME: cacheDir, XDG_CONFIG_HOME: configDir }); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("local + global"); + expect(run.stdout).toContain("both scopes register"); + expect(run.stdout).toMatch(/resolved=1\.0\.0 \(cache copy, just now\)/); + expect(run.stderr).toContain("double-load risk"); + } finally { + await rm(dir, { recursive: true, force: true }); + await rm(configDir, { recursive: true, force: true }); + await rm(cacheDir, { recursive: true, force: true }); + } + }); +}); + describe("cli clear-cache", () => { const packagesDir = (cacheDir: string) => path.join(cacheDir, "opencode", "npm"); const seedCache = seedCachedPackage; diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 9d2e87f..196083e 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -401,8 +401,8 @@ describe("Installer.install", () => { expect(outcome.action).toBe("noop"); expect(existsSync(manifestPath("local"))).toBe(true); expect(existsSync(legacyManifestPath("local"))).toBe(false); - const status = await installer.status("local", projectDir); - expect(status.version).toBe(await readPackageVersion()); + const manifestState = await installer.manifestAt("local", projectDir); + expect(manifestState?.version).toBe(await readPackageVersion()); }); test("uninstall removes manifests left at either name", async () => { @@ -592,78 +592,6 @@ describe("Installer.uninstall", () => { }); }); -describe("Installer.status", () => { - test("reports none for an empty scope", async () => { - const outcome = await installer.status("local", projectDir); - - expect(outcome.mode).toBe("none"); - expect(outcome.version).toBeNull(); - expect(outcome.configPath).toBeNull(); - }); - - test("reports plugin mode with version and registration file", async () => { - const configPath = path.join(scopeBase("local"), "opencode.json"); - await writeJson(configPath, {}); - await install("local"); - - const outcome = await installer.status("local", projectDir); - - expect(outcome.mode).toBe("plugin"); - expect(outcome.version).toBe(await readPackageVersion()); - expect(outcome.configPath).toBe(configPath); - }); - - 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 path-form registration without a manifest", async () => { - const entry = pathToFileURL(PACKAGE_ROOT).href; - await writeJson(path.join(scopeBase("local"), "opencode.json"), { - plugins: [{ package: entry }], - }); - - 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 path-form string registration without a manifest", async () => { - const entry = pathToFileURL(PACKAGE_ROOT).href; - await writeJson(path.join(scopeBase("local"), "opencode.json"), { - plugins: [entry], - }); - - 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"], - }); - - 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")); - }); -}); - describe("contentHash", () => { test("produces a stable hex digest for a directory", async () => { const dir = path.join(projectDir, "payload"); diff --git a/tests/status-effective-version.test.ts b/tests/status-effective-version.test.ts new file mode 100644 index 0000000..7e10754 --- /dev/null +++ b/tests/status-effective-version.test.ts @@ -0,0 +1,529 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { ConfigEntriesReader } from "../src/config-entries"; +import { EntrySpec } from "../src/entry-spec"; +import { Installer } from "../src/installer"; +import { LoadedVersionResolver } from "../src/loaded-version"; +import { StatusReporter } from "../src/status-reporter"; + +let projectDir = ""; +let configDir = ""; +let cacheDir = ""; +let originalXdgConfig: string | undefined; +let originalXdgCache: string | undefined; +const installer = new Installer(); +const reporter = new StatusReporter(installer); + +beforeEach(async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "oa-status-project-")); + configDir = await mkdtemp(path.join(tmpdir(), "oa-status-config-")); + cacheDir = await mkdtemp(path.join(tmpdir(), "oa-status-cache-")); + originalXdgConfig = process.env.XDG_CONFIG_HOME; + originalXdgCache = process.env.XDG_CACHE_HOME; + process.env.XDG_CONFIG_HOME = configDir; + process.env.XDG_CACHE_HOME = cacheDir; +}); + +afterEach(async () => { + restoreEnv("XDG_CONFIG_HOME", originalXdgConfig); + restoreEnv("XDG_CACHE_HOME", originalXdgCache); + await rm(projectDir, { recursive: true, force: true }); + await rm(configDir, { recursive: true, force: true }); + await rm(cacheDir, { recursive: true, force: true }); +}); + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +function cacheRoot(): string { + return path.join(cacheDir, "opencode", "npm"); +} + +async function seedCacheCopy( + key: string, + packageName: string, + version: string, + generation: string = "1738848000000", +): Promise { + const dir = path.join(cacheRoot(), key, generation, "node_modules", packageName); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "package.json"), JSON.stringify({ name: packageName, version })); + return dir; +} + +async function writeConfig(scope: "local" | "global", file: string, text: string): Promise { + const base = scope === "local" ? path.join(projectDir, ".opencode") : path.join(configDir, "opencode"); + const target = path.join(base, file); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, text); + return target; +} + +async function writeLocalManifest(version: string, configPath: string | null): Promise { + const target = path.join(projectDir, ".opencode", "opencode-architect.manifest.json"); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile( + target, + JSON.stringify({ version, mode: "plugin", entry: "opencode-architect@latest", configPath, "content-hash": null, hashes: null }), + ); +} + +describe("LoadedVersionResolver classification", () => { + const resolver = new LoadedVersionResolver(); + + test("classifies npm specs, pinning, scopes, and defaults", () => { + expect(resolver.classify("opencode-architect@latest")).toMatchObject({ + form: "npm", + name: "opencode-architect", + version: "latest", + }); + expect(resolver.classify("opencode-architect")).toMatchObject({ + form: "npm", + name: "opencode-architect", + version: "latest", + }); + expect(resolver.classify("opencode-architect@1.0.0")).toMatchObject({ version: "1.0.0" }); + expect(resolver.classify("@scope/pkg@^1.7.0")).toMatchObject({ name: "@scope/pkg", version: "^1.7.0" }); + }); + + test("classifies path forms and record forms", () => { + expect(resolver.classify("file:///tmp/plugin")).toMatchObject({ form: "path" }); + expect(resolver.classify("./plugin")).toMatchObject({ form: "path" }); + expect(resolver.classify("some/dir")).toMatchObject({ form: "path" }); + expect(resolver.classify({ package: "opencode-architect@1.0.0" })).toMatchObject({ + form: "npm", + version: "1.0.0", + }); + expect(resolver.classify(42)).toBeNull(); + }); + + test("returns null for a spec whose version carries a path separator", () => { + expect(resolver.classify("foo@bar/baz")).toBeNull(); + expect(resolver.classify("foo@bar\\baz")).toBeNull(); + expect(resolver.classify({ package: "foo@bar/baz" })).toBeNull(); + }); + + test("reports an unhandled entry for a malformed version spec", async () => { + const result = await resolver.resolve("foo@bar/baz"); + + expect(result.status).toBe("unhandled"); + }); +}); + +describe("EntrySpec", () => { + test("specOf unwraps record entries and trims strings", () => { + expect(EntrySpec.specOf({ package: " a@1 " })).toBe("a@1"); + expect(EntrySpec.specOf(42)).toBeNull(); + expect(EntrySpec.specOf(null)).toBeNull(); + }); + + test("baseName strips the version spec", () => { + expect(EntrySpec.baseName("@scope/pkg@1.0.0")).toBe("@scope/pkg"); + expect(EntrySpec.baseName("pkg")).toBe("pkg"); + }); +}); + +describe("StatusReporter.formatAge", () => { + test("formats elapsed time as a relative hint", () => { + const now = Date.now(); + expect(StatusReporter.formatAge(null)).toBeNull(); + expect(StatusReporter.formatAge(now - 30_000)).toBe("just now"); + expect(StatusReporter.formatAge(now - 5 * 60_000)).toBe("5m old"); + expect(StatusReporter.formatAge(now - 2 * 3_600_000)).toBe("2h old"); + expect(StatusReporter.formatAge(now - 3 * 86_400_000)).toBe("3d old"); + }); +}); + +describe("LoadedVersionResolver cache resolution", () => { + const resolver = new LoadedVersionResolver(); + + test("resolves a present cache copy and reports version and mtime", async () => { + await seedCacheCopy("opencode-architect@latest", "opencode-architect", "1.2.3"); + + const result = await resolver.resolve("opencode-architect@latest"); + + expect(result.status).toBe("resolved"); + if (result.status === "resolved") { + expect(result.source.version).toBe("1.2.3"); + expect(result.source.source).toBe("cache"); + expect(result.source.modifiedMs).toBeGreaterThan(0); + } + }); + + test("distinguishes pinned and @latest keys", async () => { + await seedCacheCopy("opencode-architect@1.0.0", "opencode-architect", "1.0.0"); + await seedCacheCopy("opencode-architect@latest", "opencode-architect", "1.2.0"); + + const pinned = await resolver.resolve("opencode-architect@1.0.0"); + const latest = await resolver.resolve("opencode-architect@latest"); + + expect(pinned.status === "resolved" && pinned.source.version).toBe("1.0.0"); + expect(latest.status === "resolved" && latest.source.version).toBe("1.2.0"); + }); + + test("reports a missing cache key", async () => { + const result = await resolver.resolve("opencode-architect@latest"); + + expect(result.status).toBe("missing"); + if (result.status === "missing") expect(result.attempted).toContain("opencode-architect@latest"); + }); + + test("reports a partial cache key with no extracted copy", async () => { + await mkdir(path.join(cacheRoot(), "opencode-architect@latest", ".staging-123"), { recursive: true }); + + const result = await resolver.resolve("opencode-architect@latest"); + + expect(result.status).toBe("partial"); + }); + + test("picks the newest generation when several are cached", async () => { + await seedCacheCopy("opencode-architect@latest", "opencode-architect", "1.0.0", "1700000000000"); + await seedCacheCopy("opencode-architect@latest", "opencode-architect", "2.0.0", "1790000000000"); + + const result = await resolver.resolve("opencode-architect@latest"); + + expect(result.status === "resolved" && result.source.version).toBe("2.0.0"); + }); + + test("resolves a path entry and a record-form path entry to the checkout", async () => { + const checkout = path.join(projectDir, "checkout"); + await mkdir(checkout, { recursive: true }); + await writeFile(path.join(checkout, "package.json"), JSON.stringify({ name: "opencode-architect", version: "3.4.5" })); + const entry = pathToFileURL(checkout).href; + + const direct = await resolver.resolve(entry); + const record = await resolver.resolve({ package: entry }); + + expect(direct.status === "resolved" && direct.source.source).toBe("checkout"); + expect(direct.status === "resolved" && direct.source.version).toBe("3.4.5"); + expect(record.status === "resolved" && record.source.version).toBe("3.4.5"); + }); + + test("reports a path entry that does not resolve", async () => { + const result = await resolver.resolve(path.join(projectDir, "missing-checkout")); + + expect(result.status).toBe("missing"); + }); +}); + +describe("ConfigEntriesReader", () => { + test("reads raw entries from a jsonc config with comments and trailing commas", async () => { + await writeConfig( + "local", + "opencode.jsonc", + '{\n // keep\n "plugins": ["other", { "package": "opencode-architect@latest" },],\n}\n', + ); + + const entries = await new ConfigEntriesReader().read("local", projectDir, false); + + expect(entries).toHaveLength(1); + expect(entries[0]?.rawEntries).toContain("other"); + expect(entries[0]?.rawEntries).toContainEqual({ package: "opencode-architect@latest" }); + }); + + test("warns and reports unparseable configs", async () => { + await writeConfig("local", "opencode.json", "{ not json ]"); + const warnings: string[] = []; + const original = console.warn; + console.warn = (message: string) => warnings.push(String(message)); + try { + const entries = await new ConfigEntriesReader().read("local", projectDir, true); + expect(entries[0]?.parseError).not.toBeNull(); + expect(warnings).toHaveLength(1); + } finally { + console.warn = original; + } + }); +}); + +describe("StatusReporter registration detection", () => { + test("reports none for an empty scope", async () => { + const report = await reporter.report(projectDir, { scopes: ["local"], online: false }); + + expect(report.scopes[0]?.registered).toBe(false); + expect(report.scopes[0]?.mode).toBe("none"); + expect(report.scopes[0]?.manifestVersion).toBeNull(); + expect(report.scopes[0]?.resolved).toBeNull(); + }); + + test("reports the manifest version and registration file for a live registration", async () => { + const configPath = await writeConfig("local", "opencode.json", '{ "plugins": ["opencode-architect@latest"] }\n'); + await writeLocalManifest("9.9.9", configPath); + + const report = await reporter.report(projectDir, { scopes: ["local"], online: false }); + + expect(report.scopes[0]?.registered).toBe(true); + expect(report.scopes[0]?.mode).toBe("plugin"); + expect(report.scopes[0]?.manifestVersion).toBe("9.9.9"); + expect(report.scopes[0]?.configPath).toBe(configPath); + }); + + test("a manifest without a live registration reports unregistered with the recorded version", async () => { + const configPath = await writeConfig("local", "opencode.json", "{}\n"); + await writeLocalManifest("9.9.9", configPath); + + const report = await reporter.report(projectDir, { scopes: ["local"], online: false }); + + expect(report.scopes[0]?.registered).toBe(false); + expect(report.scopes[0]?.manifestVersion).toBe("9.9.9"); + expect(report.effective.scope).toBeNull(); + }); + + test("detects an npm registration without a manifest", async () => { + await writeConfig("local", "opencode.json", '{ "plugins": ["opencode-architect"] }\n'); + + const report = await reporter.report(projectDir, { scopes: ["local"], online: false }); + + expect(report.scopes[0]?.registered).toBe(true); + expect(report.scopes[0]?.manifestVersion).toBeNull(); + expect(report.scopes[0]?.entryForm).toBe("npm"); + }); + + test("detects a path-form registration without a manifest", async () => { + const entry = pathToFileURL(path.resolve(import.meta.dirname, "..")).href; + await writeConfig("local", "opencode.json", `{ "plugins": [{ "package": "${entry}" }] }\n`); + + const report = await reporter.report(projectDir, { scopes: ["local"], online: false }); + + expect(report.scopes[0]?.registered).toBe(true); + expect(report.scopes[0]?.entryForm).toBe("path"); + }); + + test("detects a string path-form registration without a manifest", async () => { + const entry = pathToFileURL(path.resolve(import.meta.dirname, "..")).href; + await writeConfig("local", "opencode.json", `{ "plugins": ["${entry}"] }\n`); + + const report = await reporter.report(projectDir, { scopes: ["local"], online: false }); + + expect(report.scopes[0]?.registered).toBe(true); + expect(report.scopes[0]?.entryForm).toBe("path"); + }); + + test("a relative path entry resolves from the config file's directory", async () => { + const checkout = path.join(projectDir, "checkout"); + await mkdir(checkout, { recursive: true }); + await writeFile( + path.join(checkout, "package.json"), + JSON.stringify({ name: "opencode-architect", version: "3.4.5" }), + ); + const relative = path.relative(path.join(projectDir, ".opencode"), checkout).replaceAll("\\", "/"); + await writeConfig("local", "opencode.json", `{ "plugins": ["${relative}"] }\n`); + + const report = await reporter.report(projectDir, { scopes: ["local"], online: false }); + + expect(report.scopes[0]?.registered).toBe(true); + expect(report.scopes[0]?.resolved?.version).toBe("3.4.5"); + expect(report.scopes[0]?.resolved?.source).toBe("checkout"); + }); + + test("detects a legacy v1 registration without a manifest", async () => { + await writeConfig("local", "opencode.json", '{ "plugin": ["opencode-architect"] }\n'); + + const report = await reporter.report(projectDir, { scopes: ["local"], online: false }); + + expect(report.scopes[0]?.registered).toBe(true); + }); + + test("--package matches a differently named npm registration", async () => { + await writeConfig("local", "opencode.json", '{ "plugins": ["aurelia-expert@latest"] }\n'); + + const report = await reporter.report(projectDir, { + scopes: ["local"], + online: false, + packageName: "aurelia-expert", + }); + + expect(report.packageName).toBe("aurelia-expert"); + expect(report.scopes[0]?.registered).toBe(true); + expect(report.scopes[0]?.entryName).toBe("aurelia-expert"); + }); + + test("--package does not match this package's registration", async () => { + await writeConfig("local", "opencode.json", '{ "plugins": ["opencode-architect@latest"] }\n'); + + const report = await reporter.report(projectDir, { + scopes: ["local"], + online: false, + packageName: "aurelia-expert", + }); + + expect(report.scopes[0]?.registered).toBe(false); + }); + + test("--package reads the named manifest rather than this package's", async () => { + await writeConfig("local", "opencode.json", '{ "plugins": ["aurelia-expert@latest"] }\n'); + const manifest = path.join(projectDir, ".opencode", "aurelia-expert.manifest.json"); + await mkdir(path.dirname(manifest), { recursive: true }); + await writeFile( + manifest, + JSON.stringify({ version: "9.9.9", mode: "plugin", entry: "aurelia-expert@latest", configPath: null, "content-hash": null, hashes: null }), + ); + + const report = await reporter.report(projectDir, { + scopes: ["local"], + online: false, + packageName: "aurelia-expert", + }); + + expect(report.scopes[0]?.manifestVersion).toBe("9.9.9"); + expect(report.scopes[0]?.mode).toBe("plugin"); + }); +}); + +describe("StatusReporter.report", () => { + test("reports per-scope resolution and an effective verdict", async () => { + await seedCacheCopy("opencode-architect@latest", "opencode-architect", "1.0.0"); + await writeConfig("local", "opencode.jsonc", '{ "plugins": ["opencode-architect@latest"] }\n'); + + const report = await reporter.report(projectDir, { scopes: null, online: false }); + + const local = report.scopes.find((scope) => scope.scope === "local"); + expect(local?.registered).toBe(true); + expect(local?.resolved?.version).toBe("1.0.0"); + expect(report.effective.version).toBe("1.0.0"); + expect(report.effective.versionKind).toBe("cache"); + expect(report.effective.scope).toBe("local"); + expect(report.effective.registeredScopes).toEqual(["local"]); + }); + + test("a pinned spec answers the verdict even when no copy is cached", async () => { + await writeConfig("local", "opencode.json", '{ "plugins": ["opencode-architect@1.0.0"] }\n'); + + const report = await reporter.report(projectDir, { scopes: null, online: false }); + + expect(report.effective.version).toBe("1.0.0"); + expect(report.effective.versionKind).toBe("spec"); + }); + + test("@latest with --online answers the verdict with the published version", async () => { + await writeConfig("local", "opencode.json", '{ "plugins": ["opencode-architect@latest"] }\n'); + const stubFetch = ((input: string | URL | Request) => + Promise.resolve(new Response(JSON.stringify({ version: "2.0.0" }), { status: 200 }))) as unknown as typeof fetch; + + const report = await new StatusReporter(installer, stubFetch).report(projectDir, { scopes: null, online: true }); + + expect(report.effective.version).toBe("2.0.0"); + expect(report.effective.versionKind).toBe("npm"); + }); + + test("a range spec stays unresolved without a cached copy", async () => { + await writeConfig("local", "opencode.json", '{ "plugins": ["opencode-architect@^1.7.0"] }\n'); + + const report = await reporter.report(projectDir, { scopes: null, online: false }); + + expect(report.effective.version).toBeNull(); + expect(report.effective.versionKind).toBeNull(); + }); + + test("--package resolves a path-form registration for another checkout", async () => { + const checkout = path.join(projectDir, "aurelia-checkout"); + await mkdir(checkout, { recursive: true }); + await writeFile( + path.join(checkout, "package.json"), + JSON.stringify({ name: "aurelia-expert", version: "3.0.0" }), + ); + await writeConfig("local", "opencode.json", `{ "plugins": [{ "package": "${pathToFileURL(checkout).href}" }] }\n`); + + const report = await reporter.report(projectDir, { + scopes: ["local"], + online: false, + packageName: "aurelia-expert", + }); + + expect(report.scopes[0]?.registered).toBe(true); + expect(report.effective.version).toBe("3.0.0"); + expect(report.effective.versionKind).toBe("checkout"); + }); + + test("warns when registered in both scopes and annotates the verdict", async () => { + await seedCacheCopy("opencode-architect@latest", "opencode-architect", "1.0.0"); + await writeConfig("local", "opencode.json", '{ "plugins": ["opencode-architect@latest"] }\n'); + await writeConfig("global", "opencode.json", '{ "plugins": ["opencode-architect@latest"] }\n'); + + const report = await reporter.report(projectDir, { scopes: null, online: false }); + + expect(report.scopes.filter((scope) => scope.registered)).toHaveLength(2); + expect(report.warnings.join("\n")).toContain("double-load risk"); + expect(report.effective.registeredScopes).toEqual(["local", "global"]); + }); + + test("warns when the manifest version differs from the resolved copy", async () => { + await seedCacheCopy("opencode-architect@latest", "opencode-architect", "2.0.0"); + const configPath = await writeConfig("local", "opencode.json", '{ "plugins": ["opencode-architect@latest"] }\n'); + await writeLocalManifest("1.0.0", configPath); + + const report = await reporter.report(projectDir, { scopes: null, online: false }); + + expect(report.scopes[0]?.warnings.join("\n")).toContain("manifest records 1.0.0"); + }); + + test("warns when no cached copy exists for the registered spec", async () => { + await writeConfig("local", "opencode.json", '{ "plugins": ["opencode-architect@0.4.0"] }\n'); + + const report = await reporter.report(projectDir, { scopes: null, online: false }); + + expect(report.scopes[0]?.warnings.join("\n")).toContain("no cached copy"); + }); + + test("narrows to a single scope when asked", async () => { + await seedCacheCopy("opencode-architect@latest", "opencode-architect", "1.0.0"); + await writeConfig("local", "opencode.json", '{ "plugins": ["opencode-architect@latest"] }\n'); + await writeConfig("global", "opencode.json", '{ "plugins": ["opencode-architect@latest"] }\n'); + + const report = await reporter.report(projectDir, { scopes: ["global"], online: false }); + + expect(report.scopes).toHaveLength(1); + expect(report.scopes[0]?.scope).toBe("global"); + expect(report.warnings).toEqual([]); + }); + + test("--online reports the published latest and flags staleness", async () => { + await seedCacheCopy("opencode-architect@latest", "opencode-architect", "1.0.0"); + await writeConfig("local", "opencode.json", '{ "plugins": ["opencode-architect@latest"] }\n'); + const stubFetch = ((input: string | URL | Request) => + Promise.resolve(new Response(JSON.stringify({ version: "2.0.0" }), { status: 200 }))) as unknown as typeof fetch; + + const report = await new StatusReporter(installer, stubFetch).report(projectDir, { scopes: null, online: true }); + + expect(report.scopes[0]?.publishedVersion).toBe("2.0.0"); + expect(report.effective.latestVersion).toBe("2.0.0"); + expect(report.warnings.join("\n")).toContain("stale"); + }); + + test("--online derives the npm name for a path-form registration from the checkout", async () => { + const checkout = path.join(projectDir, "checkout"); + await mkdir(checkout, { recursive: true }); + await writeFile( + path.join(checkout, "package.json"), + JSON.stringify({ name: "opencode-architect", version: "3.4.5" }), + ); + const entry = pathToFileURL(checkout).href; + await writeConfig("local", "opencode.json", `{ "plugins": [{ "package": "${entry}" }] }\n`); + const stubFetch = ((input: string | URL | Request) => + Promise.resolve(new Response(JSON.stringify({ version: "2.0.0" }), { status: 200 }))) as unknown as typeof fetch; + + const report = await new StatusReporter(installer, stubFetch).report(projectDir, { scopes: null, online: true }); + + expect(report.scopes[0]?.entryForm).toBe("path"); + expect(report.scopes[0]?.publishedVersion).toBe("2.0.0"); + expect(report.effective.latestVersion).toBe("2.0.0"); + expect(report.warnings.join("\n")).toContain("resolves 3.4.5 but npm publishes 2.0.0"); + }); + + test("--online warn-and-continues when the registry is unreachable", async () => { + await seedCacheCopy("opencode-architect@latest", "opencode-architect", "1.0.0"); + await writeConfig("local", "opencode.json", '{ "plugins": ["opencode-architect@latest"] }\n'); + const failingFetch = (() => Promise.reject(new Error("offline"))) as unknown as typeof fetch; + + const report = await new StatusReporter(installer, failingFetch).report(projectDir, { scopes: null, online: true }); + + expect(report.scopes[0]?.publishedVersion).toBeNull(); + expect(report.effective.version).toBe("1.0.0"); + expect(report.warnings.join("\n")).toContain("could not query npm registry"); + }); +}); diff --git a/tests/templates.test.ts b/tests/templates.test.ts index 3bb7258..8382065 100644 --- a/tests/templates.test.ts +++ b/tests/templates.test.ts @@ -256,8 +256,8 @@ describe("rendered package passes its own gates", () => { isolatedEnv(isolatedBase), ); if (outcome.exitCode !== 0) failWithOutput("rendered package status", outcome); - expect(outcome.stdout).toMatch(/Local: installed=true/); - expect(outcome.stdout).toMatch(/pluginInConfig=true/); + expect(outcome.stdout).toMatch(/Local: mode=plugin/); + expect(outcome.stdout).toMatch(/should load:/); }, 60_000); test("activating the rendered plugin under the host contract ensures the payload", async () => {