From 1339b22295f5d87e7694a8254fc192c6afdff5f2 Mon Sep 17 00:00:00 2001 From: diegohb Date: Thu, 8 Oct 2026 20:52:00 -0400 Subject: [PATCH 1/6] feat(status): status resolves the effective version a session loads Per scope, status now reports the config file holding the registration, the raw entry as written (npm spec, pinned, { package } object, or path/file:// form), and the resolved source on disk: an npm spec against the OpenCode package-cache key @ (version read from that copy's package.json, newest generation wins, mtime carried as a staleness hint), a path entry through PluginEntryResolver.packageRoot. The effective verdict annotates a double-scope registration, the resolved line carries a relative age, and warnings cover double-scope registration, manifest/resolved mismatch, missing or incomplete cached copies, unparseable configs, and --online registry staleness (warn-and-continue, fetch injectable). --online and --path are status-only and rejected elsewhere; --path validates and normalizes the directory. Resolution lives in StatusReporter (src/status-reporter.ts) over LoadedVersionResolver, ConfigEntriesReader, and RegistryVersionChecker; Installer is install/uninstall/cache only and exposes manifestAt() as the ManifestLookup seam, retiring status()/StatusOutcome. Entry classification is unified in EntrySpec (src/entry-spec.ts). A manifest without a live registration now reports unregistered: the report answers what a session would load, not what was once installed. --- src/cli.ts | 95 +++++- src/config-entries.ts | 159 ++++++++++ src/entry-spec.ts | 58 ++++ src/installer.ts | 20 +- src/loaded-version.ts | 89 ++++++ src/plugin-entry.ts | 56 +--- src/registry-version-checker.ts | 26 ++ src/status-reporter.ts | 238 +++++++++++++++ tests/cli.test.ts | 102 +++++++ tests/installer.test.ts | 63 +--- tests/status-effective-version.test.ts | 385 +++++++++++++++++++++++++ 11 files changed, 1166 insertions(+), 125 deletions(-) create mode 100644 src/config-entries.ts create mode 100644 src/entry-spec.ts create mode 100644 src/loaded-version.ts create mode 100644 src/registry-version-checker.ts create mode 100644 src/status-reporter.ts create mode 100644 tests/status-effective-version.test.ts diff --git a/src/cli.ts b/src/cli.ts index 004c37d..8e9b5f6 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,10 @@ 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; + } const scopeInput = values.scope; if (scopeInput !== undefined && scopeInput !== "local" && scopeInput !== "global") { console.error(`Invalid scope: ${scopeInput}. Must be "local" or "global".`); @@ -91,10 +102,10 @@ 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 }); + printStatusReport(report); break; } case "clear-cache": { @@ -143,6 +154,68 @@ if (import.meta.main) { process.exitCode = await runCli(process.argv.slice(2)); } +function printStatusReport(report: StatusReport): void { + for (const scopeReport of report.scopes) { + const entry = scopeReport.entryText ?? "-"; + const config = scopeReport.configPath ?? "-"; + console.log( + `${scopeReport.scope} scope: mode=${scopeReport.mode} config=${config} entry=${entry} resolved=${resolvedLabel(scopeReport.resolved)}`, + ); + 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 kind = effective.source !== null ? sourceLabel(effective.source) : "manifest"; + const latest = effective.latestVersion !== null ? `, latest ${effective.latestVersion}` : ""; + const scopeLabel = + effective.registeredScopes.length > 1 + ? `${effective.registeredScopes.join(" + ")}; both scopes register — double-load` + : effective.scope; + return `${effective.version ?? "unknown"} (${kind}, ${scopeLabel}${latest})`; +} + +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 printHelp(): void { console.log(` opencode-architect v${VERSION} @@ -155,16 +228,23 @@ 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 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) + --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 clear-cache: remove and every @* instead; requires --yes --all clear-cache: remove the whole OpenCode cache directory; @@ -179,6 +259,9 @@ 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 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..b14b103 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,11 @@ export class Installer { return removed; } - public async status(scope: Scope, projectDir: string): Promise { + public async manifestAt(scope: Scope, projectDir: string): 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 }; - } - return { scope, mode: "none", version: null, configPath: null }; + if (manifest === null) return null; + return { mode: manifest.mode, version: manifest.version }; } public async hasManifestAnywhere(projectDir: string): Promise { diff --git a/src/loaded-version.ts b/src/loaded-version.ts new file mode 100644 index 0000000..c9f2ae7 --- /dev/null +++ b/src/loaded-version.ts @@ -0,0 +1,89 @@ +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; + 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): Promise { + const classified = this.classify(entry); + if (classified === null) return { status: "unhandled" }; + if (classified.form === "path") return this.resolvePath(classified); + return this.resolveNpm(classified); + } + + private async resolvePath(classified: ClassifiedEntry): Promise { + const root = await PluginEntryResolver.packageRoot(classified.spec); + 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 { version?: unknown }; + const version = typeof parsed.version === "string" ? parsed.version : null; + const info = await stat(dir); + return { version, source, path: dir, modifiedMs: info.mtimeMs }; + } catch { + return null; + } + } +} diff --git a/src/plugin-entry.ts b/src/plugin-entry.ts index 812079c..d7b1351 100644 --- a/src/plugin-entry.ts +++ b/src/plugin-entry.ts @@ -1,6 +1,7 @@ import { exists, lstat, readFile, realpath } from "node:fs/promises"; -import { dirname, isAbsolute, join } from "node:path"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { EntrySpec } from "./entry-spec"; const MEMO_LIMIT = 100; @@ -8,52 +9,23 @@ const resolvedRootMemo = new Map(); export class PluginEntryResolver { public static async resolvesToPackage(entry: unknown, packageName: string): Promise { - if (typeof entry === "string") { - if (PluginEntryResolver.isNpmSpec(entry)) return false; - if (!PluginEntryResolver.isPathLike(entry)) return false; - const root = await PluginEntryResolver.resolveToPackageRoot(entry); - if (root === null) return false; - const name = await PluginEntryResolver.packageNameAt(root); - if (name === null) return false; - return PluginEntryResolver.matchesName(name, packageName); - } - if (PluginEntryResolver.isRecord(entry)) { - const spec = entry.package; - if (typeof spec === "string") return PluginEntryResolver.resolvesToPackage(spec, packageName); - } - return false; - } - - private static isPathLike(entry: string): boolean { - if (entry.startsWith("file://")) return true; - if (entry.startsWith(".")) return true; - if (isAbsolute(entry)) return true; - if (entry.includes("/")) return true; - if (entry.includes("\\")) return true; - return false; + const classified = EntrySpec.classify(entry); + if (classified === null || classified.form === "npm") return false; + const root = await PluginEntryResolver.resolveToPackageRoot(classified.spec); + if (root === null) return false; + const name = await PluginEntryResolver.packageNameAt(root); + if (name === null) return false; + return PluginEntryResolver.matchesName(name, packageName); } - private static isNpmSpec(entry: string): boolean { - if (!entry.includes("@")) return false; - if (entry.startsWith("file://")) return false; - if (entry.startsWith(".")) return false; - if (isAbsolute(entry)) return false; - return true; - } - - private static isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + public static async packageRoot(entry: unknown): Promise { + const spec = EntrySpec.specOf(entry); + if (spec === null) return null; + return PluginEntryResolver.resolveToPackageRoot(spec); } private static matchesName(candidate: string, packageName: string): boolean { - return PluginEntryResolver.baseName(candidate) === PluginEntryResolver.baseName(packageName); - } - - private static baseName(raw: string): string { - const trimmed = raw.trim(); - const specIndex = trimmed.lastIndexOf("@"); - if (specIndex > 0) return trimmed.slice(0, specIndex); - return trimmed; + return EntrySpec.baseName(candidate) === EntrySpec.baseName(packageName); } private static async resolveToPackageRoot(rawEntry: string): Promise { 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..9f98773 --- /dev/null +++ b/src/status-reporter.ts @@ -0,0 +1,238 @@ +import { PACKAGE_NAME } from "./cache-cleaner"; +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): Promise; +} + +export interface ScopeStatusReport { + scope: Scope; + registered: boolean; + mode: InstallMode; + manifestVersion: string | null; + configPath: string | null; + entryText: string | null; + entryName: string | null; + entryForm: "npm" | "path" | null; + resolved: ResolvedSource | null; + warnings: string[]; +} + +export interface EffectiveVersion { + version: string | null; + source: "cache" | "checkout" | null; + scope: Scope | null; + latestVersion: string | null; + registeredScopes: Scope[]; +} + +export interface StatusReport { + projectDir: string; + scopes: ScopeStatusReport[]; + effective: EffectiveVersion; + warnings: string[]; +} + +export interface StatusReportOptions { + scopes: Scope[] | null; + online: boolean; +} + +interface MatchedEntry { + configPath: string; + entry: unknown; + display: string; + name: 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 reports: ScopeStatusReport[] = []; + for (const scope of scopes) { + reports.push(await this.resolveScope(scope, projectDir)); + } + const warnings = this.crossScopeWarnings(reports); + let effective = this.effectiveVersion(reports); + if (options.online) { + const online = await this.onlinePass(reports, effective); + warnings.push(...online.warnings); + effective = online.effective; + } + return { projectDir, 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): 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); + const matched = await this.findMatchedEntry(entries); + if (matched === null) { + return { + scope, + registered: false, + mode: manifestState?.mode ?? "none", + manifestVersion: manifestState?.version ?? null, + configPath: null, + entryText: null, + entryName: null, + entryForm: null, + resolved: null, + warnings, + }; + } + const resolution = await this.resolver.resolve(matched.entry); + 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, + entryForm: matched.form, + resolved, + warnings, + }; + } + + private async findMatchedEntry(entries: ConfigEntries[]): 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 === PACKAGE_NAME) { + return { + configPath: candidate.configPath, + entry: raw, + display: classified.display, + name: classified.name, + form: "npm", + }; + } + continue; + } + if (await PluginEntryResolver.resolvesToPackage(raw, PACKAGE_NAME)) { + return { + configPath: candidate.configPath, + entry: raw, + display: classified.display, + name: 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; + return { + version: report.resolved?.version ?? report.manifestVersion, + source: report.resolved?.source ?? null, + scope: report.scope, + latestVersion: null, + registeredScopes, + }; + } + return { version: null, source: null, scope: null, latestVersion: null, registeredScopes }; + } + + private async onlinePass( + reports: ScopeStatusReport[], + effective: EffectiveVersion, + ): Promise<{ warnings: string[]; effective: EffectiveVersion }> { + const warnings: string[] = []; + const checker = new RegistryVersionChecker(this.fetchFn); + const latest = new Map(); + const failures = new Map(); + for (const report of reports) { + if (!report.registered || report.entryName === null || latest.has(report.entryName)) continue; + const lookup = await checker.latest(report.entryName); + if (lookup.warning !== null) { + failures.set(report.entryName, lookup.warning); + latest.set(report.entryName, null); + } else { + latest.set(report.entryName, lookup.version); + } + } + for (const report of reports) { + if (!report.registered || report.entryName === null) continue; + const failure = failures.get(report.entryName); + if (failure !== undefined) { + warnings.push(`${report.scope}: ${failure}`); + continue; + } + const published = latest.get(report.entryName) ?? null; + const resolvedVersion = report.resolved?.version ?? null; + if (published !== null && resolvedVersion !== null && published !== resolvedVersion) { + warnings.push(`${report.scope}: resolves ${resolvedVersion} but npm publishes ${published} (stale)`); + } + } + const effectiveReport = reports.find((report) => report.registered && report.scope === effective.scope); + const effectiveName = effectiveReport?.entryName ?? null; + const effectiveLatest = effectiveName !== null && !failures.has(effectiveName) ? latest.get(effectiveName) ?? null : null; + return { warnings, effective: { ...effective, latestVersion: effectiveLatest } }; + } +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index df6f228..5022428 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -145,6 +145,108 @@ 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("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 47cd3e7..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,65 +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 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..d7eda0f --- /dev/null +++ b/tests/status-effective-version.test.ts @@ -0,0 +1,385 @@ +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 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); + }); +}); + +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.scope).toBe("local"); + expect(report.effective.registeredScopes).toEqual(["local"]); + }); + + 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.effective.latestVersion).toBe("2.0.0"); + expect(report.warnings.join("\n")).toContain("stale"); + }); + + 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.effective.version).toBe("1.0.0"); + expect(report.warnings.join("\n")).toContain("could not query npm registry"); + }); +}); From 27469d3c8b190346e54873e2176db956a5485672 Mon Sep 17 00:00:00 2001 From: diegohb Date: Thu, 8 Oct 2026 20:56:37 -0400 Subject: [PATCH 2/6] feat(templates): generated packages resolve effective status The generated installer's status() mirrors the suite's resolution: registration inventory from the registration detector, the raw entry as written, the resolved source on disk (package-cache key @ or path checkout via PluginEntryResolver.packageRoot), a relative-age staleness hint, an effective verdict annotating a double-scope registration, and --online registry staleness over an injectable fetch. The generated CLI renders the same shape and rejects the status-only --online/--path flags on every other command. A version segment carrying a path separator no longer produces a bogus cache lookup. registration.template.txt gains the inventory() accessor alongside detect() (detection behavior unchanged), plugin-entry.template.txt exposes packageRoot(), and the load-bearing notes of all four templates now describe the status behavior they ship with. --- templates/cli.template.txt | 92 ++++++++- templates/installer.template.txt | 279 +++++++++++++++++++++++++--- templates/plugin-entry.template.txt | 9 + templates/registration.template.txt | 40 +++- tests/templates.test.ts | 4 +- 5 files changed, 385 insertions(+), 39 deletions(-) diff --git a/templates/cli.template.txt b/templates/cli.template.txt index 855c319..ffbefae 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, 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,25 @@ 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; 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)}` ); + 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 +192,57 @@ 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 kind = effective.source === "cache" ? "cache copy" : effective.source === "checkout" ? "checkout copy" : "manifest"; + const latest = effective.latestVersion !== null ? `, latest ${effective.latestVersion}` : ""; + const scopeLabel = + effective.registeredScopes.length > 1 + ? `${effective.registeredScopes.join(" + ")}; both scopes register — double-load` + : effective.scope; + return `${effective.version ?? "unknown"} (${kind}, ${scopeLabel}${latest})`; +} + 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..2015d96 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 { 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,48 @@ export interface UninstallResult { } export interface ScopeStatus { + scope: Scope; installed: boolean; version: string | null; mode: InstallMode | null; entry: string | null; + entryName: string | null; entryConfigPath: string | null; pluginInConfig: boolean; + resolvedVersion: string | null; + resolvedSource: "cache" | "checkout" | null; + resolvedModifiedMs: number | null; + registered: boolean; + warnings: string[]; +} + +export interface EffectiveStatus { + version: string | null; + source: "cache" | "checkout" | null; + scope: Scope | null; + latestVersion: string | null; + registeredScopes: Scope[]; } export interface StatusResult { local: ScopeStatus | null; global: ScopeStatus | null; + effective: EffectiveStatus; + warnings: string[]; +} + +interface ResolvedEntrySource { + 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 +402,223 @@ 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); + const local = await scopeStatus("local", projectDir, inventory.local); + const 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(local, global); + if (online) { + const outcome = await applyOnline([local, global], effective, fetchFn); + warnings.push(...outcome.warnings); + effective = outcome.effective; + } + 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 warnings: string[] = []; + let resolvedVersion: string | null = null; + let resolvedSource: "cache" | "checkout" | null = null; + let resolvedModifiedMs: number | null = null; + if (matched !== null) { + const outcome = await resolveEntrySource(matched.spec); + if (outcome.resolved !== null) { + resolvedVersion = outcome.resolved.version; + resolvedSource = outcome.resolved.source; + resolvedModifiedMs = outcome.resolved.modifiedMs; + } 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, + entryConfigPath: matched !== null ? matched.configPath : manifestData?.entryConfigPath ?? null, + pluginInConfig: matched !== null, + resolvedVersion, + resolvedSource, + resolvedModifiedMs: resolvedModifiedMs, + 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)) { + 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): Promise<{ resolved: ResolvedEntrySource | null; status: "resolved" | "missing" | "partial" }> { + if (isPathSpec(spec)) { + const root = await PluginEntryResolver.packageRoot(spec); + 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 { version?: unknown }; + const version = typeof parsed.version === "string" ? parsed.version : null; + const info = await stat(dir); + return { version, 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; + return { + version: scopeStatusValue.resolvedVersion ?? scopeStatusValue.version, + source: scopeStatusValue.resolvedSource, + scope: scopeStatusValue.scope, + latestVersion: null, + registeredScopes, }; } - return result; + return { version: null, source: null, scope: null, latestVersion: null, registeredScopes }; +} + +async function applyOnline( + scopes: Array, + effective: EffectiveStatus, + fetchFn: typeof fetch, +): Promise<{ warnings: string[]; effective: EffectiveStatus }> { + const warnings: string[] = []; + const lookups = new Map(); + for (const scopeStatusValue of scopes) { + if (scopeStatusValue === null || !scopeStatusValue.registered || scopeStatusValue.entryName === null) continue; + if (!lookups.has(scopeStatusValue.entryName)) { + lookups.set(scopeStatusValue.entryName, await fetchLatestVersion(scopeStatusValue.entryName, fetchFn)); + } + } + for (const scopeStatusValue of scopes) { + if (scopeStatusValue === null || !scopeStatusValue.registered || scopeStatusValue.entryName === null) continue; + const lookup = lookups.get(scopeStatusValue.entryName); + if (lookup === undefined) continue; + if (lookup.warning !== null) { + warnings.push(lookup.warning); + 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)`); + } + } + const effectiveScopeStatus = scopes.find( + (scopeStatusValue) => scopeStatusValue !== null && scopeStatusValue.registered && scopeStatusValue.scope === effective.scope, + ); + const latest = effectiveScopeStatus?.entryName != null ? lookups.get(effectiveScopeStatus.entryName)?.version ?? null : null; + return { warnings, effective: { ...effective, latestVersion: latest } }; +} + +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 1eeb638..20239fc 100644 --- a/templates/plugin-entry.template.txt +++ b/templates/plugin-entry.template.txt @@ -35,6 +35,15 @@ export class PluginEntryResolver { return false; } + public static async packageRoot(entry: unknown): Promise { + if (typeof entry === "string") return PluginEntryResolver.resolveToPackageRoot(entry); + if (PluginEntryResolver.isRecord(entry)) { + const spec = entry.package; + if (typeof spec === "string") return PluginEntryResolver.resolveToPackageRoot(spec); + } + return null; + } + private static isPathLike(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 fc5736f..0b93cc1 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. `.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. `.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"; @@ -87,6 +87,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 @@ -131,6 +141,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(); @@ -148,6 +168,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/templates.test.ts b/tests/templates.test.ts index a06b29c..0110b8b 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 () => { From ef60bbea0b07336356d3ae060185bd38fd7d7492 Mon Sep 17 00:00:00 2001 From: diegohb Date: Thu, 8 Oct 2026 20:58:33 -0400 Subject: [PATCH 3/6] docs: status resolves the effective version README documents the per-scope resolution, the resolved line's age hint, the annotated double-load verdict, the warn-and-continue --online lookup, --path, and the status-only flag rejection. CHANGELOG gains the unreleased entry describing StatusReporter, the EntrySpec unification, and the manifestAt() seam that replaces the retired Installer.status(). Packager and publisher instructions describe the generated status behavior they now ship. --- CHANGELOG.md | 2 ++ README.md | 7 ++++++- agents/opencode-packager.md | 2 +- agents/opencode-publisher.md | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c04643..77af4db 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. 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 the latest published version and flags staleness (warn-and-continue on network failure), and 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. ## [1.0.0] - 2026-10-07 diff --git a/README.md b/README.md index 26e844b..30c4d78 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,10 @@ 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 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 +48,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. `status` is read-only. Without `--online` it never touches the network; `--online` adds the latest published version and flags staleness, and 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. `--online` and `--path` are status-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. From 0fa0e601b83daa274cba8f4b1603ea72a5c645f1 Mon Sep 17 00:00:00 2001 From: diegohb Date: Thu, 8 Oct 2026 21:43:48 -0400 Subject: [PATCH 4/6] fix(status): surface the npm lookup and derive the name for path-form entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --online results were easy to miss: the published version appeared only as a trailing ', latest X' on the verdict, and a registration whose cache copy could not be resolved printed 'unknown (manifest, ...)' — claiming a manifest source for a version nothing provided. The published latest now prints per scope as npm-latest=, and the verdict labels an unresolvable load 'unresolved' instead of 'manifest'. Path-form registrations had no npm name at all, so --online was silent for them; the name now derives from the resolved checkout's package.json. Reported from bunx opencode-architect status --online --path , where the pinned @1.0.0 cache key was partial and the npm result was invisible. --- CHANGELOG.md | 2 +- README.md | 2 +- src/cli.ts | 11 ++++- src/loaded-version.ts | 6 ++- src/status-reporter.ts | 60 ++++++++++++++++---------- templates/cli.template.txt | 14 ++++-- templates/installer.template.txt | 53 ++++++++++++++++------- tests/status-effective-version.test.ts | 22 ++++++++++ 8 files changed, 123 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77af4db..7c65426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ 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. 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 the latest published version and flags staleness (warn-and-continue on network failure), and 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. +- `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. 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. diff --git a/README.md b/README.md index 30c4d78..708e6b7 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ 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. `status` is read-only. Without `--online` it never touches the network; `--online` adds the latest published version and flags staleness, and 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. `--online` and `--path` are status-only: any other command rejects them with an explanatory error. +`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. `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. `--online` and `--path` are status-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. diff --git a/src/cli.ts b/src/cli.ts index 8e9b5f6..c89577d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -158,8 +158,10 @@ function printStatusReport(report: StatusReport): void { 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)}`, + `${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}`); @@ -183,7 +185,12 @@ function resolvedLabel(source: ResolvedSource | null): string { } function verdictLabel(effective: EffectiveVersion): string { - const kind = effective.source !== null ? sourceLabel(effective.source) : "manifest"; + const kind = + effective.source !== null + ? sourceLabel(effective.source) + : effective.version !== null + ? "manifest" + : "unresolved"; const latest = effective.latestVersion !== null ? `, latest ${effective.latestVersion}` : ""; const scopeLabel = effective.registeredScopes.length > 1 diff --git a/src/loaded-version.ts b/src/loaded-version.ts index c9f2ae7..4883240 100644 --- a/src/loaded-version.ts +++ b/src/loaded-version.ts @@ -6,6 +6,7 @@ import { PluginEntryResolver } from "./plugin-entry"; export interface ResolvedSource { version: string | null; + name: string | null; source: "cache" | "checkout"; path: string; modifiedMs: number | null; @@ -78,10 +79,11 @@ export class LoadedVersionResolver { const manifestPath = join(dir, "package.json"); if (!(await exists(manifestPath))) return null; try { - const parsed = JSON.parse(await readFile(manifestPath, "utf-8")) as { version?: unknown }; + 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, source, path: dir, modifiedMs: info.mtimeMs }; + return { version, name, source, path: dir, modifiedMs: info.mtimeMs }; } catch { return null; } diff --git a/src/status-reporter.ts b/src/status-reporter.ts index 9f98773..ca7499c 100644 --- a/src/status-reporter.ts +++ b/src/status-reporter.ts @@ -25,6 +25,7 @@ export interface ScopeStatusReport { entryName: string | null; entryForm: "npm" | "path" | null; resolved: ResolvedSource | null; + publishedVersion: string | null; warnings: string[]; } @@ -67,15 +68,16 @@ export class StatusReporter { public async report(projectDir: string, options: StatusReportOptions): Promise { const scopes = options.scopes ?? (["local", "global"] as Scope[]); - const reports: ScopeStatusReport[] = []; + let reports: ScopeStatusReport[] = []; for (const scope of scopes) { reports.push(await this.resolveScope(scope, projectDir)); } const warnings = this.crossScopeWarnings(reports); let effective = this.effectiveVersion(reports); - if (options.online) { - const online = await this.onlinePass(reports, effective); + const online = options.online ? await this.onlinePass(reports, effective) : null; + if (online !== null) { warnings.push(...online.warnings); + reports = online.reports; effective = online.effective; } return { projectDir, scopes: reports, effective, warnings }; @@ -114,6 +116,7 @@ export class StatusReporter { entryName: null, entryForm: null, resolved: null, + publishedVersion: null, warnings, }; } @@ -140,6 +143,7 @@ export class StatusReporter { entryName: matched.name, entryForm: matched.form, resolved, + publishedVersion: null, warnings, }; } @@ -202,37 +206,47 @@ export class StatusReporter { private async onlinePass( reports: ScopeStatusReport[], effective: EffectiveVersion, - ): Promise<{ warnings: string[]; effective: EffectiveVersion }> { + ): Promise<{ warnings: string[]; reports: ScopeStatusReport[]; effective: EffectiveVersion }> { const warnings: string[] = []; const checker = new RegistryVersionChecker(this.fetchFn); const latest = new Map(); const failures = new Map(); for (const report of reports) { - if (!report.registered || report.entryName === null || latest.has(report.entryName)) continue; - const lookup = await checker.latest(report.entryName); + const name = this.lookupName(report); + if (name === null || latest.has(name)) continue; + const lookup = await checker.latest(name); if (lookup.warning !== null) { - failures.set(report.entryName, lookup.warning); - latest.set(report.entryName, null); + failures.set(name, lookup.warning); + latest.set(name, null); } else { - latest.set(report.entryName, lookup.version); + latest.set(name, lookup.version); } } + const updated: ScopeStatusReport[] = []; for (const report of reports) { - if (!report.registered || report.entryName === null) continue; - const failure = failures.get(report.entryName); - if (failure !== undefined) { - warnings.push(`${report.scope}: ${failure}`); - continue; - } - const published = latest.get(report.entryName) ?? null; - const resolvedVersion = report.resolved?.version ?? null; - if (published !== null && resolvedVersion !== null && published !== resolvedVersion) { - warnings.push(`${report.scope}: resolves ${resolvedVersion} but npm publishes ${published} (stale)`); + 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 }); } - const effectiveReport = reports.find((report) => report.registered && report.scope === effective.scope); - const effectiveName = effectiveReport?.entryName ?? null; - const effectiveLatest = effectiveName !== null && !failures.has(effectiveName) ? latest.get(effectiveName) ?? null : null; - return { warnings, effective: { ...effective, latestVersion: effectiveLatest } }; + const effectiveReport = updated.find((report) => report.registered && report.scope === effective.scope); + return { + warnings, + reports: updated, + effective: { ...effective, latestVersion: effectiveReport?.publishedVersion ?? null }, + }; + } + + private lookupName(report: ScopeStatusReport): string | null { + if (!report.registered) return null; + if (report.entryName !== null) return report.entryName; + return report.resolved?.name ?? null; } } diff --git a/templates/cli.template.txt b/templates/cli.template.txt index ffbefae..936e60c 100644 --- a/templates/cli.template.txt +++ b/templates/cli.template.txt @@ -9,7 +9,7 @@ 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` resolves the effective load per scope — registration, target config file, raw entry as written, resolved source on disk with a relative-age staleness hint, 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`. +**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 @@ -136,11 +136,12 @@ export async function runCli(argv: string[]): Promise { 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}: mode=${scopeStatus.mode ?? "unknown"}, ` + `config=${scopeStatus.entryConfigPath ?? "-"}, ` + `entry=${scopeStatus.entry ?? "-"}, ` + - `resolved=${resolvedLabel(scopeStatus)}` + `resolved=${resolvedLabel(scopeStatus)}${npm}` ); for (const warning of scopeStatus.warnings) console.warn(`Warning: ${warning}`); } @@ -234,7 +235,14 @@ function resolvedLabel(scopeStatus: ScopeStatus): string { } function verdictLabel(effective: EffectiveStatus): string { - const kind = effective.source === "cache" ? "cache copy" : effective.source === "checkout" ? "checkout copy" : "manifest"; + const kind = + effective.source !== null + ? effective.source === "cache" + ? "cache copy" + : "checkout copy" + : effective.version !== null + ? "manifest" + : "unresolved"; const latest = effective.latestVersion !== null ? `, latest ${effective.latestVersion}` : ""; const scopeLabel = effective.registeredScopes.length > 1 diff --git a/templates/installer.template.txt b/templates/installer.template.txt index 2015d96..76ca0c3 100644 --- a/templates/installer.template.txt +++ b/templates/installer.template.txt @@ -59,6 +59,8 @@ export interface ScopeStatus { resolvedVersion: string | null; resolvedSource: "cache" | "checkout" | null; resolvedModifiedMs: number | null; + resolvedName: string | null; + publishedVersion: string | null; registered: boolean; warnings: string[]; } @@ -79,6 +81,7 @@ export interface StatusResult { } interface ResolvedEntrySource { + name: string | null; version: string | null; source: "cache" | "checkout"; path: string; @@ -405,8 +408,8 @@ export async function uninstall(scope: Scope, projectDir: string = process.cwd() 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); - const local = await scopeStatus("local", projectDir, inventory.local); - const global = await scopeStatus("global", projectDir, inventory.global); + 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}`); @@ -415,6 +418,8 @@ export async function status(projectDir: string = process.cwd(), online: boolean if (online) { const outcome = await applyOnline([local, global], effective, fetchFn); warnings.push(...outcome.warnings); + local = outcome.scopes[0] ?? null; + global = outcome.scopes[1] ?? null; effective = outcome.effective; } return { local, global, effective, warnings }; @@ -430,12 +435,14 @@ async function scopeStatus(scope: Scope, projectDir: string, entries: RawConfigE 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); 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 { @@ -457,6 +464,8 @@ async function scopeStatus(scope: Scope, projectDir: string, entries: RawConfigE resolvedVersion, resolvedSource, resolvedModifiedMs: resolvedModifiedMs, + resolvedName, + publishedVersion: null, registered: matched !== null, warnings, }; @@ -522,10 +531,11 @@ async function readCopyManifest(dir: string, source: "cache" | "checkout"): Prom const manifestPath = join(dir, "package.json"); if (!(await exists(manifestPath))) return null; try { - const parsed = JSON.parse(await readFile(manifestPath, "utf-8")) as { version?: unknown }; + 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, source, path: dir, modifiedMs: info.mtimeMs }; + return { version, name, source, path: dir, modifiedMs: info.mtimeMs }; } catch { return null; } @@ -580,32 +590,45 @@ async function applyOnline( scopes: Array, effective: EffectiveStatus, fetchFn: typeof fetch, -): Promise<{ warnings: string[]; effective: EffectiveStatus }> { +): Promise<{ warnings: string[]; scopes: Array; effective: EffectiveStatus }> { 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 || scopeStatusValue.entryName === null) continue; - if (!lookups.has(scopeStatusValue.entryName)) { - lookups.set(scopeStatusValue.entryName, await fetchLatestVersion(scopeStatusValue.entryName, fetchFn)); - } + 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 || scopeStatusValue.entryName === null) continue; - const lookup = lookups.get(scopeStatusValue.entryName); - if (lookup === undefined) continue; + 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 }); } - const effectiveScopeStatus = scopes.find( + const effectiveScopeStatus = updated.find( (scopeStatusValue) => scopeStatusValue !== null && scopeStatusValue.registered && scopeStatusValue.scope === effective.scope, ); - const latest = effectiveScopeStatus?.entryName != null ? lookups.get(effectiveScopeStatus.entryName)?.version ?? null : null; - return { warnings, effective: { ...effective, latestVersion: latest } }; + const effectiveName = effectiveScopeStatus !== null && effectiveScopeStatus !== undefined ? lookupName(effectiveScopeStatus) : null; + const latest = effectiveName !== null ? lookups.get(effectiveName)?.version ?? null : null; + return { warnings, scopes: updated, effective: { ...effective, latestVersion: latest } }; } async function fetchLatestVersion(name: string, fetchFn: typeof fetch): Promise<{ version: string | null; warning: string | null }> { diff --git a/tests/status-effective-version.test.ts b/tests/status-effective-version.test.ts index d7eda0f..23d4f12 100644 --- a/tests/status-effective-version.test.ts +++ b/tests/status-effective-version.test.ts @@ -368,10 +368,31 @@ describe("StatusReporter.report", () => { 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'); @@ -379,6 +400,7 @@ describe("StatusReporter.report", () => { 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"); }); From 0cd933e94993f7aa6366ba994ebd6f3e54fe0852 Mon Sep 17 00:00:00 2001 From: diegohb Date: Thu, 8 Oct 2026 21:54:08 -0400 Subject: [PATCH 5/6] fix(status): the effective verdict answers from the registration spec when no copy is cached A pinned entry like opencode-architect@1.0.0 with no cached copy and no manifest produced 'should load: unknown' even though the spec itself names the version the host will fetch at next start. The verdict now answers from the strongest source available: a resolved copy on disk (cache copy / checkout copy), else the spec (a pinned exact semver answers as 'pinned spec'; @latest answers with the published version under --online as 'npm latest'; ranges stay unresolved), else the manifest version. --online runs before the verdict is computed so the published version can feed it. Reported against bunx opencode-architect status --online --path . --- CHANGELOG.md | 2 +- README.md | 2 +- src/cli.ts | 20 +++++--- src/status-reporter.ts | 71 +++++++++++++++++++------- templates/cli.template.txt | 22 ++++---- templates/installer.template.txt | 62 ++++++++++++++++------ tests/cli.test.ts | 20 +++++++- tests/status-effective-version.test.ts | 30 +++++++++++ 8 files changed, 172 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c65426..f34c9fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ 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. 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. +- `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. diff --git a/README.md b/README.md index 708e6b7..c5adf0b 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ 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. `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. `--online` and `--path` are status-only: any other command rejects them with an explanatory error. +`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. `--online` and `--path` are status-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. diff --git a/src/cli.ts b/src/cli.ts index c89577d..98a8968 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -185,18 +185,22 @@ function resolvedLabel(source: ResolvedSource | null): string { } function verdictLabel(effective: EffectiveVersion): string { - const kind = - effective.source !== null - ? sourceLabel(effective.source) - : effective.version !== null - ? "manifest" - : "unresolved"; - const latest = effective.latestVersion !== null ? `, latest ${effective.latestVersion}` : ""; + 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"} (${kind}, ${scopeLabel}${latest})`; + 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 { diff --git a/src/status-reporter.ts b/src/status-reporter.ts index ca7499c..442c8e0 100644 --- a/src/status-reporter.ts +++ b/src/status-reporter.ts @@ -23,15 +23,18 @@ export interface ScopeStatusReport { 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; - source: "cache" | "checkout" | null; + versionKind: VersionKind; scope: Scope | null; latestVersion: string | null; registeredScopes: Scope[]; @@ -54,6 +57,7 @@ interface MatchedEntry { entry: unknown; display: string; name: string | null; + version: string | null; form: "npm" | "path"; } @@ -73,13 +77,12 @@ export class StatusReporter { reports.push(await this.resolveScope(scope, projectDir)); } const warnings = this.crossScopeWarnings(reports); - let effective = this.effectiveVersion(reports); - const online = options.online ? await this.onlinePass(reports, effective) : null; - if (online !== null) { + if (options.online) { + const online = await this.onlinePass(reports); warnings.push(...online.warnings); reports = online.reports; - effective = online.effective; } + const effective = this.effectiveVersion(reports); return { projectDir, scopes: reports, effective, warnings }; } @@ -114,6 +117,7 @@ export class StatusReporter { configPath: null, entryText: null, entryName: null, + entryVersion: null, entryForm: null, resolved: null, publishedVersion: null, @@ -141,6 +145,7 @@ export class StatusReporter { configPath: matched.configPath, entryText: matched.display, entryName: matched.name, + entryVersion: matched.version, entryForm: matched.form, resolved, publishedVersion: null, @@ -160,6 +165,7 @@ export class StatusReporter { entry: raw, display: classified.display, name: classified.name, + version: classified.version, form: "npm", }; } @@ -171,6 +177,7 @@ export class StatusReporter { entry: raw, display: classified.display, name: null, + version: null, form: "path", }; } @@ -192,21 +199,48 @@ export class StatusReporter { 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.resolved?.version ?? report.manifestVersion, - source: report.resolved?.source ?? null, + version: report.manifestVersion, + versionKind: report.manifestVersion !== null ? "manifest" : null, scope: report.scope, - latestVersion: null, + latestVersion: report.publishedVersion, registeredScopes, }; } - return { version: null, source: null, scope: null, latestVersion: null, 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[], - effective: EffectiveVersion, - ): Promise<{ warnings: string[]; reports: ScopeStatusReport[]; effective: EffectiveVersion }> { + private async onlinePass(reports: ScopeStatusReport[]): Promise<{ warnings: string[]; reports: ScopeStatusReport[] }> { const warnings: string[] = []; const checker = new RegistryVersionChecker(this.fetchFn); const latest = new Map(); @@ -236,12 +270,7 @@ export class StatusReporter { } updated.push({ ...report, publishedVersion: published }); } - const effectiveReport = updated.find((report) => report.registered && report.scope === effective.scope); - return { - warnings, - reports: updated, - effective: { ...effective, latestVersion: effectiveReport?.publishedVersion ?? null }, - }; + return { warnings, reports: updated }; } private lookupName(report: ScopeStatusReport): string | null { @@ -250,3 +279,7 @@ export class StatusReporter { 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 936e60c..567a41e 100644 --- a/templates/cli.template.txt +++ b/templates/cli.template.txt @@ -235,20 +235,22 @@ function resolvedLabel(scopeStatus: ScopeStatus): string { } function verdictLabel(effective: EffectiveStatus): string { - const kind = - effective.source !== null - ? effective.source === "cache" - ? "cache copy" - : "checkout copy" - : effective.version !== null - ? "manifest" - : "unresolved"; - const latest = effective.latestVersion !== null ? `, latest ${effective.latestVersion}` : ""; + 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"} (${kind}, ${scopeLabel}${latest})`; + 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) { diff --git a/templates/installer.template.txt b/templates/installer.template.txt index 76ca0c3..f4a4950 100644 --- a/templates/installer.template.txt +++ b/templates/installer.template.txt @@ -54,6 +54,7 @@ export interface ScopeStatus { mode: InstallMode | null; entry: string | null; entryName: string | null; + entryVersion: string | null; entryConfigPath: string | null; pluginInConfig: boolean; resolvedVersion: string | null; @@ -67,7 +68,7 @@ export interface ScopeStatus { export interface EffectiveStatus { version: string | null; - source: "cache" | "checkout" | null; + versionKind: "cache" | "checkout" | "spec" | "npm" | "manifest" | null; scope: Scope | null; latestVersion: string | null; registeredScopes: Scope[]; @@ -414,14 +415,14 @@ export async function status(projectDir: string = process.cwd(), online: boolean 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(local, global); + let effective: EffectiveStatus; if (online) { - const outcome = await applyOnline([local, global], effective, fetchFn); + const outcome = await applyOnline([local, global], fetchFn); warnings.push(...outcome.warnings); local = outcome.scopes[0] ?? null; global = outcome.scopes[1] ?? null; - effective = outcome.effective; } + effective = effectiveStatus(local, global); return { local, global, effective, warnings }; } @@ -431,6 +432,7 @@ async function scopeStatus(scope: Scope, projectDir: string, entries: RawConfigE 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; @@ -459,6 +461,7 @@ async function scopeStatus(scope: Scope, projectDir: string, entries: RawConfigE 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, @@ -575,22 +578,54 @@ function effectiveStatus(local: ScopeStatus | null, global: ScopeStatus | null): 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.resolvedVersion ?? scopeStatusValue.version, - source: scopeStatusValue.resolvedSource, + version: scopeStatusValue.version, + versionKind: scopeStatusValue.version !== null ? "manifest" : null, scope: scopeStatusValue.scope, - latestVersion: null, + latestVersion: scopeStatusValue.publishedVersion, registeredScopes, }; } - return { version: null, source: null, scope: null, latestVersion: null, registeredScopes }; + 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, - effective: EffectiveStatus, fetchFn: typeof fetch, -): Promise<{ warnings: string[]; scopes: Array; effective: EffectiveStatus }> { +): Promise<{ warnings: string[]; scopes: Array }> { const warnings: string[] = []; const lookups = new Map(); const lookupName = (scopeStatusValue: ScopeStatus): string | null => @@ -623,12 +658,7 @@ async function applyOnline( } updated.push({ ...scopeStatusValue, publishedVersion: lookup.version }); } - const effectiveScopeStatus = updated.find( - (scopeStatusValue) => scopeStatusValue !== null && scopeStatusValue.registered && scopeStatusValue.scope === effective.scope, - ); - const effectiveName = effectiveScopeStatus !== null && effectiveScopeStatus !== undefined ? lookupName(effectiveScopeStatus) : null; - const latest = effectiveName !== null ? lookups.get(effectiveName)?.version ?? null : null; - return { warnings, scopes: updated, effective: { ...effective, latestVersion: latest } }; + return { warnings, scopes: updated }; } async function fetchLatestVersion(name: string, fetchFn: typeof fetch): Promise<{ version: string | null; warning: string | null }> { diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 5022428..2c7faf1 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -211,8 +211,24 @@ describe("cli status", () => { expect(onlineRun.stderr).toContain("--online"); }); - 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-")); + 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 { diff --git a/tests/status-effective-version.test.ts b/tests/status-effective-version.test.ts index 23d4f12..bdeeae7 100644 --- a/tests/status-effective-version.test.ts +++ b/tests/status-effective-version.test.ts @@ -314,10 +314,40 @@ describe("StatusReporter.report", () => { 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("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'); From 0601dcd6432c64fbc003d1cc41be0abd0c2287eb Mon Sep 17 00:00:00 2001 From: diegohb Date: Thu, 8 Oct 2026 22:19:43 -0400 Subject: [PATCH 6/6] feat(status): --package resolves another installed package by name --- README.md | 3 +- src/cli.ts | 31 ++++++++++-- src/installer.ts | 14 ++++-- src/status-reporter.ts | 21 +++++---- tests/cli.test.ts | 45 +++++++++++++++++- tests/status-effective-version.test.ts | 65 ++++++++++++++++++++++++++ 6 files changed, 160 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index c5adf0b..102aa9f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ bunx opencode-architect status # resolve what a session would l 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) @@ -48,7 +49,7 @@ 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. `--online` and `--path` are status-only: any other command rejects them with an explanatory error. +`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. diff --git a/src/cli.ts b/src/cli.ts index 98a8968..b219b9f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,6 +45,14 @@ export async function runCli(argv: string[]): Promise { 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".`); @@ -104,7 +112,11 @@ export async function runCli(argv: string[]): Promise { case "status": { 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 }); + const report = await new StatusReporter(installer).report(projectDir, { + scopes, + online: values.online, + packageName: values.package, + }); printStatusReport(report); break; } @@ -155,6 +167,7 @@ if (import.meta.main) { } function printStatusReport(report: StatusReport): void { + console.log(`package: ${report.packageName}`); for (const scopeReport of report.scopes) { const entry = scopeReport.entryText ?? "-"; const config = scopeReport.configPath ?? "-"; @@ -227,6 +240,12 @@ function stripQuotes(value: string): string { 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} @@ -241,7 +260,8 @@ Commands: uninstall Remove the plugin entry, the manifest, and any residual payload 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 + 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 @@ -256,8 +276,10 @@ Options: 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 clear-cache: remove and every @* instead; - requires --yes + --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 @@ -273,6 +295,7 @@ Examples: 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/installer.ts b/src/installer.ts index b14b103..79654f8 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -238,11 +238,13 @@ export class Installer { return removed; } - public async manifestAt(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 null; - return { mode: manifest.mode, version: manifest.version }; + 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 null; } public async hasManifestAnywhere(projectDir: string): Promise { @@ -288,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/status-reporter.ts b/src/status-reporter.ts index 442c8e0..8423864 100644 --- a/src/status-reporter.ts +++ b/src/status-reporter.ts @@ -12,7 +12,7 @@ export interface ManifestState { } export interface ManifestLookup { - manifestAt(scope: Scope, projectDir: string): Promise; + manifestAt(scope: Scope, projectDir: string, packageName?: string): Promise; } export interface ScopeStatusReport { @@ -42,6 +42,7 @@ export interface EffectiveVersion { export interface StatusReport { projectDir: string; + packageName: string; scopes: ScopeStatusReport[]; effective: EffectiveVersion; warnings: string[]; @@ -50,6 +51,7 @@ export interface StatusReport { export interface StatusReportOptions { scopes: Scope[] | null; online: boolean; + packageName?: string; } interface MatchedEntry { @@ -72,9 +74,10 @@ export class StatusReporter { 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)); + reports.push(await this.resolveScope(scope, projectDir, packageName)); } const warnings = this.crossScopeWarnings(reports); if (options.online) { @@ -83,7 +86,7 @@ export class StatusReporter { reports = online.reports; } const effective = this.effectiveVersion(reports); - return { projectDir, scopes: reports, effective, warnings }; + return { projectDir, packageName, scopes: reports, effective, warnings }; } public static formatAge(modifiedMs: number | null): string | null { @@ -100,14 +103,14 @@ export class StatusReporter { return `${Math.floor(days / 365)}y old`; } - private async resolveScope(scope: Scope, projectDir: string): Promise { + 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); - const matched = await this.findMatchedEntry(entries); + const manifestState = await this.manifests.manifestAt(scope, projectDir, packageName); + const matched = await this.findMatchedEntry(entries, packageName); if (matched === null) { return { scope, @@ -153,13 +156,13 @@ export class StatusReporter { }; } - private async findMatchedEntry(entries: ConfigEntries[]): Promise { + 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 === PACKAGE_NAME) { + if (classified.name === packageName) { return { configPath: candidate.configPath, entry: raw, @@ -171,7 +174,7 @@ export class StatusReporter { } continue; } - if (await PluginEntryResolver.resolvesToPackage(raw, PACKAGE_NAME)) { + if (await PluginEntryResolver.resolvesToPackage(raw, packageName)) { return { configPath: candidate.configPath, entry: raw, diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 2c7faf1..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, ".."); @@ -211,6 +212,48 @@ describe("cli status", () => { 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-")); diff --git a/tests/status-effective-version.test.ts b/tests/status-effective-version.test.ts index bdeeae7..28187d3 100644 --- a/tests/status-effective-version.test.ts +++ b/tests/status-effective-version.test.ts @@ -301,6 +301,51 @@ describe("StatusReporter registration detection", () => { 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", () => { @@ -348,6 +393,26 @@ describe("StatusReporter.report", () => { 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');