diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 935b625..1ff1d54 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -1,6 +1,7 @@ { "$schema": "https://opencode.ai/config.json", - "plugin": [ - "../index.ts" + "plugins": [ + "./" ] + } \ No newline at end of file diff --git a/CONTEXT.md b/CONTEXT.md index 382aa3b..5dcb6d1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -245,11 +245,12 @@ covering stable OpenCode fundamentals. **Templates**: The static scaffolding files bundled with the package at `templates/`, -from which the packager and publisher render a generated package's code -files (plugin entry, manifest, name normalizer, registration detector, CLI, -installer). The structural source of truth for generated packages: example -repos may lag the corrected install pattern. Consumed at package-build -time, unlike references, which agents read for knowledge. +from which the packager, publisher, and scaffold CLI render a generated +package's adapter files (plugin entry, CLI, installer, package manifests). +Adapter renderings, not a structural source of truth (ADR-0013): the shared +machinery those files used to vendor is the `opencode-architect/core` module, +consumed by generated packages through a local `file:` dependency. Consumed +at package-build time, unlike references, which agents read for knowledge. **Reference resolution**: The load-time rewriting of backtick-quoted relative paths in agent prompts — diff --git a/agents/opencode-packager.md b/agents/opencode-packager.md index b39a93b..8ada961 100644 --- a/agents/opencode-packager.md +++ b/agents/opencode-packager.md @@ -27,7 +27,9 @@ You package OpenCode extensions for local sharing across projects as standalone In this-workspace mode, when the root already holds a `package.json` or a `plugin.ts` (root or `src/`), merge rather than overwrite: obtain user consent for every merge decision and route structural conflicts to opencode-plugin-engineer (same delegation as step 4). When the merge is extensive (both files exist with substantive content), recommend sibling mode instead and proceed there if the user agrees. -2. **Copy assets to the package root.** Skills, commands, and agents are copied, because consumers must read and edit them in their own `.opencode/`; commands in particular have no config registration, so copying is the only mechanism. The layout is package-root content directories — `skills//`, `commands/`, `agents/` — with no `assets/` intermediary (skills.sh-style scanners discover repo-root `skills/`). The installer resolves the package root through `ASSET_LAYOUT_DIR = "."` and fails loudly when a content directory is absent; only when adapting a legacy `assets/`-layout package is the constant overridden to `"assets"`. Result: skills//SKILL.md, commands/.md, agents/.md, mirroring the source. +1d. **Delegate the mechanical scaffold to the CLI.** Generating the conformant package tree and the promote mechanics are code, not agent work: run `bunx opencode-architect scaffold --name opencode-` for a fresh package (`--target here|sibling`, `--ship skills,commands,agents,tools,plugins`), or `bunx opencode-architect scaffold --promote [--yes]` to take existing `.opencode/` extensions into a package. The CLI renders the bundled templates (steps 2, 5, 6, and 7's file generation), derives the content declaration and `files` list from the shipped inventory, ensures the config reference through the surgical editor, runs the package's install, verifies the manifest and payload, and retires the promoted originals only on explicit consent (`--yes`; without it, it prints what it would remove). It aborts retirement — leaving the source in place — when the config reference cannot be established. Your work is the judgment the CLI does not make: author and refine extension content, make merge decisions (step 4 routing to opencode-plugin-engineer stands), review the generated tree, and re-run the step-10 self-audit gate before reporting done. Never hand-copy template bodies to build or extend a package; re-run the CLI instead. + +2. **Copy assets to the package root.** Skills, commands, and agents are copied, because consumers must read and edit them in their own `.opencode/`; commands in particular have no config registration, so copying is the only mechanism. The layout is package-root content directories — `skills//`, `commands/`, `agents/` — with no `assets/` intermediary (skills.sh-style scanners discover repo-root `skills/`). The installer resolves the package root through `ASSET_LAYOUT_DIR = "."` and fails loudly when a content directory is absent; only when adapting a legacy `assets/`-layout package is the constant overridden to `"assets"`. Result: skills//SKILL.md, commands/.md, agents/.md, mirroring the source. The scaffold CLI (step 1d) performs this copy; verify its result instead of re-copying by hand. 3. **Merge dependencies.** Read `.opencode/package.json` when present; carry its dependencies and peerDependencies into the generated package.json. Report them: "Including 1 dependency from .opencode/package.json: zod". @@ -45,13 +47,13 @@ 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. 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. +6. **Create src/plugin.ts** from `../templates/plugin-local.template.txt`, plus `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 machinery — name matching, entry classification and path resolution, the surgical config editor, manifest io, registration detection, cache paths, and the status pipeline — is not vendored: package.json declares a local `file:` dependency on `opencode-architect` and the adapters import it from the `opencode-architect/core` subpath (ADR-0013). 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 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()` delegates to the core status pipeline and 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)". +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 scaffold renderer inserts the local `"opencode-architect": "file:…"` dependency the adapters import their machinery through (ADR-0013) — keep it local, never a registry spec. 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)". 8. **Create the README badge row.** Build the package's `README.md` with the badge row directly below the first heading (a tagline between heading and badges is non-conformant). The row is exactly the publisher step 4b markup — npm version, Bun runtime, License, Platforms (URL-encoded, matching the repo's actual platforms), the fixed OpenCode plugin badge, and DeepWiki when indexed (confirm via a `deepwiki.com//` fetch or a DeepWiki MCP query — never assume). Never substitute custom badges: no "Bun tested", "TypeScript", or other hand-rolled variants, no extra badges, none missing — any deviation from the 4b markup is D7 non-conformant. Include the License badge only when the repo is MIT-licensed, adjusting label and color otherwise; omit DeepWiki only when the repo is not indexed. The publisher re-verifies this row; emitting it here keeps locally-used packages conformant too. -9. **Retire the promoted source.** The original `.opencode/` extensions this package was built from are removed only after the package provably serves the content, in this order: +9. **Retire the promoted source.** `scaffold --promote` (step 1d) performs this flow mechanically — reference ensure, install, payload verification, consent-gated deletion. Perform it by hand only when the CLI could not run (for example, inside a host without bunx), following exactly this order: a. **Ensure the reference.** Inspect the scope configs (project `.opencode/opencode.json(c)`, repo-root `opencode.json(c)`, both extensions) for a live reference to the new package — a `plugins` entry (`file:///` URL or package spec) or, for skills-only packages, a `skills` entry pointing into the package (facts §5, §7). When none exists, offer to add one through the surgical `PluginConfigEditor` (user consents to scope and form; entries validated against the config schema; written under the v2 `plugins` key; canonical `file:///` form). If the user declines or the write is blocked, stop — report that the source was left in place and why. Never delete with nothing pointing at the package: the extension would silently vanish from the next start. b. **Ensure and verify the payload.** Trigger the install path (`bun run index.ts install --scope local`, or the load hook on a start) and verify the scope now holds the content — the install manifest present and the promoted files (e.g. `skills//SKILL.md`) on disk matching the packaged copies. Deletion never precedes a verified payload. @@ -59,8 +61,8 @@ opencode-myextension/ 10. **Self-audit gate — verify before reporting done.** Do not trust the plan; read the tree. Verify every item below by inspecting the actual files, fix any miss (rebuilding from the templates, not hand-patching), and only then report done. A gate item you cannot verify is reported as a failure, never waved through. - - **File inventory.** `index.ts` (re-export + `import.meta.main` CLI shim) is the only TypeScript file at the package root, `src/plugin.ts`, `src/plugin-name.ts`, `src/manifest.ts`, `src/registration.ts`, `src/plugin-config.ts`, `src/installer.ts`, `src/cli.ts`, the content directories actually shipped (`skills//SKILL.md`, plus `commands/`, `agents/`, `plugins/`, `tools/` as present), `package.json`, `tsconfig.json`, `README.md`, `AGENTS.md`, `tests/`. - - **plugin.ts is the thin hook.** `src/plugin.ts` imports the installer and registration detector from its sibling `src/` modules and contains advisory + detection logic only. A `src/plugin.ts` that inlines the manifest, installer, registration detector, or config-editor code (the monolith pattern) is a structural failure — restructure into the sibling modules from the templates. + - **File inventory.** `index.ts` (re-export + `import.meta.main` CLI shim) is the only TypeScript file at the package root, `src/plugin.ts`, `src/installer.ts`, `src/cli.ts`, the content directories actually shipped (`skills//SKILL.md`, plus `commands/`, `agents/`, `plugins/`, `tools/` as present), `package.json`, `tsconfig.json`, `README.md`, `AGENTS.md`, `tests/`. No machinery files (`src/plugin-name.ts`, `src/manifest.ts`, `src/registration.ts`, `src/plugin-config.ts`, `src/plugin-entry.ts`, `src/entry-predicate.ts`) — that machinery comes from the `opencode-architect/core` dependency (ADR-0013). + - **plugin.ts is the thin hook.** `src/plugin.ts` imports the installer from its sibling `src/installer.ts` and detection from the core dependency, and contains advisory + detection logic only. A `src/plugin.ts` that inlines the manifest, installer, registration detector, or config-editor code (the monolith pattern) is a structural failure — restructure: machinery belongs to `opencode-architect/core`, orchestration to the sibling adapter files. - **package.json.** `bin` maps `` to `src/cli.ts`; `exports` declares `"./server"` (never rely on the root-index fallback — per opencode-v2-facts §14.6); `files` lists the shipped root content directories plus `src` (never `assets`); `content` declaration matches the inventory; `check`/`test` scripts present. - **Badge row.** One single line, directly below the first heading, byte-for-byte the publisher 4b badges (npm version, Bun runtime, License, URL-encoded Platforms, fixed OpenCode plugin badge, DeepWiki when indexed). Multi-line rows and custom badges are failures. - **Frontmatter.** Every frontmatter property value in every shipped markdown file is enclosed in double quotation marks (D6). @@ -74,11 +76,6 @@ Done when every self-audit gate item verifies against the tree and the plugin pe - `../templates/package-basics.template.json` - `../templates/index.template.txt` - `../templates/plugin-local.template.txt` -- `../templates/plugin-name.template.txt` -- `../templates/manifest.template.txt` -- `../templates/registration.template.txt` -- `../templates/plugin-config.template.txt` -- `../templates/plugin-entry.template.txt` - `../templates/installer.template.txt` - `../templates/cli.template.txt` - `../templates/tsconfig.template.json` diff --git a/agents/opencode-publisher.md b/agents/opencode-publisher.md index d585433..cc908fb 100644 --- a/agents/opencode-publisher.md +++ b/agents/opencode-publisher.md @@ -19,9 +19,9 @@ You are an OpenCode extension publisher: you transform locally-packaged extensio 1. **Verify the incoming package.** Confirm the packager's structure exists: bundled content directories at the package root (`skills/`, `commands/`; a legacy `assets/` wrapper is recognized but non-default), `src/plugin.ts` with inline install logic, minimal `package.json` with a `content` declaration (`assets` or `code`) and an `exports` map declaring `"./server"` (per opencode-v2-facts §7, §14.6), `tsconfig.json`. Read the packager summary for extension name, description, included assets, dependencies, warnings, and the content declaration. Confirm every asset landed in the package and custom plugins or tools got their merge decisions. Cross-check the declaration against the bundled assets: `assets` requires skills/commands only — any agent, tool, or plugin file in the package contradicts it and returns to the orchestrator for repackaging; `code` is valid for any inventory. An invalid structure returns to the orchestrator for repackaging. -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. +2. **Verify the install logic is adapter-shaped.** src/installer.ts holds install(), uninstall(), status(), migrateRootConfig(), and the self-scoped cache pruning; src/plugin.ts only calls them; the machinery — manifest io, plugin-name matching, registration detection, the surgical config editor, path-form entry resolution, cache paths, and the status pipeline — is not vendored in the package: it comes from the `opencode-architect/core` subpath of the local `file:` dependency recorded in package.json (ADR-0013). 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, 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. +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 (suite-only, per the declared divergences in `../references/template-sync-table.md` — that table, not prose, is the record of every template↔suite divergence). 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. @@ -72,14 +72,10 @@ Done when the package is live and the user has the registry URL plus consumer in ## Templates - `../templates/package-full.template.json` - Full npm-ready package.json -- `../templates/installer.template.txt` - Shared install/uninstall/status module -- `../templates/plugin-name.template.txt` - Semantic plugin-name normalizer (src/plugin-name.ts) -- `../templates/manifest.template.txt` - Install manifest with per-file sha256 (src/manifest.ts) -- `../templates/registration.template.txt` - Read-only registration-scope detector (src/registration.ts) -- `../templates/plugin-config.template.txt` - Surgical config editor for the v2 `plugins` array (src/plugin-config.ts; legacy `plugin` entries tolerated read-only with an upgrade advisory — suite-level behavior, per opencode-v2-facts §13 row 10) -- `../templates/plugin-entry.template.txt` - Path-form plugin-entry resolver: matches a `file://` URL or filesystem entry to this package by `package.json` name (src/plugin-entry.ts) +- `../templates/installer.template.txt` - Adapter holding the package's policy constants and install/uninstall/status orchestration; machinery comes from `opencode-architect/core` (ADR-0013) - `../templates/cli.template.txt` - bunx CLI entry point - `../templates/prompts.template.txt` - Interactive confirmation helpers +- `../references/template-sync-table.md` - The sync table: every template↔suite pair classified (byte-synced / declared-divergent / fixture-covered) with the declared policy, plus the machinery pairs dissolved into the core dependency ## Code style rules diff --git a/docs/adr/0005-bundled-templates-resolved-like-references.md b/docs/adr/0005-bundled-templates-resolved-like-references.md index 7456c9d..4e6aeac 100644 --- a/docs/adr/0005-bundled-templates-resolved-like-references.md +++ b/docs/adr/0005-bundled-templates-resolved-like-references.md @@ -1,5 +1,11 @@ # Bundled templates resolved like references +> **Superseded in part (ADR-0013):** the resolution mechanics below stand, +> but the templates are no longer a structural source of truth for generated +> packages — they are adapter renderings over the `opencode-architect/core` +> dependency, and the machinery templates have been dissolved into that +> module. + The packager and publisher need scaffolding files (plugin entry, package manifests, CLI, installer) to render generated packages from. Shipping them as `@assets/...` pointers broke in every deployed mode: nothing rewrote the diff --git a/docs/adr/0013-core-module-carve-out.md b/docs/adr/0013-core-module-carve-out.md new file mode 100644 index 0000000..968bd43 --- /dev/null +++ b/docs/adr/0013-core-module-carve-out.md @@ -0,0 +1,113 @@ +# Core module carve-out: one deep module, two adapters + +The suite CLI and the generated-package CLI carried genuinely identical +machinery in parallel: name matching, entry classification and path +resolution, config reading and surgical splicing, manifest io + per-file +hashes, cache paths, and the effective-version pipeline. Templates vendored +that machinery into every scaffolded package; the sync table existed mostly +to police the resulting duplication. This ADR records the carve-out of that +machinery into one deep module, exported as a slim subpath +(`package.json` → `"exports": { "./core": "./src/core/index.ts" }`), and the +shift of templates from structural source of truth to adapter renderings. + +## Design twice + +### Design A — one engine, two thin shells + +Core exports a single package engine (`install` / `uninstall` / `status` / +`ensureAssets`) that owns the whole flow; the suite CLI and the generated CLI +become thin renderings over a policy object +(`{packageName, payload, assetLayoutDir, consentCallback, ...}`). + +- Depth: maximal at the interface (one call per operation), but the depth is + fake — the two flows genuinely diverge (suite: legacy copy-install + migration, agents/references/templates payload, package-broad cache + clearing; generated: skills/commands payload, consent-gated root-config + migration, self-scoped cache hygiene). Reconciling them forces boolean and + callback parameters into the policy object — ad hoc parameterization at + exactly the seam this ticket says must declare policy instead. +- Locality: every suite change ripples through the shared engine into + generated-package behavior; the shells are thin, so divergence hides in + flags rather than living in code. +- Seam placement: the seam is a configuration object — policy arrives as + data, and nothing in the type system says which adapter may do what. + +### Design B — machinery carve-out, adapters keep orchestration (winner) + +Core exports the primitives that are genuinely identical — environment +adapter, scope bases, cache paths and cache-target removal, name normalizer, +entry predicate and path resolver, config reader, registration detector, +config splicer, plugin-config editor, manifest io + per-file sha256 hashes, +loaded-version resolver, registry version check, and the status/effective- +version pipeline (already packageName-parameterized with an injected +`ManifestLookup`). Each package keeps its own orchestration, which is where +the real policy lives, and declares that policy as named constants at the top +of its adapter (package name, payload names, asset layout, allowed cache +commands, scope coverage). + +- Depth: each core module is deep in the ordinary sense — a small surface + (a class or a function family) over a large, invariant-carrying behavior + (ADR-0006's manifest gating, semantic entry matching, surgical splices, + ADR-0007 never-throw hooks all live inside core, not in adapters). +- Locality: the residual divergence (suite vs generated flows) sits in two + small adapters, and the sync table names exactly what diverges. +- Seam placement: the seam is the `opencode-architect/core` import path. + Policy is declared in adapter code, not threaded through parameters, and + the template sync table is the written policy of what each adapter keeps. + +### Why B wins + +A parameterizes what the ticket says to declare; B declares it. The +divergence manifest (references/template-sync-table.md) already names the +per-adapter policy — suite-only broad cache modes, generated-only consent- +gated migration — and B's seam makes that manifest executable: a divergence +is either an adapter-local constant or it is drift. A also couples the two +packages' release cadence at every behavior change, while B couples them +only where behavior is truly shared. + +## Manifest-format compatibility rule + +Before any generated package depends on the core module, the formats are +fixed as follows: + +1. Core's manifest io is the generated-package format, **frozen** at the + carve-out: `/.manifest.json` holding + `{version, mode?, entry?, entryConfigPath?, files: Record}`. Readers accept everything earlier templates wrote: a missing + or malformed manifest reads as "not installed" (drift), a missing `mode` + reads as `"copy"`, per-file sha256 hashes are the only idempotency + mechanism (ADR-0006). Adopting the core dependency is not a manifest + migration — file name, location, keys, and tolerance are unchanged. +2. The suite adapter's own manifest (`opencode-architect.manifest.json`, + `{version, mode, entry, configPath, content-hash, hashes: [{path, + hash}]}`) is a legacy format owned by the suite Installer; core never + reads or writes it, and the file name is reserved. +3. The shared status pipeline is format-agnostic: it consumes manifests only + through the injected `ManifestLookup` surface (`{mode, version}` per + scope), so both formats feed one effective-version computation. + +## Templates become adapter renderings + +Templates stop being a structural source of truth. The machinery templates +(`plugin-name`, `plugin-entry`, `entry-predicate`, `manifest`, +`registration`, `plugin-config`) dissolve: their content is the core module, +and generated packages import it via the `./core` subpath of a `file:` +dependency on `opencode-architect` recorded at render time. `installer` and +`cli` templates shrink to adapter renderings — policy constants plus +orchestration over core calls — that the scaffold renderer emits. +ADR-0005's resolution mechanics (bundled assets under the installed package, +backtick-relative addressing) stand; its role for these files as the truth +source of generated-package structure is superseded by the core dependency. + +## Consequences + +- Machinery bugs fix once; generated packages pick them up through the + dependency at their next install, not through re-scaffolding. +- Consumer packages gain a `file:`-based `opencode-architect` dependency; + scaffolding and promotion must establish the local link without registry + access (the rendered fixture proves the end-to-end path). +- The sync table loses its byte-synced machinery rows; what remains are the + adapter renderings and their declared policies. +- `promote` inherits the new shape automatically (it renders through the + same scaffold renderer) but must link the core dependency before running + the generated package's install. diff --git a/package.json b/package.json index 2fb8192..fa35c05 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "module": "index.ts", "exports": { ".": "./index.ts", - "./server": "./index.ts" + "./server": "./index.ts", + "./core": "./src/core/index.ts" }, "bin": { "opencode-architect": "src/cli.ts" diff --git a/references/conformance-checklist.md b/references/conformance-checklist.md index 9d175c4..44ee96e 100644 --- a/references/conformance-checklist.md +++ b/references/conformance-checklist.md @@ -46,7 +46,10 @@ item; an item with no evidence found is a finding. `@*` idempotently (nothing cached is a success) with the same warn-and-continue semantics; a `--package ` or `--all` mode on a generated package's CLI is non-conformant — broad cache deletion belongs - to the suite's own CLI only. The load-time hook never deletes cache + to the suite's own CLI only (one of the declared generated↔suite + divergences in `references/template-sync-table.md`, which classifies every + template↔suite pair and its policy; do not restate pair states here). + The load-time hook never deletes cache entries (deletion races OpenCode's in-flight installs, ADR-0007): when bundled assets are absent (partial cache artifact), its advisory instructs running `bunx clear-cache` and reinstalling. A hook diff --git a/references/template-sync-table.md b/references/template-sync-table.md new file mode 100644 index 0000000..8ce4d10 --- /dev/null +++ b/references/template-sync-table.md @@ -0,0 +1,64 @@ +# Template sync table + +The one classification of every mirrored `templates/` suite pair, answering +"are the templates and the code in sync?" without reading files two at a time. +Enforced by `tests/sync-table.test.ts`, which fails when this table and the +enforcement disagree - never restate a pair's sync state anywhere else; +reference this table. + +Since the core carve-out (ADR-0013) the templates are no longer a structural +source of truth: the generated package consumes the shared machinery through +the `opencode-architect/core` subpath of a local `file:` dependency, and the +remaining templates are adapter renderings the scaffold renderer emits. + +Three states per pair: + +- **byte-synced** - the test asserts the suite source equals the template body + byte-for-byte. A change to one side must land on the other in the same commit. +- **declared-divergent** - the pair differs on purpose, and the policy names the + difference; the test asserts that policy difference itself, not mere inequality. +- **fixture-covered** - a rendered-only file exercised by the rendered fixture in + `tests/templates.test.ts`, which scaffolds a full package, links the core + dependency, and runs typecheck, its own test gate, and install/status/uninstall + against it. + +## The table + +| Template | Suite side / rendered target | State | Declared policy | +| --- | --- | --- | --- | +| `index.template.txt` | `index.ts` | byte-synced | - | +| `cli.template.txt` | `src/cli.ts` | declared-divergent | suite-only broad clear-cache modes (`--package`/`--all`/`--yes`/`--dry-run`) and status `--package` lookup; generated-only consent-gated `migrate` subcommand; both are thin adapters over the core dependency | +| `installer.template.txt` | `src/installer.ts` | declared-divergent | suite installer is an `Installer` class orchestrating the suite payload; the generated installer is a function-style adapter whose policy constants (package, skill, command, asset layout) sit over the core dependency | +| `package-full.template.json` | `templates/package-basics.template.json` | declared-divergent | publisher-only npm-field expansion of package-basics (`repository`, `publisher`, `author`, `bugs`, `license`); the content declaration and `exports` carry through unchanged | +| `plugin-local.template.txt` | `src/plugin.ts` (rendered) | fixture-covered | - | +| `skill-structure.template.md` | `skills//SKILL.md` (rendered) | fixture-covered | - | +| `package-basics.template.json` | `package.json` (rendered) | fixture-covered | - | +| `tsconfig.template.json` | `tsconfig.json` (rendered) | fixture-covered | - | + +## Dissolved into the core dependency (ADR-0013) + +These former pairs no longer exist as templates; their content is the +`opencode-architect/core` module and generated packages import it through the +dependency the renderer records in `package.json`. The manifest format those +templates wrote is frozen and still read by the core (ADR-0013, compatibility +rule). + +| Former template | Former rendered target | Now provided by | +| --- | --- | --- | +| `plugin-name.template.txt` | `src/plugin-name.ts` | `core/plugin-name.ts` | +| `plugin-entry.template.txt` | `src/plugin-entry.ts` | `core/plugin-entry.ts` | +| `entry-predicate.template.txt` | `src/entry-predicate.ts` | `core/entry-predicate.ts` | +| `manifest.template.txt` | `src/manifest.ts` | `core/manifest.ts` | +| `registration.template.txt` | `src/registration.ts` | `core/config-reader.ts` + `core/registration-detector.ts` | +| `plugin-config.template.txt` | `src/plugin-config.ts` | `core/plugin-config.ts` | + +Not mirrored: `prompts.template.txt` is an authoring aid (interactive +confirmation helpers for publisher flows) with no suite counterpart and no +rendered output - no sync state applies. + +## Widening rule + +A pair moves to byte-synced whenever the two bodies can genuinely be identical: +make them identical, then move the row. A divergence that is not in this table +is drift - either declare it here with its policy, or fix it. Machinery that +both sides need belongs in `src/core`, not in a widened pair. diff --git a/src/cache-cleaner.ts b/src/cache-cleaner.ts index 0c411b5..c55ba01 100644 --- a/src/cache-cleaner.ts +++ b/src/cache-cleaner.ts @@ -1,7 +1,8 @@ import { exists, readdir, rm } from "node:fs/promises"; import path from "node:path"; import { ClearCacheUsageError } from "./clear-cache-usage-error"; -import { NpmCache } from "./npm-cache"; +import { realEnvironment, type Environment } from "./core/environment"; +import { NpmCache } from "./core/npm-cache"; export const PACKAGE_NAME = "opencode-architect"; @@ -19,9 +20,11 @@ export interface ClearCacheOutcome { } export class CacheCleaner { - private readonly cache = new NpmCache(); + private readonly cache: NpmCache; - constructor(private readonly rmFn: typeof rm = rm) {} + constructor(private readonly environment: Environment = realEnvironment, private readonly rmFn: typeof rm = rm) { + this.cache = new NpmCache(environment); + } public isUnsafePackageName(name: string): boolean { return name.includes("/") || name.includes("\\") || name.includes("..") || name === "."; diff --git a/src/cli.ts b/src/cli.ts index b219b9f..7e90657 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,10 +4,12 @@ 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 { StatusReporter, type EffectiveVersion, type StatusReport } from "./core/status-reporter"; +import type { ResolvedSource } from "./core/loaded-version"; +import { CacheCleaner, PACKAGE_NAME } from "./cache-cleaner"; import { ClearCacheUsageError } from "./clear-cache-usage-error"; +import { Scaffolder } from "./scaffold/scaffolder"; +import { Promoter } from "./scaffold/promoter"; const VERSION = (JSON.parse(await Bun.file(`${import.meta.dirname}/../package.json`).text()) as { version: string }).version; @@ -24,6 +26,10 @@ export async function runCli(argv: string[]): Promise { "dry-run": { type: "boolean", default: false }, online: { type: "boolean", default: false }, path: { type: "string" }, + name: { type: "string" }, + target: { type: "string" }, + ship: { type: "string" }, + promote: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, version: { type: "boolean", short: "v", default: false }, }, @@ -45,6 +51,14 @@ export async function runCli(argv: string[]): Promise { console.error("--path and --online only apply to the status command."); return 1; } + if (command !== "scaffold" && (values.name !== undefined || values.target !== undefined || values.ship !== undefined || values.promote)) { + console.error("--name, --target, --ship, and --promote only apply to the scaffold command."); + return 1; + } + if (command === "scaffold" && (values.scope !== undefined || values.mode !== undefined || values.package !== undefined || values.all || values.online || values.path !== undefined)) { + console.error("--scope, --mode, --package, --all, --online, and --path do not apply to the scaffold 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; @@ -115,7 +129,7 @@ export async function runCli(argv: string[]): Promise { const report = await new StatusReporter(installer).report(projectDir, { scopes, online: values.online, - packageName: values.package, + packageName: values.package ?? PACKAGE_NAME, }); printStatusReport(report); break; @@ -149,6 +163,50 @@ export async function runCli(argv: string[]): Promise { } break; } + case "scaffold": { + if (positionals.length > 1) { + console.error(`Unexpected arguments for scaffold: ${positionals.slice(1).join(" ")}`); + return 1; + } + if (values.promote) { + const promoter = new Promoter(); + const outcome = await promoter.promote({ + projectDir: path.resolve(positionals[1] ?? "."), + name: values.name ?? null, + target: values.target ?? null, + yes: values.yes, + }); + if (!outcome.ok) { + console.error(outcome.error); + return 1; + } + console.log(`Promoted ${outcome.packageName} at ${outcome.packageDir}`); + if (outcome.configPath !== null) console.log(` Config reference: ${outcome.configPath}`); + for (const note of outcome.notes) console.warn(` Note: ${note}`); + for (const managed of outcome.managed) console.log(` Already managed: ${managed}`); + if (outcome.retired) { + for (const removed of outcome.removed) console.log(` Removed: ${removed}`); + } else { + console.log("Retirement needs your consent; pass --yes to delete the promoted originals:"); + for (const target of outcome.wouldRemove) console.log(` Would remove: ${target}`); + } + break; + } + const scaffolder = new Scaffolder(); + const outcome = await scaffolder.fresh({ + name: values.name ?? null, + target: values.target ?? null, + ship: values.ship ?? null, + workspaceRoot: process.cwd(), + }); + if (!outcome.ok) { + console.error(outcome.error); + return 1; + } + console.log(`Scaffolded ${outcome.packageName} at ${outcome.packageDir}`); + for (const file of outcome.files) console.log(` Wrote: ${file}`); + break; + } default: console.error(`Unknown command: ${command}`); printHelp(); @@ -264,6 +322,11 @@ Commands: installed package instead of this one clear-cache Remove cached copies from OpenCode's package cache; default targets this package only + scaffold Generate a conformant extension package from the bundled + templates. Fresh mode (default) renders a new package tree; + --promote takes existing .opencode/ extensions into a package, + ensures the config reference, verifies the payload, and retires + the sources only on explicit consent Options: -s, --scope status: narrow to "local" or "global"; by default both @@ -282,6 +345,15 @@ Options: every @* instead; requires --yes --all clear-cache: remove the whole OpenCode cache directory; requires --yes + --name scaffold: the package name, opencode- + --target scaffold: "here" (default) or "sibling" deployment + --ship scaffold: comma-separated list of skills, commands, + agents, tools, plugins; default skills + --promote scaffold: promote existing .opencode/ extensions (or + the .opencode/ at ) into a package; retirement + of the originals requires --yes + --yes scaffold --promote: consent to deleting the promoted + originals after the payload verifies --yes clear-cache: confirm a destructive broad mode --dry-run clear-cache: list what would be removed without deleting -h, --help Show this help message @@ -300,6 +372,9 @@ Examples: opencode-architect clear-cache --package some-pkg --yes opencode-architect clear-cache --all --yes opencode-architect clear-cache --dry-run + opencode-architect scaffold --name opencode-myextension + opencode-architect scaffold --name opencode-myextension --target sibling --ship skills,commands + opencode-architect scaffold --promote --yes `); } diff --git a/src/config-entries.ts b/src/config-entries.ts deleted file mode 100644 index 51c62e9..0000000 --- a/src/config-entries.ts +++ /dev/null @@ -1,159 +0,0 @@ -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/core/cache-hygiene.ts b/src/core/cache-hygiene.ts new file mode 100644 index 0000000..d3a65e2 --- /dev/null +++ b/src/core/cache-hygiene.ts @@ -0,0 +1,42 @@ +import { readdir, rm } from "node:fs/promises"; +import { join } from "node:path"; + +export interface CacheOutcome { + removed: string[]; + warnings: string[]; +} + +/** + * Best-effort removal of cache targets under one package-cache root, + * warn-and-continue per target (ADR-0007 hygiene): a failing removal is a + * warning, never a throw, and never touches anything outside the root. + */ +export async function removeCacheTargets( + packagesRoot: string, + wanted: (name: string) => boolean, + rmFn: typeof rm = rm, +): Promise { + const removed: string[] = []; + const warnings: string[] = []; + let entries: string[]; + try { + entries = await readdir(packagesRoot); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { removed, warnings }; + throw error; + } + const targets = entries + .filter(wanted) + .sort() + .map((name) => join(packagesRoot, name)); + for (const target of targets) { + try { + await rmFn(target, { recursive: true }); + removed.push(target); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warnings.push(`Could not clear cached package ${target}: ${message}`); + } + } + return { removed, warnings }; +} diff --git a/src/core/config-reader.ts b/src/core/config-reader.ts new file mode 100644 index 0000000..0c6ecb5 --- /dev/null +++ b/src/core/config-reader.ts @@ -0,0 +1,288 @@ +import { exists, readFile } from "node:fs/promises"; +import path from "node:path"; +import { EntryPredicate } from "./entry-predicate"; +import { realEnvironment, type Environment, type WarnChannel } from "./environment"; +import { scopeBase } from "./scope-base"; + +export type ConfigScope = "local" | "global"; +export type PluginConfigKey = "plugins" | "plugin"; + +export interface EnsurePluginEntryOptions { + scope: ConfigScope; + projectDir: string; +} + +export interface CandidateConfig { + path: string; + lenient: boolean; + writable: boolean; +} + +export interface CandidateRead { + candidate: CandidateConfig; + text: string; + entries: Record; + rawEntries: Record; +} + +export interface ConfigEntries { + path: string; + lenient: boolean; + writable: boolean; + rawEntries: unknown[]; + parseError: string | null; +} + +export class ConfigReader { + constructor(private readonly environment: Environment = realEnvironment) {} + + public async entries( + scope: ConfigScope, + projectDir: string, + warn: WarnChannel | null = null, + ): 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.losslessParse(text, candidate.lenient); + if (config === null) { + if (warn !== null) { + warn(`Warning: ${candidate.path} could not be parsed; its plugin entries are ignored.`); + } + results.push({ + path: candidate.path, + lenient: candidate.lenient, + writable: candidate.writable, + rawEntries: [], + parseError: `config file ${candidate.path} could not be parsed`, + }); + continue; + } + results.push({ + path: candidate.path, + lenient: candidate.lenient, + writable: candidate.writable, + rawEntries: [...this.entriesOf(config, "plugins"), ...this.entriesOf(config, "plugin")], + parseError: null, + }); + } + return results; + } + + private losslessParse(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; + } + } + + public async readCandidates(options: EnsurePluginEntryOptions): Promise { + const reads: CandidateRead[] = []; + for (const candidate of this.candidates(options)) { + if (!(await exists(candidate.path))) continue; + const text = await readFile(candidate.path, "utf-8"); + const config = this.parseConfig(text, candidate.lenient); + const entries: Record = { plugins: null, plugin: null }; + const rawEntries: Record = { plugins: null, plugin: null }; + if (config === null) { + this.environment.warn( + `Warning: ${candidate.path} could not be parsed; refusing to treat it as a registration candidate.`, + ); + } else { + for (const key of ["plugins", "plugin"] as PluginConfigKey[]) { + entries[key] = this.keyEntries(config, key); + rawEntries[key] = this.entriesOf(config, key); + } + } + reads.push({ candidate, text, entries, rawEntries }); + } + return reads; + } + + public candidates(options: EnsurePluginEntryOptions): CandidateConfig[] { + const scopeBasePath = scopeBase(options.scope, options.projectDir, this.environment); + const configs: CandidateConfig[] = []; + if (options.scope === "local") { + for (const root of [scopeBasePath, options.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(scopeBasePath, file), lenient: file.endsWith(".jsonc"), writable: true }); + } + } + configs.push({ path: path.join(scopeBasePath, "config.json"), lenient: false, writable: false }); + return configs; + } + + public parsePluginArray(text: string, lenient: boolean): string[] | null { + const config = this.parseConfig(text, lenient); + if (config === null) return null; + return this.keyEntries(config, "plugins"); + } + + public parseConfig(text: string, lenient: boolean): Record | null { + const parseable = lenient ? this.blankComments(text).replace(/,(\s*[}\]])/g, "$1") : text; + try { + return JSON.parse(parseable) as Record; + } catch { + return null; + } + } + + public blankComments(text: string): string { + const chars = text.split(""); + let inString = false; + let inLineComment = false; + let inBlockComment = false; + for (let i = 0; i < chars.length; i++) { + const current = chars[i]; + const next = i + 1 < chars.length ? chars[i + 1] : ""; + if (inLineComment) { + if (current === "\n") inLineComment = false; + else chars[i] = " "; + continue; + } + if (inBlockComment) { + if (current === "*" && next === "/") { + chars[i] = " "; + chars[i + 1] = " "; + i++; + inBlockComment = false; + } else { + chars[i] = " "; + } + continue; + } + if (inString) { + if (current === "\\") { + i++; + continue; + } + if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + continue; + } + if (current === "/" && next === "/") { + chars[i] = " "; + chars[i + 1] = " "; + i++; + inLineComment = true; + continue; + } + if (current === "/" && next === "*") { + chars[i] = " "; + chars[i + 1] = " "; + i++; + inBlockComment = true; + continue; + } + } + return chars.join(""); + } + + public keyEntries(config: Record, key: PluginConfigKey): string[] { + const value = config[key]; + if (!Array.isArray(value)) return []; + return value.map((entry) => this.normalizeEntry(entry)).filter((entry): entry is string => entry !== null); + } + + public entriesOf(config: Record, key: PluginConfigKey): unknown[] { + const value = config[key]; + return Array.isArray(value) ? value : []; + } + + public normalizeEntry(entry: unknown): string | null { + return EntryPredicate.specOf(entry); + } + + public defaultConfigPath(options: EnsurePluginEntryOptions): string { + if (options.scope === "global") { + return path.join(scopeBase("global", options.projectDir, this.environment), "opencode.jsonc"); + } + return path.join(options.projectDir, "opencode.jsonc"); + } +} + +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/core/config-splicer.ts b/src/core/config-splicer.ts new file mode 100644 index 0000000..9d7eb40 --- /dev/null +++ b/src/core/config-splicer.ts @@ -0,0 +1,278 @@ +import { ConfigReader } from "./config-reader"; +import { RegistrationDetector } from "./registration-detector"; +import { EntryPredicate } from "./entry-predicate"; + +interface PluginArrayRange { + bracketStart: number; + bracketEnd: number; +} + +export class ConfigSplicer { + private readonly reader: ConfigReader; + private readonly detector: RegistrationDetector; + + constructor(reader: ConfigReader, detector: RegistrationDetector) { + this.reader = reader; + this.detector = detector; + } + + public spliceEntry( + text: string, + entryToWrite: string, + packageName: string, + lenient: boolean, + ): string | null { + const navigable = this.reader.blankComments(text); + const range = this.findPluginArrayRange(navigable); + const spliced = + range === null + ? this.splicePluginKey(text, navigable, entryToWrite) + : this.spliceArrayEntry(text, navigable, range, entryToWrite); + if (spliced === null) return null; + const plugins = this.reader.parsePluginArray(spliced, lenient); + if (plugins === null || !this.detector.hasMatchingEntry(plugins, packageName)) return null; + return spliced; + } + + public async spliceOutEntry( + text: string, + packageName: string, + lenient: boolean, + configDir: string, + ): Promise { + const navigable = this.reader.blankComments(text); + const range = this.findPluginArrayRange(navigable); + if (range === null) return null; + const innerStart = range.bracketStart + 1; + const innerEnd = range.bracketEnd; + const elements = this.arrayElementRanges(navigable, innerStart, innerEnd); + const target = await this.matchingElement(text, elements, packageName, configDir); + if (!target) return null; + const withComma = this.dropAdjacentComma(navigable, elements, target, innerStart, innerEnd); + const result = text.slice(0, withComma.start) + text.slice(withComma.end); + const config = this.reader.parseConfig(result, lenient); + if (config === null) return null; + if (await this.detector.entryResolves(this.reader.entriesOf(config, "plugins"), packageName, configDir)) { + return null; + } + return result; + } + + private async matchingElement( + text: string, + elements: Array<{ start: number; end: number }>, + packageName: string, + configDir: string, + ): Promise<{ start: number; end: number } | null> { + for (const element of elements) { + const spec = this.elementSpec(text.slice(element.start, element.end)); + if (await EntryPredicate.matches(spec, packageName, configDir)) return element; + } + return null; + } + + private arrayElementRanges( + navigable: string, + innerStart: number, + innerEnd: number, + ): Array<{ start: number; end: number }> { + const elements: Array<{ start: number; end: number }> = []; + let inString = false; + let depth = 0; + let start = -1; + for (let i = innerStart; i < innerEnd; i++) { + const current = navigable[i]; + if (inString) { + if (current === "\\") i++; + else if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + if (start === -1) start = i; + continue; + } + if (current === "[" || current === "{") { + depth++; + if (start === -1) start = i; + continue; + } + if (current === "]" || current === "}") { + depth--; + continue; + } + if (current === "," && depth === 0) { + if (start !== -1) elements.push({ start, end: i }); + start = -1; + continue; + } + if (!/\s/.test(current ?? "") && start === -1) start = i; + } + if (start !== -1) elements.push({ start, end: innerEnd }); + return elements; + } + + private dropAdjacentComma( + navigable: string, + elements: Array<{ start: number; end: number }>, + target: { start: number; end: number }, + innerStart: number, + innerEnd: number, + ): { start: number; end: number } { + const index = elements.indexOf(target); + const next = elements[index + 1]; + if (next) return { start: target.start, end: next.start }; + const previous = elements[index - 1]; + if (previous) return { start: previous.end, end: target.end }; + let start = target.start; + while (start > innerStart && /\s/.test(navigable[start - 1] ?? "")) start--; + let end = target.end; + while (end < innerEnd && /\s/.test(navigable[end] ?? "")) end++; + return { start, end }; + } + + private unquote(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1); + } + return trimmed; + } + + private elementSpec(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) return this.unquote(trimmed); + try { + return EntryPredicate.specOf(JSON.parse(trimmed.replace(/,(\s*[}\]])/g, "$1"))) ?? ""; + } catch { + return ""; + } + } + + private spliceArrayEntry( + text: string, + navigable: string, + range: PluginArrayRange, + packageName: string, + ): string | null { + const innerStart = range.bracketStart + 1; + const inner = text.slice(innerStart, range.bracketEnd); + const firstElementOffset = inner.search(/\S/); + if (firstElementOffset === -1) { + return text.slice(0, innerStart) + `"${packageName}"` + text.slice(range.bracketEnd); + } + const insertAt = innerStart + firstElementOffset; + const leadingWhitespace = inner.slice(0, firstElementOffset); + return text.slice(0, insertAt) + leadingWhitespace + `"${packageName}",` + text.slice(insertAt); + } + + private splicePluginKey(text: string, navigable: string, packageName: string): string | null { + const objectStart = this.firstStructuralChar(navigable, "{"); + if (objectStart === -1) return null; + const rest = text.slice(objectStart + 1); + const nextContentOffset = rest.search(/\S/); + if (nextContentOffset === -1) return null; + const insertAt = objectStart + 1 + nextContentOffset; + const isClosingBrace = rest[nextContentOffset] === "}"; + const entry = isClosingBrace ? `"plugins": ["${packageName}"]` : `"plugins": ["${packageName}"],`; + const leadingWhitespace = nextContentOffset > 0 ? rest.slice(0, nextContentOffset) : ""; + return text.slice(0, insertAt) + leadingWhitespace + entry + text.slice(insertAt); + } + + private findPluginArrayRange(navigable: string): PluginArrayRange | null { + let searchFrom = 0; + while (searchFrom < navigable.length) { + const keyIndex = navigable.indexOf('"plugins"', searchFrom); + if (keyIndex === -1) return null; + if (this.precededByStructuralChar(navigable, keyIndex, ["{", ","])) { + const colonIndex = this.nextOutsideString(navigable, keyIndex + '"plugins"'.length, ":"); + if (colonIndex !== -1) { + const bracketStart = this.nextOutsideString(navigable, colonIndex + 1, "["); + if (bracketStart !== -1) { + const bracketEnd = this.matchingBracket(navigable, bracketStart); + if (bracketEnd !== null) return { bracketStart, bracketEnd }; + } + } + } + searchFrom = keyIndex + 1; + } + return null; + } + + private precededByStructuralChar(text: string, index: number, allowed: string[]): boolean { + for (let i = index - 1; i >= 0; i--) { + const current: string = text[i] ?? ""; + if (/\s/.test(current)) continue; + return allowed.includes(current); + } + return false; + } + + private firstStructuralChar(text: string, target: string): number { + let inString = false; + for (let i = 0; i < text.length; i++) { + const current = text[i]; + if (inString) { + if (current === "\\") { + i++; + continue; + } + if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + continue; + } + if (current === target) return i; + } + return -1; + } + + private nextOutsideString(text: string, from: number, target: string): number { + let inString = false; + for (let i = from; i < text.length; i++) { + const current = text[i]; + if (inString) { + if (current === "\\") { + i++; + continue; + } + if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + continue; + } + if (current === target) return i; + } + return -1; + } + + private matchingBracket(text: string, bracketStart: number): number | null { + let depth = 0; + let inString = false; + for (let i = bracketStart; i < text.length; i++) { + const current = text[i]; + if (inString) { + if (current === "\\") { + i++; + continue; + } + if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + continue; + } + if (current === "[") depth++; + else if (current === "]") { + depth--; + if (depth === 0) return i; + } + } + return null; + } +} diff --git a/src/entry-spec.ts b/src/core/entry-predicate.ts similarity index 62% rename from src/entry-spec.ts rename to src/core/entry-predicate.ts index f561e9b..1ac8f04 100644 --- a/src/entry-spec.ts +++ b/src/core/entry-predicate.ts @@ -1,4 +1,6 @@ import { isAbsolute } from "node:path"; +import { PluginNameNormalizer } from "./plugin-name"; +import { PluginEntryResolver } from "./plugin-entry"; export type EntryForm = "npm" | "path"; @@ -10,15 +12,17 @@ export interface ClassifiedEntry { version: string | null; } -export class EntrySpec { +const namePolicy = new PluginNameNormalizer(); + +export class EntryPredicate { public static classify(entry: unknown): ClassifiedEntry | null { - const spec = EntrySpec.specOf(entry); + const spec = EntryPredicate.specOf(entry); if (spec === null) return null; const display = typeof entry === "string" ? entry : JSON.stringify(entry); - if (EntrySpec.isPath(spec)) { + if (EntryPredicate.isPath(spec)) { return { form: "path", display, spec, name: null, version: null }; } - const parsed = EntrySpec.parseNpm(spec); + const parsed = EntryPredicate.parseNpm(spec); if (parsed === null) return null; return { form: "npm", display, spec, name: parsed.name, version: parsed.version }; } @@ -48,6 +52,24 @@ export class EntrySpec { return trimmed; } + public static matchesName(spec: string, packageName: string): boolean { + return namePolicy.matches(spec, packageName); + } + + public static async matches(rawEntry: unknown, packageName: string, baseDir?: string): Promise { + return (await EntryPredicate.matched(rawEntry, packageName, baseDir)) !== null; + } + + public static async matched(rawEntry: unknown, packageName: string, baseDir?: string): Promise { + const classified = EntryPredicate.classify(rawEntry); + if (classified === null) return null; + if (classified.form === "npm") { + return namePolicy.matches(classified.spec, packageName) ? classified : null; + } + if (await PluginEntryResolver.resolvesToPackage(rawEntry, packageName, baseDir)) return classified; + return null; + } + private static parseNpm(spec: string): { name: string; version: string } | null { const at = spec.lastIndexOf("@"); if (at <= 0) return { name: spec, version: "latest" }; diff --git a/src/core/environment.ts b/src/core/environment.ts new file mode 100644 index 0000000..5622376 --- /dev/null +++ b/src/core/environment.ts @@ -0,0 +1,14 @@ +export type WarnChannel = (message: string) => void; +export type EnvLookup = (name: string) => string | undefined; + +export interface Environment { + env: EnvLookup; + warn: WarnChannel; + fetch: typeof fetch; +} + +export const realEnvironment: Environment = { + env: (name) => process.env[name], + warn: (message) => console.warn(message), + fetch, +}; diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..1433983 --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,57 @@ +/** + * The core module (ADR-0013): the machinery both the suite CLI and the + * generated-package CLI share, exported through the `./core` subpath. + * Policy lives in the adapters; the invariants live here — semantic entry + * matching, manifest-gated idempotency, surgical config edits, hooks that + * never throw (ADR-0006, ADR-0007). + */ + +export { realEnvironment, type Environment, type EnvLookup, type WarnChannel } from "./environment"; +export { scopeBase, type Scope } from "./scope-base"; +export { NpmCache } from "./npm-cache"; +export { removeCacheTargets, type CacheOutcome } from "./cache-hygiene"; + +export { PluginNameNormalizer } from "./plugin-name"; +export { PluginEntryResolver } from "./plugin-entry"; +export { EntryPredicate, type ClassifiedEntry, type EntryForm } from "./entry-predicate"; + +export { + ConfigReader, + type CandidateConfig, + type CandidateRead, + type ConfigEntries, + type ConfigScope, + type EnsurePluginEntryOptions, + type PluginConfigKey, +} from "./config-reader"; +export { RegistrationDetector } from "./registration-detector"; +export { ConfigSplicer } from "./config-splicer"; +export { + PluginConfigEditor, + type ConfigCheck, + type EnsurePluginEntryOutcome, + type RemovePluginEntryOutcome, +} from "./plugin-config"; + +export { + InstallManifest, + listFilesRecursive, + manifestPath, + sha256, + type InstallMode, + type ManifestData, + type ManifestWrite, +} from "./manifest"; + +export { LoadedVersionResolver, type EntryResolution, type ResolvedSource } from "./loaded-version"; +export { RegistryVersionChecker, type RegistryLookup } from "./registry-version-checker"; +export { + StatusReporter, + type DeploymentMode, + type EffectiveVersion, + type ManifestLookup, + type ScopeStatusReport, + type StatusReport, + type StatusReportOptions, + type VersionKind, +} from "./status-reporter"; diff --git a/src/loaded-version.ts b/src/core/loaded-version.ts similarity index 96% rename from src/loaded-version.ts rename to src/core/loaded-version.ts index 2433d83..840450e 100644 --- a/src/loaded-version.ts +++ b/src/core/loaded-version.ts @@ -1,6 +1,6 @@ import { exists, readFile, readdir, stat } from "node:fs/promises"; import { join } from "node:path"; -import { EntrySpec, type ClassifiedEntry } from "./entry-spec"; +import { EntryPredicate, type ClassifiedEntry } from "./entry-predicate"; import { NpmCache } from "./npm-cache"; import { PluginEntryResolver } from "./plugin-entry"; @@ -22,7 +22,7 @@ export class LoadedVersionResolver { constructor(private readonly cache: NpmCache = new NpmCache()) {} public classify(entry: unknown): ClassifiedEntry | null { - return EntrySpec.classify(entry); + return EntryPredicate.classify(entry); } public async resolve(entry: unknown, baseDir?: string): Promise { diff --git a/templates/manifest.template.txt b/src/core/manifest.ts similarity index 62% rename from templates/manifest.template.txt rename to src/core/manifest.ts index 56dbb5c..e47177c 100644 --- a/templates/manifest.template.txt +++ b/src/core/manifest.ts @@ -1,19 +1,22 @@ -# Inputs - -| Placeholder | Required | Description | Default | -|---|---|---|---| -| `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; the manifest file is `/.manifest.json` | — | - -**Load-bearing — do not simplify:** idempotency is per-file sha256 (`Bun.CryptoHasher`), never a `.version` marker; a missing or malformed manifest reads as "not installed" (drift), never as a reason to write without an ensure path; a manifest without a `mode` field reads as `"copy"` so pre-generalization copy installs migrate cleanly; the manifest is one record per scope for BOTH install modes — `mode` and the registration fields record the deployment plan, while `files` records whatever payload the load-time hook has ensured (so a plugin-mode manifest gains payload hashes after the first load, and the CLI must preserve them when it rewrites registration); plugin-mode up-to-dateness includes the registration — the recorded entry must still be present in the recorded config file (semantic name match, `@latest`-aware) — plus the recorded payload hashes; consumer-modified files that were skipped during install are recorded with their **packaged** hash — never the consumer's on-disk content — so the modification stays detectable and `--force` can still take ownership of it; registration presence is checked against the v2 `plugins` key first and the legacy v1 `plugin` key read-only, with entries matched by package name whether written as strings or `{ "package": ... }` objects. - ---- import { readdir, readFile, writeFile, mkdir } from "node:fs/promises"; import { join, relative } from "node:path"; -import { parseJsonc } from "./registration.ts"; -import { PluginNameNormalizer } from "./plugin-name.ts"; - +import { ConfigReader } from "./config-reader"; +import { PluginNameNormalizer } from "./plugin-name"; + +/** + * The generated-package install manifest, frozen at the core carve-out + * (ADR-0013): `/.manifest.json` holding version, mode, + * entry, entryConfigPath, and per-file sha256 hashes relative to the config + * base. Readers accept everything earlier generated packages wrote — a + * missing or malformed manifest reads as "not installed" (drift), and a + * missing `mode` reads as "copy". The suite adapter's own legacy manifest + * format is out of scope here (ADR-0013, rule 2). + */ export type InstallMode = "copy" | "plugin"; +/** Historical alias for the manifest's recorded mode (ADR-0006 vocabulary). */ +export type ManifestMode = InstallMode; + export interface ManifestData { version: string; mode: InstallMode; @@ -38,9 +41,12 @@ export function manifestPath(configBase: string, packageName: string): string { return join(configBase, `${packageName}${MANIFEST_SUFFIX}`); } +const reader = new ConfigReader(); + +const normalizer = new PluginNameNormalizer(); + export class InstallManifest { private readonly path: string; - private readonly normalizer = new PluginNameNormalizer(); constructor(configBase: string, packageName: string) { this.path = manifestPath(configBase, packageName); @@ -105,8 +111,7 @@ export class InstallManifest { return false; } const packageName = this.baseName(data.entry); - const entries = pluginEntries(text, data.entryConfigPath); - return entries.some(entry => this.normalizer.matches(entry, packageName)); + return pluginEntries(text, data.entryConfigPath).some((entry) => normalizer.matches(entry, packageName)); } async findConsumerModified(rootDir: string): Promise { @@ -133,31 +138,19 @@ export class InstallManifest { function pluginEntries(text: string, configPath: string): string[] { try { - const config = (configPath.endsWith(".jsonc") ? parseJsonc(text) : JSON.parse(text)) as Record; - const v2 = entryNames(config.plugins); + const config = reader.parseConfig(text, configPath.endsWith(".jsonc")); + if (config === null) return []; + const v2 = reader.keyEntries(config, "plugins"); if (v2.length > 0) return v2; - return entryNames(config.plugin); + return reader.keyEntries(config, "plugin"); } catch { return []; } } -function entryNames(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value - .map((entry) => (typeof entry === "string" ? entry : entryPackage(entry))) - .filter((entry): entry is string => entry !== null); -} - -function entryPackage(entry: unknown): string | null { - if (entry === null || typeof entry !== "object") return null; - const pkg = (entry as { package?: unknown }).package; - return typeof pkg === "string" ? pkg : null; -} - export async function listFilesRecursive(rootDir: string): Promise { const entries = await readdir(rootDir, { withFileTypes: true, recursive: true }); - return entries.filter(entry => entry.isFile()).map(entry => join(entry.parentPath, entry.name)); + return entries.filter((entry) => entry.isFile()).map((entry) => join(entry.parentPath, entry.name)); } export async function sha256(path: string): Promise { diff --git a/src/npm-cache.ts b/src/core/npm-cache.ts similarity index 61% rename from src/npm-cache.ts rename to src/core/npm-cache.ts index 89e79f0..1c45e61 100644 --- a/src/npm-cache.ts +++ b/src/core/npm-cache.ts @@ -1,9 +1,12 @@ import { homedir } from "node:os"; import path from "node:path"; +import { realEnvironment, type Environment } from "./environment"; export class NpmCache { + constructor(private readonly environment: Environment = realEnvironment) {} + public root(): string { - const xdgCacheHome = process.env.XDG_CACHE_HOME; + const xdgCacheHome = this.environment.env("XDG_CACHE_HOME"); if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "npm"); return path.join(homedir(), ".cache", "opencode", "npm"); } diff --git a/src/core/plugin-config.ts b/src/core/plugin-config.ts new file mode 100644 index 0000000..cd18825 --- /dev/null +++ b/src/core/plugin-config.ts @@ -0,0 +1,206 @@ +import { exists, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { CandidateConfig, CandidateRead, EnsurePluginEntryOptions } from "./config-reader"; +import { ConfigReader } from "./config-reader"; +import { realEnvironment, type Environment } from "./environment"; +import { RegistrationDetector } from "./registration-detector"; +import { ConfigSplicer } from "./config-splicer"; + +export type { ConfigScope, PluginConfigKey, EnsurePluginEntryOptions } from "./config-reader"; + +export interface EnsurePluginEntryOutcome { + action: "noop" | "updated" | "created" | "blocked"; + configPath: string | null; + warning: string | null; +} + +export interface RemovePluginEntryOutcome { + action: "noop" | "removed" | "blocked"; + configPath: string | null; + warning: string | null; +} + +export type ConfigCheck = { ok: true } | { ok: false; warning: string }; + +const DEFAULT_CONFIG_TEMPLATE = `{ + // OpenCode configuration + "$schema": "https://opencode.ai/config.json", + "plugins": ["__PACKAGE_NAME__"] +} +`; + +export class PluginConfigEditor { + private readonly reader: ConfigReader; + private readonly detector: RegistrationDetector; + private readonly splicer: ConfigSplicer; + + constructor(environment: Environment = realEnvironment) { + this.reader = new ConfigReader(environment); + this.detector = new RegistrationDetector(this.reader); + this.splicer = new ConfigSplicer(this.reader, this.detector); + } + + public async ensurePluginEntry( + packageName: string, + options: EnsurePluginEntryOptions, + entry: string | null = null, + ): Promise { + const canonical = entry ?? this.canonicalEntry(packageName); + for (const read of await this.reader.readCandidates(options)) { + const { candidate, text, entries, rawEntries } = read; + const configDir = path.dirname(candidate.path); + if (entries.plugins === null) { + return { + action: "blocked", + configPath: candidate.path, + warning: + `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + + `Fix or remove the file and re-run the install.`, + }; + } + if (await this.detector.entryResolves(rawEntries.plugins, packageName, configDir)) { + return { action: "noop", configPath: candidate.path, warning: null }; + } + if (await this.detector.entryResolves(rawEntries.plugin, packageName, configDir)) { + return { + action: "noop", + configPath: candidate.path, + warning: this.legacyEntryAdvisory(candidate, packageName), + }; + } + if (!candidate.writable) continue; + const spliced = this.splicer.spliceEntry(text, canonical, packageName, candidate.lenient); + if (spliced === null) { + return { + action: "blocked", + configPath: candidate.path, + warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, + }; + } + await mkdir(path.dirname(candidate.path), { recursive: true }); + await writeFile(candidate.path, spliced); + return { action: "updated", configPath: candidate.path, warning: null }; + } + + const target = this.reader.defaultConfigPath(options); + const content = DEFAULT_CONFIG_TEMPLATE.replace("__PACKAGE_NAME__", canonical); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content); + return { action: "created", configPath: target, warning: null }; + } + + public async checkParseable(options: EnsurePluginEntryOptions): Promise { + for (const candidate of this.reader.candidates(options)) { + if (!(await exists(candidate.path))) continue; + const text = await readFile(candidate.path, "utf-8"); + if (this.reader.parsePluginArray(text, candidate.lenient) === null) { + return { + ok: false, + warning: + `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + + `Fix or remove the file and re-run the command.`, + }; + } + } + return { ok: true }; + } + + public async findRegistration( + packageName: string, + options: EnsurePluginEntryOptions, + ): Promise { + return this.detector.findRegistration(packageName, options); + } + + public async removePluginEntry( + packageName: string, + options: EnsurePluginEntryOptions, + ): Promise { + const reads = await this.reader.readCandidates(options); + let legacyPath: string | null = null; + for (const read of reads) { + const { candidate, text, entries } = read; + const configDir = path.dirname(candidate.path); + if (!candidate.writable) continue; + if (entries.plugins === null) { + return { + action: "blocked", + configPath: candidate.path, + warning: + `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + + `Fix or remove the file and re-run the uninstall.`, + }; + } + if (await this.detector.entryResolves(read.rawEntries.plugins, packageName, configDir)) { + const spliced = await this.splicer.spliceOutEntry(text, packageName, candidate.lenient, configDir); + if (spliced === null) { + return { + action: "blocked", + configPath: candidate.path, + warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, + }; + } + await writeFile(candidate.path, spliced); + const leftover = await this.findLegacyConfig(reads, packageName); + return { + action: "removed", + configPath: candidate.path, + warning: leftover === null ? null : this.legacyRemovalAdvisory(leftover, packageName), + }; + } + if (legacyPath === null && (await this.detector.entryResolves(read.rawEntries.plugin, packageName, configDir))) { + legacyPath = candidate.path; + } + } + if (legacyPath !== null) { + return { + action: "noop", + configPath: legacyPath, + warning: this.legacyRemovalAdvisory(legacyPath, packageName), + }; + } + return { action: "noop", configPath: null, warning: null }; + } + + public hasMatchingEntry(entries: string[], packageName: string): boolean { + return this.detector.hasMatchingEntry(entries, packageName); + } + + private async findLegacyConfig(reads: CandidateRead[], packageName: string): Promise { + for (const read of reads) { + const legacyDir = path.dirname(read.candidate.path); + if (await this.detector.entryResolves(read.rawEntries.plugin, packageName, legacyDir)) { + return read.candidate.path; + } + } + return null; + } + + private legacyEntryAdvisory(candidate: CandidateConfig, packageName: string): string { + if (path.basename(candidate.path) === "config.json") { + return ( + `${candidate.path} registers ${packageName} under the legacy v1 "plugin" key. ` + + `OpenCode v2 no longer reads config.json, so the entry is inert, and this installer never edits it. ` + + `Add ${packageName} to the "plugins" key of an opencode.json or opencode.jsonc config instead.` + ); + } + return ( + `${candidate.path} registers ${packageName} under the legacy v1 "plugin" key. ` + + `OpenCode v2 still loads it through config migration, and this installer leaves legacy entries ` + + `read-only. Move the entry to the v2 "plugins" key to upgrade, then re-run the install.` + ); + } + + private legacyRemovalAdvisory(configPath: string, packageName: string): string { + return ( + `${configPath} registers ${packageName} under the legacy v1 "plugin" key; it was left untouched. ` + + `Remove it by editing the file, or move the entry to the v2 "plugins" key and re-run the uninstall.` + ); + } + + private canonicalEntry(packageName: string): string { + const specIndex = packageName.lastIndexOf("@"); + if (specIndex > 0) return packageName; + return `${packageName}@latest`; + } +} diff --git a/src/plugin-entry.ts b/src/core/plugin-entry.ts similarity index 98% rename from src/plugin-entry.ts rename to src/core/plugin-entry.ts index 6667fb6..12586d7 100644 --- a/src/plugin-entry.ts +++ b/src/core/plugin-entry.ts @@ -1,7 +1,7 @@ import { exists, lstat, readFile, realpath } from "node:fs/promises"; import { dirname, isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { PluginNameNormalizer } from "./plugin-name.ts"; +import { PluginNameNormalizer } from "./plugin-name"; const resolvedRootMemo = new Map(); diff --git a/src/plugin-name.ts b/src/core/plugin-name.ts similarity index 100% rename from src/plugin-name.ts rename to src/core/plugin-name.ts diff --git a/src/core/registration-detector.ts b/src/core/registration-detector.ts new file mode 100644 index 0000000..f8b6118 --- /dev/null +++ b/src/core/registration-detector.ts @@ -0,0 +1,43 @@ +import path from "node:path"; +import type { EnsurePluginEntryOptions } from "./config-reader"; +import { ConfigReader } from "./config-reader"; +import { EntryPredicate } from "./entry-predicate"; + +export class RegistrationDetector { + private readonly reader: ConfigReader; + + constructor(reader: ConfigReader = new ConfigReader()) { + this.reader = reader; + } + + public async findRegistration( + packageName: string, + options: EnsurePluginEntryOptions, + ): Promise { + for (const read of await this.reader.readCandidates(options)) { + if (read.entries.plugins === null) continue; + const configDir = path.dirname(read.candidate.path); + const registered = + (await this.entryResolves(read.rawEntries.plugins, packageName, configDir)) || + (await this.entryResolves(read.rawEntries.plugin, packageName, configDir)); + if (registered) return read.candidate.path; + } + return null; + } + + public hasMatchingEntry(entries: string[], packageName: string): boolean { + return entries.some((entry) => EntryPredicate.matchesName(entry, packageName)); + } + + public async entryResolves( + rawEntries: unknown[] | null, + packageName: string, + configDir: string, + ): Promise { + if (rawEntries === null) return false; + for (const entry of rawEntries) { + if (await EntryPredicate.matches(entry, packageName, configDir)) return true; + } + return false; + } +} diff --git a/src/registry-version-checker.ts b/src/core/registry-version-checker.ts similarity index 100% rename from src/registry-version-checker.ts rename to src/core/registry-version-checker.ts diff --git a/src/core/scope-base.ts b/src/core/scope-base.ts new file mode 100644 index 0000000..d4b6d01 --- /dev/null +++ b/src/core/scope-base.ts @@ -0,0 +1,16 @@ +import { homedir } from "node:os"; +import path from "node:path"; +import { realEnvironment, type Environment } from "./environment"; + +export type Scope = "local" | "global"; + +export function scopeBase( + scope: "local" | "global", + projectDir: string, + environment: Environment = realEnvironment, +): string { + if (scope === "local") return path.join(projectDir, ".opencode"); + const xdgConfigHome = environment.env("XDG_CONFIG_HOME"); + if (xdgConfigHome) return path.join(xdgConfigHome, "opencode"); + return path.join(homedir(), ".config", "opencode"); +} diff --git a/src/status-reporter.ts b/src/core/status-reporter.ts similarity index 85% rename from src/status-reporter.ts rename to src/core/status-reporter.ts index e8b1166..248cfff 100644 --- a/src/status-reporter.ts +++ b/src/core/status-reporter.ts @@ -1,14 +1,17 @@ -import { PACKAGE_NAME } from "./cache-cleaner"; import path from "node:path"; -import { ConfigEntriesReader, type ConfigEntries } from "./config-entries"; +import { ConfigReader, type ConfigEntries } from "./config-reader"; +import { realEnvironment, type Environment } from "./environment"; import { LoadedVersionResolver, type ResolvedSource } from "./loaded-version"; -import { PluginEntryResolver } from "./plugin-entry"; +import { NpmCache } from "./npm-cache"; import { RegistryVersionChecker } from "./registry-version-checker"; -import { EntrySpec } from "./entry-spec"; -import type { InstallMode, Scope } from "./installer"; +import { EntryPredicate } from "./entry-predicate"; +import type { ManifestMode } from "./manifest"; +import type { Scope } from "./scope-base"; + +export type DeploymentMode = ManifestMode | "none"; export interface ManifestState { - mode: InstallMode; + mode: ManifestMode; version: string; } @@ -19,7 +22,7 @@ export interface ManifestLookup { export interface ScopeStatusReport { scope: Scope; registered: boolean; - mode: InstallMode; + mode: DeploymentMode; manifestVersion: string | null; configPath: string | null; entryText: string | null; @@ -52,7 +55,7 @@ export interface StatusReport { export interface StatusReportOptions { scopes: Scope[] | null; online: boolean; - packageName?: string; + packageName: string; } interface MatchedEntry { @@ -65,17 +68,20 @@ interface MatchedEntry { } export class StatusReporter { - private readonly entries = new ConfigEntriesReader(); - private readonly resolver = new LoadedVersionResolver(); + private readonly configs: ConfigReader; + private readonly resolver: LoadedVersionResolver; constructor( private readonly manifests: ManifestLookup, - private readonly fetchFn: typeof fetch = fetch, - ) {} + private readonly environment: Environment = realEnvironment, + ) { + this.configs = new ConfigReader(environment); + this.resolver = new LoadedVersionResolver(new NpmCache(environment)); + } public async report(projectDir: string, options: StatusReportOptions): Promise { const scopes = options.scopes ?? (["local", "global"] as Scope[]); - const packageName = options.packageName ?? PACKAGE_NAME; + const packageName = options.packageName; let reports: ScopeStatusReport[] = []; for (const scope of scopes) { reports.push(await this.resolveScope(scope, projectDir, packageName)); @@ -106,7 +112,7 @@ export class StatusReporter { private async resolveScope(scope: Scope, projectDir: string, packageName: string): Promise { const warnings: string[] = []; - const entries = await this.entries.read(scope, projectDir, false); + const entries = await this.configs.entries(scope, projectDir); for (const candidate of entries) { if (candidate.parseError !== null) warnings.push(candidate.parseError); } @@ -160,31 +166,16 @@ export class StatusReporter { private async findMatchedEntry(entries: ConfigEntries[], packageName: string): Promise { for (const candidate of entries) { for (const raw of candidate.rawEntries) { - const classified = EntrySpec.classify(raw); + const classified = await EntryPredicate.matched(raw, packageName, path.dirname(candidate.path)); if (classified === null) continue; - if (classified.form === "npm") { - if (classified.name === packageName) { - return { - configPath: candidate.configPath, - entry: raw, - display: classified.display, - name: classified.name, - version: classified.version, - form: "npm", - }; - } - continue; - } - if (await PluginEntryResolver.resolvesToPackage(raw, packageName, path.dirname(candidate.configPath))) { - return { - configPath: candidate.configPath, - entry: raw, - display: classified.display, - name: null, - version: null, - form: "path", - }; - } + return { + configPath: candidate.path, + entry: raw, + display: classified.display, + name: classified.name, + version: classified.version, + form: classified.form, + }; } } return null; @@ -246,7 +237,7 @@ export class StatusReporter { private async onlinePass(reports: ScopeStatusReport[]): Promise<{ warnings: string[]; reports: ScopeStatusReport[] }> { const warnings: string[] = []; - const checker = new RegistryVersionChecker(this.fetchFn); + const checker = new RegistryVersionChecker(this.environment.fetch); const latest = new Map(); const failures = new Map(); for (const report of reports) { diff --git a/src/installer.ts b/src/installer.ts index 79654f8..ce3e45d 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -1,15 +1,17 @@ import { exists, mkdir, readFile, readdir, rm, rmdir, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import path from "node:path"; import { hashElement } from "folder-hash"; import { AGENT_FILENAMES } from "./agent-loader"; import { BundledAssetsMissingError } from "./bundled-assets-missing-error"; import { CopyModeUnsupportedError } from "./copy-mode-unsupported-error"; -import { NpmCache } from "./npm-cache"; -import { PluginConfigEditor } from "./plugin-config"; -import type { ManifestState } from "./status-reporter"; - -export type Scope = "local" | "global"; +import { NpmCache } from "./core/npm-cache"; +import { PluginConfigEditor } from "./core/plugin-config"; +import { realEnvironment, type Environment } from "./core/environment"; +import { scopeBase, type Scope } from "./core/scope-base"; +import { removeCacheTargets } from "./core/cache-hygiene"; +import type { ManifestState } from "./core/status-reporter"; + +export type { Scope } from "./core/scope-base"; export type InstallMode = "none" | "copy" | "plugin"; export type ManifestMode = "copy" | "plugin"; export type InstallAction = "installed" | "upgraded" | "noop" | "migrated"; @@ -62,12 +64,14 @@ const MANIFEST_NAME = "opencode-architect.manifest.json"; const LEGACY_MANIFEST_NAME = "opencode-architect.json"; export class Installer { - private readonly editor = new PluginConfigEditor(); - private readonly cache = new NpmCache(); + private readonly editor: PluginConfigEditor; + private readonly cache: NpmCache; private readonly assetsDir: string; - constructor(assetsDir: string | null = null, private readonly rmFn: typeof rm = rm) { + constructor(assetsDir: string | null = null, private readonly rmFn: typeof rm = rm, private readonly environment: Environment = realEnvironment) { this.assetsDir = assetsDir ?? path.join(import.meta.dirname, ".."); + this.editor = new PluginConfigEditor(environment); + this.cache = new NpmCache(environment); } public async install(scope: Scope, options: InstallOptions): Promise { @@ -155,24 +159,10 @@ export class Installer { } private async prunePackageCache(version: string): Promise<{ removed: string[]; warnings: string[] }> { - const removed: string[] = []; - const warnings: string[] = []; - const targets = [ - PACKAGE_NAME, - `${PACKAGE_NAME}@latest`, - `${PACKAGE_NAME}@${version}`, - ].map((name) => path.join(this.cache.root(), name)); - for (const target of targets) { - if (!(await exists(target))) continue; - try { - await this.rmFn(target, { recursive: true }); - removed.push(target); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - warnings.push(`Could not clear cached package ${target}: ${message}`); - } - } - return { removed, warnings }; + const wanted = [PACKAGE_NAME, `${PACKAGE_NAME}@latest`, `${PACKAGE_NAME}@${version}`]; + const targets = wanted.map((name) => path.join(this.cache.root(), name)); + const outcome = await removeCacheTargets(this.cache.root(), (name) => wanted.includes(name), this.rmFn); + return { removed: targets.filter((target) => outcome.removed.includes(target)), warnings: outcome.warnings }; } public async uninstall(scope: Scope, projectDir: string): Promise { @@ -346,10 +336,7 @@ export class Installer { } private scopeBase(scope: Scope, 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"); + return scopeBase(scope, projectDir, this.environment); } } diff --git a/src/plugin-config.ts b/src/plugin-config.ts deleted file mode 100644 index 7052d16..0000000 --- a/src/plugin-config.ts +++ /dev/null @@ -1,650 +0,0 @@ -import { exists, mkdir, readFile, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import path from "node:path"; -import { PluginEntryResolver } from "./plugin-entry"; - -export type ConfigScope = "local" | "global"; -export type PluginConfigKey = "plugins" | "plugin"; - -export interface EnsurePluginEntryOptions { - scope: ConfigScope; - projectDir: string; -} - -export interface EnsurePluginEntryOutcome { - action: "noop" | "updated" | "created" | "blocked"; - configPath: string | null; - warning: string | null; -} - -interface CandidateConfig { - path: string; - lenient: boolean; - writable: boolean; -} - -interface CandidateRead { - candidate: CandidateConfig; - text: string; - entries: Record; - rawEntries: Record; -} - -export interface RemovePluginEntryOutcome { - action: "noop" | "removed" | "blocked"; - configPath: string | null; - warning: string | null; -} - -export type ConfigCheck = { ok: true } | { ok: false; warning: string }; - -interface PluginArrayRange { - bracketStart: number; - bracketEnd: number; -} - -const DEFAULT_CONFIG_TEMPLATE = `{ - // OpenCode configuration - "$schema": "https://opencode.ai/config.json", - "plugins": ["__PACKAGE_NAME__"] -} -`; - -export class PluginConfigEditor { - public async ensurePluginEntry( - packageName: string, - options: EnsurePluginEntryOptions, - ): Promise { - const canonical = this.canonicalEntry(packageName); - for (const read of await this.readCandidates(options)) { - const { candidate, text, entries, rawEntries } = read; - const configDir = path.dirname(candidate.path); - if (entries.plugins === null) { - return { - action: "blocked", - configPath: candidate.path, - warning: - `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + - `Fix or remove the file and re-run the install.`, - }; - } - if (await this.entryResolves(rawEntries.plugins, packageName, configDir)) { - return { action: "noop", configPath: candidate.path, warning: null }; - } - if (await this.entryResolves(rawEntries.plugin, packageName, configDir)) { - return { - action: "noop", - configPath: candidate.path, - warning: this.legacyEntryAdvisory(candidate, packageName), - }; - } - if (!candidate.writable) continue; - const spliced = this.spliceEntry(text, canonical, packageName, candidate.lenient); - if (spliced === null) { - return { - action: "blocked", - configPath: candidate.path, - warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, - }; - } - await mkdir(path.dirname(candidate.path), { recursive: true }); - await writeFile(candidate.path, spliced); - return { action: "updated", configPath: candidate.path, warning: null }; - } - - const target = this.defaultConfigPath(options); - const content = DEFAULT_CONFIG_TEMPLATE.replace("__PACKAGE_NAME__", canonical); - await mkdir(path.dirname(target), { recursive: true }); - await writeFile(target, content); - return { action: "created", configPath: target, warning: null }; - } - - public async checkParseable(options: EnsurePluginEntryOptions): Promise { - for (const candidate of this.candidateConfigs(options)) { - if (!(await exists(candidate.path))) continue; - const text = await readFile(candidate.path, "utf-8"); - if (this.parsePluginArray(text, candidate.lenient) === null) { - return { - ok: false, - warning: - `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + - `Fix or remove the file and re-run the command.`, - }; - } - } - return { ok: true }; - } - - public async findRegistration( - packageName: string, - options: EnsurePluginEntryOptions, - ): Promise { - for (const read of await this.readCandidates(options)) { - if (read.entries.plugins === null) continue; - const configDir = path.dirname(read.candidate.path); - const registered = - (await this.entryResolves(read.rawEntries.plugins, packageName, configDir)) || - (await this.entryResolves(read.rawEntries.plugin, packageName, configDir)); - if (registered) return read.candidate.path; - } - return null; - } - - public async removePluginEntry( - packageName: string, - options: EnsurePluginEntryOptions, - ): Promise { - const reads = await this.readCandidates(options); - let legacyPath: string | null = null; - for (const read of reads) { - const { candidate, text, entries } = read; - const configDir = path.dirname(candidate.path); - if (!candidate.writable) continue; - if (entries.plugins === null) { - return { - action: "blocked", - configPath: candidate.path, - warning: - `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + - `Fix or remove the file and re-run the uninstall.`, - }; - } - if (await this.entryResolves(read.rawEntries.plugins, packageName, configDir)) { - const spliced = await this.spliceOutEntry(text, packageName, candidate.lenient, configDir); - if (spliced === null) { - return { - action: "blocked", - configPath: candidate.path, - warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, - }; - } - await writeFile(candidate.path, spliced); - const leftover = await this.findLegacyConfig(reads, packageName); - return { - action: "removed", - configPath: candidate.path, - warning: leftover === null ? null : this.legacyRemovalAdvisory(leftover, packageName), - }; - } - if (legacyPath === null && (await this.entryResolves(read.rawEntries.plugin, packageName, configDir))) { - legacyPath = candidate.path; - } - } - if (legacyPath !== null) { - return { - action: "noop", - configPath: legacyPath, - warning: this.legacyRemovalAdvisory(legacyPath, packageName), - }; - } - return { action: "noop", configPath: null, warning: null }; - } - - public hasMatchingEntry(entries: string[], packageName: string): boolean { - return entries.some((entry) => this.matchesEntry(entry, packageName)); - } - - private async entryResolves( - rawEntries: unknown[] | null, - packageName: string, - configDir: string, - ): Promise { - if (rawEntries === null) return false; - for (const entry of rawEntries) { - if (await this.entryMatches(entry, packageName, configDir)) return true; - } - return false; - } - - private async entryMatches(rawEntry: unknown, packageName: string, configDir: string): Promise { - const name = this.normalizeEntry(rawEntry); - if (name !== null && this.matchesEntry(name, packageName)) return true; - return PluginEntryResolver.resolvesToPackage(rawEntry, packageName, configDir); - } - - private async findLegacyConfig(reads: CandidateRead[], packageName: string): Promise { - for (const read of reads) { - const legacyDir = path.dirname(read.candidate.path); - if (await this.entryResolves(read.rawEntries.plugin, packageName, legacyDir)) return read.candidate.path; - } - return null; - } - - private matchesEntry(entry: string, packageName: string): boolean { - const specIndex = entry.lastIndexOf("@"); - const name = specIndex > 0 ? entry.slice(0, specIndex) : entry; - return name === packageName; - } - - private canonicalEntry(packageName: string): string { - const specIndex = packageName.lastIndexOf("@"); - if (specIndex > 0) return packageName; - return `${packageName}@latest`; - } - - private candidateConfigs(options: EnsurePluginEntryOptions): CandidateConfig[] { - const scopeBase = this.scopeBase(options.scope, options.projectDir); - const repoRoot = options.projectDir; - const configs: CandidateConfig[] = []; - if (options.scope === "local") { - for (const root of [scopeBase, repoRoot]) { - 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; - } - - private async readCandidates(options: EnsurePluginEntryOptions): Promise { - const reads: CandidateRead[] = []; - for (const candidate of this.candidateConfigs(options)) { - if (!(await exists(candidate.path))) continue; - const text = await readFile(candidate.path, "utf-8"); - const config = this.parseConfig(text, candidate.lenient); - const entries: Record = { plugins: null, plugin: null }; - const rawEntries: Record = { plugins: null, plugin: null }; - if (config === null) { - console.warn( - `Warning: ${candidate.path} could not be parsed; refusing to treat it as a registration candidate.`, - ); - } else { - for (const key of ["plugins", "plugin"] as PluginConfigKey[]) { - entries[key] = this.keyEntries(config, key); - rawEntries[key] = this.entriesOf(config, key); - } - } - reads.push({ candidate, text, entries, rawEntries }); - } - return reads; - } - - private keyEntries(config: Record, key: PluginConfigKey): string[] { - const value = config[key]; - if (!Array.isArray(value)) return []; - return value.map((entry) => this.normalizeEntry(entry)).filter((entry): entry is string => entry !== null); - } - - private entriesOf(config: Record, key: PluginConfigKey): unknown[] { - const value = config[key]; - return Array.isArray(value) ? value : []; - } - - private normalizeEntry(entry: unknown): string | null { - if (typeof entry === "string") return entry; - return this.packageOf(entry); - } - - private packageOf(entry: unknown): string | null { - if (entry === null || typeof entry !== "object") return null; - const pkg = (entry as { package: unknown }).package; - return typeof pkg === "string" ? pkg : null; - } - - private legacyEntryAdvisory(candidate: CandidateConfig, packageName: string): string { - if (path.basename(candidate.path) === "config.json") { - return ( - `${candidate.path} registers ${packageName} under the legacy v1 "plugin" key. ` + - `OpenCode v2 no longer reads config.json, so the entry is inert, and this installer never edits it. ` + - `Add ${packageName} to the "plugins" key of an opencode.json or opencode.jsonc config instead.` - ); - } - return ( - `${candidate.path} registers ${packageName} under the legacy v1 "plugin" key. ` + - `OpenCode v2 still loads it through config migration, and this installer leaves legacy entries ` + - `read-only. Move the entry to the v2 "plugins" key to upgrade, then re-run the install.` - ); - } - - private legacyRemovalAdvisory(configPath: string, packageName: string): string { - return ( - `${configPath} registers ${packageName} under the legacy v1 "plugin" key; it was left untouched. ` + - `Remove it by editing the file, or move the entry to the v2 "plugins" key and re-run the uninstall.` - ); - } - - private defaultConfigPath(options: EnsurePluginEntryOptions): string { - if (options.scope === "global") { - return path.join(this.scopeBase("global", options.projectDir), "opencode.jsonc"); - } - return path.join(options.projectDir, "opencode.jsonc"); - } - - 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 parsePluginArray(text: string, lenient: boolean): string[] | null { - const config = this.parseConfig(text, lenient); - if (config === null) return null; - return this.keyEntries(config, "plugins"); - } - - private parseConfig(text: string, lenient: boolean): Record | null { - const parseable = lenient ? this.blankComments(text).replace(/,(\s*[}\]])/g, "$1") : text; - try { - return JSON.parse(parseable) as Record; - } catch { - return null; - } - } - - private blankComments(text: string): string { - const chars = text.split(""); - let inString = false; - let inLineComment = false; - let inBlockComment = false; - for (let i = 0; i < chars.length; i++) { - const current = chars[i]; - const next = i + 1 < chars.length ? chars[i + 1] : ""; - if (inLineComment) { - if (current === "\n") inLineComment = false; - else chars[i] = " "; - continue; - } - if (inBlockComment) { - if (current === "*" && next === "/") { - chars[i] = " "; - chars[i + 1] = " "; - i++; - inBlockComment = false; - } else { - chars[i] = " "; - } - continue; - } - if (inString) { - if (current === "\\") { - i++; - continue; - } - if (current === '"') inString = false; - continue; - } - if (current === '"') { - inString = true; - continue; - } - if (current === "/" && next === "/") { - chars[i] = " "; - chars[i + 1] = " "; - i++; - inLineComment = true; - continue; - } - if (current === "/" && next === "*") { - chars[i] = " "; - chars[i + 1] = " "; - i++; - inBlockComment = true; - continue; - } - } - return chars.join(""); - } - - private spliceEntry( - text: string, - entryToWrite: string, - packageName: string, - lenient: boolean, - ): string | null { - const navigable = this.blankComments(text); - const range = this.findPluginArrayRange(navigable); - const spliced = - range === null - ? this.splicePluginKey(text, navigable, entryToWrite) - : this.spliceArrayEntry(text, navigable, range, entryToWrite); - if (spliced === null) return null; - const plugins = this.parsePluginArray(spliced, lenient); - if (plugins === null || !this.hasMatchingEntry(plugins, packageName)) return null; - return spliced; - } - - private async spliceOutEntry( - text: string, - packageName: string, - lenient: boolean, - configDir: string, - ): Promise { - const navigable = this.blankComments(text); - const range = this.findPluginArrayRange(navigable); - if (range === null) return null; - const innerStart = range.bracketStart + 1; - const innerEnd = range.bracketEnd; - const elements = this.arrayElementRanges(navigable, innerStart, innerEnd); - const target = await this.matchingElement(text, elements, packageName, configDir); - if (!target) return null; - const withComma = this.dropAdjacentComma(navigable, elements, target, innerStart, innerEnd); - const result = text.slice(0, withComma.start) + text.slice(withComma.end); - const config = this.parseConfig(result, lenient); - if (config === null) return null; - if (await this.entryResolves(this.entriesOf(config, "plugins"), packageName, configDir)) return null; - return result; - } - - private async matchingElement( - text: string, - elements: Array<{ start: number; end: number }>, - packageName: string, - configDir: string, - ): Promise<{ start: number; end: number } | null> { - for (const element of elements) { - const spec = this.elementSpec(text.slice(element.start, element.end)); - if (await this.entryMatches(spec, packageName, configDir)) return element; - } - return null; - } - - private arrayElementRanges( - navigable: string, - innerStart: number, - innerEnd: number, - ): Array<{ start: number; end: number }> { - const elements: Array<{ start: number; end: number }> = []; - let inString = false; - let depth = 0; - let start = -1; - for (let i = innerStart; i < innerEnd; i++) { - const current = navigable[i]; - if (inString) { - if (current === "\\") i++; - else if (current === '"') inString = false; - continue; - } - if (current === '"') { - inString = true; - if (start === -1) start = i; - continue; - } - if (current === "[" || current === "{") { - depth++; - if (start === -1) start = i; - continue; - } - if (current === "]" || current === "}") { - depth--; - continue; - } - if (current === "," && depth === 0) { - if (start !== -1) elements.push({ start, end: i }); - start = -1; - continue; - } - if (!/\s/.test(current ?? "") && start === -1) start = i; - } - if (start !== -1) elements.push({ start, end: innerEnd }); - return elements; - } - - private dropAdjacentComma( - navigable: string, - elements: Array<{ start: number; end: number }>, - target: { start: number; end: number }, - innerStart: number, - innerEnd: number, - ): { start: number; end: number } { - const index = elements.indexOf(target); - const next = elements[index + 1]; - if (next) return { start: target.start, end: next.start }; - const previous = elements[index - 1]; - if (previous) return { start: previous.end, end: target.end }; - let start = target.start; - while (start > innerStart && /\s/.test(navigable[start - 1] ?? "")) start--; - let end = target.end; - while (end < innerEnd && /\s/.test(navigable[end] ?? "")) end++; - return { start, end }; - } - - private unquote(raw: string): string { - const trimmed = raw.trim(); - if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { - return trimmed.slice(1, -1); - } - return trimmed; - } - - private elementSpec(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed.startsWith("{")) return this.unquote(trimmed); - try { - return this.packageOf(JSON.parse(trimmed.replace(/,(\s*[}\]])/g, "$1"))) ?? ""; - } catch { - return ""; - } - } - - private spliceArrayEntry( - text: string, - navigable: string, - range: PluginArrayRange, - packageName: string, - ): string | null { - const innerStart = range.bracketStart + 1; - const inner = text.slice(innerStart, range.bracketEnd); - const firstElementOffset = inner.search(/\S/); - if (firstElementOffset === -1) { - return text.slice(0, innerStart) + `"${packageName}"` + text.slice(range.bracketEnd); - } - const insertAt = innerStart + firstElementOffset; - const leadingWhitespace = inner.slice(0, firstElementOffset); - return text.slice(0, insertAt) + leadingWhitespace + `"${packageName}",` + text.slice(insertAt); - } - - private splicePluginKey(text: string, navigable: string, packageName: string): string | null { - const objectStart = this.firstStructuralChar(navigable, "{"); - if (objectStart === -1) return null; - const rest = text.slice(objectStart + 1); - const nextContentOffset = rest.search(/\S/); - if (nextContentOffset === -1) return null; - const insertAt = objectStart + 1 + nextContentOffset; - const isClosingBrace = rest[nextContentOffset] === "}"; - const entry = isClosingBrace ? `"plugins": ["${packageName}"]` : `"plugins": ["${packageName}"],`; - const leadingWhitespace = nextContentOffset > 0 ? rest.slice(0, nextContentOffset) : ""; - return text.slice(0, insertAt) + leadingWhitespace + entry + text.slice(insertAt); - } - - private findPluginArrayRange(navigable: string): PluginArrayRange | null { - let searchFrom = 0; - while (searchFrom < navigable.length) { - const keyIndex = navigable.indexOf('"plugins"', searchFrom); - if (keyIndex === -1) return null; - if (this.precededByStructuralChar(navigable, keyIndex, ["{", ","])) { - const colonIndex = this.nextOutsideString(navigable, keyIndex + '"plugins"'.length, ":"); - if (colonIndex !== -1) { - const bracketStart = this.nextOutsideString(navigable, colonIndex + 1, "["); - if (bracketStart !== -1) { - const bracketEnd = this.matchingBracket(navigable, bracketStart); - if (bracketEnd !== null) return { bracketStart, bracketEnd }; - } - } - } - searchFrom = keyIndex + 1; - } - return null; - } - - private precededByStructuralChar(text: string, index: number, allowed: string[]): boolean { - for (let i = index - 1; i >= 0; i--) { - const current: string = text[i] ?? ""; - if (/\s/.test(current)) continue; - return allowed.includes(current); - } - return false; - } - - private firstStructuralChar(text: string, target: string): number { - let inString = false; - for (let i = 0; i < text.length; i++) { - const current = text[i]; - if (inString) { - if (current === "\\") { - i++; - continue; - } - if (current === '"') inString = false; - continue; - } - if (current === '"') { - inString = true; - continue; - } - if (current === target) return i; - } - return -1; - } - - private nextOutsideString(text: string, from: number, target: string): number { - let inString = false; - for (let i = from; i < text.length; i++) { - const current = text[i]; - if (inString) { - if (current === "\\") { - i++; - continue; - } - if (current === '"') inString = false; - continue; - } - if (current === '"') { - inString = true; - continue; - } - if (current === target) return i; - } - return -1; - } - - private matchingBracket(text: string, bracketStart: number): number | null { - let depth = 0; - let inString = false; - for (let i = bracketStart; i < text.length; i++) { - const current = text[i]; - if (inString) { - if (current === "\\") { - i++; - continue; - } - if (current === '"') inString = false; - continue; - } - if (current === '"') { - inString = true; - continue; - } - if (current === "[") depth++; - else if (current === "]") { - depth--; - if (depth === 0) return i; - } - } - return null; - } -} diff --git a/src/plugin.ts b/src/plugin.ts index 5416718..0e446e8 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -8,7 +8,7 @@ import { AgentLoader, type LoadedAgent } from "./agent-loader"; import { AssetPermissionAdvisor } from "./asset-permission-advisor"; import { CommandLoader, type LoadedCommand } from "./command-loader"; import { Installer } from "./installer"; -import { NpmCache } from "./npm-cache"; +import { NpmCache } from "./core/npm-cache"; import { SkillLoader, UPGRADE_SKILL_ID, type LoadedSkill } from "./skill-loader"; const PACKAGE_NAME = "opencode-architect"; diff --git a/src/scaffold/promoter.ts b/src/scaffold/promoter.ts new file mode 100644 index 0000000..014ab4a --- /dev/null +++ b/src/scaffold/promoter.ts @@ -0,0 +1,328 @@ +import { exists, mkdir, readdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { PluginConfigEditor } from "../core/plugin-config"; +import { sha256 } from "../core/manifest"; +import { ScaffoldRenderer, architectPackageRoot, fileDependency, type ShipKind } from "./renderer"; +import { Scaffolder } from "./scaffolder"; + +const CONTENT_MAPPINGS: Array<{ source: string; destination: string; kind: ShipKind }> = [ + { source: "skills", destination: "skills", kind: "skills" }, + { source: "commands", destination: "commands", kind: "commands" }, + { source: "agents", destination: "agents", kind: "agents" }, + { source: "plugin", destination: "plugins", kind: "plugins" }, + { source: "tools", destination: "tools", kind: "tools" }, +]; + +const SOURCE_ARTIFACTS = ["package.json", "package-lock.json", "bun.lock", "bun.lockb", "node_modules"]; + +export interface RemovalCandidate { + sourcePath: string; + relativePath: string; +} + +export interface PromoteOptions { + projectDir: string; + name: string | null; + target: string | null; + yes: boolean; +} + +export type PromoteOutcome = + | { + ok: true; + packageName: string; + packageDir: string; + configPath: string | null; + retired: boolean; + removed: string[]; + managed: string[]; + wouldRemove: string[]; + notes: string[]; + } + | { ok: false; error: string; retired: boolean }; + +interface InventoryEntry { + kind: ShipKind; + sourcePath: string; + relativePath: string; + content: string; +} + +export class Promoter { + public async promote(options: PromoteOptions): Promise { + const sourceDir = path.join(options.projectDir, ".opencode"); + if (!(await exists(sourceDir))) { + return { ok: false, error: `No .opencode/ directory found at ${options.projectDir}.`, retired: false }; + } + const repoRootResult = await this.repoRoot(options.projectDir); + const repoRoot = repoRootResult.root; + const name = options.name ?? this.deriveName(repoRoot); + if (!/^opencode-[a-z0-9][a-z0-9-._]*$/.test(name)) { + return { ok: false, error: `Invalid package name: ${name}. Pass --name opencode-.`, retired: false }; + } + const target = options.target ?? "here"; + if (target !== "here" && target !== "sibling") { + return { ok: false, error: `Invalid target: ${target}. Must be "here" or "sibling".`, retired: false }; + } + const packageDir = + target === "sibling" ? path.join(path.dirname(repoRoot), name) : path.join(repoRoot, name); + if (target === "sibling" && (await exists(packageDir)) && (await this.isExistingPackage(packageDir))) { + return { + ok: false, + error: + `An existing package sits at the sibling target ${packageDir}. ` + + `Merging into an existing package requires per-item consent and is not automated; ` + + `route the merge decisions through the packager / plugin-engineer flow, or choose another --name.`, + retired: false, + }; + } + if (await exists(packageDir)) { + return { ok: false, error: `Target directory already exists: ${packageDir}. Remove it or choose another name.`, retired: false }; + } + + const inventory = await this.inventory(sourceDir); + if (inventory.length === 0) { + return { ok: false, error: `No skills, commands, agents, plugins, or tools found in ${sourceDir}.`, retired: false }; + } + const ship = [...new Set(inventory.map((entry) => entry.kind))]; + + const architectRoot = architectPackageRoot(); + const rendered = new ScaffoldRenderer().render({ name, ship, coreDependency: fileDependency(packageDir, architectRoot) }); + await new Scaffolder().writeRendered(packageDir, rendered); + await this.linkCoreDependency(packageDir, architectRoot); + for (const entry of inventory) { + const targetPath = path.join(packageDir, entry.relativePath); + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, entry.content); + } + + const editor = new PluginConfigEditor(); + const reference = await editor.ensurePluginEntry( + name, + { scope: "local", projectDir: options.projectDir }, + pathToFileURL(packageDir).href, + ); + if (reference.action === "blocked") { + return { + ok: false, + error: + `The config reference could not be established: ${reference.warning ?? "every candidate was unwritable"} ` + + `Retirement aborted; the source .opencode/ content is left in place.`, + retired: false, + }; + } + + const install = Bun.spawn([process.execPath, path.join(packageDir, "src", "cli.ts"), "install"], { + cwd: options.projectDir, + stdout: "pipe", + stderr: "pipe", + }); + const installExit = await install.exited; + if (installExit !== 0) { + const detail = await new Response(install.stderr).text(); + return { + ok: false, + error: `The package's install failed (exit ${installExit}); retirement aborted. ${detail.trim()}`, + retired: false, + }; + } + const verifyError = await this.verifyPayload(options.projectDir, name); + if (verifyError !== null) { + return { ok: false, error: `${verifyError} Retirement aborted.`, retired: false }; + } + + const manifest = await this.readManifest(options.projectDir, name); + if (manifest === null) { + return { ok: false, error: `The install manifest is missing; the payload could not be verified. Retirement aborted.`, retired: false }; + } + const removal = await this.removalPlan(sourceDir, packageDir, inventory, manifest.mode); + if (!options.yes) { + return { + ok: true, + packageName: name, + packageDir, + configPath: reference.configPath, + retired: false, + removed: [], + managed: removal.managed, + wouldRemove: [...removal.removable, ...removal.artifacts], + notes: repoRootResult.notes, + }; + } + const removed: string[] = []; + for (const relativePath of removal.removable) { + await rm(path.join(sourceDir, relativePath), { force: true }); + removed.push(relativePath); + } + for (const artifact of removal.artifacts) { + await rm(path.join(sourceDir, artifact), { recursive: true, force: true }); + removed.push(artifact); + } + await this.pruneEmptyDirs(sourceDir); + return { + ok: true, + packageName: name, + packageDir, + configPath: reference.configPath, + retired: true, + removed, + managed: removal.managed, + wouldRemove: [], + notes: repoRootResult.notes, + }; + } + + /** + * Links the core dependency the way a package manager links a `file:` + * dependency (ADR-0013) — a junction into the installed architect package, + * so the promoted package's install can resolve `opencode-architect/core` + * locally, with no registry access. + */ + private async linkCoreDependency(packageDir: string, architectRoot: string): Promise { + const linkPath = path.join(packageDir, "node_modules", "opencode-architect"); + if (await exists(linkPath)) return; + await mkdir(path.dirname(linkPath), { recursive: true }); + await symlink(architectRoot, linkPath, process.platform === "win32" ? "junction" : "dir"); + } + + private async repoRoot(projectDir: string): Promise<{ root: string; notes: string[] }> { + const git = Bun.spawn(["git", "rev-parse", "--show-toplevel"], { + cwd: projectDir, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([new Response(git.stdout).text(), git.exited]); + if (exitCode !== 0) { + return { root: projectDir, notes: [`git rev-parse failed; assuming ${projectDir} as the repo root.`] }; + } + const root = stdout.trim(); + if (root.length === 0 || path.basename(root) === ".opencode") { + return { root: projectDir, notes: [`git rev-parse gave no usable root; assuming ${projectDir} as the repo root.`] }; + } + return { root, notes: [] }; + } + + private async isExistingPackage(dir: string): Promise { + return (await exists(path.join(dir, "package.json"))) || (await exists(path.join(dir, "plugin.ts"))); + } + + private deriveName(repoRoot: string): string { + const base = path.basename(repoRoot) + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return base.startsWith("opencode-") ? base : `opencode-${base}`; + } + + private async inventory(sourceDir: string): Promise { + const entries: InventoryEntry[] = []; + for (const mapping of CONTENT_MAPPINGS) { + const dir = path.join(sourceDir, mapping.source); + if (!(await exists(dir))) continue; + for (const filePath of await this.listFiles(dir)) { + const relativePath = `${mapping.destination}/${path.relative(dir, filePath).replaceAll("\\", "/")}`; + entries.push({ + kind: mapping.kind, + sourcePath: filePath, + relativePath, + content: await readFile(filePath, "utf-8"), + }); + } + } + return entries; + } + + private async listFiles(dir: string): Promise { + const found: string[] = []; + for (const dirent of await readdir(dir, { withFileTypes: true })) { + const child = path.join(dir, dirent.name); + if (dirent.isDirectory()) found.push(...(await this.listFiles(child))); + else if (dirent.isFile()) found.push(child); + } + return found; + } + + public async verifyPayload(projectDir: string, name: string): Promise { + const manifestPath = path.join(projectDir, ".opencode", `${name}.manifest.json`); + if (!(await exists(manifestPath))) { + return `The install manifest is missing at ${manifestPath}; the payload could not be verified.`; + } + let manifest: { version?: unknown; mode?: unknown; files?: Record }; + try { + manifest = JSON.parse(await readFile(manifestPath, "utf-8")); + } catch { + return `The install manifest at ${manifestPath} does not parse.`; + } + if (typeof manifest.version !== "string" || typeof manifest.mode !== "string") { + return `The install manifest at ${manifestPath} is missing version or mode.`; + } + for (const [relativePath, hash] of Object.entries(manifest.files ?? {})) { + const absolute = path.join(projectDir, ".opencode", relativePath); + if (!(await exists(absolute))) { + return `Manifest lists ${relativePath} but it is missing on disk.`; + } + const actual = await sha256(absolute); + if (actual !== hash) { + return `Manifest hash mismatch for ${relativePath}: the on-disk content does not match the installed payload.`; + } + } + const configPath = await new PluginConfigEditor().findRegistration(name, { + scope: "local", + projectDir, + }); + if (configPath === null) return `No config reference for ${name} could be found.`; + return null; + } + + public async removalPlan( + sourceDir: string, + packageDir: string, + inventory: RemovalCandidate[], + mode: string, + ): Promise<{ removable: string[]; managed: string[]; artifacts: string[] }> { + const removable: string[] = []; + const managed: string[] = []; + for (const entry of inventory) { + const packageCopy = path.join(packageDir, entry.relativePath); + const original = entry.sourcePath; + const identical = await this.filesIdentical(original, packageCopy); + const managedByInstall = mode === "copy" && identical; + if (managedByInstall) managed.push(path.relative(sourceDir, original).replaceAll("\\", "/")); + else removable.push(path.relative(sourceDir, original).replaceAll("\\", "/")); + } + const artifacts: string[] = []; + for (const artifact of SOURCE_ARTIFACTS) { + if (await exists(path.join(sourceDir, artifact))) artifacts.push(artifact); + } + return { removable, managed, artifacts }; + } + + private async filesIdentical(left: string, right: string): Promise { + try { + return (await readFile(left, "utf-8")) === (await readFile(right, "utf-8")); + } catch { + return false; + } + } + + private async readManifest(projectDir: string, name: string): Promise<{ mode: string } | null> { + try { + const parsed = JSON.parse(await readFile(path.join(projectDir, ".opencode", `${name}.manifest.json`), "utf-8")); + return typeof parsed.mode === "string" ? { mode: parsed.mode } : null; + } catch { + return null; + } + } + + private async pruneEmptyDirs(sourceDir: string): Promise { + for (const mapping of CONTENT_MAPPINGS) { + const dir = path.join(sourceDir, mapping.source); + if (!(await exists(dir))) continue; + const info = await stat(dir); + if (!info.isDirectory()) continue; + if ((await this.listFiles(dir)).length > 0) continue; + await rm(dir, { recursive: true, force: true }); + } + } +} diff --git a/src/scaffold/renderer.ts b/src/scaffold/renderer.ts new file mode 100644 index 0000000..1ed5c36 --- /dev/null +++ b/src/scaffold/renderer.ts @@ -0,0 +1,128 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +export type ShipKind = "skills" | "commands" | "agents" | "tools" | "plugins"; + +export interface ScaffoldPlan { + name: string; + ship: ShipKind[]; + /** + * The `file:` dependency spec through which the rendered package consumes + * the `opencode-architect/core` machinery (ADR-0013). Callers compute it + * with `fileDependency` from their target directory to the architect + * package root. + */ + coreDependency: string; +} + +/** + * The local `file:` dependency spec linking a rendered package at `from` to + * the architect package at `to`. Purely local — no registry access. + */ +export function fileDependency(from: string, to: string): string { + let relativeSpec = path.relative(from, to).replaceAll("\\", "/"); + if (!relativeSpec.startsWith(".")) relativeSpec = `./${relativeSpec}`; + return `file:${relativeSpec}`; +} + +/** The directory of the architect package this module ships in. */ +export function architectPackageRoot(): string { + return path.resolve(import.meta.dirname, "..", ".."); +} + +export interface RenderedFile { + relativePath: string; + content: string; +} + +const TEMPLATES_DIR = path.join(import.meta.dirname, "..", "..", "templates"); + +const ASSET_KINDS: ShipKind[] = ["skills", "commands"]; + +const CODE_KINDS: ShipKind[] = ["agents", "tools", "plugins"]; + +const TEMPLATE_SOURCES: Array<{ template: string; target: string }> = [ + { template: "index.template.txt", target: "index.ts" }, + { template: "plugin-local.template.txt", target: "src/plugin.ts" }, + { template: "installer.template.txt", target: "src/installer.ts" }, + { template: "cli.template.txt", target: "src/cli.ts" }, +]; + +export class ScaffoldRenderer { + public render(plan: ScaffoldPlan): RenderedFile[] { + const identifier = plan.name.replace(/^opencode-/, ""); + function substituted(templateName: string): string { + return ScaffoldRenderer.substitute(ScaffoldRenderer.templateBody(templateName), plan.name, identifier); + } + + const files: RenderedFile[] = TEMPLATE_SOURCES.map((entry) => ({ + relativePath: entry.target, + content: substituted(entry.template), + })); + + files.push({ relativePath: "tsconfig.json", content: substituted("tsconfig.template.json") }); + files.push({ + relativePath: `skills/${identifier}/SKILL.md`, + content: substituted("skill-structure.template.md"), + }); + files.push({ + relativePath: "commands/my-command.md", + content: `---\ndescription: "Rendered command stub"\n---\n\nSay hello from ${plan.name}.\n`, + }); + for (const kind of CODE_KINDS) { + if (plan.ship.includes(kind)) files.push({ relativePath: `${kind}/.gitkeep`, content: "" }); + } + files.push({ + relativePath: "tests/plugin.contract.test.ts", + content: ScaffoldRenderer.smokeTest(plan.name), + }); + files.push({ relativePath: "package.json", content: this.manifest(plan, identifier) }); + return files; + } + + private manifest(plan: ScaffoldPlan, identifier: string): string { + const parsed = JSON.parse(ScaffoldRenderer.templateBody("package-basics.template.json")) as Record; + parsed["name"] = plan.name; + parsed["bin"] = { [plan.name]: "src/cli.ts" }; + parsed["files"] = ["index.ts", "src", ...ScaffoldRenderer.shippedDirs(plan)]; + const shipped = new Set(plan.ship); + const codeBacked = [...shipped].some((kind) => !ASSET_KINDS.includes(kind)); + parsed["content"] = codeBacked ? "code" : "assets"; + const dependencies = parsed["dependencies"] as Record; + dependencies["opencode-architect"] = plan.coreDependency; + return `${JSON.stringify(parsed, null, 2)}\n`; + } + + private static shippedDirs(plan: ScaffoldPlan): string[] { + const dirs: string[] = ["skills", "commands"]; + for (const kind of CODE_KINDS) { + if (plan.ship.includes(kind)) dirs.push(kind); + } + return dirs; + } + + private static smokeTest(name: string): string { + return `import { describe, expect, test } from "bun:test"; +import plugin from "../index.ts"; + +describe("${name}", () => { + test("exports a v2 Effect plugin definition", () => { + const definition = plugin as { id: string; effect: unknown }; + expect(definition.id).toBe("${name}"); + expect(typeof definition.effect).toBe("function"); + }); +}); +`; + } + + private static substitute(body: string, name: string, identifier: string): string { + return body.replaceAll("opencode-myextension", name).replaceAll("myextension", identifier); + } + + private static templateBody(templateName: string): string { + const source = readFileSync(path.join(TEMPLATES_DIR, templateName), "utf-8"); + const separator = source.match(/^---$/m); + if (separator === null || separator.index === undefined) return source; + return source.slice(separator.index + 3 + 1).trimStart(); + } +} diff --git a/src/scaffold/scaffolder.ts b/src/scaffold/scaffolder.ts new file mode 100644 index 0000000..bff0c1a --- /dev/null +++ b/src/scaffold/scaffolder.ts @@ -0,0 +1,90 @@ +import { exists, mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { ScaffoldRenderer, architectPackageRoot, fileDependency, type RenderedFile, type ShipKind } from "./renderer"; + +const SHIP_KINDS: ShipKind[] = ["skills", "commands", "agents", "tools", "plugins"]; + +export interface FreshOptions { + name: string | null; + target: string | null; + ship: string | null; + workspaceRoot: string; +} + +export type FreshOutcome = + | { + ok: true; + packageName: string; + packageDir: string; + files: string[]; + } + | { ok: false; error: string }; + +export class Scaffolder { + public async fresh(options: FreshOptions): Promise { + const name = options.name ?? this.askName(); + if (name === null || !/^opencode-[a-z0-9][a-z0-9-._]*$/.test(name)) { + return { ok: false, error: `Invalid or missing package name: ${name ?? "(none)"}. Pass --name opencode-.` }; + } + const target = options.target ?? "here"; + if (target !== "here" && target !== "sibling") { + return { ok: false, error: `Invalid target: ${target}. Must be "here" or "sibling".` }; + } + const ship = this.parseShip(options.ship); + if (typeof ship === "string") return { ok: false, error: ship }; + + const packageDir = + target === "sibling" ? path.join(path.dirname(options.workspaceRoot), name) : path.join(options.workspaceRoot, name); + if (await exists(packageDir)) { + return { ok: false, error: `Target directory already exists: ${packageDir}. Remove it or choose another name.` }; + } + + const rendered = new ScaffoldRenderer().render({ + name, + ship, + coreDependency: fileDependency(packageDir, architectPackageRoot()), + }); + for (const file of rendered) { + const targetPath = path.join(packageDir, file.relativePath); + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, file.content); + } + return { + ok: true, + packageName: name, + packageDir, + files: rendered.map((file) => file.relativePath), + }; + } + + public writeRendered(packageDir: string, rendered: RenderedFile[]): Promise { + return Promise.all( + rendered.map(async (file) => { + const targetPath = path.join(packageDir, file.relativePath); + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, file.content); + }), + ).then(() => undefined); + } + + private parseShip(input: string | null): ShipKind[] | string { + if (input === null) return ["skills"]; + const kinds = input + .split(",") + .map((kind) => kind.trim()) + .filter((kind) => kind.length > 0); + if (kinds.length === 0) return ["skills"]; + for (const kind of kinds) { + if (!SHIP_KINDS.includes(kind as ShipKind)) { + return `Invalid ship kind: ${kind}. Must be a comma-separated list of ${SHIP_KINDS.join(", ")}.`; + } + } + return kinds as ShipKind[]; + } + + private askName(): string | null { + if (!process.stdout.isTTY) return null; + const answer = prompt("Package name (opencode-):"); + return answer === null || answer.trim().length === 0 ? null : answer.trim(); + } +} diff --git a/templates/cli.template.txt b/templates/cli.template.txt index 567a41e..6ccea8f 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, 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`. +**Load-bearing — do not simplify:** this file is an adapter rendering (ADR-0013): argument parsing, help, and output formatting live here; the machinery comes from `./installer.ts` (this package's policy adapter) and the `opencode-architect/core` dependency (status types and the effective-version pipeline). 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` delegates to the core status pipeline (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) — the status-only `--online` (registry staleness, warn-and-continue) 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 @@ -17,7 +17,8 @@ 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, type EffectiveStatus, type ScopeStatus } from "./installer.ts"; +import { install, uninstall, status, migrateRootConfig, clearPackageCache, CopyModeUnsupportedError, type Scope, type InstallMode } from "./installer.ts"; +import { StatusReporter, type EffectiveVersion, type ResolvedSource } from "opencode-architect/core"; const VERSION = JSON.parse( await Bun.file(`${import.meta.dirname}/../package.json`).text() @@ -132,25 +133,26 @@ export async function runCli(argv: string[]): Promise { } case "status": { const projectDir = await resolveProjectDir(values.path); - const result = await status(projectDir, online); + const report = await status(projectDir, online); console.log("Status:"); - for (const [label, scopeStatus] of [["Local", result.local], ["Global", result.global]] as const) { - if (!scopeStatus) continue; - const npm = scopeStatus.publishedVersion !== null ? ` npm-latest=${scopeStatus.publishedVersion}` : ""; + for (const [label, scopeReport] of [["Local", report.scopes[0]], ["Global", report.scopes[1]]] as const) { + if (!scopeReport) continue; + if (!scopeReport.registered && scopeReport.manifestVersion === null) continue; + const npm = scopeReport.publishedVersion !== null ? ` npm-latest=${scopeReport.publishedVersion}` : ""; console.log( - ` ${label}: mode=${scopeStatus.mode ?? "unknown"}, ` + - `config=${scopeStatus.entryConfigPath ?? "-"}, ` + - `entry=${scopeStatus.entry ?? "-"}, ` + - `resolved=${resolvedLabel(scopeStatus)}${npm}` + ` ${label}: mode=${scopeReport.mode === "none" ? "unknown" : scopeReport.mode}, ` + + `config=${scopeReport.configPath ?? "-"}, ` + + `entry=${scopeReport.entryText ?? "-"}, ` + + `resolved=${resolvedLabel(scopeReport.resolved)}${npm}` ); - for (const warning of scopeStatus.warnings) console.warn(`Warning: ${warning}`); + for (const warning of scopeReport.warnings) console.warn(`Warning: ${warning}`); } - if (result.effective.scope !== null) { - console.log(` should load: ${verdictLabel(result.effective)}`); + 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 result.warnings) console.warn(`Warning: ${warning}`); + for (const warning of report.warnings) console.warn(`Warning: ${warning}`); break; } case "migrate": { @@ -213,28 +215,14 @@ function stripQuotes(value: string): string { 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(source: ResolvedSource | null): string { + if (source === null) return "-"; + const age = StatusReporter.formatAge(source.modifiedMs); + const sourceLabel = source.source === "cache" ? "cache copy" : "checkout copy"; + return `${source.version ?? "unknown"} (${age === null ? sourceLabel : `${sourceLabel}, ${age}`})`; } -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 { +function verdictLabel(effective: EffectiveVersion): string { const latest = effective.latestVersion !== null && effective.versionKind !== "npm" ? `, latest ${effective.latestVersion}` : ""; const scopeLabel = @@ -244,7 +232,7 @@ function verdictLabel(effective: EffectiveStatus): string { return `${effective.version ?? "unknown"} (${kindLabel(effective.versionKind)}, ${scopeLabel}${latest})`; } -function kindLabel(kind: EffectiveStatus["versionKind"]): string { +function kindLabel(kind: EffectiveVersion["versionKind"]): string { if (kind === "cache") return "cache copy"; if (kind === "checkout") return "checkout copy"; if (kind === "spec") return "pinned spec"; diff --git a/templates/installer.template.txt b/templates/installer.template.txt index 7dd3e7f..58caff8 100644 --- a/templates/installer.template.txt +++ b/templates/installer.template.txt @@ -2,26 +2,59 @@ | Placeholder | Required | Description | Default | |---|---|---|---| -| `EXTENSION NAME` → "myextension" | Yes | Extension identifier (e.g., "mytool") | — | -| `SKILL NAME` → "myextension" | Yes | Must match the folder name in skills/ at the package root | — | -| `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. `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. +| `EXTENSION NAME` → "myextension" | Yes | Extension identifier (e.g., "mytool") | - | +| `SKILL NAME` → "myextension" | Yes | Must match the folder name in skills/ at the package root | - | +| `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:** this file is an adapter rendering +(ADR-0013): the machinery it orchestrates comes from the +`opencode-architect/core` dependency — manifest io with per-file sha256 +hashes (`InstallManifest`), the surgical config editor (`PluginConfigEditor`), +scope bases (`scopeBase`), package-cache paths and cache-target removal +(`NpmCache`, `removeCacheTargets`), and the status/effective-version pipeline +(`StatusReporter`). The adapter declares this package's policy as the +constants below (`PLUGIN_NAME`, `SKILL_NAME`, `COMMAND_NAME`, +`ASSET_LAYOUT_DIR`) and must not re-implement machinery. The deployment plan +is content-based (ADR-0008): `"content": "code"` packages always +plugin-register and `--mode copy` throws `CopyModeUnsupportedError`; +assets-only packages copy-install by default with `--mode plugin` as opt-in; +copy install touches no config file. Idempotency is the core manifest +(ADR-0006): a matching version, a present registration, and hash-matching +payload files are a zero-write no-op; consumer-modified files are skipped +with a warning unless `force`, and are recorded with their packaged hash so +`--force` can take ownership; a missing or empty asset source is a hard error +naming the path, and a manifest is never written when zero files were +written. Cache hygiene is self-scoped (ADR-0007): installs — including +no-ops — prune only this package's cache copies through the core +`removeCacheTargets`; the load hook never prunes. `status()` delegates to the +core `StatusReporter` with a manifest lookup bound to this package, is +read-only, and only touches the npm registry when `online` (warn-and-continue +on an injectable failure). `migrateRootConfig` deletes nothing without a +consent callback and is never called from `ensureAssets`. Every function is +scope-parameterized: local and global differ only in the base path +(`scopeBase`), never in behavior. --- -import { exists, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { dirname, isAbsolute, join, relative } from "node:path"; -import { PluginNameNormalizer } from "./plugin-name.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"; - -export type Scope = "local" | "global"; +import { exists, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { + InstallManifest, + NpmCache, + PluginConfigEditor, + StatusReporter, + removeCacheTargets, + scopeBase, + sha256, + listFilesRecursive, + type ManifestData, + type StatusReport, + type Scope, +} from "opencode-architect/core"; + +export type { StatusReport, Scope }; + +export type InstallMode = "copy" | "plugin"; export interface InstallOptions { mode?: InstallMode; @@ -47,62 +80,14 @@ export interface UninstallResult { pluginRemoved: boolean; } -export interface ScopeStatus { - scope: Scope; - installed: boolean; - version: string | null; - mode: InstallMode | null; - entry: string | null; - entryName: string | null; - entryVersion: string | null; - entryConfigPath: string | null; - pluginInConfig: boolean; - resolvedVersion: string | null; - resolvedSource: "cache" | "checkout" | null; - resolvedModifiedMs: number | null; - resolvedName: string | null; - publishedVersion: string | null; - registered: boolean; - warnings: string[]; -} - -export interface EffectiveStatus { - version: string | null; - versionKind: "cache" | "checkout" | "spec" | "npm" | "manifest" | null; - scope: Scope | null; - latestVersion: string | null; - registeredScopes: Scope[]; -} - -export interface StatusResult { - local: ScopeStatus | null; - global: ScopeStatus | null; - effective: EffectiveStatus; - warnings: string[]; -} - -interface ResolvedEntrySource { - name: string | null; - version: string | null; - source: "cache" | "checkout"; - path: string; - modifiedMs: number | null; -} - -interface MatchedRegistration { - configPath: string; - spec: string; - name: string | null; - form: "npm" | "path"; -} - +// --- policy: this package's declared seam constants (ADR-0013) --- const SKILL_NAME = "myextension"; const COMMAND_NAME = "my-command.md"; const PLUGIN_NAME = "opencode-myextension"; const ASSET_LAYOUT_DIR = "."; -const normalizer = new PluginNameNormalizer(); const editor = new PluginConfigEditor(); +const cache = new NpmCache(); export class CopyModeUnsupportedError extends Error { constructor() { @@ -151,67 +136,18 @@ function getPackageDir(): string { } export function packageCacheRoot(): string { - const xdgCacheHome = process.env.XDG_CACHE_HOME; - if (xdgCacheHome) return join(xdgCacheHome, "opencode", "npm"); - return join(homedir(), ".cache", "opencode", "npm"); -} - -interface CacheOutcome { - removed: string[]; - warnings: string[]; -} - -async function removeCacheTargets(packagesRoot: string, wanted: (name: string) => boolean): Promise { - const removed: string[] = []; - const warnings: string[] = []; - let entries: string[]; - try { - entries = await readdir(packagesRoot); - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { removed, warnings }; - throw error; - } - const targets = entries - .filter(wanted) - .sort() - .map((name) => join(packagesRoot, name)); - for (const target of targets) { - try { - await rm(target, { recursive: true }); - removed.push(target); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - warnings.push(`Could not clear cached package ${target}: ${message}`); - } - } - return { removed, warnings }; + return cache.root(); } -export async function prunePackageCache(): Promise { +export async function prunePackageCache(): Promise<{ removed: string[]; warnings: string[] }> { const version = await getPackageVersion(); const wanted = [PLUGIN_NAME, `${PLUGIN_NAME}@latest`, `${PLUGIN_NAME}@${version}`]; - return removeCacheTargets(packageCacheRoot(), (name) => wanted.includes(name)); + return removeCacheTargets(cache.root(), (name) => wanted.includes(name)); } -export async function clearPackageCache(): Promise { +export async function clearPackageCache(): Promise<{ removed: string[]; warnings: string[] }> { const prefix = `${PLUGIN_NAME}@`; - return removeCacheTargets(packageCacheRoot(), (name) => name === PLUGIN_NAME || name.startsWith(prefix)); -} - -export function getGlobalConfigPath(): string { - const xdgConfig = process.env.XDG_CONFIG_HOME; - if (xdgConfig) { - return join(xdgConfig, "opencode"); - } - return join(homedir(), ".config", "opencode"); -} - -export function getLocalConfigPath(projectDir: string): string { - return join(projectDir, ".opencode"); -} - -function scopeBase(scope: Scope, projectDir: string): string { - return scope === "global" ? getGlobalConfigPath() : getLocalConfigPath(projectDir); + return removeCacheTargets(cache.root(), (name) => name === PLUGIN_NAME || name.startsWith(prefix)); } async function copyDir(src: string, dest: string, skip: Set = new Set()): Promise { @@ -299,7 +235,7 @@ export async function ensureAssets(scope: Scope, projectDir: string, force: bool async function registerPlugin(scope: Scope, projectDir: string): Promise<{ upToDate: boolean; entry: string | null; entryConfigPath: string | null }> { const version = await getPackageVersion(); const configBase = scopeBase(scope, projectDir); - const entry = normalizer.normalize(PLUGIN_NAME) ?? PLUGIN_NAME; + const entry = `${PLUGIN_NAME}@latest`; const manifest = new InstallManifest(configBase, PLUGIN_NAME); const existing = await manifest.read(); const entryPresent = existing?.mode === "plugin" && existing.entryConfigPath @@ -369,8 +305,8 @@ export async function install( options: InstallOptions = {} ): Promise { const { mode: requested, force = false } = options; - const cache = await prunePackageCache(); - for (const warning of cache.warnings) { + const cacheOutcome = await prunePackageCache(); + for (const warning of cacheOutcome.warnings) { console.warn(`Warning: ${warning}`); } const mode = await resolveMode(requested); @@ -378,10 +314,10 @@ export async function install( const { skillPath, commandPath } = await payloadPaths(configBase); if (mode === "copy") { const { upToDate, skippedModified } = await ensurePayload(scope, projectDir, force); - return { scope, mode, skillPath, commandPath, entry: null, entryConfigPath: null, skippedModified, upToDate, clearedCache: cache.removed }; + return { scope, mode, skillPath, commandPath, entry: null, entryConfigPath: null, skippedModified, upToDate, clearedCache: cacheOutcome.removed }; } const { upToDate, entry, entryConfigPath } = await registerPlugin(scope, projectDir); - return { scope, mode, skillPath, commandPath, entry, entryConfigPath, skippedModified: [], upToDate, clearedCache: cache.removed }; + return { scope, mode, skillPath, commandPath, entry, entryConfigPath, skippedModified: [], upToDate, clearedCache: cacheOutcome.removed }; } export async function uninstall(scope: Scope, projectDir: string = process.cwd()): Promise { @@ -406,272 +342,12 @@ export async function uninstall(scope: Scope, projectDir: string = process.cwd() return { scope, mode: manifestData?.mode ?? null, removed, pluginRemoved }; } -export async function status(projectDir: string = process.cwd(), online: boolean = false, fetchFn: typeof fetch = fetch): Promise { - const detector = new RegistrationDetector(PLUGIN_NAME); - const inventory = await detector.inventory(projectDir); - let local = await scopeStatus("local", projectDir, inventory.local); - let global = await scopeStatus("global", projectDir, inventory.global); - const warnings: string[] = []; - if (local !== null && global !== null && local.registered && global.registered) { - warnings.push(`registered in both scopes (double-load risk): ${local.entryConfigPath} and ${global.entryConfigPath}`); - } - let effective: EffectiveStatus; - if (online) { - const outcome = await applyOnline([local, global], fetchFn); - warnings.push(...outcome.warnings); - local = outcome.scopes[0] ?? null; - global = outcome.scopes[1] ?? null; - } - effective = effectiveStatus(local, global); - return { local, global, effective, warnings }; -} - -async function scopeStatus(scope: Scope, projectDir: string, entries: RawConfigEntries[]): Promise { - const configBase = scopeBase(scope, projectDir); - const manifest = new InstallManifest(configBase, PLUGIN_NAME); - const manifestData: ManifestData | null = await manifest.read(); - const matched = await findMatchedRegistration(entries); - if (manifestData === null && matched === null) return null; - const parsedEntry = matched !== null && !isPathSpec(matched.spec) ? parseNpmSpec(matched.spec) : null; - const warnings: string[] = []; - let resolvedVersion: string | null = null; - let resolvedSource: "cache" | "checkout" | null = null; - let resolvedModifiedMs: number | null = null; - let resolvedName: string | null = null; - if (matched !== null) { - const outcome = await resolveEntrySource(matched.spec, dirname(matched.configPath)); - if (outcome.resolved !== null) { - resolvedVersion = outcome.resolved.version; - resolvedSource = outcome.resolved.source; - resolvedModifiedMs = outcome.resolved.modifiedMs; - resolvedName = outcome.resolved.name; - } else if (outcome.status === "missing") { - warnings.push(`${scope}: no cached copy for ${matched.spec}`); - } else { - warnings.push(`${scope}: cached copy for ${matched.spec} is incomplete`); - } - if (manifestData !== null && manifestData.version !== null && resolvedVersion !== null && manifestData.version !== resolvedVersion) { - warnings.push(`${scope}: manifest records ${manifestData.version} but the resolved copy is ${resolvedVersion}`); - } - } - return { - scope, - installed: true, - version: manifestData?.version ?? null, - mode: manifestData?.mode ?? (matched !== null ? "plugin" : null), - entry: matched !== null ? matched.spec : manifestData?.entry ?? null, - entryName: matched?.name ?? null, - entryVersion: parsedEntry?.version ?? null, - entryConfigPath: matched !== null ? matched.configPath : manifestData?.entryConfigPath ?? null, - pluginInConfig: matched !== null, - resolvedVersion, - resolvedSource, - resolvedModifiedMs: resolvedModifiedMs, - resolvedName, - publishedVersion: null, - registered: matched !== null, - warnings, +export async function status(projectDir: string = process.cwd(), online: boolean = false): Promise { + const manifestAt = async (scope: Scope, dir: string): Promise<{ mode: "copy" | "plugin"; version: string } | null> => { + const data: ManifestData | null = await new InstallManifest(scopeBase(scope, dir), PLUGIN_NAME).read(); + return data === null ? null : { mode: data.mode, version: data.version }; }; -} - -async function findMatchedRegistration(entries: RawConfigEntries[]): Promise { - for (const candidate of entries) { - for (const raw of candidate.rawEntries) { - const spec = entrySpec(raw); - if (spec === null) continue; - if (isPathSpec(spec)) { - if (await PluginEntryResolver.resolvesToPackage(raw, PLUGIN_NAME, dirname(candidate.configPath))) { - return { configPath: candidate.configPath, spec, name: null, form: "path" }; - } - continue; - } - const parsed = parseNpmSpec(spec); - if (parsed === null) continue; - if (parsed.name === PLUGIN_NAME) { - return { configPath: candidate.configPath, spec, name: parsed.name, form: "npm" }; - } - } - } - return null; -} - -async function resolveEntrySource(spec: string, baseDir: string): Promise<{ resolved: ResolvedEntrySource | null; status: "resolved" | "missing" | "partial" }> { - if (isPathSpec(spec)) { - const root = await PluginEntryResolver.packageRoot(spec, baseDir); - if (root === null) return { resolved: null, status: "missing" }; - const copy = await readCopyManifest(root, "checkout"); - if (copy === null) return { resolved: null, status: "partial" }; - return { resolved: copy, status: "resolved" }; - } - const parsed = parseNpmSpec(spec); - if (parsed === null) return { resolved: null, status: "missing" }; - const keyDir = join(packageCacheRoot(), `${parsed.name}@${parsed.version}`); - const copy = await findCacheCopy(keyDir, parsed.name); - if (copy !== null) return { resolved: copy, status: "resolved" }; - return { resolved: null, status: (await exists(keyDir)) ? "partial" : "missing" }; -} - -async function findCacheCopy(keyDir: string, name: string): Promise { - const direct = await readCopyManifest(keyDir, "cache"); - if (direct !== null) return direct; - let entries; - try { - entries = await readdir(keyDir, { withFileTypes: true }); - } catch { - return null; - } - let best: ResolvedEntrySource | null = null; - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith(".")) continue; - const copy = await readCopyManifest(join(keyDir, entry.name, "node_modules", name), "cache"); - if (copy === null) continue; - if (best === null || (copy.modifiedMs ?? 0) > (best.modifiedMs ?? 0)) best = copy; - } - return best; -} - -async function readCopyManifest(dir: string, source: "cache" | "checkout"): Promise { - const manifestPath = join(dir, "package.json"); - if (!(await exists(manifestPath))) return null; - try { - const parsed = JSON.parse(await readFile(manifestPath, "utf-8")) as { name?: unknown; version?: unknown }; - const version = typeof parsed.version === "string" ? parsed.version : null; - const name = typeof parsed.name === "string" ? parsed.name : null; - const info = await stat(dir); - return { version, name, source, path: dir, modifiedMs: info.mtimeMs }; - } catch { - return null; - } -} - -function entrySpec(entry: unknown): string | null { - if (typeof entry === "string") return entry.trim(); - if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) { - const pkg = (entry as { package?: unknown }).package; - if (typeof pkg === "string") return pkg.trim(); - } - return null; -} - -function isPathSpec(spec: string): boolean { - if (spec.startsWith("file://")) return true; - if (spec.startsWith(".")) return true; - if (isAbsolute(spec)) return true; - if (spec.includes("@")) return false; - if (spec.includes("/") || spec.includes("\\")) return true; - return false; -} - -function parseNpmSpec(spec: string): { name: string; version: string } | null { - const at = spec.lastIndexOf("@"); - if (at > 0) { - const version = spec.slice(at + 1); - if (version.includes("/") || version.includes("\\")) return null; - return { name: spec.slice(0, at), version: version.length === 0 ? "latest" : version }; - } - return { name: spec, version: "latest" }; -} - -function effectiveStatus(local: ScopeStatus | null, global: ScopeStatus | null): EffectiveStatus { - const registeredScopes: Scope[] = []; - if (local?.registered) registeredScopes.push("local"); - if (global?.registered) registeredScopes.push("global"); - for (const scopeStatusValue of [local, global]) { - if (scopeStatusValue === null || !scopeStatusValue.registered) continue; - if (scopeStatusValue.resolvedVersion !== null) { - return { - version: scopeStatusValue.resolvedVersion, - versionKind: scopeStatusValue.resolvedSource, - scope: scopeStatusValue.scope, - latestVersion: scopeStatusValue.publishedVersion, - registeredScopes, - }; - } - const fallback = specFallback(scopeStatusValue); - if (fallback !== null) { - return { - version: fallback.version, - versionKind: fallback.kind, - scope: scopeStatusValue.scope, - latestVersion: scopeStatusValue.publishedVersion, - registeredScopes, - }; - } - return { - version: scopeStatusValue.version, - versionKind: scopeStatusValue.version !== null ? "manifest" : null, - scope: scopeStatusValue.scope, - latestVersion: scopeStatusValue.publishedVersion, - registeredScopes, - }; - } - return { version: null, versionKind: null, scope: null, latestVersion: null, registeredScopes }; -} - -function specFallback(scopeStatusValue: ScopeStatus): { version: string; kind: "spec" | "npm" } | null { - if (scopeStatusValue.entryVersion === null || scopeStatusValue.entryVersion === "latest") { - if (scopeStatusValue.entryVersion === "latest" && scopeStatusValue.publishedVersion !== null) { - return { version: scopeStatusValue.publishedVersion, kind: "npm" }; - } - return null; - } - return isExactSemver(scopeStatusValue.entryVersion) ? { version: scopeStatusValue.entryVersion, kind: "spec" } : null; -} - -function isExactSemver(version: string): boolean { - return /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(version); -} - -async function applyOnline( - scopes: Array, - fetchFn: typeof fetch, -): Promise<{ warnings: string[]; scopes: Array }> { - const warnings: string[] = []; - const lookups = new Map(); - const lookupName = (scopeStatusValue: ScopeStatus): string | null => - scopeStatusValue.entryName ?? scopeStatusValue.resolvedName; - for (const scopeStatusValue of scopes) { - if (scopeStatusValue === null || !scopeStatusValue.registered) continue; - const name = lookupName(scopeStatusValue); - if (name === null || lookups.has(name)) continue; - lookups.set(name, await fetchLatestVersion(name, fetchFn)); - } - const updated: Array = []; - for (const scopeStatusValue of scopes) { - if (scopeStatusValue === null || !scopeStatusValue.registered) { - updated.push(scopeStatusValue); - continue; - } - const name = lookupName(scopeStatusValue); - const lookup = name !== null ? lookups.get(name) : undefined; - if (lookup === undefined) { - updated.push(scopeStatusValue); - continue; - } - if (lookup.warning !== null) { - warnings.push(lookup.warning); - updated.push({ ...scopeStatusValue, publishedVersion: null }); - continue; - } - if (lookup.version !== null && scopeStatusValue.resolvedVersion !== null && lookup.version !== scopeStatusValue.resolvedVersion) { - warnings.push(`${lookup.version} is the latest published version but ${scopeStatusValue.resolvedVersion} resolves (stale)`); - } - updated.push({ ...scopeStatusValue, publishedVersion: lookup.version }); - } - return { warnings, scopes: updated }; -} - -async function fetchLatestVersion(name: string, fetchFn: typeof fetch): Promise<{ version: string | null; warning: string | null }> { - try { - const response = await fetchFn(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(3000) }); - if (!response.ok) return { version: null, warning: `npm registry returned ${response.status} for ${name}` }; - const parsed = (await response.json()) as { version?: unknown }; - if (typeof parsed.version !== "string") return { version: null, warning: `npm registry response for ${name} carried no version` }; - return { version: parsed.version, warning: null }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { version: null, warning: `could not query npm registry for ${name}: ${message}` }; - } + return new StatusReporter({ manifestAt }).report(projectDir, { scopes: null, online, packageName: PLUGIN_NAME }); } export async function isInstalledAnywhere(projectDir: string = process.cwd()): Promise { @@ -689,14 +365,10 @@ export async function isInstalledAnywhere(projectDir: string = process.cwd()): P return false; } -function parseConfig(raw: string, path: string): unknown { - return path.endsWith(".jsonc") ? parseJsonc(raw) : JSON.parse(raw); -} - async function readJsonConfig(path: string): Promise | null> { if (!(await exists(path))) return {}; try { - return parseConfig(await readFile(path, "utf-8"), path) as Record; + return JSON.parse(await readFile(path, "utf-8")) as Record; } catch { console.warn(`Warning: ${path} is not valid JSON; refusing to modify it.`); return null; diff --git a/templates/package-basics.template.json b/templates/package-basics.template.json index 1894da6..b35261c 100644 --- a/templates/package-basics.template.json +++ b/templates/package-basics.template.json @@ -54,6 +54,11 @@ DEPENDENCIES generated packages resolve current versions at time of use; never pin a consumer version (per opencode-v2-facts §15) + "opencode-architect": inserted by the scaffold renderer as a local + `file:` dependency — the rendered package + imports its machinery from the + `opencode-architect/core` subpath (ADR-0013); + never replace it with a registry spec here METADATA "Publisher Name" → your name or organization diff --git a/templates/plugin-config.template.txt b/templates/plugin-config.template.txt deleted file mode 100644 index 692702c..0000000 --- a/templates/plugin-config.template.txt +++ /dev/null @@ -1,659 +0,0 @@ -# Inputs - -None — emit as-is as src/plugin-config.ts. - -**Load-bearing — do not simplify:** the editor is a surgical text splice into the v2 `plugins` array with every other byte of the config untouched — never a parse-then-reserialize (comments, key order, and formatting survive); candidates are consulted in priority order: for local scope the nested `.opencode/opencode.json` then `opencode.jsonc`, then the repo-root `opencode.json` then `opencode.jsonc`; for global scope the same pair in the global scope base; and finally the global `config.json` which is read-only (detection only, never written); every candidate is read up front and every unparseable one is warned about **before** any short-circuit on a successful match; a config that fails to parse blocks the edit — never rewrite config from `{}`; `.jsonc` parses leniently (comments blanked string-aware so `$schema` URLs and escaped quotes survive, trailing commas removed) and `.json` stays strict; both array shapes are read: the v2 `plugins` key is authoritative and the legacy v1 `plugin` key is tolerated read-only — a semantically matching legacy entry is a zero-write no-op with an upgrade advisory, and a legacy entry in `config.json` is reported as inert (v2 never reads that file); entries match by package name whether written as strings or `{ "package": ... }` objects; matching is semantic (`name`, `name@latest`, `name@x.y.z` are the same package) and new entries are written canonically as `name@latest` under `plugins`; an existing v2 entry is a zero-write no-op; a path-form entry (`file://` URL or filesystem path, string or `{ package }` object form) that resolves to this package also counts as registered and blocks the duplicate splice — `PluginEntryResolver.resolvesToPackage` (src/plugin-entry.ts) performs that best-effort resolution, resolving relative entries against the config file's directory; when no config exists a minimal `opencode.jsonc` is created; every splice re-parses the result and aborts untouched if the entry is not present after the edit. Two deliberate divergences from the suite's own `src/plugin-config.ts` keep every `config.json` mention self-labelling as read-only (the `LEGACY_READ_ONLY_GLOBAL` constant and the `!candidate.writable` inert-advisory branch — `config.json` is the only candidate declared `writable: false`); preserve them when re-syncing. - ---- -import { exists, mkdir, readFile, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import path from "node:path"; -import { PluginEntryResolver } from "./plugin-entry.ts"; - -export type ConfigScope = "local" | "global"; -export type PluginConfigKey = "plugins" | "plugin"; - -export interface EnsurePluginEntryOptions { - scope: ConfigScope; - projectDir: string; -} - -export interface EnsurePluginEntryOutcome { - action: "noop" | "updated" | "created" | "blocked"; - configPath: string | null; - warning: string | null; -} - -interface CandidateConfig { - path: string; - lenient: boolean; - writable: boolean; -} - -interface CandidateRead { - candidate: CandidateConfig; - text: string; - entries: Record; - rawEntries: Record; -} - -export interface RemovePluginEntryOutcome { - action: "noop" | "removed" | "blocked"; - configPath: string | null; - warning: string | null; -} - -export type ConfigCheck = { ok: true } | { ok: false; warning: string }; - -interface PluginArrayRange { - bracketStart: number; - bracketEnd: number; -} - -const DEFAULT_CONFIG_TEMPLATE = `{ - // OpenCode configuration - "$schema": "https://opencode.ai/config.json", - "plugins": ["__PACKAGE_NAME__"] -} -`; - -const LEGACY_READ_ONLY_GLOBAL = "config.json"; - -export class PluginConfigEditor { - public async ensurePluginEntry( - packageName: string, - options: EnsurePluginEntryOptions, - ): Promise { - const canonical = this.canonicalEntry(packageName); - for (const read of await this.readCandidates(options)) { - const { candidate, text, entries, rawEntries } = read; - const configDir = path.dirname(candidate.path); - if (entries.plugins === null) { - return { - action: "blocked", - configPath: candidate.path, - warning: - `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + - `Fix or remove the file and re-run the install.`, - }; - } - if (await this.entryResolves(rawEntries.plugins, packageName, configDir)) { - return { action: "noop", configPath: candidate.path, warning: null }; - } - if (await this.entryResolves(rawEntries.plugin, packageName, configDir)) { - return { - action: "noop", - configPath: candidate.path, - warning: this.legacyEntryAdvisory(candidate, packageName), - }; - } - if (!candidate.writable) continue; - const spliced = this.spliceEntry(text, canonical, packageName, candidate.lenient); - if (spliced === null) { - return { - action: "blocked", - configPath: candidate.path, - warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, - }; - } - await mkdir(path.dirname(candidate.path), { recursive: true }); - await writeFile(candidate.path, spliced); - return { action: "updated", configPath: candidate.path, warning: null }; - } - - const target = this.defaultConfigPath(options); - const content = DEFAULT_CONFIG_TEMPLATE.replace("__PACKAGE_NAME__", canonical); - await mkdir(path.dirname(target), { recursive: true }); - await writeFile(target, content); - return { action: "created", configPath: target, warning: null }; - } - - public async checkParseable(options: EnsurePluginEntryOptions): Promise { - for (const candidate of this.candidateConfigs(options)) { - if (!(await exists(candidate.path))) continue; - const text = await readFile(candidate.path, "utf-8"); - if (this.parsePluginArray(text, candidate.lenient) === null) { - return { - ok: false, - warning: - `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + - `Fix or remove the file and re-run the command.`, - }; - } - } - return { ok: true }; - } - - public async findRegistration( - packageName: string, - options: EnsurePluginEntryOptions, - ): Promise { - for (const read of await this.readCandidates(options)) { - if (read.entries.plugins === null) continue; - const configDir = path.dirname(read.candidate.path); - const registered = - (await this.entryResolves(read.rawEntries.plugins, packageName, configDir)) || - (await this.entryResolves(read.rawEntries.plugin, packageName, configDir)); - if (registered) return read.candidate.path; - } - return null; - } - - public async removePluginEntry( - packageName: string, - options: EnsurePluginEntryOptions, - ): Promise { - const reads = await this.readCandidates(options); - let legacyPath: string | null = null; - for (const read of reads) { - const { candidate, text, entries } = read; - const configDir = path.dirname(candidate.path); - if (!candidate.writable) continue; - if (entries.plugins === null) { - return { - action: "blocked", - configPath: candidate.path, - warning: - `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + - `Fix or remove the file and re-run the uninstall.`, - }; - } - if (await this.entryResolves(read.rawEntries.plugins, packageName, configDir)) { - const spliced = await this.spliceOutEntry(text, packageName, candidate.lenient, configDir); - if (spliced === null) { - return { - action: "blocked", - configPath: candidate.path, - warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, - }; - } - await writeFile(candidate.path, spliced); - const leftover = await this.findLegacyConfig(reads, packageName); - return { - action: "removed", - configPath: candidate.path, - warning: leftover === null ? null : this.legacyRemovalAdvisory(leftover, packageName), - }; - } - if (legacyPath === null && (await this.entryResolves(read.rawEntries.plugin, packageName, configDir))) { - legacyPath = candidate.path; - } - } - if (legacyPath !== null) { - return { - action: "noop", - configPath: legacyPath, - warning: this.legacyRemovalAdvisory(legacyPath, packageName), - }; - } - return { action: "noop", configPath: null, warning: null }; - } - - public hasMatchingEntry(entries: string[], packageName: string): boolean { - return entries.some((entry) => this.matchesEntry(entry, packageName)); - } - - private async entryResolves( - rawEntries: unknown[] | null, - packageName: string, - configDir: string, - ): Promise { - if (rawEntries === null) return false; - for (const entry of rawEntries) { - if (await this.entryMatches(entry, packageName, configDir)) return true; - } - return false; - } - - private async entryMatches(rawEntry: unknown, packageName: string, configDir: string): Promise { - const name = this.normalizeEntry(rawEntry); - if (name !== null && this.matchesEntry(name, packageName)) return true; - return PluginEntryResolver.resolvesToPackage(rawEntry, packageName, configDir); - } - - private async findLegacyConfig(reads: CandidateRead[], packageName: string): Promise { - for (const read of reads) { - const legacyDir = path.dirname(read.candidate.path); - if (await this.entryResolves(read.rawEntries.plugin, packageName, legacyDir)) return read.candidate.path; - } - return null; - } - - private matchesEntry(entry: string, packageName: string): boolean { - const specIndex = entry.lastIndexOf("@"); - const name = specIndex > 0 ? entry.slice(0, specIndex) : entry; - return name === packageName; - } - - private canonicalEntry(packageName: string): string { - const specIndex = packageName.lastIndexOf("@"); - if (specIndex > 0) return packageName; - return `${packageName}@latest`; - } - - private candidateConfigs(options: EnsurePluginEntryOptions): CandidateConfig[] { - const scopeBase = this.scopeBase(options.scope, options.projectDir); - const repoRoot = options.projectDir; - const configs: CandidateConfig[] = []; - if (options.scope === "local") { - for (const root of [scopeBase, repoRoot]) { - 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, LEGACY_READ_ONLY_GLOBAL), lenient: false, writable: false }); - return configs; - } - - private async readCandidates(options: EnsurePluginEntryOptions): Promise { - const reads: CandidateRead[] = []; - for (const candidate of this.candidateConfigs(options)) { - if (!(await exists(candidate.path))) continue; - const text = await readFile(candidate.path, "utf-8"); - const config = this.parseConfig(text, candidate.lenient); - const entries: Record = { plugins: null, plugin: null }; - const rawEntries: Record = { plugins: null, plugin: null }; - if (config === null) { - console.warn( - `Warning: ${candidate.path} could not be parsed; refusing to treat it as a registration candidate.`, - ); - } else { - for (const key of ["plugins", "plugin"] as PluginConfigKey[]) { - entries[key] = this.keyEntries(config, key); - rawEntries[key] = this.entriesOf(config, key); - } - } - reads.push({ candidate, text, entries, rawEntries }); - } - return reads; - } - - private keyEntries(config: Record, key: PluginConfigKey): string[] { - const value = config[key]; - if (!Array.isArray(value)) return []; - return value.map((entry) => this.normalizeEntry(entry)).filter((entry): entry is string => entry !== null); - } - - private entriesOf(config: Record, key: PluginConfigKey): unknown[] { - const value = config[key]; - return Array.isArray(value) ? value : []; - } - - private normalizeEntry(entry: unknown): string | null { - if (typeof entry === "string") return entry; - return this.packageOf(entry); - } - - private packageOf(entry: unknown): string | null { - if (entry === null || typeof entry !== "object") return null; - const pkg = (entry as { package: unknown }).package; - return typeof pkg === "string" ? pkg : null; - } - - private legacyEntryAdvisory(candidate: CandidateConfig, packageName: string): string { - if (!candidate.writable) { - return ( - `${candidate.path} registers ${packageName} under the legacy v1 "plugin" key. ` + - `OpenCode v2 no longer reads config.json, so the entry is inert, and this installer never edits it. ` + - `Add ${packageName} to the "plugins" key of an opencode.json or opencode.jsonc config instead.` - ); - } - return ( - `${candidate.path} registers ${packageName} under the legacy v1 "plugin" key. ` + - `This installer leaves legacy entries read-only; the v2 "plugins" key is the authoritative registration. ` + - `Move the entry to the v2 "plugins" key to upgrade, then re-run the install.` - ); - } - - private legacyRemovalAdvisory(configPath: string, packageName: string): string { - return ( - `${configPath} registers ${packageName} under the legacy v1 "plugin" key; it was left untouched. ` + - `Remove it by editing the file, or move the entry to the v2 "plugins" key and re-run the uninstall.` - ); - } - - private defaultConfigPath(options: EnsurePluginEntryOptions): string { - if (options.scope === "global") { - return path.join(this.scopeBase("global", options.projectDir), "opencode.jsonc"); - } - return path.join(options.projectDir, "opencode.jsonc"); - } - - 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 parsePluginArray(text: string, lenient: boolean): string[] | null { - const config = this.parseConfig(text, lenient); - if (config === null) return null; - return this.keyEntries(config, "plugins"); - } - - private parseConfig(text: string, lenient: boolean): Record | null { - const parseable = lenient ? this.blankComments(text).replace(/,(\s*[}\]])/g, "$1") : text; - try { - return JSON.parse(parseable) as Record; - } catch { - return null; - } - } - - private blankComments(text: string): string { - const chars = text.split(""); - let inString = false; - let inLineComment = false; - let inBlockComment = false; - for (let i = 0; i < chars.length; i++) { - const current = chars[i]; - const next = i + 1 < chars.length ? chars[i + 1] : ""; - if (inLineComment) { - if (current === "\n") inLineComment = false; - else chars[i] = " "; - continue; - } - if (inBlockComment) { - if (current === "*" && next === "/") { - chars[i] = " "; - chars[i + 1] = " "; - i++; - inBlockComment = false; - } else { - chars[i] = " "; - } - continue; - } - if (inString) { - if (current === "\\") { - i++; - continue; - } - if (current === '"') inString = false; - continue; - } - if (current === '"') { - inString = true; - continue; - } - if (current === "/" && next === "/") { - chars[i] = " "; - chars[i + 1] = " "; - i++; - inLineComment = true; - continue; - } - if (current === "/" && next === "*") { - chars[i] = " "; - chars[i + 1] = " "; - i++; - inBlockComment = true; - continue; - } - } - return chars.join(""); - } - - private spliceEntry( - text: string, - entryToWrite: string, - packageName: string, - lenient: boolean, - ): string | null { - const navigable = this.blankComments(text); - const range = this.findPluginArrayRange(navigable); - const spliced = - range === null - ? this.splicePluginKey(text, navigable, entryToWrite) - : this.spliceArrayEntry(text, navigable, range, entryToWrite); - if (spliced === null) return null; - const plugins = this.parsePluginArray(spliced, lenient); - if (plugins === null || !this.hasMatchingEntry(plugins, packageName)) return null; - return spliced; - } - - private async spliceOutEntry( - text: string, - packageName: string, - lenient: boolean, - configDir: string, - ): Promise { - const navigable = this.blankComments(text); - const range = this.findPluginArrayRange(navigable); - if (range === null) return null; - const innerStart = range.bracketStart + 1; - const innerEnd = range.bracketEnd; - const elements = this.arrayElementRanges(navigable, innerStart, innerEnd); - const target = await this.matchingElement(text, elements, packageName, configDir); - if (!target) return null; - const withComma = this.dropAdjacentComma(navigable, elements, target, innerStart, innerEnd); - const result = text.slice(0, withComma.start) + text.slice(withComma.end); - const config = this.parseConfig(result, lenient); - if (config === null) return null; - if (await this.entryResolves(this.entriesOf(config, "plugins"), packageName, configDir)) return null; - return result; - } - - private async matchingElement( - text: string, - elements: Array<{ start: number; end: number }>, - packageName: string, - configDir: string, - ): Promise<{ start: number; end: number } | null> { - for (const element of elements) { - const spec = this.elementSpec(text.slice(element.start, element.end)); - if (await this.entryMatches(spec, packageName, configDir)) return element; - } - return null; - } - - private arrayElementRanges( - navigable: string, - innerStart: number, - innerEnd: number, - ): Array<{ start: number; end: number }> { - const elements: Array<{ start: number; end: number }> = []; - let inString = false; - let depth = 0; - let start = -1; - for (let i = innerStart; i < innerEnd; i++) { - const current = navigable[i]; - if (inString) { - if (current === "\\") i++; - else if (current === '"') inString = false; - continue; - } - if (current === '"') { - inString = true; - if (start === -1) start = i; - continue; - } - if (current === "[" || current === "{") { - depth++; - if (start === -1) start = i; - continue; - } - if (current === "]" || current === "}") { - depth--; - continue; - } - if (current === "," && depth === 0) { - if (start !== -1) elements.push({ start, end: i }); - start = -1; - continue; - } - if (!/\s/.test(current ?? "") && start === -1) start = i; - } - if (start !== -1) elements.push({ start, end: innerEnd }); - return elements; - } - - private dropAdjacentComma( - navigable: string, - elements: Array<{ start: number; end: number }>, - target: { start: number; end: number }, - innerStart: number, - innerEnd: number, - ): { start: number; end: number } { - const index = elements.indexOf(target); - const next = elements[index + 1]; - if (next) return { start: target.start, end: next.start }; - const previous = elements[index - 1]; - if (previous) return { start: previous.end, end: target.end }; - let start = target.start; - while (start > innerStart && /\s/.test(navigable[start - 1] ?? "")) start--; - let end = target.end; - while (end < innerEnd && /\s/.test(navigable[end] ?? "")) end++; - return { start, end }; - } - - private unquote(raw: string): string { - const trimmed = raw.trim(); - if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { - return trimmed.slice(1, -1); - } - return trimmed; - } - - private elementSpec(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed.startsWith("{")) return this.unquote(trimmed); - try { - return this.packageOf(JSON.parse(trimmed.replace(/,(\s*[}\]])/g, "$1"))) ?? ""; - } catch { - return ""; - } - } - - private spliceArrayEntry( - text: string, - navigable: string, - range: PluginArrayRange, - packageName: string, - ): string | null { - const innerStart = range.bracketStart + 1; - const inner = text.slice(innerStart, range.bracketEnd); - const firstElementOffset = inner.search(/\S/); - if (firstElementOffset === -1) { - return text.slice(0, innerStart) + `"${packageName}"` + text.slice(range.bracketEnd); - } - const insertAt = innerStart + firstElementOffset; - const leadingWhitespace = inner.slice(0, firstElementOffset); - return text.slice(0, insertAt) + leadingWhitespace + `"${packageName}",` + text.slice(insertAt); - } - - private splicePluginKey(text: string, navigable: string, packageName: string): string | null { - const objectStart = this.firstStructuralChar(navigable, "{"); - if (objectStart === -1) return null; - const rest = text.slice(objectStart + 1); - const nextContentOffset = rest.search(/\S/); - if (nextContentOffset === -1) return null; - const insertAt = objectStart + 1 + nextContentOffset; - const isClosingBrace = rest[nextContentOffset] === "}"; - const entry = isClosingBrace ? `"plugins": ["${packageName}"]` : `"plugins": ["${packageName}"],`; - const leadingWhitespace = nextContentOffset > 0 ? rest.slice(0, nextContentOffset) : ""; - return text.slice(0, insertAt) + leadingWhitespace + entry + text.slice(insertAt); - } - - private findPluginArrayRange(navigable: string): PluginArrayRange | null { - let searchFrom = 0; - while (searchFrom < navigable.length) { - const keyIndex = navigable.indexOf('"plugins"', searchFrom); - if (keyIndex === -1) return null; - if (this.precededByStructuralChar(navigable, keyIndex, ["{", ","])) { - const colonIndex = this.nextOutsideString(navigable, keyIndex + '"plugins"'.length, ":"); - if (colonIndex !== -1) { - const bracketStart = this.nextOutsideString(navigable, colonIndex + 1, "["); - if (bracketStart !== -1) { - const bracketEnd = this.matchingBracket(navigable, bracketStart); - if (bracketEnd !== null) return { bracketStart, bracketEnd }; - } - } - } - searchFrom = keyIndex + 1; - } - return null; - } - - private precededByStructuralChar(text: string, index: number, allowed: string[]): boolean { - for (let i = index - 1; i >= 0; i--) { - const current: string = text[i] ?? ""; - if (/\s/.test(current)) continue; - return allowed.includes(current); - } - return false; - } - - private firstStructuralChar(text: string, target: string): number { - let inString = false; - for (let i = 0; i < text.length; i++) { - const current = text[i]; - if (inString) { - if (current === "\\") { - i++; - continue; - } - if (current === '"') inString = false; - continue; - } - if (current === '"') { - inString = true; - continue; - } - if (current === target) return i; - } - return -1; - } - - private nextOutsideString(text: string, from: number, target: string): number { - let inString = false; - for (let i = from; i < text.length; i++) { - const current = text[i]; - if (inString) { - if (current === "\\") { - i++; - continue; - } - if (current === '"') inString = false; - continue; - } - if (current === '"') { - inString = true; - continue; - } - if (current === target) return i; - } - return -1; - } - - private matchingBracket(text: string, bracketStart: number): number | null { - let depth = 0; - let inString = false; - for (let i = bracketStart; i < text.length; i++) { - const current = text[i]; - if (inString) { - if (current === "\\") { - i++; - continue; - } - if (current === '"') inString = false; - continue; - } - if (current === '"') { - inString = true; - continue; - } - if (current === "[") depth++; - else if (current === "]") { - depth--; - if (depth === 0) return i; - } - } - return null; - } -} diff --git a/templates/plugin-entry.template.txt b/templates/plugin-entry.template.txt deleted file mode 100644 index 46226bb..0000000 --- a/templates/plugin-entry.template.txt +++ /dev/null @@ -1,126 +0,0 @@ -# Inputs - -None — emit as-is as src/plugin-entry.ts. - -**Load-bearing — do not simplify:** `PluginEntryResolver.resolvesToPackage(entry, packageName, baseDir?)` answers whether a config entry names this package even when written as a path. An entry counts when its path form (`file://` URL, absolute, relative, or a symlink) resolves to a package root, entrypoint file, or directory whose nearest `package.json` declares a `name` that normalized-matches the package. Relative paths resolve against `baseDir` — the config file's directory — and fall back to the process working directory when no base is passed. `PluginEntryResolver.packageRoot(entry, baseDir?)` exposes the same resolution as a path lookup, so a caller can read the resolved checkout's `package.json` (name, version). Resolution is best-effort: an unroutable or missing path, or a `package.json` with no string `name`, never matches and never throws. Resolution memoizes per resolved path in a module-level map; that mutable module state is safe here because npm-loaded packages are not watched directories. Resolution follows symlinks and compares `package.json` names case-insensitively on Windows. A bare npm spec (`name`, `name@latest`, `@scope/name`) never counts as a path. Both the string form and the `{ "package": "..." }` object form count. - ---- - -import { exists, lstat, readFile, realpath } from "node:fs/promises"; -import { dirname, isAbsolute, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { PluginNameNormalizer } from "./plugin-name.ts"; - -const resolvedRootMemo = new Map(); - -const normalizer = new PluginNameNormalizer(); - -export class PluginEntryResolver { - public static async resolvesToPackage(entry: unknown, packageName: string, baseDir?: string): Promise { - if (typeof entry === "string") { - if (PluginEntryResolver.isNpmSpec(entry)) return false; - if (!PluginEntryResolver.isPathLike(entry)) return false; - const root = await PluginEntryResolver.resolveToPackageRoot(entry, baseDir); - if (root === null) return false; - const name = await PluginEntryResolver.packageNameAt(root); - if (name === null) return false; - return normalizer.matches(name, packageName); - } - if (PluginEntryResolver.isRecord(entry)) { - const spec = entry.package; - if (typeof spec === "string") return PluginEntryResolver.resolvesToPackage(spec, packageName, baseDir); - } - return false; - } - - public static async packageRoot(entry: unknown, baseDir?: string): Promise { - if (typeof entry === "string") return PluginEntryResolver.resolveToPackageRoot(entry, baseDir); - if (PluginEntryResolver.isRecord(entry)) { - const spec = entry.package; - if (typeof spec === "string") return PluginEntryResolver.resolveToPackageRoot(spec, baseDir); - } - return null; - } - - private static hasExplicitPathForm(entry: string): boolean { - if (entry.startsWith("file://")) return true; - if (entry.startsWith(".")) return true; - if (isAbsolute(entry)) return true; - return false; - } - - private static isPathLike(entry: string): boolean { - if (PluginEntryResolver.hasExplicitPathForm(entry)) return true; - if (entry.includes("/")) return true; - if (entry.includes("\\")) return true; - return false; - } - - private static isNpmSpec(entry: string): boolean { - return entry.includes("@") && !PluginEntryResolver.hasExplicitPathForm(entry); - } - - private static isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); - } - - private static async resolveToPackageRoot(rawEntry: string, baseDir: string | undefined): Promise { - let targetPath: string; - try { - targetPath = rawEntry.startsWith("file://") - ? fileURLToPath(rawEntry) - : isAbsolute(rawEntry) - ? rawEntry - : join(baseDir ?? process.cwd(), rawEntry); - } catch { - return null; - } - const memoKey = targetPath; - const cached = resolvedRootMemo.get(memoKey); - if (cached !== undefined) return cached; - - try { - targetPath = await realpath(targetPath); - } catch {} - - let stats; - try { - stats = await lstat(targetPath); - } catch { - resolvedRootMemo.set(memoKey, null); - return null; - } - if (!stats.isFile() && !stats.isDirectory()) { - resolvedRootMemo.set(memoKey, null); - return null; - } - - const start = stats.isFile() ? dirname(targetPath) : targetPath; - const found = await PluginEntryResolver.walkToPackageRoot(start); - resolvedRootMemo.set(memoKey, found); - return found; - } - - private static async walkToPackageRoot(start: string): Promise { - let current = start; - let previous = ""; - while (current !== previous) { - const name = await PluginEntryResolver.packageNameAt(current); - if (name !== null) return current; - previous = current; - current = dirname(current); - } - return null; - } - - private static async packageNameAt(root: string): Promise { - const candidate = join(root, "package.json"); - if (!(await exists(candidate))) return null; - try { - const parsed = JSON.parse(await readFile(candidate, "utf-8")) as { name: unknown }; - return typeof parsed.name === "string" ? parsed.name : null; - } catch { - return null; - } - } -} diff --git a/templates/plugin-local.template.txt b/templates/plugin-local.template.txt index 66b0e9f..c456dfd 100644 --- a/templates/plugin-local.template.txt +++ b/templates/plugin-local.template.txt @@ -7,14 +7,14 @@ | `COMMAND NAME` → "my-command.md" | Yes | Command file name in commands/ at the package root | — | | `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; must match package.json; also the stable plugin id | — | -**Load-bearing — do not simplify:** the entry is an Effect-first v2 plugin definition (`Plugin.define({ id, effect })` from `@opencode/plugin/effect`) whose `id` is the stable package name — duplicate ids die activation and storage is scoped per id (facts §2); scope detection is read-only config inspection (both `opencode.json` and `opencode.jsonc`, global and project, plus global `config.json` via the detector), reading the launch directory from `context.location.directory` — never directory-identity comparisons (`import.meta.dirname`, `process.cwd()`, realpath) — a maintainer's own checkout is served by that repo's own config registration; the effect body calls only `ensureAssets` — it never resolves an install mode, never edits `plugins` arrays, permission, or any config file, and writes stay inside the detected registration scope(s) only; assets-only and code-backed packages share this body unchanged: assets are ensured in registered scopes either way, and code-backed assets resolve from the package at load; the **entire effect body** (detection, ensures, advisory) is wrapped so a failure degrades to a warning and OpenCode still launches — the effect's error channel stays `never` and it must never interrupt startup (ADR-0007); the failure advisory names both the remediation command (`bunx install --scope global`) and the package-qualified cache dir (`/opencode/npm/@`), tells the consumer to run `bunx clear-cache` for a partial cache artifact instead of describing manual removal, is built inside its own guarded effect with a static fallback, and each emitter swallows independently; the failure advisory and the not-installed advisory carry **separate** once-guards so neither suppresses the other; the zero-write no-op covers a fully matching manifest **and** a present plugin entry (an up-to-date manifest alone is not enough for plugin installs), so a healthy startup performs no writes at all; the effect never deletes the cache — it instructs only. +**Load-bearing — do not simplify:** the entry is an Effect-first v2 plugin definition (`Plugin.define({ id, effect })` from `@opencode/plugin/effect`) whose `id` is the stable package name — duplicate ids die activation and storage is scoped per id (facts §2); scope detection is read-only config inspection through the `opencode-architect/core` dependency (`PluginConfigEditor.findRegistration` per scope: both `opencode.json` and `opencode.jsonc`, global and project, plus the legacy global `config.json` read-only), reading the launch directory from `context.location.directory` — never directory-identity comparisons (`import.meta.dirname`, `process.cwd()`, realpath) — a maintainer's own checkout is served by that repo's own config registration; the effect body calls only `ensureAssets` — it never resolves an install mode, never edits `plugins` arrays, permission, or any config file, and writes stay inside the detected registration scope(s) only; assets-only and code-backed packages share this body unchanged: assets are ensured in registered scopes either way, and code-backed assets resolve from the package at load; the **entire effect body** (detection, ensures, advisory) is wrapped so a failure degrades to a warning and OpenCode still launches — the effect's error channel stays `never` and it must never interrupt startup (ADR-0007); the failure advisory names both the remediation command (`bunx install --scope global`) and the package-qualified cache dir (`/opencode/npm/@`), tells the consumer to run `bunx clear-cache` for a partial cache artifact instead of describing manual removal, is built inside its own guarded effect with a static fallback, and each emitter swallows independently; the failure advisory and the not-installed advisory carry **separate** once-guards so neither suppresses the other; the zero-write no-op covers a fully matching manifest **and** a present plugin entry (an up-to-date manifest alone is not enough for plugin installs), so a healthy startup performs no writes at all; the effect never deletes the cache — it instructs only. --- import { sep } from "node:path"; import { Effect, type Scope } from "effect"; import { Plugin } from "@opencode/plugin/effect"; +import { PluginConfigEditor } from "opencode-architect/core"; import { ensureAssets, isInstalledAnywhere, packageCacheRoot } from "./installer.ts"; -import { RegistrationDetector, type RegistrationScope } from "./registration.ts"; const PACKAGE_NAME = "opencode-myextension"; const EXTENSION_TITLE = "MyExtension"; @@ -61,15 +61,11 @@ function ensureRegisteredScopeAssets(context: Plugin.Context): Effect.Effect { const directory = context.location.directory; - const scope: RegistrationScope = await new RegistrationDetector(PACKAGE_NAME).detect(directory); - if (scope === "none") { - await adviseNotInstalledOnce(); - return; - } - if (scope === "global" || scope === "both") { + const editor = new PluginConfigEditor(); + if ((await editor.findRegistration(PACKAGE_NAME, { scope: "global", projectDir: directory })) !== null) { await ensureAssets("global", directory, false); } - if (scope === "repo-local" || scope === "both") { + if ((await editor.findRegistration(PACKAGE_NAME, { scope: "local", projectDir: directory })) !== null) { await ensureAssets("local", directory, false); } if (await isInstalledAnywhere(directory)) return; diff --git a/templates/plugin-name.template.txt b/templates/plugin-name.template.txt deleted file mode 100644 index 975de48..0000000 --- a/templates/plugin-name.template.txt +++ /dev/null @@ -1,40 +0,0 @@ -# Inputs - -None — emit as-is as src/plugin-name.ts. - -**Load-bearing — do not simplify:** `name`, `name@latest`, and `name@x.y.z` are the same package; `normalize` always outputs the canonical `name@latest` form and `matches` compares base names semantically, case-insensitively on Windows. Never replace with exact-string equality. - ---- -const CANONICAL_SPEC = "latest"; - -const SPEC_PATTERN = /^(?[^@\s]+)@(?:latest|\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.]+)?)$/; - -export class PluginNameNormalizer { - normalize(raw: string): string | null { - const bare = this.baseName(raw); - if (bare === null) return null; - return `${bare}@${CANONICAL_SPEC}`; - } - - matches(raw: string, packageName: string): boolean { - const left = this.baseName(raw); - const right = this.baseName(packageName); - if (left === null || right === null) return false; - return this.sameName(left, right); - } - - private sameName(left: string, right: string): boolean { - if (process.platform !== "win32") return left === right; - return left.toLowerCase() === right.toLowerCase(); - } - - private baseName(raw: string): string | null { - const trimmed = raw.trim(); - if (trimmed.length === 0) return null; - if (SPEC_PATTERN.test(trimmed)) { - return trimmed.slice(0, trimmed.lastIndexOf("@")); - } - if (trimmed.includes("@", 1)) return null; - return trimmed; - } -} diff --git a/templates/registration.template.txt b/templates/registration.template.txt deleted file mode 100644 index fe67fcd..0000000 --- a/templates/registration.template.txt +++ /dev/null @@ -1,204 +0,0 @@ -# Inputs - -None — emit as-is as src/registration.ts. - -**Load-bearing — do not simplify:** detection is read-only (no writes, ever); it checks the global config base (`opencode.json`, `opencode.jsonc`, and the legacy `config.json`), the repo's `.opencode/opencode.json(c)`, and a repo-root `opencode.json(c)` — both extensions at every base plus global `config.json` — with `@latest`-aware name matching, never the launch directory, which opencode always sets to the consumer repo, and never any directory-identity comparison (plugin dir vs `directory`, `import.meta.dirname`, `process.cwd()`, realpath); a maintainer's own checkout is served by that repo's own config registration, not by a directory check. The v2 `plugins` key is authoritative and the legacy v1 `plugin` key is tolerated read-only — an entry under either key counts as registered, and entries match by package name whether written as strings or `{ "package": ... }` objects. A path-form entry (`file://` URL or filesystem path, string or `{ package }` object form) counts as registered too when it resolves to this package — `PluginEntryResolver.resolvesToPackage` (src/plugin-entry.ts) supplies that best-effort, memoized resolution, resolving a relative entry against the directory of the config file that declares it (never the launch directory, which is not a resolution base). `.jsonc` parses leniently (string-aware `//` and `/* */` comment stripping plus trailing-comma removal, so a `$schema` URL's `//` and escaped quotes survive); `.json` stays strict. Every unparseable candidate is warned about before any short-circuit. Nested and root configs combine with OR — a present-but-unregistered nested file never masks a repo-root registration. 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"; -import { homedir } from "node:os"; -import { dirname, join } from "node:path"; -import { PluginNameNormalizer } from "./plugin-name.ts"; -import { PluginEntryResolver } from "./plugin-entry.ts"; - -export type RegistrationScope = "none" | "global" | "repo-local" | "both"; - -const LEGACY_READ_ONLY_GLOBAL = "config.json"; - -function getGlobalConfigPath(): string { - const xdgConfig = process.env.XDG_CONFIG_HOME; - if (xdgConfig) return join(xdgConfig, "opencode"); - return join(homedir(), ".config", "opencode"); -} - -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; -} - -export function parseJsonc(text: string): unknown { - return JSON.parse(stripTrailingCommas(stripJsoncComments(text))); -} - -interface PluginEntries { - directory: string; - plugins: string[]; - legacyPlugins: string[]; - rawPlugins: unknown[]; - 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 - .map((entry) => (typeof entry === "string" ? entry : entryPackage(entry))) - .filter((entry): entry is string => entry !== null); -} - -function entryPackage(entry: unknown): string | null { - if (entry === null || typeof entry !== "object") return null; - const pkg = (entry as { package: unknown }).package; - return typeof pkg === "string" ? pkg : null; -} - -async function readPluginEntries(path: string): Promise { - if (!(await exists(path))) return null; - try { - const raw = await readFile(path, "utf-8"); - const config = (path.endsWith(".jsonc") ? parseJsonc(raw) : JSON.parse(raw)) as Record; - return { - directory: dirname(path), - plugins: entryNames(config.plugins), - legacyPlugins: entryNames(config.plugin), - rawPlugins: Array.isArray(config.plugins) ? config.plugins : [], - rawLegacyPlugins: Array.isArray(config.plugin) ? config.plugin : [], - }; - } catch { - console.warn(`Warning: ${path} could not be parsed; ignoring it as a registration candidate.`); - return null; - } -} - -async function readEntriesForBase(base: string): Promise { - for (const file of ["opencode.json", "opencode.jsonc"]) { - const entries = await readPluginEntries(join(base, file)); - if (entries !== null) return entries; - } - return null; -} - -async function readGlobalEntries(): Promise { - const entries = await readEntriesForBase(getGlobalConfigPath()); - if (entries !== null) return entries; - 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(); - - constructor(private readonly packageName: string) {} - - async detect(projectDir: string): Promise { - const globalEntries = await readGlobalEntries(); - const nestedEntries = await readEntriesForBase(join(projectDir, ".opencode")); - const rootEntries = await readEntriesForBase(projectDir); - const inGlobal = await this.matchesAny(globalEntries); - const inLocal = (await this.matchesAny(nestedEntries)) || (await this.matchesAny(rootEntries)); - if (inGlobal && inLocal) return "both"; - if (inGlobal) return "global"; - if (inLocal) return "repo-local"; - 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]; - if (names.some((entry) => this.normalizer.matches(entry, this.packageName))) return true; - return this.resolvesAny([...entries.rawPlugins, ...entries.rawLegacyPlugins], entries.directory); - } - - private async resolvesAny(rawEntries: unknown[], baseDir: string): Promise { - for (const entry of rawEntries) { - if (await PluginEntryResolver.resolvesToPackage(entry, this.packageName, baseDir)) return true; - } - return false; - } -} diff --git a/tests/cache-cleaner.test.ts b/tests/cache-cleaner.test.ts index 7e1b3b2..c9bd676 100644 --- a/tests/cache-cleaner.test.ts +++ b/tests/cache-cleaner.test.ts @@ -4,25 +4,21 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { CacheCleaner } from "../src/cache-cleaner"; -import { NpmCache } from "../src/npm-cache"; -import { seedCachedPackage, expectClearCacheUsageError } from "./test-helpers"; +import { NpmCache } from "../src/core/npm-cache"; +import { fakeEnvironment, seedCachedPackage, expectClearCacheUsageError } from "./test-helpers"; let cacheDir = ""; -let originalXdgCache: string | undefined; let cleaner = new CacheCleaner(); let cache = new NpmCache(); beforeEach(async () => { cacheDir = await mkdtemp(path.join(tmpdir(), "oa-clear-cache-")); - originalXdgCache = process.env.XDG_CACHE_HOME; - process.env.XDG_CACHE_HOME = cacheDir; - cleaner = new CacheCleaner(); - cache = new NpmCache(); + const environment = fakeEnvironment({ vars: { XDG_CACHE_HOME: cacheDir } }); + cleaner = new CacheCleaner(environment); + cache = new NpmCache(environment); }); afterEach(async () => { - if (originalXdgCache === undefined) delete process.env.XDG_CACHE_HOME; - else process.env.XDG_CACHE_HOME = originalXdgCache; await rm(cacheDir, { recursive: true, force: true }); }); @@ -188,7 +184,7 @@ describe("clearCache failure tolerance", () => { test("warns and continues when a removal fails", async () => { const kept = await seedCachedPackage(cacheRoot(), "opencode-architect"); const removed = await seedCachedPackage(cacheRoot(), "opencode-architect@0.1.0"); - const failingCleaner = new CacheCleaner(async (target, options) => { + const failingCleaner = new CacheCleaner(fakeEnvironment({ vars: { XDG_CACHE_HOME: cacheDir } }), async (target, options) => { if (target === kept) throw new Error("EPERM: simulated removal failure"); await rm(target, options); }); diff --git a/tests/core.test.ts b/tests/core.test.ts new file mode 100644 index 0000000..4750373 --- /dev/null +++ b/tests/core.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import * as core from "../src/core/index"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); + +describe("the core module seam", () => { + test("package.json exports ./core", async () => { + const manifest = JSON.parse(await readFile(path.join(REPO_ROOT, "package.json"), "utf-8")) as { + exports: Record; + }; + expect(manifest.exports["./core"]).toBe("./src/core/index.ts"); + }); + + test("the core module exposes the machinery the adapters share", async () => { + for (const symbol of [ + // environment + scope + cache paths + "realEnvironment", + "scopeBase", + "NpmCache", + "removeCacheTargets", + // name matching + "PluginNameNormalizer", + "PluginEntryResolver", + "EntryPredicate", + // config splice + "ConfigReader", + "RegistrationDetector", + "ConfigSplicer", + "PluginConfigEditor", + // manifest io + hashes (generated-package format, frozen per ADR-0013) + "InstallManifest", + "manifestPath", + "sha256", + "listFilesRecursive", + // effective-version pipeline + "LoadedVersionResolver", + "StatusReporter", + "RegistryVersionChecker", + ]) { + expect(core, `src/core/index.ts must export ${symbol}`).toHaveProperty(symbol); + } + }); + + test("core exports the frozen generated-manifest types", async () => { + const source = await readFile(path.join(REPO_ROOT, "src", "core", "index.ts"), "utf-8"); + expect(source).toContain("InstallMode"); + expect(source).toContain("ManifestData"); + expect(source).toContain("StatusReport"); + expect(source).toContain("Scope"); + }); +}); diff --git a/tests/deployment-plan.test.ts b/tests/deployment-plan.test.ts index 3d738db..31abf81 100644 --- a/tests/deployment-plan.test.ts +++ b/tests/deployment-plan.test.ts @@ -36,22 +36,22 @@ describe("content-based deployment plan (issue #18)", () => { expect(source).not.toContain("removePluginFromConfig"); }); - test("surgical editor template exists and never rewrites whole configs", async () => { - const source = await readTemplate("plugin-config.template.txt"); + test("the core surgical editor never rewrites whole configs", async () => { + const source = await readCore("plugin-config.ts"); expect(source).toContain("class PluginConfigEditor"); expect(source).toContain("spliceEntry"); expect(source).not.toMatch(/JSON\.stringify\(config/); }); - test("manifest records mode, entry, and target config file", async () => { - const source = await readTemplate("manifest.template.txt"); + test("the core manifest records mode, entry, and target config file", async () => { + const source = await readCore("manifest.ts"); expect(source).toMatch(/mode: InstallMode/); expect(source).toContain("entry: string | null"); expect(source).toContain("entryConfigPath: string | null"); }); - test("registration detection covers both bases, repo root, both extensions, and global config.json", async () => { - const source = await readTemplate("registration.template.txt"); + test("core registration detection covers both bases, repo root, both extensions, and global config.json", async () => { + const source = await readCore("config-reader.ts"); expect(source).toContain("opencode.json"); expect(source).toContain("opencode.jsonc"); expect(source).toContain('"config.json"'); @@ -79,9 +79,9 @@ describe("content-based deployment plan (issue #18)", () => { test("packager and publisher instructions reference the generalized flow", async () => { const packager = await readAgent("opencode-packager.md"); const publisher = await readAgent("opencode-publisher.md"); - expect(packager).toContain("plugin-config.template.txt"); + expect(packager).toContain("installer.template.txt"); expect(packager).toContain("content-based"); - expect(publisher).toContain("plugin-config.template.txt"); + expect(publisher).toContain("installer.template.txt"); expect(publisher).toContain("--mode"); }); }); @@ -90,11 +90,14 @@ describe("generated-package cache hygiene (issue #14)", () => { test("installer template prunes self cache copies on install, warn-and-continue", async () => { const source = await readTemplate("installer.template.txt"); expect(source).toContain("prunePackageCache"); - expect(source).toContain("const cache = await prunePackageCache();"); + expect(source).toContain("const cacheOutcome = await prunePackageCache();"); expect(source).toContain("clearPackageCache"); - expect(source).toMatch(/Could not clear cached package/); - expect(source).toContain('"opencode", "npm"'); + expect(source).toContain("removeCacheTargets"); expect(source).not.toContain('"opencode", "packages"'); + const coreCache = await readCore("cache-hygiene.ts"); + expect(coreCache).toMatch(/Could not clear cached package/); + const npmCache = await readCore("npm-cache.ts"); + expect(npmCache).toContain('"opencode", "npm"'); }); test("installer prunes every invocation including no-ops, before mode dispatch", async () => { @@ -306,6 +309,10 @@ async function readTemplate(name: string): Promise { return source.split("---").slice(1).join("---"); } +async function readCore(name: string): Promise { + return readFile(path.join(REPO_ROOT, "src", "core", name), "utf-8"); +} + async function readAgent(name: string): Promise { return readFile(path.join(REPO_ROOT, "agents", name), "utf-8"); } diff --git a/tests/entry-predicate.test.ts b/tests/entry-predicate.test.ts new file mode 100644 index 0000000..4170568 --- /dev/null +++ b/tests/entry-predicate.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { EntryPredicate } from "../src/core/entry-predicate"; + +describe("EntryPredicate.classify", () => { + test("parses string entries, { package } objects, and rejects junk", () => { + expect(EntryPredicate.specOf({ package: " a@1 " })).toBe("a@1"); + expect(EntryPredicate.specOf(42)).toBeNull(); + expect(EntryPredicate.specOf(null)).toBeNull(); + }); + + test("classifies npm and path forms with name and version", () => { + const npm = EntryPredicate.classify("my-pkg@1.2.3"); + expect(npm).toEqual({ form: "npm", display: "my-pkg@1.2.3", spec: "my-pkg@1.2.3", name: "my-pkg", version: "1.2.3" }); + const scoped = EntryPredicate.classify("@scope/pkg"); + expect(scoped?.form).toBe("npm"); + expect(scoped?.name).toBe("@scope/pkg"); + const path = EntryPredicate.classify("./local/dir"); + expect(path?.form).toBe("path"); + expect(path?.name).toBeNull(); + }); + + test("baseName strips the version spec", () => { + expect(EntryPredicate.baseName("@scope/pkg@1.0.0")).toBe("@scope/pkg"); + expect(EntryPredicate.baseName("pkg")).toBe("pkg"); + }); +}); + +describe("EntryPredicate.matched", () => { + test("matches npm entries by scoped name, any canonical spec", async () => { + for (const entry of ["my-pkg", "my-pkg@latest", "my-pkg@1.2.3", { package: "my-pkg@latest" }]) { + expect(await EntryPredicate.matched(entry, "my-pkg"), JSON.stringify(entry)).not.toBeNull(); + } + expect(await EntryPredicate.matched("other-pkg@latest", "my-pkg")).toBeNull(); + expect(await EntryPredicate.matched("@scope/my-pkg@latest", "my-pkg")).toBeNull(); + }); + + test("matches path-form entries only when they resolve to the package", async () => { + expect(await EntryPredicate.matched("./nowhere/at/all", "my-pkg", import.meta.dirname)).toBeNull(); + expect(await EntryPredicate.matched("file:///Z:/no/such/dir", "my-pkg")).toBeNull(); + expect(await EntryPredicate.matched("https://example.com/pkg", "my-pkg")).toBeNull(); + }); + + test("matches a file:/// entry whose target names the package", async () => { + const root = await mkdtemp(path.join(tmpdir(), "entry-predicate-")); + await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "my-pkg", version: "1.0.0" })); + const url = pathToFileURL(root).href; + const matched = await EntryPredicate.matched(url, "my-pkg"); + expect(matched?.form).toBe("path"); + expect(await EntryPredicate.matched(url, "other-pkg")).toBeNull(); + expect(await EntryPredicate.matched(url.replace("file:///", "file:///Z:/"), "my-pkg")).toBeNull(); + }); +}); + diff --git a/tests/fixture-config-schema.test.ts b/tests/fixture-config-schema.test.ts index ae2cbe6..2754305 100644 --- a/tests/fixture-config-schema.test.ts +++ b/tests/fixture-config-schema.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { PluginConfigEditor } from "../src/plugin-config"; +import { PluginConfigEditor } from "../src/core/plugin-config"; import { ConfigSchemaValidator } from "./config-schema-validator"; const REPO_ROOT = path.resolve(import.meta.dirname, ".."); diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 196083e..fba534c 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -6,37 +6,29 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { Installer, contentHash, type Manifest, type Scope } from "../src/installer"; import { CopyModeUnsupportedError } from "../src/copy-mode-unsupported-error"; +import { fakeEnvironment } from "./test-helpers"; const PACKAGE_ROOT = path.resolve(import.meta.dirname, ".."); let projectDir = ""; let homeDir = ""; let cacheDir = ""; -let originalXdgConfig: string | undefined; -let originalXdgCache: string | undefined; -const installer = new Installer(); +let installer = new Installer(); beforeEach(async () => { projectDir = await mkdtemp(path.join(tmpdir(), "oa-installer-project-")); homeDir = await mkdtemp(path.join(tmpdir(), "oa-installer-home-")); cacheDir = await mkdtemp(path.join(tmpdir(), "oa-installer-cache-")); - originalXdgConfig = process.env.XDG_CONFIG_HOME; - originalXdgCache = process.env.XDG_CACHE_HOME; - process.env.XDG_CONFIG_HOME = homeDir; - process.env.XDG_CACHE_HOME = cacheDir; + installer = new Installer(null, rm, isolatedEnvironment()); }); +function isolatedEnvironment() { + return fakeEnvironment({ + vars: { XDG_CONFIG_HOME: homeDir, XDG_CACHE_HOME: cacheDir }, + }); +} + afterEach(async () => { - if (originalXdgConfig === undefined) { - delete process.env.XDG_CONFIG_HOME; - } else { - process.env.XDG_CONFIG_HOME = originalXdgConfig; - } - if (originalXdgCache === undefined) { - delete process.env.XDG_CACHE_HOME; - } else { - process.env.XDG_CACHE_HOME = originalXdgCache; - } await rm(projectDir, { recursive: true, force: true }); await rm(homeDir, { recursive: true, force: true }); await rm(cacheDir, { recursive: true, force: true }); @@ -247,7 +239,7 @@ describe("Installer.install", () => { test("fails loudly when bundled assets are absent (partial cache artifact)", async () => { const partialCache = await mkdtemp(path.join(tmpdir(), "oa-partial-cache-")); try { - const broken = new Installer(partialCache); + const broken = new Installer(partialCache, rm, isolatedEnvironment()); await expect( broken.install("local", { force: false, mode: "plugin", projectDir }), @@ -263,7 +255,7 @@ describe("Installer.install", () => { const partialCache = await mkdtemp(path.join(tmpdir(), "oa-partial-cache-")); await mkdir(path.join(partialCache, "agents"), { recursive: true }); try { - const broken = new Installer(partialCache); + const broken = new Installer(partialCache, rm, isolatedEnvironment()); await expect( broken.install("local", { force: false, mode: "plugin", projectDir }), @@ -488,7 +480,7 @@ describe("Installer.install cache pruning", () => { const failingInstaller = new Installer(null, async (target, options) => { if (target === blocked) throw new Error("EPERM: simulated removal failure"); await rm(target, options); - }); + }, isolatedEnvironment()); const outcome = await failingInstaller.install("local", { force: false, mode: "plugin", projectDir }); diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts index 1eae8fd..940fa42 100644 --- a/tests/plugin-config.test.ts +++ b/tests/plugin-config.test.ts @@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { PluginConfigEditor } from "../src/plugin-config"; -import { captureConsole } from "./test-helpers"; +import { PluginConfigEditor } from "../src/core/plugin-config"; +import { RegistrationDetector } from "../src/core/registration-detector"; +import { ConfigReader } from "../src/core/config-reader"; +import { fakeEnvironment } from "./test-helpers"; const ROOT = path.join(import.meta.dirname, "..", ".tmp-plugin-config-test"); @@ -20,8 +22,22 @@ async function write(relative: string, content: string): Promise { return filePath; } -function editor(): PluginConfigEditor { - return new PluginConfigEditor(); +function editor(environment?: ConstructorParameters[0]): PluginConfigEditor { + return new PluginConfigEditor(environment ?? fakeEnvironment()); +} + +function warningCollector(): { environment: ConstructorParameters[0]; lines: string[] } { + const lines: string[] = []; + return { environment: fakeEnvironment({ warn: (message) => lines.push(message) }), lines }; +} + +function readerWithWarnings(): { reader: ConfigReader; lines: string[] } { + const lines: string[] = []; + return { reader: new ConfigReader(fakeEnvironment({ warn: (message) => lines.push(message) })), lines }; +} + +function detector(): RegistrationDetector { + return new RegistrationDetector(); } function parseJsonc(text: string): Record { @@ -166,51 +182,41 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { const projectDir = await makeDir("project"); await write("project/.opencode/opencode.json", '{ "plugins": ["my-pkg"] }\n'); const brokenRoot = await write("project/opencode.json", "{ broken ]"); - const captured = captureConsole("warn"); + const { environment, lines } = warningCollector(); - try { - const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + const outcome = await editor(environment).ensurePluginEntry("my-pkg", { scope: "local", projectDir }); - expect(outcome.action).toBe("noop"); - expect(captured.lines.some((message) => message.includes(brokenRoot))).toBe(true); - } finally { - captured.restore(); - } + expect(outcome.action).toBe("noop"); + expect(lines.some((message) => message.includes(brokenRoot))).toBe(true); }); test("findRegistration warns about unparseable candidates and still finds later registrations", async () => { const projectDir = await makeDir("project"); await write("project/.opencode/opencode.json", "{ broken ]"); const rootConfig = await write("project/opencode.json", '{ "plugins": ["my-pkg@1.0.0"] }\n'); - const captured = captureConsole("warn"); + const { reader, lines } = readerWithWarnings(); - try { - const found = await editor().findRegistration("my-pkg", { scope: "local", projectDir }); + const found = await new RegistrationDetector(reader).findRegistration("my-pkg", { scope: "local", projectDir }); - expect(found).toBe(rootConfig); - expect(captured.lines.some((message) => message.includes(".opencode"))).toBe(true); - } finally { - captured.restore(); - } + expect(found).toBe(rootConfig); + expect(lines.some((message) => message.includes(".opencode"))).toBe(true); }); test("unparseable read-only candidate is warned about and skipped, never blocking removal", async () => { const projectDir = await makeDir("project"); const xdg = await makeDir("xdg"); const readOnlyConfig = await write("xdg/opencode/config.json", "{ broken ]"); - process.env.XDG_CONFIG_HOME = xdg; - const captured = captureConsole("warn"); + const lines: string[] = []; + const xdgEnvironment = fakeEnvironment({ + vars: { XDG_CONFIG_HOME: xdg }, + warn: (message) => lines.push(message), + }); - try { - const outcome = await editor().removePluginEntry("my-pkg", { scope: "global", projectDir }); + const outcome = await editor(xdgEnvironment).removePluginEntry("my-pkg", { scope: "global", projectDir }); - expect(outcome.action).toBe("noop"); - expect(captured.lines.some((message) => message.includes(readOnlyConfig))).toBe(true); - expect(await readFile(readOnlyConfig, "utf-8")).toBe("{ broken ]"); - } finally { - captured.restore(); - delete process.env.XDG_CONFIG_HOME; - } + expect(outcome.action).toBe("noop"); + expect(lines.some((message) => message.includes(readOnlyConfig))).toBe(true); + expect(await readFile(readOnlyConfig, "utf-8")).toBe("{ broken ]"); }); test("strict .json rejects comments and trailing commas", async () => { @@ -271,14 +277,12 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { test("global scope creates scope-base opencode.jsonc when nothing exists", async () => { const projectDir = await makeDir("project"); const xdg = await makeDir("xdg"); - process.env.XDG_CONFIG_HOME = xdg; - try { - const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "global", projectDir }); - expect(outcome.action).toBe("created"); - expect(outcome.configPath).toBe(path.join(xdg, "opencode", "opencode.jsonc")); - } finally { - delete process.env.XDG_CONFIG_HOME; - } + const xdgEnvironment = fakeEnvironment({ vars: { XDG_CONFIG_HOME: xdg } }); + + const outcome = await editor(xdgEnvironment).ensurePluginEntry("my-pkg", { scope: "global", projectDir }); + + expect(outcome.action).toBe("created"); + expect(outcome.configPath).toBe(path.join(xdg, "opencode", "opencode.jsonc")); }); test("global config.json with the entry is a read-only no-op with an upgrade advisory", async () => { @@ -288,33 +292,29 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { "xdg/opencode/config.json", '{ "plugin": ["my-pkg@2.0.0"] }\n', ); - process.env.XDG_CONFIG_HOME = xdg; - try { - const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "global", projectDir }); - expect(outcome.action).toBe("noop"); - expect(outcome.configPath).toBe(globalConfig); - expect(outcome.warning).toContain("legacy v1"); - expect(outcome.warning).toContain("no longer reads config.json"); - expect(await readFile(globalConfig, "utf-8")).toBe('{ "plugin": ["my-pkg@2.0.0"] }\n'); - } finally { - delete process.env.XDG_CONFIG_HOME; - } + const xdgEnvironment = fakeEnvironment({ vars: { XDG_CONFIG_HOME: xdg } }); + + const outcome = await editor(xdgEnvironment).ensurePluginEntry("my-pkg", { scope: "global", projectDir }); + + expect(outcome.action).toBe("noop"); + expect(outcome.configPath).toBe(globalConfig); + expect(outcome.warning).toContain("legacy v1"); + expect(outcome.warning).toContain("no longer reads config.json"); + expect(await readFile(globalConfig, "utf-8")).toBe('{ "plugin": ["my-pkg@2.0.0"] }\n'); }); test("does not write into read-only global config.json when entry is absent", async () => { const projectDir = await makeDir("project"); const xdg = await makeDir("xdg"); const globalConfig = await write("xdg/opencode/config.json", '{ "model": "x/y" }\n'); - process.env.XDG_CONFIG_HOME = xdg; - try { - const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "global", projectDir }); - expect(outcome.action).toBe("created"); - expect(await readFile(globalConfig, "utf-8")).toBe('{ "model": "x/y" }\n'); - const created = parseJsonc(await readFile(path.join(xdg, "opencode", "opencode.jsonc"), "utf-8")); - expect(created.plugins).toEqual(["my-pkg@latest"]); - } finally { - delete process.env.XDG_CONFIG_HOME; - } + const xdgEnvironment = fakeEnvironment({ vars: { XDG_CONFIG_HOME: xdg } }); + + const outcome = await editor(xdgEnvironment).ensurePluginEntry("my-pkg", { scope: "global", projectDir }); + + expect(outcome.action).toBe("created"); + expect(await readFile(globalConfig, "utf-8")).toBe('{ "model": "x/y" }\n'); + const created = parseJsonc(await readFile(path.join(xdg, "opencode", "opencode.jsonc"), "utf-8")); + expect(created.plugins).toEqual(["my-pkg@latest"]); }); test("splices an inline empty plugin array in a minified config", async () => { @@ -388,7 +388,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { const projectDir = await makeDir("project"); const configPath = await write("project/opencode.json", '{ "plugin": ["my-pkg"] }\n'); - const found = await editor().findRegistration("my-pkg", { scope: "local", projectDir }); + const found = await detector().findRegistration("my-pkg", { scope: "local", projectDir }); expect(found).toBe(configPath); }); @@ -450,7 +450,7 @@ describe("PluginConfigEditor path-form entries", () => { `{ "plugins": ["${pathToFileURL(pkgDir).href}"] }\n`, ); - const found = await editor().findRegistration("my-pkg", { scope: "local", projectDir }); + const found = await detector().findRegistration("my-pkg", { scope: "local", projectDir }); expect(found).toBe(configPath); }); diff --git a/tests/scaffold.test.ts b/tests/scaffold.test.ts new file mode 100644 index 0000000..8f22115 --- /dev/null +++ b/tests/scaffold.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ScaffoldRenderer, type RenderedFile } from "../src/scaffold/renderer"; +import { sha256 } from "../src/core/manifest"; +import { Promoter } from "../src/scaffold/promoter"; + +const PACKAGE_ROOT = path.resolve(import.meta.dirname, ".."); +const CLI_PATH = path.join(PACKAGE_ROOT, "src", "cli.ts"); + +async function runCli(args: string[], cwd?: string): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const proc = Bun.spawn([process.execPath, CLI_PATH, ...args], { + cwd: cwd ?? PACKAGE_ROOT, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { exitCode, stdout, stderr }; +} + +function pathSet(files: RenderedFile[]): string[] { + return files.map((file) => file.relativePath).sort(); +} + +function contentOf(files: RenderedFile[], relativePath: string): string { + const found = files.find((file) => file.relativePath === relativePath); + if (found === undefined) throw new Error(`no rendered file: ${relativePath}`); + return found.content; +} + +describe("scaffold renderer", () => { + test("an assets-only plan renders the full conformant tree", () => { + const files = new ScaffoldRenderer().render({ name: "opencode-mytool", ship: ["skills"], coreDependency: "file:../opencode-architect" }); + + expect(pathSet(files)).toEqual( + [ + "index.ts", + "src/cli.ts", + "src/installer.ts", + "src/plugin.ts", + "commands/my-command.md", + "package.json", + "skills/mytool/SKILL.md", + "tests/plugin.contract.test.ts", + "tsconfig.json", + ].sort(), + ); + }); + + test("the package manifest carries the derived name, bin, files, and assets content", () => { + const files = new ScaffoldRenderer().render({ name: "opencode-mytool", ship: ["skills"], coreDependency: "file:../opencode-architect" }); + const manifest = JSON.parse(contentOf(files, "package.json")) as { + name: string; + content: string; + bin: Record; + files: string[]; + exports: Record; + dependencies: Record; + }; + + expect(manifest.name).toBe("opencode-mytool"); + expect(manifest.bin).toEqual({ "opencode-mytool": "src/cli.ts" }); + expect(manifest.files).toEqual(["index.ts", "src", "skills", "commands"]); + expect(manifest.content).toBe("assets"); + expect(manifest.exports["./server"]).toBe("./index.ts"); + expect(manifest.exports["."]).toBe("./index.ts"); + expect(manifest.dependencies["@opencode/plugin"]).toBe("latest"); + expect(manifest.dependencies["effect"]).toBe("latest"); + }); + + test("template bodies are substituted with the package name and identifier", () => { + const files = new ScaffoldRenderer().render({ name: "opencode-mytool", ship: ["skills"], coreDependency: "file:../opencode-architect" }); + + expect(contentOf(files, "src/plugin.ts")).toContain("opencode-mytool"); + expect(contentOf(files, "skills/mytool/SKILL.md")).toContain('name: "mytool"'); + expect(contentOf(files, "tests/plugin.contract.test.ts")).toContain("opencode-mytool"); + expect(contentOf(files, "src/plugin.ts")).not.toContain("myextension"); + }); + + test("shipping agents declares code content and lists the directory", () => { + const files = new ScaffoldRenderer().render({ name: "opencode-mytool", ship: ["skills", "agents"], coreDependency: "file:../opencode-architect" }); + + const manifest = JSON.parse(contentOf(files, "package.json")) as { files: string[]; content: string }; + expect(manifest.content).toBe("code"); + expect(manifest.files).toEqual(["index.ts", "src", "skills", "commands", "agents"]); + expect(pathSet(files)).toContain("agents/.gitkeep"); + }); +}); + +describe("scaffold command (fresh)", () => { + test("flag-driven fresh scaffold writes the conformant tree into the workspace", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-scaffold-")); + try { + const run = await runCli(["scaffold", "--name", "opencode-mytool"], dir); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("opencode-mytool"); + const manifest = JSON.parse(await readFile(path.join(dir, "opencode-mytool", "package.json"), "utf-8")) as { + name: string; + content: string; + bin: Record; + }; + expect(manifest.name).toBe("opencode-mytool"); + expect(manifest.bin["opencode-mytool"]).toBe("src/cli.ts"); + expect(existsSync(path.join(dir, "opencode-mytool", "src", "plugin.ts"))).toBe(true); + expect(existsSync(path.join(dir, "opencode-mytool", "skills", "mytool", "SKILL.md"))).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("a missing package name in a non-interactive session fails with guidance", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-scaffold-")); + try { + const run = await runCli(["scaffold"], dir); + + expect(run.exitCode).toBe(1); + expect(run.stderr).toContain("--name"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("an invalid ship kind is rejected", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-scaffold-")); + try { + const run = await runCli(["scaffold", "--name", "opencode-mytool", "--ship", "gadgets"], dir); + + expect(run.exitCode).toBe(1); + expect(run.stderr).toContain("gadgets"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("an existing target package directory is refused", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-scaffold-")); + try { + await mkdir(path.join(dir, "opencode-mytool"), { recursive: true }); + const run = await runCli(["scaffold", "--name", "opencode-mytool"], dir); + + expect(run.exitCode).toBe(1); + expect(run.stderr).toContain("already exists"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +async function writeSkill(dir: string, name: string): Promise { + await mkdir(path.join(dir, ".opencode", "skills", name), { recursive: true }); + await writeFile( + path.join(dir, ".opencode", "skills", name, "SKILL.md"), + `---\nname: "${name}"\ndescription: "The ${name} skill."\n---\n\n${name} workflow.\n`, + ); +} + +async function promoteFixture(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "oa-promote-")); + await writeSkill(dir, "alpha"); + await writeSkill(dir, "beta"); + await mkdir(path.join(dir, ".opencode", "commands"), { recursive: true }); + await writeFile( + path.join(dir, ".opencode", "commands", "hello.md"), + '---\ndescription: "Hello command."\n---\n\nSay hello.\n', + ); + return dir; +} + +describe("scaffold command (promote)", () => { + test("promote packages .opencode extensions, ensures the config reference, and gates retirement", async () => { + const dir = await promoteFixture(); + try { + const run = await runCli(["scaffold", "--promote", "--name", "opencode-mytool"], dir); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("Already managed"); + expect(run.stdout).toContain("Retirement needs your consent"); + const config = await readFile(path.join(dir, "opencode.jsonc"), "utf-8"); + expect(config).toContain("opencode-mytool"); + const manifest = JSON.parse(await readFile(path.join(dir, "opencode-mytool", "package.json"), "utf-8")) as { + name: string; + content: string; + dependencies: Record; + }; + expect(manifest.name).toBe("opencode-mytool"); + expect(manifest.content).toBe("assets"); + // The machinery comes from the core dependency, linked locally (ADR-0013). + expect(manifest.dependencies["opencode-architect"]).toMatch(/^file:/); + expect(existsSync(path.join(dir, "opencode-mytool", "node_modules", "opencode-architect"))).toBe(true); + expect(existsSync(path.join(dir, "opencode-mytool", "skills", "alpha", "SKILL.md"))).toBe(true); + expect(existsSync(path.join(dir, "opencode-mytool", "skills", "beta", "SKILL.md"))).toBe(true); + expect(existsSync(path.join(dir, ".opencode", "skills", "alpha", "SKILL.md"))).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, 60_000); + + test("promote --yes retires the sources to the managed end state", async () => { + const dir = await promoteFixture(); + try { + const run = await runCli(["scaffold", "--promote", "--name", "opencode-mytool", "--yes"], dir); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("Already managed"); + const managedSkill = await readFile( + path.join(dir, "opencode-mytool", "skills", "alpha", "SKILL.md"), + "utf-8", + ); + const installedSkill = await readFile(path.join(dir, ".opencode", "skills", "alpha", "SKILL.md"), "utf-8"); + expect(installedSkill).toBe(managedSkill); + const entries = await readdir(path.join(dir, ".opencode")); + expect(entries).toContain("opencode-mytool.manifest.json"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, 60_000); + + test("a code-backed promote retires .opencode to the manifest only", async () => { + const dir = await promoteFixture(); + await mkdir(path.join(dir, ".opencode", "agents"), { recursive: true }); + await writeFile( + path.join(dir, ".opencode", "agents", "researcher.md"), + '---\ndescription: "Researcher agent."\nmode: subagent\n---\n\nResearch things.\n', + ); + try { + const run = await runCli(["scaffold", "--promote", "--name", "opencode-mytool", "--yes"], dir); + + expect(run.exitCode).toBe(0); + const manifest = JSON.parse(await readFile(path.join(dir, "opencode-mytool", "package.json"), "utf-8")) as { + content: string; + }; + expect(manifest.content).toBe("code"); + expect(await readdir(path.join(dir, ".opencode"))).toEqual(["opencode-mytool.manifest.json"]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, 60_000); + + test("an unestablishable config reference aborts retirement with the source in place", async () => { + const dir = await promoteFixture(); + await writeFile(path.join(dir, ".opencode", "opencode.json"), "{ not json"); + try { + const run = await runCli(["scaffold", "--promote", "--name", "opencode-mytool", "--yes"], dir); + + expect(run.exitCode).toBe(1); + expect(existsSync(path.join(dir, ".opencode", "skills", "alpha", "SKILL.md"))).toBe(true); + expect(existsSync(path.join(dir, ".opencode", "skills", "beta", "SKILL.md"))).toBe(true); + expect(run.stderr).toContain("config reference"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, 60_000); + + test("promote --yes deletes the source package.json, lockfile, and node_modules with the originals", async () => { + const dir = await promoteFixture(); + await writeFile(path.join(dir, ".opencode", "package.json"), '{}\n'); + await writeFile(path.join(dir, ".opencode", "bun.lock"), ""); + await mkdir(path.join(dir, ".opencode", "node_modules", "left-pad"), { recursive: true }); + await writeFile(path.join(dir, ".opencode", "node_modules", "left-pad", "index.js"), "module.exports = {};\n"); + try { + const run = await runCli(["scaffold", "--promote", "--name", "opencode-mytool", "--yes"], dir); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("Removed: package.json"); + expect(run.stdout).toContain("Removed: bun.lock"); + expect(run.stdout).toContain("Removed: node_modules"); + expect(existsSync(path.join(dir, ".opencode", "package.json"))).toBe(false); + expect(existsSync(path.join(dir, ".opencode", "bun.lock"))).toBe(false); + expect(existsSync(path.join(dir, ".opencode", "node_modules"))).toBe(false); + expect(existsSync(path.join(dir, ".opencode", "opencode-mytool.manifest.json"))).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, 60_000); + + test("without consent the source artifacts are listed for removal but kept", async () => { + const dir = await promoteFixture(); + await writeFile(path.join(dir, ".opencode", "package.json"), '{}\n'); + try { + const run = await runCli(["scaffold", "--promote", "--name", "opencode-mytool"], dir); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("Would remove: package.json"); + expect(existsSync(path.join(dir, ".opencode", "package.json"))).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, 60_000); + + test("a sibling target holding an existing package is refused with merge routing", async () => { + const dir = await promoteFixture(); + const name = `opencode-sib-${Math.random().toString(36).slice(2, 8)}`; + const sibling = path.join(path.dirname(dir), name); + await mkdir(sibling, { recursive: true }); + await writeFile(path.join(sibling, "package.json"), '{}\n'); + try { + const run = await runCli(["scaffold", "--promote", "--name", name, "--target", "sibling"], dir); + + expect(run.exitCode).toBe(1); + expect(run.stderr).toContain("packager"); + expect(run.stderr).toContain("merge"); + expect(existsSync(path.join(dir, ".opencode", "skills", "alpha", "SKILL.md"))).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + await rm(sibling, { recursive: true, force: true }); + } + }, 60_000); + + test("verifyPayload fails on a manifest hash mismatch and passes on matching content", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-verify-")); + try { + await mkdir(path.join(dir, ".opencode", "skills", "alpha"), { recursive: true }); + const skillPath = path.join(dir, ".opencode", "skills", "alpha", "SKILL.md"); + await writeFile(skillPath, "---\nname: \"alpha\"\n---\n\nalpha.\n"); + await writeFile( + path.join(dir, ".opencode", "opencode.json"), + JSON.stringify({ plugins: ["opencode-mytool@latest"] }), + ); + await writeFile( + path.join(dir, ".opencode", "opencode-mytool.manifest.json"), + JSON.stringify({ version: "0.1.0", mode: "copy", files: { "skills/alpha/SKILL.md": await sha256(skillPath) } }), + ); + const promoter = new Promoter(); + + expect(await promoter.verifyPayload(dir, "opencode-mytool")).toBeNull(); + + await writeFile(skillPath, "---\nname: \"alpha\"\n---\n\nconsumer-edited.\n"); + const mismatch = await promoter.verifyPayload(dir, "opencode-mytool"); + expect(mismatch).toContain("hash mismatch"); + expect(mismatch).toContain("skills/alpha/SKILL.md"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("the removal plan lists a non-identical copy-mode original for removal and an identical one as managed", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-removal-")); + try { + await mkdir(path.join(dir, ".opencode", "skills", "alpha"), { recursive: true }); + const original = path.join(dir, ".opencode", "skills", "alpha", "SKILL.md"); + await writeFile(original, "original content\n"); + await mkdir(path.join(dir, "pkg", "skills", "alpha"), { recursive: true }); + await writeFile(path.join(dir, "pkg", "skills", "alpha", "SKILL.md"), "original content\n"); + await mkdir(path.join(dir, ".opencode", "skills", "beta"), { recursive: true }); + const modified = path.join(dir, ".opencode", "skills", "beta", "SKILL.md"); + await writeFile(modified, "consumer-modified content\n"); + await mkdir(path.join(dir, "pkg", "skills", "beta"), { recursive: true }); + await writeFile(path.join(dir, "pkg", "skills", "beta", "SKILL.md"), "packaged content\n"); + + const plan = await new Promoter().removalPlan( + path.join(dir, ".opencode"), + path.join(dir, "pkg"), + [ + { sourcePath: original, relativePath: "skills/alpha/SKILL.md" }, + { sourcePath: modified, relativePath: "skills/beta/SKILL.md" }, + ], + "copy", + ); + + expect(plan.managed).toContain("skills/alpha/SKILL.md"); + expect(plan.removable).toContain("skills/beta/SKILL.md"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/status-effective-version.test.ts b/tests/status-effective-version.test.ts index 7e10754..3a01015 100644 --- a/tests/status-effective-version.test.ts +++ b/tests/status-effective-version.test.ts @@ -3,43 +3,41 @@ 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 { ConfigReader } from "../src/core/config-reader"; +import { EntryPredicate } from "../src/core/entry-predicate"; +import type { Environment } from "../src/core/environment"; import { Installer } from "../src/installer"; -import { LoadedVersionResolver } from "../src/loaded-version"; -import { StatusReporter } from "../src/status-reporter"; +import { LoadedVersionResolver } from "../src/core/loaded-version"; +import { NpmCache } from "../src/core/npm-cache"; +import { StatusReporter } from "../src/core/status-reporter"; +import { fakeEnvironment } from "./test-helpers"; let projectDir = ""; let configDir = ""; let cacheDir = ""; -let originalXdgConfig: string | undefined; -let originalXdgCache: string | undefined; -const installer = new Installer(); -const reporter = new StatusReporter(installer); +let installer: Installer; +let reporter: StatusReporter; +let resolver: LoadedVersionResolver; + +function isolatedEnvironment(): Environment { + return fakeEnvironment({ vars: { XDG_CONFIG_HOME: configDir, XDG_CACHE_HOME: cacheDir } }); +} 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; + installer = new Installer(null, rm, isolatedEnvironment()); + reporter = new StatusReporter(installer, isolatedEnvironment()); + resolver = new LoadedVersionResolver(new NpmCache(isolatedEnvironment())); }); 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"); } @@ -74,8 +72,6 @@ async function writeLocalManifest(version: string, configPath: string | null): P } describe("LoadedVersionResolver classification", () => { - const resolver = new LoadedVersionResolver(); - test("classifies npm specs, pinning, scopes, and defaults", () => { expect(resolver.classify("opencode-architect@latest")).toMatchObject({ form: "npm", @@ -115,16 +111,16 @@ describe("LoadedVersionResolver classification", () => { }); }); -describe("EntrySpec", () => { +describe("EntryPredicate", () => { 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(); + expect(EntryPredicate.specOf({ package: " a@1 " })).toBe("a@1"); + expect(EntryPredicate.specOf(42)).toBeNull(); + expect(EntryPredicate.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"); + expect(EntryPredicate.baseName("@scope/pkg@1.0.0")).toBe("@scope/pkg"); + expect(EntryPredicate.baseName("pkg")).toBe("pkg"); }); }); @@ -140,8 +136,6 @@ describe("StatusReporter.formatAge", () => { }); 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"); @@ -211,7 +205,7 @@ describe("LoadedVersionResolver cache resolution", () => { }); }); -describe("ConfigEntriesReader", () => { +describe("ConfigReader.entries", () => { test("reads raw entries from a jsonc config with comments and trailing commas", async () => { await writeConfig( "local", @@ -219,31 +213,43 @@ describe("ConfigEntriesReader", () => { '{\n // keep\n "plugins": ["other", { "package": "opencode-architect@latest" },],\n}\n', ); - const entries = await new ConfigEntriesReader().read("local", projectDir, false); + const entries = await new ConfigReader().entries("local", projectDir); expect(entries).toHaveLength(1); expect(entries[0]?.rawEntries).toContain("other"); expect(entries[0]?.rawEntries).toContainEqual({ package: "opencode-architect@latest" }); + expect(entries[0]?.parseError).toBeNull(); + }); + + test("reports candidates with path, leniency, writability, and legacy key entries", async () => { + await writeConfig( + "local", + "opencode.json", + '{ "plugins": ["a"], "plugin": ["legacy"] }', + ); + + const entries = await new ConfigReader().entries("local", projectDir); + + const json = entries.find((candidate) => candidate.path.endsWith("opencode.json")); + expect(json?.lenient).toBe(false); + expect(json?.writable).toBe(true); + expect(json?.rawEntries).toEqual(["a", "legacy"]); }); 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; - } + const warn = (message: string) => warnings.push(message); + const entries = await new ConfigReader().entries("local", projectDir, warn); + expect(entries[0]?.parseError).not.toBeNull(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("could not be parsed"); }); }); describe("StatusReporter registration detection", () => { test("reports none for an empty scope", async () => { - const report = await reporter.report(projectDir, { scopes: ["local"], online: false }); + const report = await reporter.report(projectDir, { scopes: ["local"], online: false, packageName: "opencode-architect" }); expect(report.scopes[0]?.registered).toBe(false); expect(report.scopes[0]?.mode).toBe("none"); @@ -255,7 +261,7 @@ describe("StatusReporter registration detection", () => { 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 }); + const report = await reporter.report(projectDir, { scopes: ["local"], online: false, packageName: "opencode-architect" }); expect(report.scopes[0]?.registered).toBe(true); expect(report.scopes[0]?.mode).toBe("plugin"); @@ -267,7 +273,7 @@ describe("StatusReporter registration detection", () => { const configPath = await writeConfig("local", "opencode.json", "{}\n"); await writeLocalManifest("9.9.9", configPath); - const report = await reporter.report(projectDir, { scopes: ["local"], online: false }); + const report = await reporter.report(projectDir, { scopes: ["local"], online: false, packageName: "opencode-architect" }); expect(report.scopes[0]?.registered).toBe(false); expect(report.scopes[0]?.manifestVersion).toBe("9.9.9"); @@ -277,7 +283,7 @@ describe("StatusReporter registration detection", () => { 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 }); + const report = await reporter.report(projectDir, { scopes: ["local"], online: false, packageName: "opencode-architect" }); expect(report.scopes[0]?.registered).toBe(true); expect(report.scopes[0]?.manifestVersion).toBeNull(); @@ -288,7 +294,7 @@ describe("StatusReporter registration detection", () => { 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 }); + const report = await reporter.report(projectDir, { scopes: ["local"], online: false, packageName: "opencode-architect" }); expect(report.scopes[0]?.registered).toBe(true); expect(report.scopes[0]?.entryForm).toBe("path"); @@ -298,7 +304,7 @@ describe("StatusReporter registration detection", () => { const entry = pathToFileURL(path.resolve(import.meta.dirname, "..")).href; await writeConfig("local", "opencode.json", `{ "plugins": ["${entry}"] }\n`); - const report = await reporter.report(projectDir, { scopes: ["local"], online: false }); + const report = await reporter.report(projectDir, { scopes: ["local"], online: false, packageName: "opencode-architect" }); expect(report.scopes[0]?.registered).toBe(true); expect(report.scopes[0]?.entryForm).toBe("path"); @@ -314,7 +320,7 @@ describe("StatusReporter registration detection", () => { const relative = path.relative(path.join(projectDir, ".opencode"), checkout).replaceAll("\\", "/"); await writeConfig("local", "opencode.json", `{ "plugins": ["${relative}"] }\n`); - const report = await reporter.report(projectDir, { scopes: ["local"], online: false }); + const report = await reporter.report(projectDir, { scopes: ["local"], online: false, packageName: "opencode-architect" }); expect(report.scopes[0]?.registered).toBe(true); expect(report.scopes[0]?.resolved?.version).toBe("3.4.5"); @@ -324,7 +330,7 @@ describe("StatusReporter registration detection", () => { 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 }); + const report = await reporter.report(projectDir, { scopes: ["local"], online: false, packageName: "opencode-architect" }); expect(report.scopes[0]?.registered).toBe(true); }); @@ -380,7 +386,7 @@ describe("StatusReporter.report", () => { 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 report = await reporter.report(projectDir, { scopes: null, online: false, packageName: "opencode-architect" }); const local = report.scopes.find((scope) => scope.scope === "local"); expect(local?.registered).toBe(true); @@ -394,7 +400,7 @@ describe("StatusReporter.report", () => { 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 }); + const report = await reporter.report(projectDir, { scopes: null, online: false, packageName: "opencode-architect" }); expect(report.effective.version).toBe("1.0.0"); expect(report.effective.versionKind).toBe("spec"); @@ -405,7 +411,7 @@ describe("StatusReporter.report", () => { 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 }); + const report = await new StatusReporter(installer, { ...isolatedEnvironment(), fetch: stubFetch }).report(projectDir, { scopes: null, online: true, packageName: "opencode-architect" }); expect(report.effective.version).toBe("2.0.0"); expect(report.effective.versionKind).toBe("npm"); @@ -414,7 +420,7 @@ describe("StatusReporter.report", () => { 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 }); + const report = await reporter.report(projectDir, { scopes: null, online: false, packageName: "opencode-architect" }); expect(report.effective.version).toBeNull(); expect(report.effective.versionKind).toBeNull(); @@ -445,7 +451,7 @@ describe("StatusReporter.report", () => { 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 }); + const report = await reporter.report(projectDir, { scopes: null, online: false, packageName: "opencode-architect" }); expect(report.scopes.filter((scope) => scope.registered)).toHaveLength(2); expect(report.warnings.join("\n")).toContain("double-load risk"); @@ -457,7 +463,7 @@ describe("StatusReporter.report", () => { 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 }); + const report = await reporter.report(projectDir, { scopes: null, online: false, packageName: "opencode-architect" }); expect(report.scopes[0]?.warnings.join("\n")).toContain("manifest records 1.0.0"); }); @@ -465,7 +471,7 @@ describe("StatusReporter.report", () => { 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 }); + const report = await reporter.report(projectDir, { scopes: null, online: false, packageName: "opencode-architect" }); expect(report.scopes[0]?.warnings.join("\n")).toContain("no cached copy"); }); @@ -475,7 +481,7 @@ describe("StatusReporter.report", () => { 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 }); + const report = await reporter.report(projectDir, { scopes: ["global"], online: false, packageName: "opencode-architect" }); expect(report.scopes).toHaveLength(1); expect(report.scopes[0]?.scope).toBe("global"); @@ -488,7 +494,7 @@ describe("StatusReporter.report", () => { 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 }); + const report = await new StatusReporter(installer, { ...isolatedEnvironment(), fetch: stubFetch }).report(projectDir, { scopes: null, online: true, packageName: "opencode-architect" }); expect(report.scopes[0]?.publishedVersion).toBe("2.0.0"); expect(report.effective.latestVersion).toBe("2.0.0"); @@ -507,7 +513,7 @@ describe("StatusReporter.report", () => { 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 }); + const report = await new StatusReporter(installer, { ...isolatedEnvironment(), fetch: stubFetch }).report(projectDir, { scopes: null, online: true, packageName: "opencode-architect" }); expect(report.scopes[0]?.entryForm).toBe("path"); expect(report.scopes[0]?.publishedVersion).toBe("2.0.0"); @@ -520,10 +526,12 @@ describe("StatusReporter.report", () => { 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 }); + const report = await new StatusReporter(installer, { ...isolatedEnvironment(), fetch: failingFetch }).report(projectDir, { scopes: null, online: true, packageName: "opencode-architect" }); expect(report.scopes[0]?.publishedVersion).toBeNull(); expect(report.effective.version).toBe("1.0.0"); expect(report.warnings.join("\n")).toContain("could not query npm registry"); }); }); + + diff --git a/tests/sync-table.test.ts b/tests/sync-table.test.ts new file mode 100644 index 0000000..e218d91 --- /dev/null +++ b/tests/sync-table.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { ScaffoldRenderer, fileDependency, type ShipKind } from "../src/scaffold/renderer"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); +const TEMPLATES_DIR = path.join(REPO_ROOT, "templates"); +const TABLE_DOC = path.join(REPO_ROOT, "references", "template-sync-table.md"); + +const TEMPLATE_SEPARATOR = "---"; + +function templateBody(source: string): string { + const separator = source.match(/^---$/m); + if (separator === null || separator.index === undefined) return source; + return source.slice(separator.index + TEMPLATE_SEPARATOR.length + 1).trimStart(); +} + +async function readTemplate(name: string): Promise { + return templateBody(await readFile(path.join(TEMPLATES_DIR, name), "utf-8")); +} + +async function readSource(relativePath: string): Promise { + return readFile(path.join(REPO_ROOT, relativePath), "utf-8"); +} + +type SyncState = "byte-synced" | "declared-divergent" | "fixture-covered"; + +interface SyncPair { + template: string; + /** Suite-side counterpart path (byte-synced, declared-divergent) or the rendered target the fixture exercises (fixture-covered). */ + counterpart: string; + state: SyncState; + /** For declared-divergent rows: the policy difference the tests assert. */ + policy?: string; +} + +/** + * The sync table: the one classification of every mirrored template↔suite pair. + * Mirrored into references/template-sync-table.md — the test below fails when + * the doc and this table disagree, so "are we in sync?" has a single answer. + * + * Since the core carve-out (ADR-0013) the machinery templates are gone: the + * generated package imports the machinery from the `opencode-architect/core` + * dependency, and what remains are adapter renderings the scaffold renderer + * emits. + */ +const SYNC_TABLE: SyncPair[] = [ + // --- byte-synced: the test asserts source === template body --- + { template: "index.template.txt", counterpart: "index.ts", state: "byte-synced" }, + + // --- declared-divergent: the test asserts the named policy difference --- + { + template: "cli.template.txt", + counterpart: "src/cli.ts", + state: "declared-divergent", + policy: + "suite-only broad clear-cache modes (--package/--all/--yes/--dry-run) and status --package lookup; generated-only migrate subcommand; both are thin adapters over the core dependency", + }, + { + template: "installer.template.txt", + counterpart: "src/installer.ts", + state: "declared-divergent", + policy: + "suite installer is an Installer class orchestrating the suite payload; the generated installer is a function-style adapter whose policy constants (package, skill, command, asset layout) sit over the core dependency", + }, + { + template: "package-full.template.json", + counterpart: "templates/package-basics.template.json", + state: "declared-divergent", + policy: + "publisher-only npm-field expansion of package-basics (repository, publisher, author, bugs, license); the content declaration and exports carry through unchanged", + }, + + // --- fixture-covered: the rendered fixture (tests/templates.test.ts) exercises the file --- + { template: "plugin-local.template.txt", counterpart: "src/plugin.ts", state: "fixture-covered" }, + { template: "skill-structure.template.md", counterpart: "skills//SKILL.md", state: "fixture-covered" }, + { template: "package-basics.template.json", counterpart: "package.json", state: "fixture-covered" }, + { template: "tsconfig.template.json", counterpart: "tsconfig.json", state: "fixture-covered" }, +]; + +/** Machinery that used to be vendored through templates and now comes from the core dependency (ADR-0013). */ +const CORE_PROVIDED = [ + { template: "plugin-name.template.txt", formerTarget: "src/plugin-name.ts" }, + { template: "plugin-entry.template.txt", formerTarget: "src/plugin-entry.ts" }, + { template: "entry-predicate.template.txt", formerTarget: "src/entry-predicate.ts" }, + { template: "manifest.template.txt", formerTarget: "src/manifest.ts" }, + { template: "registration.template.txt", formerTarget: "src/registration.ts" }, + { template: "plugin-config.template.txt", formerTarget: "src/plugin-config.ts" }, +] as const; + +const RENDER_PLAN = { + name: "opencode-myextension", + ship: ["skills", "commands"] as ShipKind[], + coreDependency: fileDependency( + path.join(REPO_ROOT, ".rendered-fixture", "opencode-myextension"), + REPO_ROOT, + ), +}; + +describe("template sync table", () => { + describe("byte-synced pairs are byte-identical", () => { + for (const pair of SYNC_TABLE.filter((row) => row.state === "byte-synced")) { + test(`templates/${pair.template} === ${pair.counterpart}`, async () => { + const [body, source] = await Promise.all([readTemplate(pair.template), readSource(pair.counterpart)]); + expect(source).toBe(body); + }); + } + }); + + describe("declared-divergent pairs diverge exactly as the policy names", () => { + test("cli: suite-only broad clear-cache and status --package lookup; generated-only migrate", async () => { + const [suite, generated] = await Promise.all([ + readSource("src/cli.ts"), + readTemplate("cli.template.txt"), + ]); + // Suite-only: broad clear-cache modes and the status --package lookup. + expect(suite).toContain("values.all"); + expect(suite).toContain(`values["dry-run"]`); + expect(suite).toContain("values.package"); + // Generated-only: the consent-gated migrate subcommand. + expect(generated).toContain(`case "migrate"`); + expect(suite).not.toContain(`case "migrate"`); + // Generated clear-cache stays self-only: no broad flags reach its parser. + expect(generated).not.toContain("values.all"); + expect(generated).not.toContain(`values["dry-run"]`); + }); + + test("installer: suite is an Installer class, generated is an adapter over the core dependency", async () => { + const [suite, generated] = await Promise.all([ + readSource("src/installer.ts"), + readTemplate("installer.template.txt"), + ]); + expect(suite).toContain("export class Installer"); + expect(generated).not.toContain("class Installer"); + expect(generated).toContain("export async function install("); + expect(generated).toContain("export async function uninstall("); + // The adapter declares policy and delegates machinery to the core dependency. + expect(generated).toContain('from "opencode-architect/core"'); + expect(generated).not.toContain("class InstallManifest"); + expect(generated).not.toContain("class PluginConfigEditor"); + }); + + test("package-full expands package-basics with npm fields, carrying the declaration through", async () => { + const [basics, full] = await Promise.all([ + readTemplate("package-basics.template.json"), + readTemplate("package-full.template.json"), + ]); + expect(basics).not.toContain('"repository"'); + for (const npmField of ['"repository"', '"bugs"', '"license"', '"author"']) { + expect(full).toContain(npmField); + } + // The declaration and exports carry through unchanged. + for (const carried of ['"content": "assets"', '"./server": "./index.ts"', '"@opencode/plugin": "latest"']) { + expect(basics).toContain(carried); + expect(full).toContain(carried); + } + }); + }); + + describe("fixture-covered pairs are exercised by the rendered fixture", () => { + const rendered = new ScaffoldRenderer().render(RENDER_PLAN); + + for (const pair of SYNC_TABLE.filter((row) => row.state === "fixture-covered")) { + test(`the scaffold renderer emits ${pair.counterpart} from templates/${pair.template}`, () => { + const targets = rendered.map((file) => file.relativePath); + // `` rows render with the plan's identifier substituted. + expect(targets).toContain(pair.counterpart.replaceAll("", "myextension")); + }); + } + }); + + describe("machinery pairs dissolved into the core dependency (ADR-0013)", () => { + const rendered = new ScaffoldRenderer().render(RENDER_PLAN); + + for (const row of CORE_PROVIDED) { + test(`templates/${row.template} no longer exists and ${row.formerTarget} is not rendered`, async () => { + await expect(readFile(path.join(TEMPLATES_DIR, row.template), "utf-8")).rejects.toThrow(); + expect(rendered.map((file) => file.relativePath)).not.toContain(row.formerTarget); + }); + } + + test("the rendered package.json depends on opencode-architect through a local file spec", () => { + const manifest = JSON.parse( + rendered.find((file) => file.relativePath === "package.json")?.content ?? "{}", + ) as { dependencies: Record }; + expect(manifest.dependencies["opencode-architect"]).toMatch(/^file:(\.\/|\.\.\/)/); + }); + }); + + test("references/template-sync-table.md mirrors this table", async () => { + const doc = await readFile(TABLE_DOC, "utf-8"); + for (const pair of SYNC_TABLE) { + const rowLines = doc.split("\n").filter((line) => line.includes(`\`${pair.template}\``)); + expect(rowLines.length, `the doc must have a row for ${pair.template}`).toBeGreaterThan(0); + const row = rowLines.join("\n"); + expect(row).toContain(pair.counterpart); + expect(row).toContain(pair.state); + } + // The doc names every template in the templates/ directory — nothing + // unclassified: the dissolved rows appear in the core-provided section. + for (const row of CORE_PROVIDED) { + expect(doc).toContain(row.template); + } + expect(doc).toContain("prompts.template.txt"); + }); +}); diff --git a/tests/templates.test.ts b/tests/templates.test.ts index 8382065..d553742 100644 --- a/tests/templates.test.ts +++ b/tests/templates.test.ts @@ -1,33 +1,29 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import { DocsFactGate } from "./docs-fact-gate"; -import { ConfigSchemaValidator } from "./config-schema-validator"; -import { V2HostHarness } from "./v2-host-harness"; - -const REPO_ROOT = path.resolve(import.meta.dirname, ".."); -const TEMPLATES_DIR = path.join(REPO_ROOT, "templates"); -const RENDER_ROOT = path.join(REPO_ROOT, ".rendered-fixture"); -const RENDERED_PACKAGE = path.join(RENDER_ROOT, "opencode-myextension"); -const PACKAGE_NAME = "opencode-myextension"; - -const TEMPLATE_FILES = [ - "cli.template.txt", - "index.template.txt", - "installer.template.txt", - "manifest.template.txt", - "package-basics.template.json", - "package-full.template.json", - "plugin-config.template.txt", - "plugin-entry.template.txt", - "plugin-local.template.txt", - "plugin-name.template.txt", - "prompts.template.txt", - "registration.template.txt", - "skill-structure.template.md", - "tsconfig.template.json", +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { DocsFactGate } from "./docs-fact-gate"; +import { ConfigSchemaValidator } from "./config-schema-validator"; +import { V2HostHarness } from "./v2-host-harness"; +import { ScaffoldRenderer, fileDependency } from "../src/scaffold/renderer"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); +const TEMPLATES_DIR = path.join(REPO_ROOT, "templates"); +const RENDER_ROOT = path.join(REPO_ROOT, ".rendered-fixture"); +const RENDERED_PACKAGE = path.join(RENDER_ROOT, "opencode-myextension"); +const PACKAGE_NAME = "opencode-myextension"; + +const TEMPLATE_FILES = [ + "cli.template.txt", + "index.template.txt", + "installer.template.txt", + "package-basics.template.json", + "package-full.template.json", + "plugin-local.template.txt", + "prompts.template.txt", + "skill-structure.template.md", + "tsconfig.template.json", ] as const; const gate = new DocsFactGate(REPO_ROOT); @@ -42,46 +38,29 @@ function templateBody(name: string): Promise { if (separator === null || separator.index === undefined) return source; return source.slice(separator.index + TEMPLATE_SEPARATOR.length + 1).trimStart(); }); -} - -const SMOKE_TEST = `import { describe, expect, test } from "bun:test"; -import plugin from "../index.ts"; - -describe("${PACKAGE_NAME}", () => { - test("exports a v2 Effect plugin definition", () => { - const definition = plugin as { id: string; effect: unknown }; - expect(definition.id).toBe("${PACKAGE_NAME}"); - expect(typeof definition.effect).toBe("function"); - }); -}); -`; - -async function renderPackage(): Promise { - const write = async (relative: string, content: string): Promise => { - const target = path.join(RENDERED_PACKAGE, relative); - await mkdir(path.dirname(target), { recursive: true }); - await writeFile(target, content); - }; - await rm(RENDER_ROOT, { recursive: true, force: true }); - await write("index.ts", await templateBody("index.template.txt")); - await write("src/plugin.ts", await templateBody("plugin-local.template.txt")); - await write("src/plugin-name.ts", await templateBody("plugin-name.template.txt")); - await write("src/manifest.ts", await templateBody("manifest.template.txt")); - await write("src/registration.ts", await templateBody("registration.template.txt")); - await write("src/plugin-config.ts", await templateBody("plugin-config.template.txt")); - await write("src/plugin-entry.ts", await templateBody("plugin-entry.template.txt")); - await write("src/installer.ts", await templateBody("installer.template.txt")); - await write("src/cli.ts", await templateBody("cli.template.txt")); - await write("package.json", await templateBody("package-basics.template.json")); - await write("tsconfig.json", await templateBody("tsconfig.template.json")); - await write("skills/myextension/SKILL.md", await templateBody("skill-structure.template.md")); - await write( - "commands/my-command.md", - `---\ndescription: "Rendered command stub"\n---\n\nSay hello from ${PACKAGE_NAME}.\n`, - ); - await write("tests/plugin.contract.test.ts", SMOKE_TEST); -} - +} + +async function renderPackage(): Promise { + const rendered = new ScaffoldRenderer().render({ + name: PACKAGE_NAME, + ship: ["skills", "commands"], + coreDependency: fileDependency(RENDERED_PACKAGE, REPO_ROOT), + }); + await rm(RENDER_ROOT, { recursive: true, force: true }); + for (const file of rendered) { + const target = path.join(RENDERED_PACKAGE, file.relativePath); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, file.content); + } + // Link the core dependency the way a package manager links a `file:` + // dependency — no registry access, no network. Every gate below (typecheck, + // the package's own tests, install/status/uninstall) then resolves + // `opencode-architect/core` through this dependency link. + const linkDir = path.join(RENDERED_PACKAGE, "node_modules"); + await mkdir(linkDir, { recursive: true }); + await symlink(REPO_ROOT, path.join(linkDir, "opencode-architect"), process.platform === "win32" ? "junction" : "dir"); +} + interface SpawnOutcome { exitCode: number; stdout: string; @@ -206,28 +185,35 @@ describe("rendered package passes its own gates", () => { expect(check.id).toBe(PACKAGE_NAME); }); - test("install --mode plugin registers the package in the v2 plugins key", async () => { - const outcome = await runBun( - [path.join(RENDERED_PACKAGE, "src", "cli.ts"), "install", "--scope", "local", "--mode", "plugin"], - projectDir, - isolatedEnv(isolatedBase), - ); - if (outcome.exitCode !== 0) failWithOutput("rendered package install", outcome); - expect(outcome.stdout).toContain("mode: plugin"); - - const configPath = path.join(projectDir, "opencode.jsonc"); - const configText = await readFile(configPath, "utf-8"); - const verdict = validator.validateText(configText, true); - expect(verdict.ok, `created config must decode against the v2 schema: ${verdict.issue}`).toBe(true); - expect(verdict.config?.plugins).toEqual([`${PACKAGE_NAME}@latest`]); - - const manifest = JSON.parse( - await readFile(path.join(projectDir, ".opencode", `${PACKAGE_NAME}.manifest.json`), "utf-8"), - ) as { mode: string; entry: string; entryConfigPath: string }; - expect(manifest.mode).toBe("plugin"); - expect(manifest.entry).toBe(`${PACKAGE_NAME}@latest`); - expect(manifest.entryConfigPath.replaceAll("\\", "/")).toContain("opencode.jsonc"); - }, 60_000); + test("install --mode plugin registers the package in the v2 plugins key", async () => { + const outcome = await runBun( + [path.join(RENDERED_PACKAGE, "src", "cli.ts"), "install", "--scope", "local", "--mode", "plugin"], + projectDir, + isolatedEnv(isolatedBase), + ); + if (outcome.exitCode !== 0) failWithOutput("rendered package install", outcome); + expect(outcome.stdout).toContain("mode: plugin"); + + const configPath = path.join(projectDir, "opencode.jsonc"); + const configText = await readFile(configPath, "utf-8"); + const verdict = validator.validateText(configText, true); + expect(verdict.ok, `created config must decode against the v2 schema: ${verdict.issue}`).toBe(true); + expect(verdict.config?.plugins).toEqual([`${PACKAGE_NAME}@latest`]); + + const manifest = JSON.parse( + await readFile(path.join(projectDir, ".opencode", `${PACKAGE_NAME}.manifest.json`), "utf-8"), + ) as { mode: string; entry: string; entryConfigPath: string }; + expect(manifest.mode).toBe("plugin"); + expect(manifest.entry).toBe(`${PACKAGE_NAME}@latest`); + expect(manifest.entryConfigPath.replaceAll("\\", "/")).toContain("opencode.jsonc"); + }, 60_000); + + test("the rendered package.json depends on the core module through a local file spec", async () => { + const manifest = JSON.parse( + await readFile(path.join(RENDERED_PACKAGE, "package.json"), "utf-8"), + ) as { dependencies: Record }; + expect(manifest.dependencies["opencode-architect"]).toMatch(/^file:(\.\/|\.\.\/)/); + }); test("a live path-form entry prevents a duplicate name entry", async () => { const pathProject = path.join(RENDER_ROOT, "path-consumer"); @@ -281,33 +267,18 @@ describe("rendered package passes its own gates", () => { expect(Object.keys(manifest.files).length).toBeGreaterThan(0); }); - test("uninstall removes the registration and leaves a parseable config", async () => { - const outcome = await runBun( - [path.join(RENDERED_PACKAGE, "src", "cli.ts"), "uninstall"], - projectDir, - isolatedEnv(isolatedBase), - ); - if (outcome.exitCode !== 0) failWithOutput("rendered package uninstall", outcome); - expect(outcome.stdout).toContain("Removed plugin registration"); - - const configText = await readFile(path.join(projectDir, "opencode.jsonc"), "utf-8"); - const verdict = validator.validateText(configText, true); - expect(verdict.ok).toBe(true); - expect(verdict.config?.plugins ?? []).not.toContain(`${PACKAGE_NAME}@latest`); - }, 60_000); -}); - -describe("suite src mirrors template bodies byte-for-byte", () => { - const SYNCED_PAIRS = [ - { template: "plugin-name.template.txt", source: "plugin-name.ts" }, - { template: "plugin-entry.template.txt", source: "plugin-entry.ts" }, - ] as const; - - for (const pair of SYNCED_PAIRS) { - test(`templates/${pair.template} === src/${pair.source}`, async () => { - const body = await templateBody(pair.template); - const source = await readFile(path.join(REPO_ROOT, "src", pair.source), "utf-8"); - expect(source).toBe(body); - }); - } + test("uninstall removes the registration and leaves a parseable config", async () => { + const outcome = await runBun( + [path.join(RENDERED_PACKAGE, "src", "cli.ts"), "uninstall"], + projectDir, + isolatedEnv(isolatedBase), + ); + if (outcome.exitCode !== 0) failWithOutput("rendered package uninstall", outcome); + expect(outcome.stdout).toContain("Removed plugin registration"); + + const configText = await readFile(path.join(projectDir, "opencode.jsonc"), "utf-8"); + const verdict = validator.validateText(configText, true); + expect(verdict.ok).toBe(true); + expect(verdict.config?.plugins ?? []).not.toContain(`${PACKAGE_NAME}@latest`); + }, 60_000); }); diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts index 320487f..4a9c833 100644 --- a/tests/test-helpers.ts +++ b/tests/test-helpers.ts @@ -2,6 +2,22 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, spyOn } from "bun:test"; import { ClearCacheUsageError } from "../src/clear-cache-usage-error"; +import { realEnvironment, type Environment } from "../src/core/environment"; + +export interface FakeEnvironmentOptions { + vars: Record; + warn: ((message: string) => void) | null; + fetch: typeof fetch | null; +} + +export function fakeEnvironment(options: Partial = {}): Environment { + const vars = options.vars ?? {}; + return { + env: (name) => vars[name], + warn: options.warn ?? realEnvironment.warn, + fetch: options.fetch ?? realEnvironment.fetch, + }; +} export interface CapturedConsole { lines: string[];