Skip to content

Add Vortex extension tool acquisition and env-var configuration (Unit F) - #17

Merged
TheValiantOne merged 2 commits into
mainfrom
feature/vortex-tool-acquisition
Aug 10, 2026
Merged

Add Vortex extension tool acquisition and env-var configuration (Unit F)#17
TheValiantOne merged 2 commits into
mainfrom
feature/vortex-tool-acquisition

Conversation

@TheValiantOne

Copy link
Copy Markdown
Owner

Summary

Unit F: tool acquisition & configuration for the Vortex companion extension. Builds on
Unit E's scaffold (src/mcpClient.ts, src/gating.ts, src/index.ts) to add the piece
that makes the extension actually usable: acquiring a WSM binary and telling Vortex
about it as a discovered tool, configured entirely via the WSM_<KeyName>
environment-variable mechanism (WitcherScriptMerger.Core/AppSettings.cs) rather than
by editing .exe.config/.dll.config XML.

New files under vortex-extension/src/:

  • storage.ts - extension-private storage layout under Vortex's userData directory.
  • wsmEnv.ts - builds WSM_<KeyName> env-var overrides; the single mechanism both the
    MCP spawn path (mcpClient.ts) and a future one-shot merge CLI invocation path
    should use.
  • githubRelease.ts - GitHub Releases download logic (asset resolution + download),
    parameterized on repo/tag, real logic behind an injectable HttpClient seam.
  • archiveExtractor.ts - wraps Vortex's own api.openArchive/extractAll for zip
    extraction (no hand-rolled parser, no new npm dependency).
  • discoveredTool.ts - builds and registers the IDiscoveredTool via
    actions.addDiscoveredTool.
  • toolAcquisition.ts - orchestrates download -> verify -> extract -> register
    (acquireWsmTool) and a network-free local re-registration path
    (ensureWsmToolRegistered), wired into index.ts's context.once.

Ran /code-review against the diff before finalizing; 15 findings came back, and this
PR fixes 13 of them for real (not just acknowledged) - see "Fixes from code review"
below for the two deliberately left as-is and why.

Tool ID choice

WitcherScriptMergerEnhanced - deliberately distinct from Vortex's own built-in
game-witcher3 extension's W3ScriptMerger (confirmed via direct source review,
documented in docs/vortex-extension-design.md section 0). There is no Vortex API to
hide/disable another extension's tool registration, so this ships as a clearly-labeled
alternative alongside it, not a replacement - both tools will show up in Vortex's Tools
dashboard.

Env-var configuration convention

wsmEnv.ts's buildWsmEnv(config) maps {gameDirectory, modsDirectory, mergedModName, quickBmsPath, quickBmsPluginPath, wccLitePath} to WSM_GameDirectory,
WSM_ModsDirectory, etc. - exactly the WSM_<KeyName> prefix
AppSettings.EnvironmentVariablePrefix expects. mergeWithProcessEnv(overrides) spreads
these on top of process.env for child_process.spawn's env option (which replaces
the child's entire environment when set, rather than augmenting it). This is meant to be
the single source of truth for both the MCP spawn path (mcpClient.ts's
WsmMcpClientOptions.env, demonstrated end-to-end below) and the not-yet-built one-shot
merge CLI invocation path (a later "merge panel" unit) - neither should duplicate the
WSM_ prefix mapping independently.

Never reads or writes WitcherScriptMerger.exe.config/WitcherScriptMerger.Headless.dll.config
anywhere in this unit's code.

QuickBMS/wcc_lite storage convention (for the future bundle-tooling unit)

