From 9ef2360082ae5c26481f25292d4f698099e69a35 Mon Sep 17 00:00:00 2001 From: trick77 Date: Wed, 26 Aug 2026 21:44:30 +0200 Subject: [PATCH 1/2] feat: supersede stacked plugin versions, pin litellm-pricing 0.9.0 append deduped by deep equality, so every pinned bump added a second entry and opencode loaded the plugin at both versions. An incoming `name@spec` entry now replaces every existing entry with the same package name, in place, and a config that already stacked several bumps collapses on the next install. Non-`name@spec` entries (cache fetch dests, prompted dirs, git URLs with credentials) stay plain append. remove is unchanged, still exact-match only. plugin-litellm-pricing 0.9.0 drops options.catalogURL: pricing now comes from the proxy itself, so provider-litellm no longer prompts for a catalog URL and the README section is rewritten around it. Re-running provider-litellm is what clears a stale catalogURL out of an existing config. --- AGENTS.md | 10 +++++ README.md | 50 +++++++++--------------- presets/plugin-litellm-pricing.conf | 14 +++---- presets/provider-litellm.conf | 14 +++---- src/batch.ts | 3 ++ src/merge.ts | 42 +++++++++++++++++++- test/builtin-presets.test.ts | 20 +++------- test/merge.test.ts | 59 +++++++++++++++++++++++++++++ 8 files changed, 146 insertions(+), 66 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7dd681d..6d5e030 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,16 @@ Body is JSONC, must parse to JSON matching the `@path` leaf: any shape for `replace`, object for `merge`/`merge-overwrite`, array for `append`. Substitutions in body and `@path`: `{{cache}}`, `{{prompt:}}`. +## Append supersedes a version bump + +`append` dedupes by deep equality, so `pkg@0.8.1` and `pkg@0.9.0` are two +entries and opencode loads the plugin twice. An incoming `name@spec` entry +therefore replaces every existing entry with the same package name, in place +(`specName` in `src/merge.ts`), and stacked configs collapse on next install. +Non-`name@spec` entries (`{{cache}}` fetch dests, prompted dirs, git URLs with +credentials) stay plain append — a heuristic there would delete unrelated +entries. `remove` is untouched: it still deletes only exact matches. + ## Merge stays additive `merge` MUST NOT overwrite existing keys — existing values win, only missing diff --git a/README.md b/README.md index 6606611..38e9425 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,8 @@ something outside your opencode config say so in their description. | `mcp-litellm-passthrough` | MCP | replace | Install `mcp-litellm` first — re-running it replaces `mcp.litellm` and drops these headers. Adds one `x-mcp--
` passthrough header to the `mcp.litellm` server so an upstream MCP server authenticates as you (run once per header) | | `mcp-playwright` | MCP | replace | Add the Playwright MCP server (local stdio via npx; pins `@playwright/mcp` 0.0.79) | | `mcp-vscode` | MCP | replace | Requires the `JuehangQin.vscode-mcp-server` extension installed, enabled and toggled active in VS Code first — this preset does not install it. Adds the VS Code MCP server via that extension (loopback HTTP, default port 3000) | -| `plugin-litellm-pricing` | Plugin | append | Install `provider-litellm` too — the catalog URL has no default and lives there, and without it the models are still discovered, just unpriced. Adds `opencode-plugin-litellm-pricing`: discovers a LiteLLM proxy's models at runtime and adds them to the picker with real per-model pricing from a LiteLLM-format model catalog instead of `$0` (pins `opencode-plugin-litellm-pricing` 0.8.1) | -| `provider-litellm` | Provider | replace | Point the `litellm` provider at your proxy URL for `plugin-litellm-pricing`, and name the model catalog it prices against — neither the plugin nor this preset has a default one (prompts for base URL, API key and catalog URL; no models list) | +| `plugin-litellm-pricing` | Plugin | append | Install `provider-litellm` too — without a `litellm` provider pointing at your proxy the plugin does nothing. Adds `opencode-plugin-litellm-pricing`: discovers a LiteLLM proxy's models at runtime and adds them to the picker with the proxy's own per-model pricing instead of `$0` (pins `opencode-plugin-litellm-pricing` 0.9.0) | +| `provider-litellm` | Provider | replace | Point the `litellm` provider at your proxy URL and key for `plugin-litellm-pricing`, which prices the models against that same proxy (prompts for base URL and API key; no models list) | | `plugin-superpowers` | Plugin | append | Add the Superpowers OpenCode plugin from `obra/superpowers` (brainstorming, plans, TDD, review workflows; pins tag `v6.3.0`) | | `plugin-dcg` | Plugin | append | Install the external `dcg` binary yourself first — `brew install dicklesworthstone/tap/dcg`; or `cargo install destructive_command_guard` wherever a Rust toolchain is available (dcg is not in nixpkgs, so on nix that means `nix-env -iA nixpkgs.cargo` then the cargo line, and `~/.cargo/bin` on `PATH`); or a signed release binary from [the releases page](https://github.com/Dicklesworthstone/destructive_command_guard/releases), which ships a `.sha256` and sigstore attestations per asset — the x86_64 Linux build is static musl, so it needs nothing from the distro. Upstream also has a curl-pipe `install.sh` if you like those. The install refuses while `dcg` is not on `PATH`, because without it the plugin warns once and every command runs unchecked. **Experimental** — the plugin is at 0.2.x and its behaviour can still change. Adds `opencode-plugin-dcg`: runs every bash command past `dcg` and blocks the destructive ones (pins `opencode-plugin-dcg` 0.2.0) | | `privacy-share-disabled` | Privacy | replace | In the bundle. Sets `share` to "disabled" so opencode never publishes a session, automatically or on command | @@ -182,42 +182,22 @@ opencode-presets reset mcp.openrag-tom ### Pricing a LiteLLM proxy -`plugin-litellm-pricing` has no built-in catalog URL: it fetches the model -catalog you name in `options.catalogURL` and nothing else. Name none and the -models are still discovered and injected — they just carry no cost, and the -startup log says so, naming the provider: - -``` -[litellm-pricing] provider "litellm" has no options.catalogURL — set it to a - model catalog in LiteLLM `model_prices_and_context_window.json` format; - every model will be injected without pricing. -``` - -`provider-litellm` prompts for that URL and writes it. It offers no default -either — a default here would put back the third-party host the plugin -deliberately does not reach for, one layer down — so a blank answer ends the -install rather than writing someone else's URL into your config. Two answers are -usual: - -``` -https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json -``` - -LiteLLM's published catalog, which covers the public model line. Or, if your -gateway serves an enriched copy — upstream's entries plus your own model names, -with their real context and pricing — name that instead: those models then price -by exact name rather than by substring against the public line. The URL is -repeated in the preset's description, so it is on screen when the prompt asks. - -Non-interactively, pass it in: +Pricing takes no configuration of its own. `plugin-litellm-pricing` reads each +model's cost, limits and capabilities from the proxy `provider-litellm` already +points at, so the base URL and key are the whole setup. The numbers are +LiteLLM's own resolved ones, your config-level `model_info` overrides included, +so what opencode displays is what the gateway bills. A model the proxy reports +no cost for is injected without a `cost` block rather than with a wrong one, and +the startup log names it: ```sh -opencode-presets install provider-litellm --set catalogURL=https://…/model_prices_and_context_window.json +grep litellm-pricing ~/.local/share/opencode/log/opencode.log ``` `provider-litellm` is a `replace` preset, so re-running it rewrites the whole `provider.litellm` block and re-prompts for the base URL and key as well — have -the key to hand. +the key to hand. Coming from an earlier install, re-run it once: that is what +clears the now-unused `options.catalogURL` out of your config. ### Checking commands with `dcg` @@ -368,7 +348,11 @@ on a readline for the next. rules so user edits stick around. - `append` — the preset's array entries are appended if missing; existing array entries are preserved. Use this for shared arrays - like `plugin`. + like `plugin`. An entry of the form `name@version` supersedes every + existing entry naming the same package, in place: bumping a pinned + plugin replaces the old pin instead of leaving both in the array for + opencode to load twice. A config that already stacked several bumps + collapses to the newest one on the next install. Re-installing is always safe: a no-op produces no backup and no write. diff --git a/presets/plugin-litellm-pricing.conf b/presets/plugin-litellm-pricing.conf index a835542..52c0029 100644 --- a/presets/plugin-litellm-pricing.conf +++ b/presets/plugin-litellm-pricing.conf @@ -1,13 +1,13 @@ // @name: plugin-litellm-pricing -// @description: Needs provider-litellm installed too — the catalog URL has no -// default and lives there, and without it the models arrive unpriced. Adds the +// @description: Needs provider-litellm installed too — without a litellm provider +// pointing at your proxy the plugin does nothing. Adds the // opencode-plugin-litellm-pricing plugin: discovers a LiteLLM proxy's models at -// runtime and adds them to the picker with real per-model pricing from a -// LiteLLM-format model catalog instead of $0. +// runtime and adds them to the picker with the proxy's own per-model pricing +// instead of $0. // @author: Jan -// @version: 0.9.2 +// @version: 0.10.0 // @path: plugin // @mode: append -// @pins: opencode-plugin-litellm-pricing 0.8.1 +// @pins: opencode-plugin-litellm-pricing 0.9.0 -["opencode-plugin-litellm-pricing@0.8.1"] +["opencode-plugin-litellm-pricing@0.9.0"] diff --git a/presets/provider-litellm.conf b/presets/provider-litellm.conf index 9a565a6..cab01d9 100644 --- a/presets/provider-litellm.conf +++ b/presets/provider-litellm.conf @@ -1,21 +1,17 @@ // @name: provider-litellm -// @description: Points opencode's litellm provider at your proxy URL and key, -// plus the model catalog to price against, for use with plugin-litellm-pricing. -// No default catalog: name your own, or LiteLLM's published one at -// https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json -// Declares no models — the plugin fills them in at runtime. +// @description: Points opencode's litellm provider at your proxy URL and key, for +// use with plugin-litellm-pricing, which prices the models against the same +// proxy. Declares no models — the plugin fills them in at runtime. // @author: Jan -// @version: 0.7.0 +// @version: 0.8.0 // @path: provider.litellm // @prompt: baseURL | text | LiteLLM proxy base URL (OpenAI-compatible /v1 endpoint) | http://localhost:4000/v1 // @prompt: apiKey | secret | LiteLLM proxy API key (virtual key or master key) -// @prompt: catalogURL | text | Model catalog URL in LiteLLM model_prices_and_context_window.json format — no default, see the description for the published one { "npm": "@ai-sdk/openai-compatible", "name": "LiteLLM (proxy)", "options": { "baseURL": "{{prompt:baseURL}}", - "apiKey": "{{prompt:apiKey}}", - "catalogURL": "{{prompt:catalogURL}}" + "apiKey": "{{prompt:apiKey}}" } } diff --git a/src/batch.ts b/src/batch.ts index f795fdc..519bbbc 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -473,6 +473,7 @@ function renderFooter( summary += c.dim(` (${m.stats.preservedBatch} dedup'd in batch)`); } if (m.stats.overwritten) summary += `, overwritten ${m.stats.overwritten}`; + if (m.stats.superseded) summary += `, superseded ${m.stats.superseded}`; } lines.push(' • ' + c.bold(m.meta.name) + c.meta(' — ') + summary); for (const line of shadowedDenyWarnings(m)) lines.push(line); @@ -483,12 +484,14 @@ function renderFooter( const leavesReplaced = modules.filter(m => m.stats && m.stats.mode === 'replace' && m.stats.replaced).length; const keysAdded = modules.reduce((n, m) => n + (m.stats?.added || 0), 0); const dedups = modules.reduce((n, m) => n + (m.stats?.preservedBatch || 0), 0); + const superseded = modules.reduce((n, m) => n + (m.stats?.superseded || 0), 0); lines.push(''); if (resetStats.length > 0) lines.push(' ' + c.dim('Resets applied: ') + resetsApplied); lines.push(' ' + c.dim('Leaves replaced: ') + leavesReplaced); lines.push(' ' + c.dim('Keys added: ') + keysAdded); if (dedups > 0) lines.push(' ' + c.dim('Duplicates skipped:') + ' ' + dedups); + if (superseded > 0) lines.push(' ' + c.dim('Older versions replaced:') + ' ' + superseded); if (backupPath) lines.push(' ' + c.dim('Backup: ') + backupPath); return lines.join('\n'); diff --git a/src/merge.ts b/src/merge.ts index ddb65bd..93ddd90 100644 --- a/src/merge.ts +++ b/src/merge.ts @@ -9,6 +9,9 @@ // keys. // 'append' — additive array merge: existing entries are preserved // and missing incoming entries are appended. Idempotent. +// An incoming `name@spec` entry supersedes every existing +// entry naming the same package, rather than stacking a +// second version of it alongside the first. export type MergeMode = 'replace' | 'merge' | 'merge-overwrite' | 'append'; @@ -17,6 +20,7 @@ export interface ApplyStats { added: number; preserved: number; overwritten: number; + superseded: number; replaced: boolean; } @@ -58,8 +62,26 @@ export function applyAtPath( return { next, stats }; } +// The package named by a `name@spec` append entry — `pkg@1.2.3`, +// `@scope/pkg@1.2.3`, `superpowers@git+https://…#v6.3.0`. opencode loads every +// entry in the array, so two specs naming the same package load that plugin +// twice at two versions; append mode uses this to supersede instead of stack. +// Anything that is not a `name@spec` string returns null and keeps the plain +// append behaviour: a `{{cache}}` fetch destination, a prompted directory, or a +// git URL carrying credentials (`git+https://user@host/…`), whose trailing `@` +// would otherwise split in the wrong place. +function specName(value: Json): string | null { + if (typeof value !== 'string') return null; + const at = value.lastIndexOf('@'); + // at === 0 is a bare scope (`@scope/pkg`), which names no version. + if (at <= 0 || at === value.length - 1) return null; + const name = value.slice(0, at); + if (name.includes('/') && !name.startsWith('@')) return null; + return name; +} + function combine(existing: Json, incoming: Json, mode: MergeMode): { value: Json; stats: ApplyStats } { - const stats: ApplyStats = { mode, added: 0, preserved: 0, overwritten: 0, replaced: false }; + const stats: ApplyStats = { mode, added: 0, preserved: 0, overwritten: 0, superseded: 0, replaced: false }; if (mode === 'append') { if (!Array.isArray(incoming)) { @@ -72,10 +94,26 @@ function combine(existing: Json, incoming: Json, mode: MergeMode): { value: Json for (const v of incoming) { if (target.some(existingValue => deepEqual(existingValue, v))) { stats.preserved++; - } else { + continue; + } + const name = specName(v); + const at = name === null ? -1 : target.findIndex(existingValue => specName(existingValue) === name); + if (at === -1) { target.push(v); stats.added++; + continue; + } + // Same package, different version. Overwrite in place so the entry keeps + // its position, and drop any further copies of the same package: a config + // that already stacked several bumps collapses to one on the next install. + target[at] = v; + for (let i = target.length - 1; i > at; i--) { + if (specName(target[i]) === name) { + target.splice(i, 1); + stats.superseded++; + } } + stats.superseded++; } return { value: target, stats }; } diff --git a/test/builtin-presets.test.ts b/test/builtin-presets.test.ts index 4d4fba3..9e59631 100644 --- a/test/builtin-presets.test.ts +++ b/test/builtin-presets.test.ts @@ -50,7 +50,7 @@ test('ships a litellm plugin preset that appends the runtime-discovery plugin', assert.equal(meta.name, 'plugin-litellm-pricing'); assert.equal(meta.path, 'plugin'); assert.equal(meta.mode, 'append'); - assert.deepEqual(body, ['opencode-plugin-litellm-pricing@0.8.1']); + assert.deepEqual(body, ['opencode-plugin-litellm-pricing@0.9.0']); }); test('ships a dcg plugin preset that appends the destructive-command guard plugin', async () => { @@ -152,23 +152,14 @@ test('ships a litellm provider preset that points at a proxy URL, no models', as assert.equal(meta.mode, 'replace'); // The key is prompted for as a secret (hidden input) and written into the - // config, matching how mcp-http handles header credentials. `catalogURL` is - // the model catalog the plugin prices against — context windows, modalities - // and costs — so anyone serving an enriched copy can point at that instead - // of LiteLLM's published file. - // `catalogURL` has no default, and that is asserted rather than assumed: - // plugin 0.6.0 removed DEFAULT_PRICE_TABLE_URL so it would never fetch a - // host its operator had not named, and a default here would have reinstated - // exactly that, one layer down. The cost is that a blank answer aborts the - // install (src/batch.ts) instead of quietly writing someone else's URL into - // your config — which is the intended trade. The published table is in the - // preset's @description, where it can be read and pasted. + // config, matching how mcp-http handles header credentials. Base URL and key + // are the whole configuration: plugin 0.9.0 prices against the same proxy, so + // there is nothing else to name. assert.deepEqual( meta.prompts.map((p) => ({ name: p.name, type: p.type, default: p.default })), [ { name: 'baseURL', type: 'text', default: 'http://localhost:4000/v1' }, { name: 'apiKey', type: 'secret', default: undefined }, - { name: 'catalogURL', type: 'text', default: undefined }, ], ); @@ -180,7 +171,6 @@ test('ships a litellm provider preset that points at a proxy URL, no models', as options: { baseURL: '{{prompt:baseURL}}', apiKey: '{{prompt:apiKey}}', - catalogURL: '{{prompt:catalogURL}}', }, }); }); @@ -244,7 +234,7 @@ test('records the pinned third-party version of every preset that installs one', 'jdtls-lombok': [{ name: 'lombok', version: '1.18.46' }], 'mcp-playwright': [{ name: '@playwright/mcp', version: '0.0.79' }], 'plugin-dcg': [{ name: 'opencode-plugin-dcg', version: '0.2.0' }], - 'plugin-litellm-pricing': [{ name: 'opencode-plugin-litellm-pricing', version: '0.8.1' }], + 'plugin-litellm-pricing': [{ name: 'opencode-plugin-litellm-pricing', version: '0.9.0' }], 'plugin-opencode-planify-german': [{ name: 'opencode-planify-german', version: '0.3.2' }], 'plugin-superpowers': [{ name: 'superpowers', version: '6.3.0' }], }; diff --git a/test/merge.test.ts b/test/merge.test.ts index f763094..28ef62d 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -100,6 +100,65 @@ describe('applyAtPath — append mode', () => { assert.equal(stats.preserved, 1); }); + // opencode loads every entry in `plugin`, so leaving an old `pkg@0.8.1` + // behind next to a new `pkg@0.9.0` loads the plugin twice at two versions. + test('supersedes an older version of the same package instead of stacking it', () => { + const root = { plugin: ['opencode-plugin-dcg@0.2.0', 'pricing@0.8.1'] }; + const { next, stats } = applyAtPath(root, 'plugin', ['pricing@0.9.0'], 'append'); + assert.deepEqual((next as any).plugin, ['opencode-plugin-dcg@0.2.0', 'pricing@0.9.0']); + assert.equal(stats.added, 0); + assert.equal(stats.superseded, 1); + }); + + test('collapses a config that already stacked several versions, keeping position', () => { + const root = { plugin: ['pricing@0.7.0', 'dcg@0.2.0', 'pricing@0.8.0', 'pricing@0.8.1'] }; + const { next, stats } = applyAtPath(root, 'plugin', ['pricing@0.9.0'], 'append'); + assert.deepEqual((next as any).plugin, ['pricing@0.9.0', 'dcg@0.2.0']); + assert.equal(stats.superseded, 3); + }); + + test('reinstalling the same version stays a preserved no-op', () => { + const root = { plugin: ['pricing@0.9.0'] }; + const { next, stats } = applyAtPath(root, 'plugin', ['pricing@0.9.0'], 'append'); + assert.deepEqual((next as any).plugin, ['pricing@0.9.0']); + assert.equal(stats.preserved, 1); + assert.equal(stats.superseded, 0); + }); + + test('matches scoped packages and git specs on the package name', () => { + const scoped = applyAtPath({ plugin: ['@scope/pkg@1.0.0'] }, 'plugin', ['@scope/pkg@2.0.0'], 'append'); + assert.deepEqual((scoped.next as any).plugin, ['@scope/pkg@2.0.0']); + + const git = applyAtPath( + { plugin: ['superpowers@git+https://github.com/obra/superpowers.git#v6.3.0'] }, + 'plugin', + ['superpowers@git+https://github.com/obra/superpowers.git#v6.4.0'], + 'append' + ); + assert.deepEqual((git.next as any).plugin, ['superpowers@git+https://github.com/obra/superpowers.git#v6.4.0']); + }); + + // Only `name@spec` entries carry a package identity. A fetched skill path or + // a prompted directory must keep the plain additive behaviour, or unrelated + // entries sharing a prefix would silently delete each other. + test('leaves non-package entries to plain append', () => { + const root = { skill: ['{{cache}}/planify-skills-0.3.2'] }; + const { next, stats } = applyAtPath(root, 'skill', ['{{cache}}/planify-skills-0.4.0'], 'append'); + assert.deepEqual((next as any).skill, ['{{cache}}/planify-skills-0.3.2', '{{cache}}/planify-skills-0.4.0']); + assert.equal(stats.added, 1); + assert.equal(stats.superseded, 0); + }); + + // `git+https://user@host/...` has a trailing `@` that does not split a + // package name; splitting there would key on a URL fragment. + test('ignores a git spec whose URL carries credentials', () => { + const root = { plugin: ['pkg@git+https://user@host/a.git#v1'] }; + const { next, stats } = applyAtPath(root, 'plugin', ['pkg@git+https://user@host/a.git#v2'], 'append'); + assert.equal((next as any).plugin.length, 2); + assert.equal(stats.added, 1); + assert.equal(stats.superseded, 0); + }); + test('rejects non-array body', () => { assert.throws(() => applyAtPath({}, 'plugin', { a: 1 }, 'append'), /JSON array/); }); From c74eb9f462203cdb0fb5895aca97bbe0f013dff8 Mon Sep 17 00:00:00 2001 From: trick77 Date: Wed, 26 Aug 2026 21:51:06 +0200 Subject: [PATCH 2/2] fix: collapse stacked entries when the incoming version is already present The deep-equality check ran before the package-name lookup, so installing pricing@0.9.0 over a config already holding both 0.8.1 and 0.9.0 matched 0.9.0, counted it preserved and returned unchanged, leaving the stale entry behind. That is the repair path the README advertises, and it was a silent no-op: no write, no backup, no warning. The name lookup now runs first, and the trailing-duplicate sweep runs whether the anchored entry matched or not. Also shortens the summary label so it keeps the padded column. --- src/batch.ts | 2 +- src/merge.ts | 27 ++++++++++++++++++--------- test/merge.test.ts | 11 +++++++++++ 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/batch.ts b/src/batch.ts index 519bbbc..e7e99f9 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -491,7 +491,7 @@ function renderFooter( lines.push(' ' + c.dim('Leaves replaced: ') + leavesReplaced); lines.push(' ' + c.dim('Keys added: ') + keysAdded); if (dedups > 0) lines.push(' ' + c.dim('Duplicates skipped:') + ' ' + dedups); - if (superseded > 0) lines.push(' ' + c.dim('Older versions replaced:') + ' ' + superseded); + if (superseded > 0) lines.push(' ' + c.dim('Versions replaced: ') + superseded); if (backupPath) lines.push(' ' + c.dim('Backup: ') + backupPath); return lines.join('\n'); diff --git a/src/merge.ts b/src/merge.ts index 93ddd90..859b3fc 100644 --- a/src/merge.ts +++ b/src/merge.ts @@ -92,28 +92,37 @@ function combine(existing: Json, incoming: Json, mode: MergeMode): { value: Json } const target: Json[] = Array.isArray(existing) ? [...existing] : []; for (const v of incoming) { - if (target.some(existingValue => deepEqual(existingValue, v))) { - stats.preserved++; - continue; - } const name = specName(v); + // The first entry naming the same package anchors the position. Look it + // up before the deep-equality check: a config holding both `pkg@0.8.1` + // and `pkg@0.9.0` must still collapse when `pkg@0.9.0` is installed, and + // an equality-first check would call that a preserved no-op and leave the + // stale entry behind. const at = name === null ? -1 : target.findIndex(existingValue => specName(existingValue) === name); if (at === -1) { + if (target.some(existingValue => deepEqual(existingValue, v))) { + stats.preserved++; + continue; + } target.push(v); stats.added++; continue; } - // Same package, different version. Overwrite in place so the entry keeps - // its position, and drop any further copies of the same package: a config - // that already stacked several bumps collapses to one on the next install. - target[at] = v; + // Same package. Overwrite in place so the entry keeps its position, and + // drop any further copies of the same package: a config that already + // stacked several bumps collapses to one on the next install. + if (deepEqual(target[at], v)) { + stats.preserved++; + } else { + target[at] = v; + stats.superseded++; + } for (let i = target.length - 1; i > at; i--) { if (specName(target[i]) === name) { target.splice(i, 1); stats.superseded++; } } - stats.superseded++; } return { value: target, stats }; } diff --git a/test/merge.test.ts b/test/merge.test.ts index 28ef62d..40d68c4 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -125,6 +125,17 @@ describe('applyAtPath — append mode', () => { assert.equal(stats.superseded, 0); }); + // The upgrade path from a config the old code stacked: the newest version is + // already present next to a stale one, so an equality-first check would call + // this a preserved no-op and leave the plugin loading twice. + test('collapses a stale sibling even when the incoming version is already present', () => { + const root = { plugin: ['pricing@0.8.1', 'pricing@0.9.0'] }; + const { next, stats } = applyAtPath(root, 'plugin', ['pricing@0.9.0'], 'append'); + assert.deepEqual((next as any).plugin, ['pricing@0.9.0']); + assert.equal(stats.added, 0); + assert.equal(stats.superseded, 2); // stale entry replaced, exact duplicate dropped + }); + test('matches scoped packages and git specs on the package name', () => { const scoped = applyAtPath({ plugin: ['@scope/pkg@1.0.0'] }, 'plugin', ['@scope/pkg@2.0.0'], 'append'); assert.deepEqual((scoped.next as any).plugin, ['@scope/pkg@2.0.0']);