storage.ts's getBundleToolsDir(api) returns
<userData>/witcherscriptmerger-vortex/bundle-tools/ - exported now, unused by anything
in this unit (bundle-tooling acquisition hasn't landed), specifically so that later unit
doesn't have to re-derive where this extension keeps its own files. The convention: once
that unit exists, QuickBMS (quickbms.exe + witcher3.bms) and wcc_lite.exe should be
acquired into subdirectories under this path, and WSM_QuickBmsPath/
WSM_QuickBmsPluginPath/WSM_WccLitePath (already accepted by wsmEnv.ts's
WsmEnvConfig, just unpopulated by anything yet) should point at the resulting files
inside it.

Full storage layout (storage.ts):

<userData>/witcherscriptmerger-vortex/
  tool/                 <- acquired WSM Headless build (exe + its .dll.config, etc.)
                            flat, single "current" install - re-acquiring a different
                            repo or version wipes and overwrites it (see "Fixes from
                            code review" below). installed-version.txt records
                            "<repo>@<version>" for the currently-installed build.
  downloads/            <- scratch .zip cache before extraction - the zip is deleted
                            after a successful extraction; safe to delete at any time.
  bundle-tools/         <- CONVENTION for the future bundle-tooling unit, unused here

What was/wasn't verified against a real GitHub Release

Not verified: the actual GitHub-Releases download path end-to-end against a real
release (githubRelease.ts's nodeHttpsClient implementation - the redirect-following,
timeout, and stream-lifecycle logic in particular). No version tag has been pushed to
this repo, so no GitHub Release exists yet - that's a deliberate, separate decision for
the repo owner to make later, not part of this unit. That logic is code-reviewed and
fixed against Node's own documented HTTP/stream semantics (see "Fixes from code review"),
not verified via a live socket test - a real HTTPS test server was judged not worth the
added complexity for this unit given no real release exists yet to validate against
regardless.

Verified:

  1. githubRelease.test.ts - the download/asset-resolution orchestration logic against
    a mocked HttpClient (no real network calls anywhere in this repo's tests): correct
    release-by-tag URL construction, correct asset selected by exact name match
    (matching release.yml's WitcherScriptMerger.Headless-<version>-win-x64.zip
    naming), clear errors when the release/asset isn't found, the download-size integrity
    check (downloaded byte count vs. GitHub's reported asset size - the only integrity
    check available, since release.yml publishes no checksum manifest), and that a
    corrupt/truncated download is deleted rather than left behind.
  2. test/toolAcquisition.integration.test.ts - a real, no-mocks integration test:
    • Publishes WitcherScriptMerger.Headless with the exact profile invocation
      release.yml itself uses (dotnet publish ... -c Release -p:PublishProfile=win-x64 - self-contained, single-file), standing in for "the
      downloaded-and-extracted binary."
    • Lays it out on disk exactly the way acquireWsmTool would have (same directory,
      same installed-version.txt marker format).
    • Calls ensureWsmToolRegistered for real and asserts the dispatched
      addDiscoveredTool action's shape (game ID, tool ID, path).
    • The core proof: writes a scratch .dll.config with deliberately wrong
      placeholder ModsDirectory/MergedModName values, then spawns the real exe via
      WsmMcpClient.connect({ exePath, env: mergeWithProcessEnv(buildWsmEnv(...)) }) and
      asserts a real get_status MCP call reports back the env-var values, explicitly
      asserting they are not the XML's values - not just "some value came back", but
      specifically that the env var won over an explicit, non-blank config value.
    • Also confirmed manually (not part of the automated suite, just derisking before
      writing the test) that this exact scenario works, including surfacing a pre-existing,
      out-of-scope minor wart: WSM's headless MCP server writes one non-JSON diagnostic
      line ([WSM] Can't find any mods in the Mods directory.) directly to stdout when
      the mods directory is empty, technically violating "stdout carries protocol frames
      only" - harmless here since mcpClient.ts's line parser already defensively
      ignores non-JSON lines, but worth flagging for whoever owns WsmMcpTools.cs/Core
      Console output next.

archiveExtractor.ts's real implementation (Vortex's own api.openArchive/
extractAll) is not exercised by any test - doing so would need a real Vortex host
providing a real archive-handler extension, which nothing in this repo's test setup can
provide. It's tested only at the seam (archiveExtractor.test.ts with a fake api),
matching how mcpClient.ts's own MCP-frame-shape assumptions were flagged as
"genuinely unverified until exercised" in Unit E.

Fixes from code review

Ran /code-review before finalizing. Fixed for real (not just noted):

  • index.ts only checked isWitcher3Active once, at context.once time -
    contradicted this file's own doc comment about live game-mode switches. Fixed:
    re-checks on every 'gamemode-activated' event too (confirmed real via
    @nexusmods/vortex-api's own README), proven by a new index.test.ts (this unit's
    first test for index.ts - it had no real logic to test before).
  • A throw inside the HTTPS response callback would have escaped as an uncaught
    exception
    , not a Promise rejection (the callback runs outside new Promise's own
    synchronous try/catch). Fixed: wrapped in try/catch, rejects properly.
  • No request timeout - a connection accepted but never responded would hang
    acquireWsmTool forever. Fixed: 30s timeout via https.get's own timeout option
    plus a 'timeout' handler that destroys the request.
  • Download-completion resolved on 'finish' instead of 'close' - 'finish'
    doesn't guarantee the OS file handle is actually released yet (Node's own docs),
    risking a Windows sharing violation when archiveExtractor.ts immediately re-opens
    the same file for extraction. Fixed.
  • Write stream never destroyed on error - pipe() doesn't auto-cascade destruction
    between source/destination. Fixed: both streams explicitly destroyed on either's
    error.
  • A corrupt/truncated download was left on disk after the size-mismatch check
    failed. Fixed: best-effort unlink before throwing.
  • The downloaded .zip was never deleted from the cache after a successful
    extraction
    , despite storage.ts's own doc comment calling that directory
    disposable. Fixed.
  • Re-acquiring extracted directly on top of the existing install without clearing
    it first, so stale files from a prior version (or a prior failed/partial extraction)
    could persist indefinitely - contradicted storage.ts's own "overwrites" doc comment.
    Fixed: installDir is wiped (fs.rm(..., {recursive:true, force:true})) immediately
    before extraction. If a WSM process is actively running out of that directory, this
    now fails loudly (Windows won't delete a running exe's backing file) instead of
    silently corrupting a running install.
  • The idempotency check ignored repo, so requesting the same version from a
    different repo silently reused the old repo's binary. Fixed: the installed-version
    marker now records <repo>@<version>, and both must match to skip re-acquisition.
  • registerAcquiredTool derived WSM_GameDirectory from
    selectors.currentGameDiscovery
    (whichever game happens to be active right now)
    even though the tool is always registered under a hardcoded WITCHER3_GAME_ID.
    Fixed: uses selectors.discoveryByGame(state, WITCHER3_GAME_ID) instead - Witcher 3's
    own discovery, not whatever's active when an async call happens to resolve.
  • pathExists/readInstalledVersion swallowed every filesystem error, not just
    "doesn't exist" - a permission error or locked file would silently read as "nothing
    installed," triggering a doomed re-download instead of surfacing the real problem.
    Fixed: only ENOENT is treated as "not found"; everything else propagates. Covered by
    a new test that injects an EACCES error via vi.spyOn(fs.promises, 'access').
  • No protection against concurrent acquireWsmTool calls for the same install
    (e.g. a double-clicked "Get/Update" action) - both would race the same download/extract
    target. Fixed: concurrent calls for the same installDir now coalesce onto the
    first call's in-flight promise (documented limitation: a second call requesting
    different version/repo while the first is in flight silently gets the first
    call's result - acceptable for this unit's only real trigger shape; see
    acquireWsmTool's own doc comment).
  • registerWsmDiscoveredTool silently no-op'd if api.store was undefined
    (api.store?.dispatch(...)), while callers still reported success. Fixed: throws
    instead, so ensureWsmToolRegistered/acquireWsmTool can't report true when
    nothing was actually dispatched.
  • Also adopted util.writeFileAtomic (a real @nexusmods/vortex-api export, confirmed
    in its typings) for the installed-version marker write specifically - the one write
    where a crash mid-write producing a corrupt/truncated marker would actually matter
    for correctness.

Deliberately left as-is (both noted in code comments rather than silently dropped):

  • Reuse of vortex-api's fs namespace (ensureDirAsync etc.) throughout, instead
    of plain fs.promises
    - only partially adopted (see writeFileAtomic above).
    githubRelease.ts intentionally has zero vortex-api dependency for testability
    (confirmed: its unit tests run without needing the vortex-api stub at all); the
    reviewer's own note confirms its hand-rolled HTTPS/JSON client isn't actually
    redundant with vortex-api's request helpers either way. The remaining plain
    fs.promises.mkdir/rm/unlink calls in toolAcquisition.ts don't carry the same
    atomicity hazard the marker-file write did (idempotent/best-effort operations), so
    switching them was judged not worth the added coupling for this unit.
  • Redundant mkdir between toolAcquisition.ts and archiveExtractor.ts - no
    longer purely redundant now that installDir is wiped before extraction (see above):
    toolAcquisition.ts's mkdir recreates a fresh directory right after the wipe;
    archiveExtractor.ts's own mkdir is a general ArchiveExtractor interface
    contract, independent of what a given caller already did. Left as two call sites each
    owning their own precondition, with a comment explaining why.

Other design notes

  • Chose the Headless host's win-x64 asset (WitcherScriptMerger.Headless-<version>-win-x64.zip),
    not the WinForms host's - WitcherScriptMerger.Headless/CLAUDE.md's "Dependency
    gating" section: the WinForms host's merge and mcp verbs both gate on the
    combined ValidateDependencyPaths() (QuickBMS + wcc_lite), so it refuses to even
    start without bundle tooling this unit doesn't acquire. Headless gates on
    ValidateTextMergeDependencies() only, so it works for flat-file conflicts with
    nothing else installed.
  • index.ts's context.once calls ensureWsmToolRegistered (network-free) on load and
    on every 'gamemode-activated' event, not acquireWsmTool (the actual download) - an
    eager background download at every Vortex startup/game switch would be a guaranteed,
    noisy failure while no release exists. acquireWsmTool is exported for a later unit's
    own explicit-user-action UI trigger (e.g. a "Get WitcherScriptMerger" button) to call
    instead - not added here, since it's unverifiable without a running Vortex host and not
    asked for by this unit's scope.
  • Known, documented limitation: WitcherScriptMerger.Headless.exe with no arguments
    prints usage and exits 1, so manually double-clicking this tool's tile in Vortex's
    Tools dashboard does nothing useful today. discoveredTool.ts deliberately sets no
    default parameters rather than picking an arbitrary verb that would be equally
    unhelpful for a human - every actual invocation (this unit's integration test,
    mcpClient.ts, a future CLI-invocation unit) always passes its own explicit args.
  • Known, documented limitation: ITool.executable is typed as a function, which cannot
    survive Vortex's Redux-state persistence to disk - the same shape game-witcher3's
    own W3ScriptMerger registration uses in production, not a new risk this unit
    introduces. discoveredTool.test.ts asserts every other field round-trips through
    JSON.parse(JSON.stringify(...)) correctly, and ensureWsmToolRegistered re-registers
    on every load/game-switch regardless, which papers over this if persistence does drop
    it.
  • webpack.config.cjs's nodeBuiltins externals list gained https/http/stream
    (for githubRelease.ts's download logic).
  • test/testUtils/vortexApiStub.ts gained actions.addDiscoveredTool,
    selectors.discoveryByGame, util.writeFileAtomic, and log - real (if simplified)
    fakes, since production code now calls each of them as values at runtime, unlike the
    types namespace (compile-time only, elided from emitted JS, so it's never needed in
    this stub - see gating.test.ts's existing comment on why).

Verification

  • npm run build (typecheck + webpack) - passes.
  • npm run lint - clean.
  • npm test (45 unit tests across 8 files, all fast/Node-only, no network) - passes.
  • npm run test:integration (adds 4 real, no-mocks integration tests, 49 total) -
    passes.
  • dotnet build WitcherScriptMerger.sln and dotnet format whitespace WitcherScriptMerger.sln --verify-no-changes at the repo root - both unaffected,
    confirming vortex-extension/ stays fully outside the .NET solution's reach.

AI-assisted development: this PR was substantially produced by Claude Code, per this
repo's CONTRIBUTING.md disclosure requirement.

Chris Knight and others added 2 commits August 9, 2026 23:29
Adds the piece that makes the Vortex companion extension actually usable:
downloading a WSM release build from GitHub Releases, verifying/extracting
it, and registering it as a discovered Vortex tool (WitcherScriptMergerEnhanced,
distinct from game-witcher3's built-in W3ScriptMerger), configured entirely
via WSM_<KeyName> environment variables rather than by editing .exe.config XML.

New src/ modules: storage.ts (extension-private storage layout, including the
QuickBMS/wcc_lite storage convention a future bundle-tooling unit will reuse),
wsmEnv.ts (WSM_ env-var builder), githubRelease.ts (download logic behind an
injectable HttpClient seam), archiveExtractor.ts (wraps Vortex's own
api.openArchive instead of a hand-rolled zip parser or new dependency),
discoveredTool.ts, and toolAcquisition.ts (orchestration + a network-free
local re-registration path wired into index.ts, now re-checked live on
gamemode-activated rather than only once at load).

Verified via a mocked-HTTP unit test for the download logic (no real GitHub
Release exists yet - no tag has been pushed) and a real, no-mocks integration
test that publishes WitcherScriptMerger.Headless with release.yml's exact
profile invocation and proves WSM_* env vars override a deliberately-wrong
scratch XML config in a real spawned MCP process.

Ran /code-review before finalizing; fixed 13 of 15 findings for real (redirect
handling, request timeout, write-stream close-vs-finish, stale-install wipe on
re-acquire, repo-aware idempotency, correct discoveryByGame selector, ENOENT-only
error swallowing, concurrent-call coalescing, silent dispatch no-op) - see PR
description for full detail and the two findings deliberately left as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
README's test:integration paragraph still described only the pre-existing
dotnet build path; it now also documents toolAcquisition.integration.test.ts's
dotnet publish -p:PublishProfile=win-x64 invocation (slower, produces a
self-contained single-file exe) so a contributor isn't surprised by it on a
cold checkout. archiveExtractor.ts's verify:true comment overstated what's
actually known about Vortex's archive-handler behavior; softened to match
the file's own "unverified" disclosure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
@TheValiantOne
TheValiantOne merged commit b7fec88 into main Aug 10, 2026
1 check passed
@TheValiantOne
TheValiantOne deleted the feature/vortex-tool-acquisition branch August 11, 2026 01:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant