From a54b65ee3df8eee15298cdc273f25a7af2086816 Mon Sep 17 00:00:00 2001 From: Nick Gomez <122398915+nick-inkeep@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:02:12 -0700 Subject: [PATCH] Fix harness MCP-config write safety (data-loss on launch) (#2192) * docs(open-knowledge): spec for harness MCP-config write safety Plan to fix the codex-config-trash data-loss bug across all five coding harnesses (Claude Code, Claude Desktop, Cursor, OpenCode, Codex). OK's weak parsers (smol-toml, JSON.parse) throw on valid configs, and the every-launch repair sweep then resets the file; lossy round-trips also strip comments and retype values. The spec covers a napi-rs Rust addon porting Codex's toml_edit config editor (insert-only, format-preserving) for Codex TOML, a surgical jsonc-parser editor for the four JSON harnesses, a non-destructive classify/repair path, symlink write-through, and BOM/EOL fidelity. Grounded by reproductions plus eleven evidence files. Implementation lands in follow-up commits on this branch. * chore(md-conformance): refresh ng-anchors catalog file counts Pre-existing baseline drift, independent of any feature work. The committed ng-anchors-catalog.json recorded productionFileCount 83 and testFileCount 179, but the deterministic enumeration over packages/core/src/markdown, packages/core/src/extensions, and the test globs now counts 84 and 180. A source/test file was added without regenerating the catalog, leaving the freshness gate in bun run check red on the baseline. Regenerated via enumerate-ng-anchors; only the count metadata changed, no anchors or slugs. * [US-001] non-destructive MCP-config classification: split corrupt into absent + decline classifyExistingMcpEntry no longer collapses blank and unparseable configs into one 'corrupt' kind. Blank/whitespace/0-byte files now classify as 'absent' (safe to create into); a present non-empty config OK cannot parse or read classifies as 'decline' carrying a bounded reason enum (McpDeclineReason), never the raw parser message. A half-written file read mid-write by a concurrent harness classifies as decline, not creatable-blank. The McpEntryClassification union keeps four kinds (absent, no-entry, present, decline). The three classification consumers (claude-readiness, mcp-wiring, project-mcp-reclaim) move to the decline kind; their reclaim disposition is unchanged here and becomes non-destructive in the next story. Tests: blank/whitespace to absent, invalid JSON/TOML to decline with a bounded reason, truncated JSON/TOML to decline, and a declined config is left byte-unchanged by classify. * [US-002] non-destructive desktop MCP reclaim: delete .broken rename Both desktop reclaim sweeps now leave a present, unparseable harness config byte-untouched and skip registering, emitting a bounded mcp-config-decline signal {scope, surface, editorId, reason} instead of renaming it to .broken- and writing a fresh near-empty config. mcp-wiring.ts startup sweep drops the rename + fresh-write branch and the corruptBackupFailures/now/fs state. project-mcp-reclaim.ts drops moveCorruptAside, the backup DI, and the reclaimed-from-corrupt status. Both push a decline disposition and emit the signal. The bounded decline-event builder is wired and exported from index.ts (McpConfigDeclineInput kept module-local). A reset-repro regression (real fs + real classifyExistingMcpEntry on an i64 TOML config) asserts the file stays byte-unchanged with no .broken sidecar and no write dispatched. The project-reclaim decline tests are updated to the non-destructive behavior and an AC3 create-into-blank test is added. repair-mcp-configs.ts was already non-destructive and is unchanged. * [US-003] capable JSONC parse on the read/classify path Swap the JSON read/classify path from JSON.parse to jsonc-parser so valid harness configs are no longer false-flagged unparseable and reset: - readJsonConfig and classifyExistingMcpEntry now parse via a tolerant JSONC scanner (line and block comments, trailing commas) and forgive the lone InvalidSymbol a leading UTF-8 BOM produces, so a comment-rich or BOM-prefixed config classifies by its real content. - classify surfaces a duplicate top-level container key as a decline (duplicate-container) instead of silently editing whichever block the value parse happens to keep. The check is keyed to each harness's real container key, so a repeated unrelated sibling key stays no-entry. - The JSON branch parses the bytes classify already read (no second read) and shares classifyContainer with the TOML branch; TOML keeps its current parser. - Add jsonc-parser to tsdown alwaysBundle so the bundled CLI resolves it inside the packaged app. Add classify unit tests for JSONC comments, trailing commas, leading BOM, duplicate container (cursor and opencode), and the unrelated-sibling-key case. * [US-004] surgical comment-preserving JSON/JSONC MCP write editor Replace the whole-file JSON.stringify spread-merge in the write spine with a jsonc-parser modify/applyEdits upsert that touches only OK's own entry, so a registration write preserves the user's comments, formatting, key order, and a leading BOM in their harness config instead of reflowing the whole document. - upsertJsonMcpConfig: absent/blank create fresh; present+parseable upsert in place; oversize (>10 MiB), duplicate container key, and unparseable all decline and leave the file byte-unchanged. Idempotent: an already-current entry short-circuits to a byte-identical no-op. - New 'declined' EditorMcpResult action carrying a bounded decline reason, so a guest-ownership leave-untouched is neither a write nor a failure. Threaded through formatInitResult, the desktop wiring mirror + logging, and the CLI repair outcome. - Pre-write backup of the original bytes on a present, parseable write; size gate runs before the parse+modify so a multi-MB file costs only a read. - Resolve jsonc-parser to its ESM entry in tsdown so its submodule loads inline in the bundle. Its UMD main left bare require('./impl/*') calls that crashed the desktop-loaded dist; a bundled-load integration test guards the fix. TOML keeps the whole-file path for now; the format-preserving native write lands separately. * [US-005] napi-rs native-config crate + capable TOML classify + load-probe fallback Add packages/native-config, a Rust/napi addon wrapping toml_edit, and wire its capable parse into the harness-config classify path so a valid Codex config carrying a 64-bit integer or a microsecond datetime is no longer mis-flagged unparseable (the enabler of the reported config reset). A synchronous load-probe factory returns the native engine when the .node loads and a JS smol-toml fallback otherwise; on the fallback the write spine declines a present non-trivial TOML config rather than risk a lossy whole-file rewrite, and only creates into an absent or blank file. Crate: pure N-API-free parse/JSON-projection module (accepts i64 past 2^53 and microsecond datetimes, errors on genuinely-malformed input) with 6 cargo tests, a thin napi 3 wrapper, and a turbo test task that runs cargo test so the gate covers it. Engine: src/native/toml-config-engine.ts probes and falls back the way createTokenStore does, with a loader parameter for deterministic tests. The classify TOML branch now reads through the engine using the bytes already read. Updated the reset-repro regression: under the capable parser the i64 config classifies as no-entry, so the no-create sweep leaves it untouched rather than declining, and it is still byte-unchanged with no .broken sidecar. Packaging: cli workspace dependency, tsdown neverBundle, biome and knip ignores for the generated loader, notices regen. The packaged-desktop extraResources copy and the cross-platform prebuild matrix are tracked as their own work; the packaged fallback stays a non-destructive decline until then. * [US-006] insert-only single-key toml_edit upsert/remove engine in native-config * [US-007] port Codex edit-test conformance floor + symlink resolution + Codex attribution * [US-008] wire Codex TOML write through the native addon + BOM/EOL/trailing-newline wrapper Route writeEditorMcpConfig's TOML branch through upsertTomlMcpConfig: the native toml_edit insert-only upsert touches only OK's own entry, and a thin wrapper captures and re-applies the source file's leading BOM, dominant EOL, and trailing-newline state (toml_edit strips the BOM, normalizes CRLF to LF, and always emits a trailing newline). A present, parseable config gets a pre-write backup before the atomic write; an unchanged config is a byte-identical no-op. On the JS fallback a present non-blank config declines rather than a lossy whole-file rewrite. The engine becomes a discriminated union on backend so only the native arm carries upsertEntry; the fallback can only parse. OK's own string values now serialize as single-line escaped basic strings (matching the prior smol-toml writer) so every newline in the document is structural, which makes the blanket CRLF re-apply provably safe and keeps OK's resolver chain LF-internal on any line-ending convention. The native upsert reports whether OK's entry already existed so the spine labels register-vs-update without re-parsing. * [US-009] symlink write-through on the harness write path Resolve a (possibly symlinked) harness config to its real write target so a dotfile-managed (stow/chezmoi) config is written through to its target instead of being replaced by a regular file and orphaning the dotfiles-repo copy. New cli/src/native/symlink-resolve.ts: resolveHarnessWritePaths prefers the native conformance-tested resolver and degrades to a pure-JS mirror of Codex's resolve_symlink_write_paths. Symlink traversal has no capability gap, so the JS mirror keeps write-through working for all five harnesses and on platforms with no prebuilt binary. writeEditorMcpConfig resolves inside the file lock and writes to the resolved target; a cyclic chain collapses to a regular file, breaking the link. Atomicity and the cross-process lock are unchanged; core stays native-free. Tests run one contract suite against both the JS and native resolvers, and a real-spine write-through regression asserts the symlink survives and the real target received the entry (JSON and TOML), plus cycle-break. * [US-010] package native-config addon into the desktop bundle The Codex TOML harness write goes through the native-config toml_edit addon from two runtime consumers in a packaged build: the bundled CLI subprocess and the desktop main process (the MCP repair sweep calls writeUserMcpConfigs in-process). Mirror the @napi-rs/keyring packaging so both can resolve it. - Declare @inkeep/open-knowledge-native-config as a desktop dependency so electron-builder includes it in app.asar for the in-process main consumer. - extraResources copies the addon onto the bundled CLI's own resolution path (cli/node_modules), filtered to the napi loader, types, manifest, and the platform .node so the Rust target/ tree never ships. Unlike keyring this is one workspace package whose binary sits beside its loader. - asarUnpack the addon package dir so the loader and the .node it requires stay co-located on the real filesystem. - Extend the forcing-function packaging test: the addon now ships, so it leaves KNOWN_UNCOVERED; add guards that the extraResources rule ships both loader and binary, that asarUnpack covers it, and that the build artifacts exist after a build. When the binary cannot ship or load the TOML write degrades to a non-destructive decline, never a lossy write. This is the packaging sub-piece of US-010. The cross-platform prebuild matrix (release CI) and the packaged-DMG smoke remain, both gated. * [US-011] consolidated cross-harness FR1 acceptance matrix + surgical-text counterfactual guard * [US-010] fix knip dependency tracing for the native addon Remove the now-stale cli ignoreDependencies entry for @inkeep/open-knowledge-native-config (knip traces it via the native binding test requires), and add a desktop ignoreDependencies entry (the desktop main reaches the engine through the bundled CLI's require factory at runtime, so there is no static specifier for knip to follow, but the dependency is declared for the electron-builder extraResources copy). * [US-010] bundle native-config prebuilt binaries into the CLI + prebuild CI Ship the toml_edit addon to all platforms via Option 3 (bundle the prebuilt binaries into the CLI tarball; native-config stays private, no per-platform optional-dependency packages): - prebuild CI: a standalone 8-target napi-rs matrix workflow builds one .node per target (darwin arm64/x64, linux gnu/musl arm64/x64, windows msvc arm64/x64) and a combine job asserts all 8 are present - CLI build copies the napi loader + every present .node into dist/native/ (shipped via files:[dist]); a CommonJS package.json beside the loader keeps Node from mis-parsing it as ESM under the CLI's type:module tree - a shared resolver tries the bundled dist/native loader first, then the workspace package, then the existing non-destructive fallback - THIRD_PARTY_NOTICES gains a maintained, Cargo.lock-checked attribution section for the addon's statically linked Rust crates - AC5 committable smoke: a verifier that loads the bundled addon from the packaged layout and round-trips it, a driver test, and a runbook (the live in-Electron-process step is deferred to a signed-DMG QA run) * [US-010] wire native-config prebuilt binaries into the npm release Add a resilient release.yml step that stages the latest successful native-config-prebuild bundle into packages/native-config/ before the CLI build, so the published npm CLI carries all eight platform binaries rather than only the release runner's host target. Warn-not-fail: a missing bundle ships host-only and non-host platforms use the non-destructive smol-toml fallback, so it can never make a release worse than the host-only status quo. Binaries are ABI-stable; the latest run's set is reused until native-config changes. Also strip two branch-introduced process markers from source comments (comment-discipline): the decision reference in the Rust conformance test and the story reference in the smoke driver header. * fixup! local-review: baseline (pre-review state) * fixup! local-review: baseline (pre-review state) * fixup! local-review: address findings (pass 1) * fix(open-knowledge): native-config to devDependencies (npm install fix) The published CLI listed the private, never-published @inkeep/open-knowledge-native-config in runtime dependencies. At publish, workspace:* rewrites to the on-disk 0.0.0, so npm install @inkeep/open-knowledge would try to resolve that from the registry, 404, and abort for every user. Move it to devDependencies alongside the bundled siblings -app/-core/-server; the addon still ships in dist/native/ and loads dist-relative, and only tests bare-require it. Validated in reports/bundled-native-addon-dep-placement. Also append post-ship corrigendum breadcrumbs to SPEC D14/AC5 and the scope/failure-mode/non-goal references: the additive-path pre-write backup was removed entirely. It was write-only (nothing reads .ok-backup, so no recovery) and a secret-disclosure footprint; EC5 concurrent-truncation is covered by D13/AC7 truncation-to-decline plus the atomic tmp+rename. * feat(open-knowledge): pin fallback non-destruction + stable-release binary gate Four changes hardening MCP-config write safety and the native-config release path: - mcp-wiring.test.ts: add a sibling regression test in the reset-repro block that forces the JS smol-toml fallback engine (the path every off-macOS / no-prebuilt-binary user runs) and proves the boot sweep leaves a valid i64 Codex config byte-identical: no fresh write, no .broken-* sidecar, only a bounded mcp-config-decline signal. Drives the real classify through the cli surface; imports classify from cli source so the engine override binds to the same instance the classify reads. - release.yml: hard-fail a stable @latest cut when fewer than 8 native-config binaries stage or the prebuild run is missing, while beta keeps the resilient warn-and-proceed path. A stable publish silently shipping host-only native binaries is a fidelity regression. The staged count is written to the job summary either way. - release.yml: verify the staged prebuild run's head commit is an ancestor of the release commit before trusting its binaries, so the native code shipped on every CLI invocation was built from source actually in the release. A non-ancestor degrades like the missing-binaries case (warn for beta, hard-fail for stable). - native-config/src/lib.rs: note that remove_mcp_server is exposed for the future ok uninstall flow so it is not re-flagged as dead surface; it stays as the symmetric counterpart to upsert_mcp_server. * chore(open-knowledge): regenerate md-audit byte-audit after rebase onto main * ci(open-knowledge): use bun-install-ci.sh in native-config prebuild (bunfig cooldown) * ci(open-knowledge): gate native-config cargo conformance tests in OK validation native-config's 40 toml_edit conformance tests (test = cargo test) ran in no required CI job. The per-package test loop in the open-knowledge / test matrix cell never listed it, and native-config-prebuild.yml only builds the addon, so the fidelity core of the MCP-config write-safety work was unguarded. check:ok-ci-test-coverage flagged the gap. Gate it rather than allowlist it away: add the package to the per-package loop with a 360s budget sized for a cold cargo compile, and add a test-gated dtolnay/rust-toolchain step plus a cargo cache so the test cell has a toolchain (the pre-build napi build and cargo test both need cargo). The action SHA and toolchain match release.yml and native-config-prebuild.yml. * fix(open-knowledge): gate classify path on config size + review hardening Close the classify-path oversize asymmetry. classifyExistingMcpEntry now stat-gates a config past JSON_CONFIG_MAX_BYTES before reading, mirroring the write path's oversize decline, so a history-bloated ~/.claude.json (tens of MB) is left untouched without a full read and JSONC parse on every launch. It returns the existing decline/oversize classification, so no caller changes are needed (no caller branches on the reason value, and the decline telemetry event already accepts oversize). statSync avoids pulling the file into memory just to measure it. A RED test pins that the gate fires ahead of the parse: a valid JSON payload that would otherwise classify no-entry now declines oversize and is left byte-unchanged. Also addresses the code review findings on this PR: - cargo cache key now includes hashFiles(Cargo.lock, Cargo.toml) plus restore-keys in native-config-prebuild.yml and the OK validation workflow, so a dependency bump no longer reuses a stale crate cache - init.ts native upsert and config-read catches emit debugNativeLoadFailure so a present-but-broken native addon is diagnosable under OK_DEBUG_NATIVE instead of collapsing silently to unparseable - mcp-decline-event surface is narrowed from open string to a bounded union so the decline event stays bounded-cardinality at the type level - symlink-resolve native catch and the readlinkSync catch emit debug traces * fix(open-knowledge): honest fallback decline reason + CRLF JSON write test Two re-review findings, both in this PR's own code: - The TOML write path's JS-fallback branch declined a present config with reason 'unparseable', but it never parses the config: the real cause is that the format-preserving native engine is unavailable and a fallback rewrite would be lossy. Add a distinct 'no-native-writer' McpDeclineReason and emit it there, so decline telemetry is no longer misattributed to a parse failure. - Add CRLF coverage to the JSON surgical-write path (the TOML path already had it). A uniformly-CRLF config keeps its line endings byte-for-byte and the inserted entry is written as CRLF too, with no bare LF leaking in. * docs(open-knowledge): ok:macos-vm CLI-on-VM validation reference + golden gotchas Add an ok:macos-vm reference (references/cli-on-vm-validation.md) for the build-free CLI validation path: npm pack + rsyncToVM + npm i -g, where the prebuilt arm64 native-config .node matches the golden so the napi config writer loads with no build-ok-desktop and no Rust toolchain. Document the consumer-side delta (ok init writes the open-knowledge MCP entry; the general consumer-fidelity method lives in eng:qa, loaded via /qa). Record golden substrate gotchas in macos-vm + bake-lume-golden failure modes (stale npm _cacache, no Rust, no timeout binary, ok init needs git), and note inject.sh does not cover the Claude Code CLI. * docs(open-knowledge): fix stale classify comment + CRLF JSDoc * chore(open-knowledge): add Rust toolchain to lume golden 2026-06-28 (#2244) * chore(open-knowledge): add Rust toolchain to lume golden 2026-06-28 Bake a Rust toolchain into the lume macOS golden so build-ok-desktop builds the native-config napi addon in-VM, retiring the per-fork rustup install and the build-free-only workaround. - layer 13d: rustup + cargo + the aarch64-apple-darwin target, shimmed into /usr/local/bin so non-login ssh finds it - layer 13e: sudo-purge the inherited root-owned ~/.npm/_cacache (the EEXIST/EACCES that broke a fork's npm i -g) and chown ~/.npm to lume - verify.sh: check 13 asserts cargo + rustc + the aarch64 target - promote DEFAULT_GOLDEN to ok-tahoe-base-substrate-only-2026-06-28 (fork-extend off 06-06); update lineage in the 3 allowlisted docs - retire the now-false "No Rust toolchain" notes - macos-vm skill: add an Observability section (headless vs VNC, the password rationale, the shutdown-hang caveat) Validated on a fresh fork: verify.sh 16/0 (Aqua healthy, Rust present) plus a cargo build of toml_edit, native-config's core dep, in 3.98s. * docs(open-knowledge): propagate Rust + 13e to secondary doc references * fix(open-knowledge): exclude declined writes from startup-repair status * test(open-knowledge): add Codex MCP tool-call QA scenario (cx-s1) Adds a lume-QA scenario proving Codex.app invokes a real OpenKnowledge MCP tool, asserted by the load-bearing mcp-tool-trace rung. It is the Codex analogue of cd-s1-claude and the first scenario showing an agent, not a bare Node client, fire a tools/call to OK. - cx-s1-codex.ts: seeds a project, wires ~/.codex/config.toml through the MCP-trace proxy (published bunx server, approval_policy=never so the tool call is not gated behind an approval card), drives Codex over CDP, and asserts a tools/call frame in the trace. - lume-qa-cx-s1-codex.e2e.ts: host wrapper (fork golden, inject auth, install trace proxy, run, evaluate rungs). - codex-cdp-evals.ts: shared Codex CDP-driving primitives (wizard dismiss, fill and submit, chat scrape) plus a new tool-approval handler. - ok-e2e-codex.ts: migrated to import the shared evals, removing the duplicated inline copies. Manual-only (OK_LUME_QA=1 bun run lume-qa cx-s1-codex); not in CI. Validated on the 2026-06-28 golden: 3 of 3 pass with approval_policy=never (Codex gpt-5.4 agent calling the OK config tool). --------- GitOrigin-RevId: afa186ece2dfb2e8384973e7db52e64e3088ec35 --- .changeset/harness-mcp-config-write-safety.md | 5 + .github/workflows/native-config-prebuild.yml | 188 +++++++ .github/workflows/release.yml | 85 +++ THIRD_PARTY_NOTICES.md | 116 ++-- biome.jsonc | 6 + bun.lock | 208 +++++++- docs/content/get-started/quickstart.mdx | 2 +- docs/content/integrations/claude-code.mdx | 2 +- docs/content/integrations/codex.mdx | 2 +- docs/content/integrations/cursor.mdx | 2 +- docs/content/integrations/opencode.mdx | 2 +- docs/content/reference/cli.mdx | 6 +- knip.config.ts | 2 + packages/cli/package.json | 5 +- packages/cli/src/commands/init.test.ts | 276 +++++++++- packages/cli/src/commands/init.ts | 367 ++++++++++--- .../mcp-cross-harness-acceptance.test.ts | 296 +++++++++++ .../cli/src/commands/mcp-decline-event.ts | 31 ++ .../commands/mcp-json-surgical-write.test.ts | 300 +++++++++++ .../mcp-symlink-write-through.test.ts | 113 ++++ .../commands/mcp-toml-surgical-write.test.ts | 260 +++++++++ .../cli/src/commands/repair-mcp-configs.ts | 13 +- packages/cli/src/index.ts | 6 + .../project-integration-writers.test.ts | 15 + .../project-integration-writers.ts | 14 +- .../cli/src/native/load-native-config.test.ts | 121 +++++ packages/cli/src/native/load-native-config.ts | 45 ++ .../src/native/mcp-toml-edit-binding.test.ts | 95 ++++ .../cli/src/native/symlink-resolve.test.ts | 115 ++++ packages/cli/src/native/symlink-resolve.ts | 73 +++ .../cli/src/native/toml-config-engine.test.ts | 114 ++++ packages/cli/src/native/toml-config-engine.ts | 97 ++++ .../mcp-json-write-bundled.test.ts | 55 ++ packages/cli/tsdown.config.ts | 14 +- packages/desktop/electron-builder.yml | 34 ++ packages/desktop/package.json | 1 + packages/desktop/src/main/claude-readiness.ts | 7 +- packages/desktop/src/main/index.ts | 15 + packages/desktop/src/main/mcp-wiring.test.ts | 406 +++++++++++++- packages/desktop/src/main/mcp-wiring.ts | 160 +++--- .../src/main/project-mcp-reclaim.test.ts | 109 ++-- .../desktop/src/main/project-mcp-reclaim.ts | 115 ++-- .../tests/main/claude-readiness.test.ts | 4 +- .../desktop/tests/smoke/native-config-e2e.md | 64 +++ .../electron-builder-cli-native-deps.test.ts | 116 +++- .../unit/verify-native-config-driver.test.mjs | 193 +++++++ packages/native-config/.gitignore | 8 + packages/native-config/Cargo.lock | 496 ++++++++++++++++++ packages/native-config/Cargo.toml | 40 ++ packages/native-config/build.rs | 14 + packages/native-config/package.json | 35 ++ .../native-config/src/document_helpers.rs | 90 ++++ packages/native-config/src/lib.rs | 96 ++++ packages/native-config/src/mcp_edit.rs | 446 ++++++++++++++++ .../src/mcp_edit_conformance_tests.rs | 243 +++++++++ packages/native-config/src/path_resolve.rs | 185 +++++++ packages/native-config/src/toml_json.rs | 130 +++++ packages/native-config/turbo.json | 14 + scripts/generate-third-party-notices.mjs | 179 ++++++- 59 files changed, 5898 insertions(+), 353 deletions(-) create mode 100644 .changeset/harness-mcp-config-write-safety.md create mode 100644 .github/workflows/native-config-prebuild.yml create mode 100644 packages/cli/src/commands/mcp-cross-harness-acceptance.test.ts create mode 100644 packages/cli/src/commands/mcp-decline-event.ts create mode 100644 packages/cli/src/commands/mcp-json-surgical-write.test.ts create mode 100644 packages/cli/src/commands/mcp-symlink-write-through.test.ts create mode 100644 packages/cli/src/commands/mcp-toml-surgical-write.test.ts create mode 100644 packages/cli/src/native/load-native-config.test.ts create mode 100644 packages/cli/src/native/load-native-config.ts create mode 100644 packages/cli/src/native/mcp-toml-edit-binding.test.ts create mode 100644 packages/cli/src/native/symlink-resolve.test.ts create mode 100644 packages/cli/src/native/symlink-resolve.ts create mode 100644 packages/cli/src/native/toml-config-engine.test.ts create mode 100644 packages/cli/src/native/toml-config-engine.ts create mode 100644 packages/cli/tests/integration/mcp-json-write-bundled.test.ts create mode 100644 packages/desktop/tests/smoke/native-config-e2e.md create mode 100644 packages/desktop/tests/unit/verify-native-config-driver.test.mjs create mode 100644 packages/native-config/.gitignore create mode 100644 packages/native-config/Cargo.lock create mode 100644 packages/native-config/Cargo.toml create mode 100644 packages/native-config/build.rs create mode 100644 packages/native-config/package.json create mode 100644 packages/native-config/src/document_helpers.rs create mode 100644 packages/native-config/src/lib.rs create mode 100644 packages/native-config/src/mcp_edit.rs create mode 100644 packages/native-config/src/mcp_edit_conformance_tests.rs create mode 100644 packages/native-config/src/path_resolve.rs create mode 100644 packages/native-config/src/toml_json.rs create mode 100644 packages/native-config/turbo.json diff --git a/.changeset/harness-mcp-config-write-safety.md b/.changeset/harness-mcp-config-write-safety.md new file mode 100644 index 000000000..0600676fe --- /dev/null +++ b/.changeset/harness-mcp-config-write-safety.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": minor +--- + +Make MCP server registration non-destructive and format-preserving. `ok init` and the desktop reclaim sweeps now only ever add OpenKnowledge's own entry to a harness config: comments, formatting, key order, value types, and byte-level encoding (BOM, line endings, trailing newline) are preserved. A config that can't be parsed safely — invalid JSON/TOML, a duplicate server block, or an oversized file — is left byte-for-byte untouched and reported as `left unchanged ()` rather than being reset or rewritten. Codex `config.toml` is edited through a new napi-rs `toml_edit` addon so 64-bit integers and microsecond datetimes are no longer mis-flagged, and symlinked configs are written through to their real target instead of being replaced. diff --git a/.github/workflows/native-config-prebuild.yml b/.github/workflows/native-config-prebuild.yml new file mode 100644 index 000000000..37c728160 --- /dev/null +++ b/.github/workflows/native-config-prebuild.yml @@ -0,0 +1,188 @@ +name: native-config prebuild + +# Cross-platform prebuild matrix for the `@inkeep/open-knowledge-native-config` +# napi-rs addon (the toml_edit parse + format-preserving Codex TOML edit engine). +# Builds one `.node` per target so the published CLI can bundle all of them into +# `packages/cli/dist/native/` (Option 3 — bundle into the CLI tarball; see +# reports/native-addon-npm-distribution/REPORT.md). Standalone by design: it is +# NOT grafted into release.yml so the pure-JS beta-cadence publisher stays +# untouched; the release flow downloads these artifacts before the CLI build. +# +# Mirrors the napi-rs package-template CI build matrix (runner mapping + +# --use-napi-cross for linux-gnu cross builds + cargo-zigbuild for musl). Every +# `uses:` is SHA-pinned with a version comment, matching release.yml. + +on: + workflow_dispatch: + push: + paths: + - "packages/native-config/**" + - ".github/workflows/native-config-prebuild.yml" + pull_request: + paths: + - "packages/native-config/**" + - ".github/workflows/native-config-prebuild.yml" + +# Per-ref isolation — a new push to the same branch cancels the in-flight run. +# Deliberately NOT the `ok-release-cadence` group (that serializes beta/stable +# cuts); this prebuild is independent of the release pipeline. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + name: build - ${{ matrix.settings.target }} + runs-on: ${{ matrix.settings.host }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + settings: + # macos-26 is Apple-Silicon (arm64); it cross-builds the x64 darwin + # target with the same SDK, so both darwin targets share the host. + - host: macos-26 + target: aarch64-apple-darwin + napiFlags: "" + - host: macos-26 + target: x86_64-apple-darwin + napiFlags: "" + # --use-napi-cross pulls the zig-backed cross toolchain napi-rs ships + # for glibc Linux targets, so an x64 ubuntu host builds both arches. + - host: ubuntu-latest + target: x86_64-unknown-linux-gnu + napiFlags: "--use-napi-cross" + - host: ubuntu-latest + target: aarch64-unknown-linux-gnu + napiFlags: "--use-napi-cross" + # -x routes the build through cargo-zigbuild for musl (Alpine) targets. + - host: ubuntu-latest + target: x86_64-unknown-linux-musl + napiFlags: "-x" + - host: ubuntu-latest + target: aarch64-unknown-linux-musl + napiFlags: "-x" + - host: windows-latest + target: x86_64-pc-windows-msvc + napiFlags: "" + - host: windows-latest + target: aarch64-pc-windows-msvc + napiFlags: "" + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + targets: ${{ matrix.settings.target }} + + - name: Cache cargo + target + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + packages/native-config/target/ + # Hash the manifest + lockfile into the key so a crate bump can't be + # served a stale registry/target tree from an older resolution. The + # restore-keys prefix still allows a partial warm-start (registry + + # unchanged crates) on a manifest change. + key: ${{ matrix.settings.target }}-cargo-${{ matrix.settings.host }}-${{ hashFiles('packages/native-config/Cargo.lock', 'packages/native-config/Cargo.toml') }} + restore-keys: | + ${{ matrix.settings.target }}-cargo-${{ matrix.settings.host }}- + + - name: Setup Zig (musl targets only) + if: contains(matrix.settings.target, 'musl') + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + with: + version: 0.14.1 + + - name: Install cargo-zigbuild (musl targets only) + if: contains(matrix.settings.target, 'musl') + uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2.82.5 + with: + tool: cargo-zigbuild + + - name: Install JS dependencies + run: bash scripts/bun-install-ci.sh + + - name: Build addon + working-directory: packages/native-config + shell: bash + # Appends the per-target flags to the package's own + # `napi build --platform --release` script. + run: bun run build -- --target ${{ matrix.settings.target }} ${{ matrix.settings.napiFlags }} + + - name: Upload binding + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bindings-${{ matrix.settings.target }} + path: packages/native-config/*.node + if-no-files-found: error + + combine: + name: combine + verify all targets present + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download all bindings + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + path: artifacts + + - name: Assert every target produced exactly one binding + shell: bash + run: | + set -euo pipefail + targets=( + aarch64-apple-darwin + x86_64-apple-darwin + x86_64-unknown-linux-gnu + aarch64-unknown-linux-gnu + x86_64-unknown-linux-musl + aarch64-unknown-linux-musl + x86_64-pc-windows-msvc + aarch64-pc-windows-msvc + ) + missing=0 + for t in "${targets[@]}"; do + dir="artifacts/bindings-$t" + count=$(find "$dir" -name '*.node' 2>/dev/null | wc -l | tr -d ' ') + if [ "$count" -lt 1 ]; then + echo "::error::no .node found for target $t (artifact bindings-$t)" + missing=1 + else + echo "ok: $t -> $(find "$dir" -name '*.node')" + fi + done + total=$(find artifacts -name '*.node' | wc -l | tr -d ' ') + echo "total .node files: $total" + if [ "$total" -ne 8 ]; then + echo "::error::expected 8 .node binaries (one per target), found $total" + missing=1 + fi + [ "$missing" -eq 0 ] + + - name: Upload combined bindings bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-config-bindings-all + path: artifacts/**/*.node + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 917b3af0f..3c60cba6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,6 +155,91 @@ jobs: - name: Install run: bash scripts/bun-install-ci.sh + # The cross-platform native-config `.node` binaries are produced by the + # standalone native-config-prebuild workflow (kept separate so the beta + # cadence stays pure-JS-fast). Stage the latest successful run's bundle + # into packages/native-config/ so the cli `build:native` step copies all + # eight platforms into dist/native/ before publish. The bundle must come + # from a prebuild run whose head commit is an ancestor of the release + # commit (provenance), so the native code we ship was built from source + # actually in this release rather than a later main commit racing the cut. + # Beta is resilient by design: a missing, failed, stale, or incomplete + # bundle warns and ships host-only, with non-host platforms on the + # non-destructive smol-toml fallback, so a beta is never worse than + # host-only and the cadence never blocks. Stable @latest is held higher: + # an incomplete set hard-fails the cut rather than silently shipping + # degraded native fidelity to the default install channel. Binaries are + # ABI-stable (napi6); the latest qualifying run's set is reused until + # native-config changes and a fresh prebuild run completes. + - name: Stage native-config prebuilt binaries + env: + GH_TOKEN: ${{ github.token }} + # Stable @latest cuts arrive as repository_dispatch (publish-stable); + # beta + manual recovery arrive as push/workflow_dispatch. Only stable + # hard-fails on an incomplete native-config set. + IS_STABLE: ${{ github.event_name == 'repository_dispatch' }} + run: | + # Record the staged-binary outcome in the job summary, then either + # warn-and-proceed (beta) or hard-fail (stable). A stable @latest + # publish shipping host-only native binaries is a fidelity regression, + # so it pages rather than ships silently degraded. + summarize() { + echo "Staged native-config binaries: $1" >> "$GITHUB_STEP_SUMMARY" + } + degrade_or_fail() { + # $1: human-readable reason, $2: staged-count label for the summary + summarize "$2 — $1" + if [ "$IS_STABLE" = "true" ]; then + echo "::error::$1; refusing to cut a stable @latest release with an incomplete native-config binary set" + exit 1 + fi + echo "::warning::$1; CLI ships the host binary only (other platforms use the smol-toml fallback)" + exit 0 + } + + # Scope to runs on `main` triggered by `push` only. The prebuild also + # runs on `pull_request` (including from the public mirror's external + # PRs), so without this filter the latest-green run could be an + # unmerged branch whose Rust source is not what shipped to main — a + # supply-chain gap for native code executed on every CLI invocation. + run_id=$(gh run list --workflow=native-config-prebuild.yml --branch main --event push \ + --status success --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true) + if [ -z "$run_id" ]; then + degrade_or_fail "no successful native-config-prebuild run on main found" "0/8" + fi + + # Provenance: the prebuild run's head commit must be an ancestor of the + # release commit, so the binaries staged were built from source that is + # actually in this release. fetch-depth:0 in checkout has full history. + head_sha=$(gh run view "$run_id" --json headSha -q .headSha 2>/dev/null || true) + if [ -z "$head_sha" ]; then + degrade_or_fail "could not resolve head commit of native-config-prebuild run $run_id" "0/8" + fi + if ! git merge-base --is-ancestor "$head_sha" HEAD 2>/dev/null; then + degrade_or_fail "native-config-prebuild run $run_id (commit $head_sha) is not an ancestor of the release commit" "0/8" + fi + + if ! gh run download "$run_id" --name native-config-bindings-all --dir /tmp/nc-bindings; then + degrade_or_fail "could not download native-config-bindings-all from run $run_id" "0/8" + fi + find /tmp/nc-bindings -name '*.node' -exec cp -f {} packages/native-config/ \; + count=$(find packages/native-config -maxdepth 1 -name '*.node' | wc -l | tr -d ' ') + echo "staged $count native-config prebuilt binaries from run $run_id (commit $head_sha)" + if [ "$count" -lt 8 ]; then + degrade_or_fail "staged only $count/8 native-config binaries" "$count/8" + fi + summarize "$count/8 from run $run_id (commit $head_sha)" + + # `bun run build` builds the native-config addon's host `.node` via + # `napi build` (turbo dep of the cli build), which needs a Rust toolchain. + # Pin it explicitly rather than relying on whatever `ubuntu-latest` + # preinstalls, so an image change can't silently break the whole publish at + # the unguarded cargo invocation. Mirrors native-config-prebuild.yml. + - name: Setup Rust toolchain for the native-config build + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + # `bun changeset publish` invokes `npm publish` per package, which # in turn fires the cli's `prepublishOnly` (-> `bun run build`). The # cli's own build chain includes `cp -r ../app/dist dist/public` diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f97bcf2df..723e5b582 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -388,7 +388,7 @@ Library. ## Apache License, Version 2.0 -Each package in this section is licensed under the Apache License, Version 2.0. The full text of the license is reproduced once below and applies to every entry; per-package `NOTICE` file content is reproduced inline with each entry. +The packages and vendored source in this section are licensed under the Apache License, Version 2.0. The full text of the license is reproduced once below and applies to every entry; per-package `NOTICE` file content is reproduced inline with each entry. ``` Apache License @@ -902,6 +902,66 @@ Homepage: https://github.com/mikeal/tunnel-agent _(LICENSE template present but no copyright line filled in; refer to the package source for canonical attribution.)_ +### OpenAI Codex (vendored into `packages/native-config`) +Homepage: https://github.com/openai/codex + +Copyright 2025 OpenAI + +The native harness-config addon contains Rust code derived from OpenAI Codex's `toml_edit`-based config-edit implementation, adapted to an insert-only single-entry upsert. The Apache License, Version 2.0 reproduced above applies. Derived files and their upstream origins: + +- `src/document_helpers.rs` — from `codex-rs/core/src/config/edit/document_helpers.rs` +- `src/mcp_edit.rs` — adapted from `codex-rs/core/src/config/edit.rs` (insert-only, not `replace_mcp_servers`) +- `src/path_resolve.rs` — from `codex-rs/utils/path-utils/src/lib.rs` +- `src/mcp_edit_conformance_tests.rs` — ported from `codex-rs/core/src/config/edit_tests.rs` + +NOTICE: + +``` +OpenAI Codex +Copyright 2025 OpenAI +``` + +--- + +## Bundled Rust crates (native-config addon) + +The native harness-config addon's `.node` binaries statically link the Rust crates below. Each is redistributed under the license shown; every license here is MIT, Apache-2.0, or ISC, whose full texts are reproduced elsewhere in this document (for dual- or multi-licensed crates OpenKnowledge elects a reproduced license). Compile-time-only crates (proc-macros, build scripts) and test-only dev-dependencies are not listed — their code is not present in the distributed binary. Versions track `packages/native-config/Cargo.lock`. + +- `bitflags@2.13.0` — MIT OR Apache-2.0 — https://github.com/bitflags/bitflags +- `cfg-if@1.0.4` — MIT OR Apache-2.0 — https://github.com/rust-lang/cfg-if +- `ctor@1.0.7` — Apache-2.0 OR MIT — https://github.com/mmastrac/linktime +- `equivalent@1.0.2` — Apache-2.0 OR MIT — https://github.com/indexmap-rs/equivalent +- `futures@0.3.32` — MIT OR Apache-2.0 — https://github.com/rust-lang/futures-rs +- `futures-channel@0.3.32` — MIT OR Apache-2.0 — https://github.com/rust-lang/futures-rs +- `futures-core@0.3.32` — MIT OR Apache-2.0 — https://github.com/rust-lang/futures-rs +- `futures-executor@0.3.32` — MIT OR Apache-2.0 — https://github.com/rust-lang/futures-rs +- `futures-io@0.3.32` — MIT OR Apache-2.0 — https://github.com/rust-lang/futures-rs +- `futures-sink@0.3.32` — MIT OR Apache-2.0 — https://github.com/rust-lang/futures-rs +- `futures-task@0.3.32` — MIT OR Apache-2.0 — https://github.com/rust-lang/futures-rs +- `futures-util@0.3.32` — MIT OR Apache-2.0 — https://github.com/rust-lang/futures-rs +- `hashbrown@0.17.1` — MIT OR Apache-2.0 — https://github.com/rust-lang/hashbrown +- `indexmap@2.14.0` — Apache-2.0 OR MIT — https://github.com/indexmap-rs/indexmap +- `itoa@1.0.18` — MIT OR Apache-2.0 — https://github.com/dtolnay/itoa +- `libloading@0.9.0` — ISC — https://github.com/nagisa/rust_libloading +- `memchr@2.8.2` — Unlicense OR MIT — https://github.com/BurntSushi/memchr +- `napi@3.9.4` — MIT — https://github.com/napi-rs/napi-rs +- `napi-sys@3.2.2` — MIT — https://github.com/napi-rs/napi-rs +- `nohash-hasher@0.2.0` — Apache-2.0 OR MIT — https://github.com/paritytech/nohash-hasher +- `pin-project-lite@0.2.17` — Apache-2.0 OR MIT — https://github.com/taiki-e/pin-project-lite +- `rustc-hash@2.1.2` — Apache-2.0 OR MIT — https://github.com/rust-lang/rustc-hash +- `serde@1.0.228` — MIT OR Apache-2.0 — https://github.com/serde-rs/serde +- `serde_core@1.0.228` — MIT OR Apache-2.0 — https://github.com/serde-rs/serde +- `serde_json@1.0.150` — MIT OR Apache-2.0 — https://github.com/serde-rs/json +- `slab@0.4.12` — MIT — https://github.com/tokio-rs/slab +- `toml_datetime@0.7.5+spec-1.1.0` — MIT OR Apache-2.0 — https://github.com/toml-rs/toml +- `toml_edit@0.24.1+spec-1.1.0` — MIT OR Apache-2.0 — https://github.com/toml-rs/toml +- `toml_parser@1.1.2+spec-1.1.0` — MIT OR Apache-2.0 — https://github.com/toml-rs/toml +- `toml_writer@1.1.1+spec-1.1.0` — MIT OR Apache-2.0 — https://github.com/toml-rs/toml +- `windows-link@0.2.1` — MIT OR Apache-2.0 — https://github.com/microsoft/windows-rs +- `winnow@0.7.15` — MIT — https://github.com/winnow-rs/winnow +- `winnow@1.0.3` — MIT — https://github.com/winnow-rs/winnow +- `zmij@1.0.21` — MIT — https://github.com/dtolnay/zmij + --- ## MIT License @@ -1260,12 +1320,7 @@ Homepage: https://iconify.design/docs/libraries/utils/ Copyright (c) 2021-PRESENT Vjacheslav Trushkin -### `@inquirer/ansi@2.0.5` -Homepage: https://github.com/SBoudrias/Inquirer.js/blob/main/packages/ansi/README.md - -Copyright (c) 2025 Simon Boudrias - -### `@inquirer/ansi@2.0.6` +### `@inquirer/ansi@2.0.7` Homepage: https://github.com/SBoudrias/Inquirer.js/blob/main/packages/ansi/README.md Copyright (c) 2025 Simon Boudrias @@ -1275,7 +1330,7 @@ Homepage: https://github.com/SBoudrias/Inquirer.js/blob/main/packages/ansi/READM Copyright (c) 2025 Simon Boudrias -### `@inquirer/checkbox@5.1.4` +### `@inquirer/checkbox@5.2.1` Homepage: https://github.com/SBoudrias/Inquirer.js/blob/main/packages/checkbox/README.md Copyright (c) 2025 Simon Boudrias @@ -1285,17 +1340,7 @@ Homepage: https://github.com/SBoudrias/Inquirer.js/blob/main/packages/confirm/RE Copyright (c) 2025 Simon Boudrias -### `@inquirer/core@11.1.9` -Homepage: https://github.com/SBoudrias/Inquirer.js/blob/main/packages/core/README.md - -Copyright (c) 2025 Simon Boudrias - -### `@inquirer/core@11.1.8` -Homepage: https://github.com/SBoudrias/Inquirer.js/blob/main/packages/core/README.md - -Copyright (c) 2025 Simon Boudrias - -### `@inquirer/core@11.2.0` +### `@inquirer/core@11.2.1` Homepage: https://github.com/SBoudrias/Inquirer.js/blob/main/packages/core/README.md Copyright (c) 2025 Simon Boudrias @@ -1305,12 +1350,7 @@ Homepage: https://github.com/SBoudrias/Inquirer.js/blob/main/packages/core/READM Copyright (c) 2025 Simon Boudrias -### `@inquirer/figures@2.0.5` -Homepage: https://github.com/SBoudrias/Inquirer.js - -Copyright (c) 2025 Simon Boudrias - -### `@inquirer/figures@2.0.6` +### `@inquirer/figures@2.0.7` Homepage: https://github.com/SBoudrias/Inquirer.js Copyright (c) 2025 Simon Boudrias @@ -1320,22 +1360,17 @@ Homepage: https://github.com/SBoudrias/Inquirer.js Copyright (c) 2025 Simon Boudrias -### `@inquirer/password@5.0.11` +### `@inquirer/password@5.1.1` Homepage: https://github.com/SBoudrias/Inquirer.js/blob/main/packages/password/README.md Copyright (c) 2025 Simon Boudrias -### `@inquirer/select@5.2.0` +### `@inquirer/select@5.2.1` Homepage: https://github.com/SBoudrias/Inquirer.js/blob/main/packages/select/README.md Copyright (c) 2025 Simon Boudrias -### `@inquirer/type@4.0.5` -Homepage: https://github.com/SBoudrias/Inquirer.js - -Copyright (c) 2025 Simon Boudrias - -### `@inquirer/type@4.0.6` +### `@inquirer/type@4.0.7` Homepage: https://github.com/SBoudrias/Inquirer.js Copyright (c) 2025 Simon Boudrias @@ -3844,6 +3879,11 @@ Homepage: http://json5.org/ Copyright (c) 2012-2018 Aseem Kishore, and [others]. +### `jsonc-parser@3.3.1` +Homepage: https://github.com/microsoft/node-jsonc-parser + +Copyright (c) Microsoft + ### `jsonfile@6.2.0` Homepage: https://github.com/jprichardson/node-jsonfile @@ -5988,11 +6028,6 @@ Homepage: https://github.com/npm/mute-stream Copyright (c) Isaac Z. Schlueter and Contributors -### `mute-stream@4.0.0` -Homepage: https://github.com/npm/mute-stream - -Copyright (c) Isaac Z. Schlueter and Contributors - ### `mute-stream@2.0.0` Homepage: https://github.com/npm/mute-stream @@ -6008,6 +6043,11 @@ Homepage: https://github.com/alexeyraspopov/picocolors Copyright (c) 2021-2024 Oleksii Raspopov, Kostiantyn Denysov, Anton Verinov +### `semver@7.8.5` +Homepage: https://github.com/npm/node-semver + +Copyright (c) Isaac Z. Schlueter and Contributors + ### `semver@7.7.4` Homepage: https://github.com/npm/node-semver diff --git a/biome.jsonc b/biome.jsonc index 45b413dfd..f6eb1713f 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -253,6 +253,12 @@ "**", "!**/node_modules", "!**/dist", + // napi-rs build artifacts: a per-platform `.node` binary and the + // auto-generated CommonJS loader + d.ts. Regenerated by `napi build`, + // gitignored, and owned by the napi-rs toolchain's formatting. + "!packages/native-config/index.js", + "!packages/native-config/index.d.ts", + "!packages/native-config/target", "!**/.next", "!**/.source", "!**/.turbo", diff --git a/bun.lock b/bun.lock index 4384ae53e..74e233433 100644 --- a/bun.lock +++ b/bun.lock @@ -232,6 +232,7 @@ "@octokit/rest": "^22.0.1", "cli-boxes": "^4.0.1", "commander": "^14.0.0", + "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "pino": "^10.3.1", "simple-git": "^3.36.0", @@ -244,6 +245,7 @@ "devDependencies": { "@inkeep/open-knowledge-app": "workspace:*", "@inkeep/open-knowledge-core": "workspace:*", + "@inkeep/open-knowledge-native-config": "workspace:*", "@inkeep/open-knowledge-server": "workspace:*", "@types/node": "^24.7.0", "@types/yazl": "^3.3.1", @@ -338,6 +340,7 @@ "dependencies": { "@inkeep/open-knowledge": "workspace:*", "@inkeep/open-knowledge-core": "workspace:*", + "@inkeep/open-knowledge-native-config": "workspace:*", "@inkeep/open-knowledge-server": "workspace:*", "@napi-rs/keyring": "^1.3.0", "electron-updater": "6.8.4", @@ -362,6 +365,13 @@ "vite": "^8.0.0", }, }, + "packages/native-config": { + "name": "@inkeep/open-knowledge-native-config", + "version": "0.0.0", + "devDependencies": { + "@napi-rs/cli": "^3.7.2", + }, + }, "packages/plugin": { "name": "@inkeep/open-knowledge-plugin", "version": "0.3.1", @@ -824,27 +834,43 @@ "@inkeep/open-knowledge-docs": ["@inkeep/open-knowledge-docs@workspace:docs"], + "@inkeep/open-knowledge-native-config": ["@inkeep/open-knowledge-native-config@workspace:packages/native-config"], + "@inkeep/open-knowledge-plugin": ["@inkeep/open-knowledge-plugin@workspace:packages/plugin"], "@inkeep/open-knowledge-server": ["@inkeep/open-knowledge-server@workspace:packages/server"], - "@inquirer/ansi": ["@inquirer/ansi@2.0.5", "", {}, "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="], - "@inquirer/checkbox": ["@inquirer/checkbox@5.1.4", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.9", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-w6KF8ZYRvqHhROkOTHXYC3qIV/KYEu5o12oLqQySvch61vrYtRxNSHTONSdJqWiFJPlCUQAHT5OgOIyuTr+MHQ=="], + "@inquirer/checkbox": ["@inquirer/checkbox@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw=="], "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], - "@inquirer/core": ["@inquirer/core@11.1.9", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg=="], + "@inquirer/core": ["@inquirer/core@11.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA=="], + + "@inquirer/editor": ["@inquirer/editor@5.2.2", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/external-editor": "^3.0.3", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg=="], + + "@inquirer/expand": ["@inquirer/expand@5.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g=="], "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], - "@inquirer/figures": ["@inquirer/figures@2.0.5", "", {}, "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ=="], + "@inquirer/figures": ["@inquirer/figures@2.0.7", "", {}, "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw=="], - "@inquirer/password": ["@inquirer/password@5.0.11", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.8", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-9KZFeRaNHIcejtPb0wN4ddFc7EvobVoAFa049eS3LrDZFxI8O7xUXiITEOinBzkZFAIwY5V4yzQae/QfO9cbbg=="], + "@inquirer/input": ["@inquirer/input@5.1.2", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg=="], - "@inquirer/select": ["@inquirer/select@5.2.0", "", { "dependencies": { "@inquirer/ansi": "^2.0.6", "@inquirer/core": "^11.2.0", "@inquirer/figures": "^2.0.6", "@inquirer/type": "^4.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6IzkcmEbEXfgVbxZ2d1UyJFbCBoc6dTofulFmrYuomIp88HXiVqRbqbg4/mbfZhvnNo6xYmnYo2AEmDof6fQkg=="], + "@inquirer/number": ["@inquirer/number@4.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA=="], - "@inquirer/type": ["@inquirer/type@4.0.5", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q=="], + "@inquirer/password": ["@inquirer/password@5.1.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg=="], + + "@inquirer/prompts": ["@inquirer/prompts@8.5.2", "", { "dependencies": { "@inquirer/checkbox": "^5.2.1", "@inquirer/confirm": "^6.1.1", "@inquirer/editor": "^5.2.2", "@inquirer/expand": "^5.1.1", "@inquirer/input": "^5.1.2", "@inquirer/number": "^4.1.1", "@inquirer/password": "^5.1.1", "@inquirer/rawlist": "^5.3.1", "@inquirer/search": "^4.2.1", "@inquirer/select": "^5.2.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@5.3.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og=="], + + "@inquirer/search": ["@inquirer/search@4.2.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g=="], + + "@inquirer/select": ["@inquirer/select@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw=="], + + "@inquirer/type": ["@inquirer/type@4.0.7", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g=="], "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], @@ -980,6 +1006,10 @@ "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="], + "@napi-rs/cli": ["@napi-rs/cli@3.7.2", "", { "dependencies": { "@inquirer/prompts": "^8.5.2", "@napi-rs/cross-toolchain": "^1.0.3", "@napi-rs/wasm-tools": "^1.0.1", "@octokit/rest": "^22.0.1", "clipanion": "^4.0.0-rc.4", "colorette": "^2.0.20", "emnapi": "^1.11.1", "es-toolkit": "^1.47.0", "js-yaml": "^4.2.0", "obug": "^2.1.2", "semver": "^7.8.2", "typanion": "^3.14.0" }, "peerDependencies": { "@emnapi/runtime": "^1.7.1" }, "optionalPeers": ["@emnapi/runtime"], "bin": { "napi": "dist/cli.js", "napi-raw": "cli.mjs" } }, "sha512-shDW0Td/XZQpP04Yy+OsMt1ILMKGGkoLcy1zVAsSAK0fLfWm0Upgkmfs/NOV2ZhMQwkgpR3ZEdyHmTwgrUDQuA=="], + + "@napi-rs/cross-toolchain": ["@napi-rs/cross-toolchain@1.0.3", "", { "dependencies": { "@napi-rs/lzma": "^1.4.5", "@napi-rs/tar": "^1.1.0", "debug": "^4.4.1" }, "peerDependencies": { "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" }, "optionalPeers": ["@napi-rs/cross-toolchain-arm64-target-aarch64", "@napi-rs/cross-toolchain-arm64-target-armv7", "@napi-rs/cross-toolchain-arm64-target-ppc64le", "@napi-rs/cross-toolchain-arm64-target-s390x", "@napi-rs/cross-toolchain-arm64-target-x86_64", "@napi-rs/cross-toolchain-x64-target-aarch64", "@napi-rs/cross-toolchain-x64-target-armv7", "@napi-rs/cross-toolchain-x64-target-ppc64le", "@napi-rs/cross-toolchain-x64-target-s390x", "@napi-rs/cross-toolchain-x64-target-x86_64"] }, "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg=="], + "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], "@napi-rs/keyring-darwin-arm64": ["@napi-rs/keyring-darwin-arm64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ=="], @@ -1006,8 +1036,106 @@ "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="], + "@napi-rs/lzma": ["@napi-rs/lzma@1.4.5", "", { "optionalDependencies": { "@napi-rs/lzma-android-arm-eabi": "1.4.5", "@napi-rs/lzma-android-arm64": "1.4.5", "@napi-rs/lzma-darwin-arm64": "1.4.5", "@napi-rs/lzma-darwin-x64": "1.4.5", "@napi-rs/lzma-freebsd-x64": "1.4.5", "@napi-rs/lzma-linux-arm-gnueabihf": "1.4.5", "@napi-rs/lzma-linux-arm64-gnu": "1.4.5", "@napi-rs/lzma-linux-arm64-musl": "1.4.5", "@napi-rs/lzma-linux-ppc64-gnu": "1.4.5", "@napi-rs/lzma-linux-riscv64-gnu": "1.4.5", "@napi-rs/lzma-linux-s390x-gnu": "1.4.5", "@napi-rs/lzma-linux-x64-gnu": "1.4.5", "@napi-rs/lzma-linux-x64-musl": "1.4.5", "@napi-rs/lzma-wasm32-wasi": "1.4.5", "@napi-rs/lzma-win32-arm64-msvc": "1.4.5", "@napi-rs/lzma-win32-ia32-msvc": "1.4.5", "@napi-rs/lzma-win32-x64-msvc": "1.4.5" } }, "sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg=="], + + "@napi-rs/lzma-android-arm-eabi": ["@napi-rs/lzma-android-arm-eabi@1.4.5", "", { "os": "android", "cpu": "arm" }, "sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q=="], + + "@napi-rs/lzma-android-arm64": ["@napi-rs/lzma-android-arm64@1.4.5", "", { "os": "android", "cpu": "arm64" }, "sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ=="], + + "@napi-rs/lzma-darwin-arm64": ["@napi-rs/lzma-darwin-arm64@1.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA=="], + + "@napi-rs/lzma-darwin-x64": ["@napi-rs/lzma-darwin-x64@1.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw=="], + + "@napi-rs/lzma-freebsd-x64": ["@napi-rs/lzma-freebsd-x64@1.4.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw=="], + + "@napi-rs/lzma-linux-arm-gnueabihf": ["@napi-rs/lzma-linux-arm-gnueabihf@1.4.5", "", { "os": "linux", "cpu": "arm" }, "sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw=="], + + "@napi-rs/lzma-linux-arm64-gnu": ["@napi-rs/lzma-linux-arm64-gnu@1.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg=="], + + "@napi-rs/lzma-linux-arm64-musl": ["@napi-rs/lzma-linux-arm64-musl@1.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w=="], + + "@napi-rs/lzma-linux-ppc64-gnu": ["@napi-rs/lzma-linux-ppc64-gnu@1.4.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ=="], + + "@napi-rs/lzma-linux-riscv64-gnu": ["@napi-rs/lzma-linux-riscv64-gnu@1.4.5", "", { "os": "linux", "cpu": "none" }, "sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA=="], + + "@napi-rs/lzma-linux-s390x-gnu": ["@napi-rs/lzma-linux-s390x-gnu@1.4.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw=="], + + "@napi-rs/lzma-linux-x64-musl": ["@napi-rs/lzma-linux-x64-musl@1.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ=="], + + "@napi-rs/lzma-wasm32-wasi": ["@napi-rs/lzma-wasm32-wasi@1.4.5", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA=="], + + "@napi-rs/lzma-win32-arm64-msvc": ["@napi-rs/lzma-win32-arm64-msvc@1.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg=="], + + "@napi-rs/lzma-win32-ia32-msvc": ["@napi-rs/lzma-win32-ia32-msvc@1.4.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg=="], + + "@napi-rs/lzma-win32-x64-msvc": ["@napi-rs/lzma-win32-x64-msvc@1.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q=="], + + "@napi-rs/tar": ["@napi-rs/tar@1.1.0", "", { "optionalDependencies": { "@napi-rs/tar-android-arm-eabi": "1.1.0", "@napi-rs/tar-android-arm64": "1.1.0", "@napi-rs/tar-darwin-arm64": "1.1.0", "@napi-rs/tar-darwin-x64": "1.1.0", "@napi-rs/tar-freebsd-x64": "1.1.0", "@napi-rs/tar-linux-arm-gnueabihf": "1.1.0", "@napi-rs/tar-linux-arm64-gnu": "1.1.0", "@napi-rs/tar-linux-arm64-musl": "1.1.0", "@napi-rs/tar-linux-ppc64-gnu": "1.1.0", "@napi-rs/tar-linux-s390x-gnu": "1.1.0", "@napi-rs/tar-linux-x64-gnu": "1.1.0", "@napi-rs/tar-linux-x64-musl": "1.1.0", "@napi-rs/tar-wasm32-wasi": "1.1.0", "@napi-rs/tar-win32-arm64-msvc": "1.1.0", "@napi-rs/tar-win32-ia32-msvc": "1.1.0", "@napi-rs/tar-win32-x64-msvc": "1.1.0" } }, "sha512-7cmzIu+Vbupriudo7UudoMRH2OA3cTw67vva8MxeoAe5S7vPFI7z0vp0pMXiA25S8IUJefImQ90FeJjl8fjEaQ=="], + + "@napi-rs/tar-android-arm-eabi": ["@napi-rs/tar-android-arm-eabi@1.1.0", "", { "os": "android", "cpu": "arm" }, "sha512-h2Ryndraj/YiKgMV/r5by1cDusluYIRT0CaE0/PekQ4u+Wpy2iUVqvzVU98ZPnhXaNeYxEvVJHNGafpOfaD0TA=="], + + "@napi-rs/tar-android-arm64": ["@napi-rs/tar-android-arm64@1.1.0", "", { "os": "android", "cpu": "arm64" }, "sha512-DJFyQHr1ZxNZorm/gzc1qBNLF/FcKzcH0V0Vwan5P+o0aE2keQIGEjJ09FudkF9v6uOuJjHCVDdK6S6uHtShAw=="], + + "@napi-rs/tar-darwin-arm64": ["@napi-rs/tar-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Zz2sXRzjIX4e532zD6xm2SjXEym6MkvfCvL2RMpG2+UwNVDVscHNcz3d47Pf3sysP2e2af7fBB3TIoK2f6trPw=="], + + "@napi-rs/tar-darwin-x64": ["@napi-rs/tar-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EI+CptIMNweT0ms9S3mkP/q+J6FNZ1Q6pvpJOEcWglRfyfQpLqjlC0O+dptruTPE8VamKYuqdjxfqD8hifZDOA=="], + + "@napi-rs/tar-freebsd-x64": ["@napi-rs/tar-freebsd-x64@1.1.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J0PIqX+pl6lBIAckL/c87gpodLbjZB1OtIK+RDscKC9NLdpVv6VGOxzUV/fYev/hctcE8EfkLbgFOfpmVQPg2g=="], + + "@napi-rs/tar-linux-arm-gnueabihf": ["@napi-rs/tar-linux-arm-gnueabihf@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-SLgIQo3f3EjkZ82ZwvrEgFvMdDAhsxCYjyoSuWfHCz0U16qx3SuGCp8+FYOPYCECHN3ZlGjXnoAIt9ERd0dEUg=="], + + "@napi-rs/tar-linux-arm64-gnu": ["@napi-rs/tar-linux-arm64-gnu@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-d014cdle52EGaH6GpYTQOP9Py7glMO1zz/+ynJPjjzYFSxvdYx0byrjumZk2UQdIyGZiJO2MEFpCkEEKFSgPYA=="], + + "@napi-rs/tar-linux-arm64-musl": ["@napi-rs/tar-linux-arm64-musl@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ=="], + + "@napi-rs/tar-linux-ppc64-gnu": ["@napi-rs/tar-linux-ppc64-gnu@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ=="], + + "@napi-rs/tar-linux-s390x-gnu": ["@napi-rs/tar-linux-s390x-gnu@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ=="], + + "@napi-rs/tar-linux-x64-gnu": ["@napi-rs/tar-linux-x64-gnu@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww=="], + + "@napi-rs/tar-linux-x64-musl": ["@napi-rs/tar-linux-x64-musl@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ=="], + + "@napi-rs/tar-wasm32-wasi": ["@napi-rs/tar-wasm32-wasi@1.1.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw=="], + + "@napi-rs/tar-win32-arm64-msvc": ["@napi-rs/tar-win32-arm64-msvc@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-vfpG71OB0ijtjemp3WTdmBKJm9R70KM8vsSExMsIQtV0lVzP07oM1CW6JbNRPXNLhRoue9ofYLiUDk8bE0Hckg=="], + + "@napi-rs/tar-win32-ia32-msvc": ["@napi-rs/tar-win32-ia32-msvc@1.1.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-hGPyPW60YSpOSgzfy68DLBHgi6HxkAM+L59ZZZPMQ0TOXjQg+p2EW87+TjZfJOkSpbYiEkULwa/f4a2hcVjsqQ=="], + + "@napi-rs/tar-win32-x64-msvc": ["@napi-rs/tar-win32-x64-msvc@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@napi-rs/wasm-tools": ["@napi-rs/wasm-tools@1.0.1", "", { "optionalDependencies": { "@napi-rs/wasm-tools-android-arm-eabi": "1.0.1", "@napi-rs/wasm-tools-android-arm64": "1.0.1", "@napi-rs/wasm-tools-darwin-arm64": "1.0.1", "@napi-rs/wasm-tools-darwin-x64": "1.0.1", "@napi-rs/wasm-tools-freebsd-x64": "1.0.1", "@napi-rs/wasm-tools-linux-arm64-gnu": "1.0.1", "@napi-rs/wasm-tools-linux-arm64-musl": "1.0.1", "@napi-rs/wasm-tools-linux-x64-gnu": "1.0.1", "@napi-rs/wasm-tools-linux-x64-musl": "1.0.1", "@napi-rs/wasm-tools-wasm32-wasi": "1.0.1", "@napi-rs/wasm-tools-win32-arm64-msvc": "1.0.1", "@napi-rs/wasm-tools-win32-ia32-msvc": "1.0.1", "@napi-rs/wasm-tools-win32-x64-msvc": "1.0.1" } }, "sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ=="], + + "@napi-rs/wasm-tools-android-arm-eabi": ["@napi-rs/wasm-tools-android-arm-eabi@1.0.1", "", { "os": "android", "cpu": "arm" }, "sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw=="], + + "@napi-rs/wasm-tools-android-arm64": ["@napi-rs/wasm-tools-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-WDR7S+aRLV6LtBJAg5fmjKkTZIdrEnnQxgdsb7Cf8pYiMWBHLU+LC49OUVppQ2YSPY0+GeYm9yuZWW3kLjJ7Bg=="], + + "@napi-rs/wasm-tools-darwin-arm64": ["@napi-rs/wasm-tools-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qWTI+EEkiN0oIn/N2gQo7+TVYil+AJ20jjuzD2vATS6uIjVz+Updeqmszi7zq7rdFTLp6Ea3/z4kDKIfZwmR9g=="], + + "@napi-rs/wasm-tools-darwin-x64": ["@napi-rs/wasm-tools-darwin-x64@1.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-bA6hubqtHROR5UI3tToAF/c6TDmaAgF0SWgo4rADHtQ4wdn0JeogvOk50gs2TYVhKPE2ZD2+qqt7oBKB+sxW3A=="], + + "@napi-rs/wasm-tools-freebsd-x64": ["@napi-rs/wasm-tools-freebsd-x64@1.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-90+KLBkD9hZEjPQW1MDfwSt5J1L46EUKacpCZWyRuL6iIEO5CgWU0V/JnEgFsDOGyyYtiTvHc5bUdUTWd4I9Vg=="], + + "@napi-rs/wasm-tools-linux-arm64-gnu": ["@napi-rs/wasm-tools-linux-arm64-gnu@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-rG0QlS65x9K/u3HrKafDf8cFKj5wV2JHGfl8abWgKew0GVPyp6vfsDweOwHbWAjcHtp2LHi6JHoW80/MTHm52Q=="], + + "@napi-rs/wasm-tools-linux-arm64-musl": ["@napi-rs/wasm-tools-linux-arm64-musl@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jAasbIvjZXCgX0TCuEFQr+4D6Lla/3AAVx2LmDuMjgG4xoIXzjKWl7c4chuaD+TI+prWT0X6LJcdzFT+ROKGHQ=="], + + "@napi-rs/wasm-tools-linux-x64-gnu": ["@napi-rs/wasm-tools-linux-x64-gnu@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Plgk5rPqqK2nocBGajkMVbGm010Z7dnUgq0wtnYRZbzWWxwWcXfZMPa8EYxrK4eE8SzpI7VlZP1tdVsdjgGwMw=="], + + "@napi-rs/wasm-tools-linux-x64-musl": ["@napi-rs/wasm-tools-linux-x64-musl@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GW7AzGuWxtQkyHknHWYFdR0CHmW6is8rG2Rf4V6GNmMpmwtXt/ItWYWtBe4zqJWycMNazpfZKSw/BpT7/MVCXQ=="], + + "@napi-rs/wasm-tools-wasm32-wasi": ["@napi-rs/wasm-tools-wasm32-wasi@1.0.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-/nQVSTrqSsn7YdAc2R7Ips/tnw5SPUcl3D7QrXCNGPqjbatIspnaexvaOYNyKMU6xPu+pc0BTnKVmqhlJJCPLA=="], + + "@napi-rs/wasm-tools-win32-arm64-msvc": ["@napi-rs/wasm-tools-win32-arm64-msvc@1.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-PFi7oJIBu5w7Qzh3dwFea3sHRO3pojMsaEnUIy22QvsW+UJfNQwJCryVrpoUt8m4QyZXI+saEq/0r4GwdoHYFQ=="], + + "@napi-rs/wasm-tools-win32-ia32-msvc": ["@napi-rs/wasm-tools-win32-ia32-msvc@1.0.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-gXkuYzxQsgkj05Zaq+KQTkHIN83dFAwMcTKa2aQcpYPRImFm2AQzEyLtpXmyCWzJ0F9ZYAOmbSyrNew8/us6bw=="], + + "@napi-rs/wasm-tools-win32-x64-msvc": ["@napi-rs/wasm-tools-win32-x64-msvc@1.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rEAf05nol3e3eei2sRButmgXP+6ATgm0/38MKhz9Isne82T4rPIMYsCIFj0kOisaGeVwoi2fnm7O9oWp5YVnYQ=="], + "@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="], @@ -2116,6 +2244,8 @@ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "clipanion": ["clipanion@4.0.0-rc.4", "", { "dependencies": { "typanion": "^3.8.0" } }, "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], @@ -2396,6 +2526,8 @@ "electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], + "emnapi": ["emnapi@1.11.1", "", { "peerDependencies": { "node-addon-api": ">= 6.1.0" }, "optionalPeers": ["node-addon-api"] }, "sha512-kSRjhIcxjMFsBqk7ORvoc9aA5SBKDmecrtF5RMcmOTao0kD/zamaxsuTxMI8C1//wGUuvE7a+19pCE7AEhGVnA=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], @@ -2434,6 +2566,8 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], @@ -2940,6 +3074,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "just-bash": ["just-bash@2.14.2", "", { "dependencies": { "diff": "^8.0.2", "fast-xml-parser": "^5.3.3", "file-type": "^21.2.0", "ini": "^6.0.0", "minimatch": "^10.1.1", "modern-tar": "^0.7.3", "papaparse": "^5.5.3", "quickjs-emscripten": "^0.32.0", "re2js": "^1.2.1", "seek-bzip": "^2.0.0", "smol-toml": "^1.6.0", "sprintf-js": "^1.1.3", "sql.js": "^1.13.0", "turndown": "^7.2.2", "yaml": "^2.8.2" }, "optionalDependencies": { "@mongodb-js/zstd": "^7.0.0", "node-liblzma": "^2.0.3" }, "bin": { "just-bash": "dist/bin/just-bash.js", "just-bash-shell": "dist/bin/shell/shell.js" } }, "sha512-9Na1rH03Ta5ydHTNotJ7dms1iZwb2kToOnKbnS29AlrCvi1CQ21Fm2lfu4S4rfwDGHYi4E4evgTDC/DcDx8tuQ=="], @@ -3752,7 +3888,7 @@ "seek-bzip": ["seek-bzip@2.0.0", "", { "dependencies": { "commander": "^6.0.0" }, "bin": { "seek-bunzip": "bin/seek-bunzip", "seek-table": "bin/seek-bzip-table" } }, "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg=="], - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], @@ -4004,6 +4140,8 @@ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "typanion": ["typanion@3.14.0", "", {}, "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug=="], + "type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], @@ -4232,6 +4370,14 @@ "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@changesets/apply-release-plan/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@changesets/assemble-release-plan/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@changesets/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@changesets/get-dependents-graph/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], @@ -4254,6 +4400,8 @@ "@electron/rebuild/ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], + "@electron/rebuild/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@electron/universal/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], "@electron/universal/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -4300,15 +4448,9 @@ "@inquirer/confirm/@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], - "@inquirer/password/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], - - "@inquirer/select/@inquirer/ansi": ["@inquirer/ansi@2.0.6", "", {}, "sha512-I/INw4sHGlVZ/afZOckpLiDP9SmbMl1g/GCqeHjLw1Afw/0PlRs2tRFgTGWmdI0hoNuWZn3y2iHNmG1vyECyQQ=="], - - "@inquirer/select/@inquirer/core": ["@inquirer/core@11.2.0", "", { "dependencies": { "@inquirer/ansi": "^2.0.6", "@inquirer/figures": "^2.0.6", "@inquirer/type": "^4.0.6", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^4.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-joR1YS2sI0us+9d0I8ViqFbrRLONO8CFTuyvBX4ZVBSch+VsZiugUABdrhBXXJR1VyEzvpz5SQCix3keETQ58g=="], - - "@inquirer/select/@inquirer/figures": ["@inquirer/figures@2.0.6", "", {}, "sha512-dsZgQtH2t5Q6ah3aPbZbeEZAxsD9qQu0DXf01AltuEfRTm+NoLN6+rLVbr+4edeEbNCp/wBNM6mALRWtsQpfkw=="], + "@inquirer/editor/@inquirer/external-editor": ["@inquirer/external-editor@3.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA=="], - "@inquirer/select/@inquirer/type": ["@inquirer/type@4.0.6", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-J+9tdxOskuYuGjsvGaq00AamhDgjR7anhEW2dP4QdQpFCMPngCeC/bCYWQ5NsMWZRdsy53is7kAHb/+7cwDk2g=="], + "@inquirer/prompts/@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -4346,8 +4488,14 @@ "@mongodb-js/zstd/node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], + "@napi-rs/cli/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + + "@napi-rs/cli/obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@npmcli/fs/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.1.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ=="], "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.1.0", "", { "dependencies": { "@opentelemetry/core": "2.1.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw=="], @@ -4378,6 +4526,8 @@ "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.1.0", "", { "dependencies": { "@opentelemetry/core": "2.1.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw=="], + "@puppeteer/browsers/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "@radix-ui/react-popover/@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="], @@ -4462,6 +4612,8 @@ "app-builder-lib/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "ast-kit/@babel/parser": ["@babel/parser@8.0.0-rc.3", "", { "dependencies": { "@babel/types": "^8.0.0-rc.3" }, "bin": "./bin/babel-parser.js" }, "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ=="], @@ -4514,6 +4666,8 @@ "electron-updater/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + "electron-updater/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -4550,6 +4704,8 @@ "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "global-agent/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "happy-dom/whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -4598,6 +4754,12 @@ "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "node-api-version/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "node-gyp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "node-liblzma/node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], @@ -4696,6 +4858,10 @@ "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "simple-update-notifier/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], @@ -4706,6 +4872,8 @@ "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + "tsdown/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "util/inherits": ["inherits@2.0.3", "", {}, "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw=="], @@ -4872,8 +5040,6 @@ "@inquirer/confirm/@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "@inquirer/select/@inquirer/core/mute-stream": ["mute-stream@4.0.0", "", {}, "sha512-gSrprq0fJ3EiOErzjdIZrjysVVmJ4uu1QWfCDss5LypA5OXvrMje5Ym5z6V6RLyJ2eF87lasX7t6a0AnFvZblg=="], - "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -4992,6 +5158,8 @@ "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "find-chrome-bin/@puppeteer/browsers/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "fumadocs-mdx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], @@ -5084,10 +5252,14 @@ "ora/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "prebuild-install/node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "prebuild-install/tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], "prebuild-install/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "puppeteer-core/@puppeteer/browsers/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "puppeteer-core/chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "puppeteer/puppeteer-core/webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="], diff --git a/docs/content/get-started/quickstart.mdx b/docs/content/get-started/quickstart.mdx index bebaab93d..fdc568129 100644 --- a/docs/content/get-started/quickstart.mdx +++ b/docs/content/get-started/quickstart.mdx @@ -132,7 +132,7 @@ ok start --open ### Open the knowledge base in your AI agent -`ok init` already registered OpenKnowledge with the AI editors it detected, so your agent can use the OpenKnowledge tools right away. Open your project in your agent — or, from the editor, right-click the project in the sidebar, choose **Open with AI ▸**, and pick one. (The bottom **Ask AI** field is available in the desktop app only.) +`ok init` already registered OpenKnowledge with the AI editors it could configure, so your agent can use the OpenKnowledge tools right away. Open your project in your agent — or, from the editor, right-click the project in the sidebar, choose **Open with AI ▸**, and pick one. (The bottom **Ask AI** field is available in the desktop app only.) If a tool's MCP entry didn't appear, see [registration outcomes](/docs/reference/cli#set-up-a-project). In the agent's chat window, add the following to get started or use a topic of your choice: diff --git a/docs/content/integrations/claude-code.mdx b/docs/content/integrations/claude-code.mdx index 605cffa96..f4b3a056f 100644 --- a/docs/content/integrations/claude-code.mdx +++ b/docs/content/integrations/claude-code.mdx @@ -26,7 +26,7 @@ Open the project in Claude Code and ask: -If Claude Code doesn't use the OpenKnowledge `exec` tool, restart it so it picks up the new MCP entry. +If Claude Code doesn't use the OpenKnowledge `exec` tool, restart it so it picks up the new MCP entry. If it's still missing and `ok init` reported your config was `left unchanged`, OpenKnowledge couldn't parse `~/.claude.json` safely and left it untouched — fix the config and re-run `ok init`. ## Live editing diff --git a/docs/content/integrations/codex.mdx b/docs/content/integrations/codex.mdx index 6f4e6fb70..2f7e45afa 100644 --- a/docs/content/integrations/codex.mdx +++ b/docs/content/integrations/codex.mdx @@ -21,4 +21,4 @@ Open the project in Codex and ask: -If you don't see the tool, restart Codex. +If you don't see the tool, restart Codex. If it's still missing and `ok init` reported your config was `left unchanged`, OpenKnowledge couldn't parse `~/.codex/config.toml` safely and left it untouched — fix the config and re-run `ok init`. diff --git a/docs/content/integrations/cursor.mdx b/docs/content/integrations/cursor.mdx index c1111e0b4..f688aa155 100644 --- a/docs/content/integrations/cursor.mdx +++ b/docs/content/integrations/cursor.mdx @@ -27,4 +27,4 @@ Ask the agent: -If it still doesn't see the tool after approval, restart Cursor so it picks up the new MCP entry. +If it still doesn't see the tool after approval, restart Cursor so it picks up the new MCP entry. If it's still missing and `ok init` reported your config was `left unchanged`, OpenKnowledge couldn't parse `~/.cursor/mcp.json` safely and left it untouched — fix the config and re-run `ok init`. diff --git a/docs/content/integrations/opencode.mdx b/docs/content/integrations/opencode.mdx index 24fdb4b6e..672a6f263 100644 --- a/docs/content/integrations/opencode.mdx +++ b/docs/content/integrations/opencode.mdx @@ -41,4 +41,4 @@ Open the project in OpenCode and ask: -If OpenCode doesn't use the OpenKnowledge `exec` tool, restart it so it picks up the new MCP entry, and make sure the model you selected supports tool calling. +If OpenCode doesn't use the OpenKnowledge `exec` tool, restart it so it picks up the new MCP entry, and make sure the model you selected supports tool calling. If it's still missing and `ok init` reported your config was `left unchanged`, OpenKnowledge couldn't parse your `opencode.json` safely and left it untouched — fix the config and re-run `ok init`. diff --git a/docs/content/reference/cli.mdx b/docs/content/reference/cli.mdx index e65ba81b0..033755a73 100644 --- a/docs/content/reference/cli.mdx +++ b/docs/content/reference/cli.mdx @@ -20,13 +20,17 @@ npm install -g @inkeep/open-knowledge ## Set up a project -Run `ok init` in any folder to turn it into an OpenKnowledge project. It scaffolds a `.ok/` directory and registers the OpenKnowledge MCP server with the AI editors it detects on your machine (Claude Code, Cursor, Codex, OpenCode). +Run `ok init` in any folder to turn it into an OpenKnowledge project. It scaffolds a `.ok/` directory and registers the OpenKnowledge MCP server with the AI editors it detects on your machine (Claude Code, Cursor, Codex, OpenCode). It only ever adds its own entry — your other settings, comments, and formatting are left untouched. ```bash cd my-project ok init ``` + + When an editor's config can't be parsed safely — it's not valid JSON/TOML, has two MCP server blocks, or is unusually large — OpenKnowledge leaves the file **byte-for-byte untouched** and prints `left unchanged ()` instead of registering. It never rewrites or resets a config it can't fully understand. Fix the underlying config (the editor itself usually reports the parse error) and re-run `ok init`. + + Once you have initialized your project, launch the app in your browser: ```bash diff --git a/knip.config.ts b/knip.config.ts index c3ba40caa..020d6d598 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -78,6 +78,7 @@ export default { 'packages/cli/src/commands/diagnose-health-checks/index.ts': ['exports', 'types'], 'packages/cli/src/commands/diagnose-health-checks/types.ts': ['types'], 'packages/desktop/src/main/git-preflight-handler.ts': ['types'], + 'packages/native-config/index.js': ['unlisted', 'unresolved'], }, workspaces: { 'packages/app': { @@ -133,6 +134,7 @@ export default { 'tests/**/*.test.mjs', ], ignoreUnresolved: [/utility\/pty-host\.js$/], + ignoreDependencies: ['@inkeep/open-knowledge-native-config'], project: 'src/**', }, }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 786654b8c..11fcf4cf8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,8 +36,9 @@ "build:skill-asset": "bun ../server/scripts/build-skill-bundles.ts && mkdir -p dist/assets/skills && cp -R ../server/dist/assets/skills/. dist/assets/skills/", "build:notices": "mkdir -p dist && cp ../../THIRD_PARTY_NOTICES.md dist/THIRD_PARTY_NOTICES.md", "build:license": "mkdir -p dist && cp ../../LICENSE dist/LICENSE", + "build:native": "mkdir -p dist/native && cp ../native-config/index.js ../native-config/index.d.ts ../native-config/package.json dist/native/ && (cp ../native-config/*.node dist/native/ 2>/dev/null || true)", "build:schema": "bun --conditions=development scripts/build-config-schema.mjs", - "build:assets": "bun run build:app && bun run build:skill-asset && bun run build:notices && bun run build:license && bun run build:schema", + "build:assets": "bun run build:app && bun run build:skill-asset && bun run build:notices && bun run build:license && bun run build:native && bun run build:schema", "build": "bun run build:cli && bun run build:assets", "postinstall": "node scripts/postinstall.mjs", "test": "bun run build:schema && bun test", @@ -56,6 +57,7 @@ "@octokit/rest": "^22.0.1", "cli-boxes": "^4.0.1", "commander": "^14.0.0", + "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "pino": "^10.3.1", "simple-git": "^3.36.0", @@ -71,6 +73,7 @@ "devDependencies": { "@inkeep/open-knowledge-app": "workspace:*", "@inkeep/open-knowledge-core": "workspace:*", + "@inkeep/open-knowledge-native-config": "workspace:*", "@inkeep/open-knowledge-server": "workspace:*", "@types/node": "^24.7.0", "@types/yazl": "^3.3.1", diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index c31801b7c..5671edbed 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -4,6 +4,7 @@ import { existsSync, lstatSync, mkdirSync, + readdirSync, readFileSync, realpathSync, rmSync, @@ -28,6 +29,10 @@ import { const PUBLISHED_CHAIN_ENTRY = { command: '/bin/sh', args: ['-l', '-c', CHAIN_V1] } as const; +import { + createTomlConfigEngine, + setTomlConfigEngineForTesting, +} from '../native/toml-config-engine.ts'; import { applySharingMode, classifyExistingMcpEntry, @@ -42,6 +47,7 @@ import { resolveMcpScope, resolveSharingMode, runInit, + writeEditorMcpConfig, writeUserMcpConfigs, } from './init.ts'; import { LAUNCH_JSON_PORT } from './ui.ts'; @@ -361,12 +367,18 @@ describe('runInit', () => { expect(secondConfig).toBe(firstConfig); }); - it('returns failed mcpAction when ~/.claude.json is invalid JSON', async () => { - writeFileSync(claudeConfigPath(), '{not valid json'); + it('declines and leaves ~/.claude.json byte-unchanged when it is invalid JSON', async () => { + const original = '{not valid json'; + writeFileSync(claudeConfigPath(), original); const result = await runInitForTest(); - expect(result.mcpAction).toBe('failed'); - expect(result.mcpError).toMatch(/invalid JSON/i); + expect(result.mcpAction).toBe('declined'); + expect(result.editors[0].action).toBe('declined'); + expect(result.editors[0].declineReason).toBe('unparseable'); + expect(readFileSync(claudeConfigPath(), 'utf-8')).toBe(original); + + const output = formatInitResult(result, testDir); + expect(output).toContain('left unchanged (config not readable)'); expect(existsSync(join(testDir, OK_DIR, 'config.yml'))).toBe(true); }); @@ -684,7 +696,7 @@ describe('runInit', () => { expect(cursor.mcpServers[result.editors[1].serverName]).toEqual(PUBLISHED_CHAIN_ENTRY); }); - it('partial failure — one editor fails, others succeed', async () => { + it('mixed outcome — one editor declines (unparseable), others succeed', async () => { mkdirSync(dirname(cursorConfigPath()), { recursive: true }); writeFileSync(cursorConfigPath(), '{broken'); @@ -693,8 +705,9 @@ describe('runInit', () => { expect(result.editors[0].editorId).toBe('claude'); expect(result.editors[0].action).toBe('written'); expect(result.editors[1].editorId).toBe('cursor'); - expect(result.editors[1].action).toBe('failed'); - expect(result.editors[1].error).toMatch(/invalid JSON/i); + expect(result.editors[1].action).toBe('declined'); + expect(result.editors[1].declineReason).toBe('unparseable'); + expect(readFileSync(cursorConfigPath(), 'utf-8')).toBe('{broken'); }); it('idempotent per-editor across two runs', async () => { @@ -1764,6 +1777,23 @@ describe('writeUserMcpConfigs', () => { expect(cursorConfig.mcpServers['open-knowledge']).toEqual(CANONICAL); }); + it('creates OK entry into a blank config with no .broken sidecar', async () => { + const claudePath = resolveClaudeCodeConfigPath({ home: fakeHome }); + mkdirSync(dirname(claudePath), { recursive: true }); + writeFileSync(claudePath, ' \n'); + + const results: EditorMcpResult[] = await writeUserMcpConfigs({ + editors: ['claude'], + home: fakeHome, + }); + expect(results[0]?.action).toBe('written'); + + const config = JSON.parse(readFileSync(claudePath, 'utf-8')); + expect(config.mcpServers['open-knowledge']).toEqual(CANONICAL); + + expect(readdirSync(dirname(claudePath)).some((name) => name.includes('.broken-'))).toBe(false); + }); + it('does NOT create project-scoped side effects under the fake HOME', async () => { await writeUserMcpConfigs({ editors: ['claude', 'cursor'], home: fakeHome }); @@ -1846,6 +1876,65 @@ describe('writeUserMcpConfigs', () => { }); }); +describe('writeEditorMcpConfig — TOML fallback declines a present config', () => { + let fakeHome: string; + let testDir: string; + + beforeEach(() => { + testDir = resolve( + tmpdir(), + `toml-fallback-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(testDir, { recursive: true }); + fakeHome = join(testDir, 'fakehome'); + mkdirSync(fakeHome, { recursive: true }); + setTomlConfigEngineForTesting(createTomlConfigEngine(() => null)); + }); + + afterEach(() => { + setTomlConfigEngineForTesting(null); + rmSync(testDir, { recursive: true, force: true }); + }); + + it('declines a present config rather than the lossy whole-file write, byte-unchanged', () => { + const path = EDITOR_TARGETS.codex.configPath('', fakeHome); + mkdirSync(dirname(path), { recursive: true }); + const original = + '# do not clobber my comments\nmodel = "gpt-5"\n\n[mcp_servers.other]\ncommand = "node"\n'; + writeFileSync(path, original, 'utf-8'); + + const result = writeEditorMcpConfig( + EDITOR_TARGETS.codex, + '', + { skipAvailabilityCheck: true }, + fakeHome, + ); + + expect(result.action).toBe('declined'); + expect(result.declineReason).toBe('no-native-writer'); + expect(readFileSync(path, 'utf-8')).toBe(original); + expect(readdirSync(dirname(path)).some((n) => n.includes('.broken-'))).toBe(false); + }); + + it('still creates OK’s entry into a blank config on the fallback (nothing to preserve)', () => { + const path = EDITOR_TARGETS.codex.configPath('', fakeHome); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, ' \n', 'utf-8'); + + const result = writeEditorMcpConfig( + EDITOR_TARGETS.codex, + '', + { skipAvailabilityCheck: true }, + fakeHome, + ); + + expect(result.action).toBe('written'); + const written = readFileSync(path, 'utf-8'); + expect(written).toContain('mcp_servers'); + expect(written).toContain('open-knowledge'); + }); +}); + describe('readExistingMcpEntry (Pass 0 Major #13)', () => { let fakeHome: string; let testDir: string; @@ -2004,36 +2093,67 @@ describe('classifyExistingMcpEntry', () => { }); }); - it('corrupt when the file is blank (zero bytes)', () => { + it('absent (creatable) when the file is blank (zero bytes)', () => { const path = resolveCursorConfigPath({ home: fakeHome }); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, '', 'utf-8'); - const result = classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome); - expect(result.kind).toBe('corrupt'); + expect(classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome)).toEqual({ + kind: 'absent', + }); }); - it('corrupt when the file is whitespace-only', () => { + it('absent (creatable) when the file is whitespace-only', () => { const path = resolveCursorConfigPath({ home: fakeHome }); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, ' \n\n \t ', 'utf-8'); - const result = classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome); - expect(result.kind).toBe('corrupt'); + expect(classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome)).toEqual({ + kind: 'absent', + }); }); - it('corrupt on invalid JSON', () => { + it('decline with a bounded reason on invalid JSON — never a creatable kind, no raw contents', () => { const path = resolveCursorConfigPath({ home: fakeHome }); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, '{ not valid JSON', 'utf-8'); - const result = classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome); - expect(result.kind).toBe('corrupt'); + expect(classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome)).toEqual({ + kind: 'decline', + reason: 'unparseable', + }); }); - it('corrupt on invalid TOML (Codex)', () => { + it('decline with a bounded reason on invalid TOML (Codex)', () => { const path = resolveCodexConfigPath({ home: fakeHome, env: {} }); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, 'not = valid = toml = at = all', 'utf-8'); + expect(classifyExistingMcpEntry(EDITOR_TARGETS.codex, '', fakeHome)).toEqual({ + kind: 'decline', + reason: 'unparseable', + }); + }); + + it('no-entry (not decline) on a valid Codex config with a 2^53+ integer', () => { + const path = resolveCodexConfigPath({ home: fakeHome, env: {} }); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + '# keep my comments\nmodel = "gpt-5"\n[mcp_servers.other]\ncommand = "node"\nstartup_timeout_ms = 9223372036854775807\n', + 'utf-8', + ); + expect(classifyExistingMcpEntry(EDITOR_TARGETS.codex, '', fakeHome)).toEqual({ + kind: 'no-entry', + }); + }); + + it('present on a valid Codex config with a microsecond datetime and OK entry', () => { + const path = resolveCodexConfigPath({ home: fakeHome, env: {} }); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + 'last_seen = 2026-06-26T12:34:56.123456Z\n[mcp_servers."open-knowledge"]\ncommand = "npx"\nargs = ["-y", "@inkeep/open-knowledge@latest", "mcp"]\n', + 'utf-8', + ); const result = classifyExistingMcpEntry(EDITOR_TARGETS.codex, '', fakeHome); - expect(result.kind).toBe('corrupt'); + expect(result.kind).toBe('present'); }); it('no-entry when JSON parses but has no mcpServers key', () => { @@ -2066,6 +2186,126 @@ describe('classifyExistingMcpEntry', () => { const result = classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome); expect(result).toEqual({ kind: 'present', entry }); }); + + it('decline (not creatable-blank) on a half-written / truncated JSON config', () => { + const path = resolveCursorConfigPath({ home: fakeHome }); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + '{\n "mcpServers": {\n "open-knowledge": {\n "command": "np', + 'utf-8', + ); + expect(classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome).kind).toBe('decline'); + }); + + it('decline (not creatable-blank) on a half-written / truncated TOML config', () => { + const path = resolveCodexConfigPath({ home: fakeHome, env: {} }); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, '[mcp_servers."open-knowledge"]\ncommand = "np', 'utf-8'); + expect(classifyExistingMcpEntry(EDITOR_TARGETS.codex, '', fakeHome).kind).toBe('decline'); + }); + + it('leaves a declined config byte-unchanged — classify never modifies or renames it', () => { + const path = resolveCursorConfigPath({ home: fakeHome }); + mkdirSync(dirname(path), { recursive: true }); + const original = '{ "mcpServers": [ deliberately malformed\n'; + writeFileSync(path, original, 'utf-8'); + + const result = classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome); + + expect(result.kind).toBe('decline'); + expect(existsSync(path)).toBe(true); + expect(readFileSync(path, 'utf-8')).toBe(original); + expect(readExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome)).toBeNull(); + }); + + it('no-entry on a JSONC config with // and block comments (not unparseable)', () => { + const path = resolveCursorConfigPath({ home: fakeHome }); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, '{\n // my servers\n "other": { "command": "x" } /* keep */\n}', 'utf-8'); + expect(classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome)).toEqual({ + kind: 'no-entry', + }); + }); + + it('present on a JSONC config whose comments and trailing commas surround our entry', () => { + const path = resolveCursorConfigPath({ home: fakeHome }); + mkdirSync(dirname(path), { recursive: true }); + const entry = { command: 'npx', args: ['-y', '@inkeep/open-knowledge@latest', 'mcp'] }; + writeFileSync( + path, + `{\n // managed by ok\n "mcpServers": {\n "open-knowledge": ${JSON.stringify(entry)}, // ours\n },\n}`, + 'utf-8', + ); + expect(classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome)).toEqual({ + kind: 'present', + entry, + }); + }); + + it('present on a config with a leading UTF-8 BOM (InvalidSymbol@0 is not corruption)', () => { + const path = resolveCursorConfigPath({ home: fakeHome }); + mkdirSync(dirname(path), { recursive: true }); + const entry = { command: 'npx', args: ['-y', '@inkeep/open-knowledge@latest', 'mcp'] }; + writeFileSync( + path, + `\uFEFF${JSON.stringify({ mcpServers: { 'open-knowledge': entry } })}`, + 'utf-8', + ); + expect(classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome)).toEqual({ + kind: 'present', + entry, + }); + }); + + it('decline (duplicate-container) when the mcpServers container appears twice', () => { + const path = resolveCursorConfigPath({ home: fakeHome }); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + '{ "mcpServers": { "a": { "command": "x" } }, "mcpServers": { "b": { "command": "y" } } }', + 'utf-8', + ); + expect(classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome)).toEqual({ + kind: 'decline', + reason: 'duplicate-container', + }); + }); + + it('duplicate-container is keyed to each harness container, not a hardcoded mcpServers', () => { + const path = resolveOpenCodeConfigPath({ home: fakeHome }); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, '{ "mcp": { "a": {} }, "mcp": { "b": {} } }', 'utf-8'); + expect(classifyExistingMcpEntry(EDITOR_TARGETS.opencode, '', fakeHome)).toEqual({ + kind: 'decline', + reason: 'duplicate-container', + }); + }); + + it('no-entry (not duplicate-container) when only an unrelated sibling key repeats', () => { + const path = resolveCursorConfigPath({ home: fakeHome }); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + '{ "theme": "dark", "theme": "light", "mcpServers": { "other": {} } }', + 'utf-8', + ); + expect(classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome)).toEqual({ + kind: 'no-entry', + }); + }); + + it('decline (oversize) on a config past the size bound — gated before the parse, left byte-unchanged', () => { + const path = resolveCursorConfigPath({ home: fakeHome }); + mkdirSync(dirname(path), { recursive: true }); + const oversized = `{ "mcpServers": {}, "_history": "${'x'.repeat(11 * 1024 * 1024)}" }`; + writeFileSync(path, oversized, 'utf-8'); + expect(classifyExistingMcpEntry(EDITOR_TARGETS.cursor, '', fakeHome)).toEqual({ + kind: 'decline', + reason: 'oversize', + }); + expect(readFileSync(path, 'utf-8')).toBe(oversized); + }); }); describe('runInit — sharing mode', () => { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index b72c336bc..b2941c5e6 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { atomicWriteFileSync, withFileLockSync } from '@inkeep/open-knowledge-core/server'; import type { @@ -18,7 +18,15 @@ import { import checkbox from '@inquirer/checkbox'; import select from '@inquirer/select'; import { Command, Option } from 'commander'; -import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; +import { + applyEdits as applyJsoncEdits, + getNodeValue, + type Node as JsoncNode, + type ParseError as JsoncParseError, + modify as modifyJsonc, + parseTree as parseJsoncTree, +} from 'jsonc-parser'; +import { stringify as stringifyToml } from 'smol-toml'; import { OK_DIR } from '../constants.ts'; import { formatPreviewBlock, type PreviewResult } from '../content/preview.ts'; import { resolveProjectRoot } from '../integrations/resolve-project-root.ts'; @@ -27,6 +35,13 @@ import { type ProjectSkillResult, writeProjectSkill, } from '../integrations/write-project-skill.ts'; +import { debugNativeLoadFailure } from '../native/load-native-config.ts'; +import { resolveHarnessWritePaths } from '../native/symlink-resolve.ts'; +import { + getTomlConfigEngine, + type TomlConfigEngine, + type TomlUpsertResult, +} from '../native/toml-config-engine.ts'; import { addOkPathsToGitExclude, type ExcludeWriteResult, @@ -49,41 +64,31 @@ import { } from './editors.ts'; import { LAUNCH_JSON_PORT } from './ui.ts'; -function readJsonConfig(path: string): Record { - if (!existsSync(path)) return {}; - const raw = readFileSync(path, 'utf-8'); - const trimmed = raw.trim(); - if (trimmed === '') return {}; - try { - const parsed = JSON.parse(trimmed); - if (isObject(parsed)) { - return parsed; - } - throw new Error(`${path} root must be a JSON object`); - } catch (err) { - if (err instanceof SyntaxError) { - throw new Error(`${path} contains invalid JSON: ${err.message}`); - } - throw err; - } +const JSONC_PARSE_OPTIONS = { allowTrailingComma: true, disallowComments: false }; + +const JSONC_INVALID_SYMBOL_CODE: number = 1; + +function isBenignBomError(error: JsoncParseError, raw: string): boolean { + return ( + error.error === JSONC_INVALID_SYMBOL_CODE && error.offset === 0 && raw.charCodeAt(0) === 0xfeff + ); } -function readTomlConfig(path: string): Record { - if (!existsSync(path)) return {}; - const raw = readFileSync(path, 'utf-8'); - const trimmed = raw.trim(); - if (trimmed === '') return {}; - try { - const parsed = parseToml(trimmed); - if (isObject(parsed)) { - return parsed; - } - throw new Error(`${path} root must be a TOML table`); - } catch (err) { - throw new Error( - `${path} contains invalid TOML: ${err instanceof Error ? err.message : String(err)}`, - ); +function parseJsoncObjectTree(raw: string): JsoncNode | null { + const errors: JsoncParseError[] = []; + const tree = parseJsoncTree(raw, errors, JSONC_PARSE_OPTIONS); + if (errors.some((error) => !isBenignBomError(error, raw))) return null; + if (!tree || tree.type !== 'object') return null; + return tree; +} + +function countTopLevelKey(objectNode: JsoncNode, key: string): number { + let count = 0; + for (const property of objectNode.children ?? []) { + const keyNode = property.children?.[0]; + if (keyNode !== undefined && getNodeValue(keyNode) === key) count += 1; } + return count; } function writeJsonConfig(path: string, config: Record): void { @@ -95,6 +100,160 @@ function writeTomlConfig(path: string, config: Record): void { atomicWriteFileSync(path, serialized.endsWith('\n') ? serialized : `${serialized}\n`); } +function isCrlfDominant(text: string): boolean { + const crlf = (text.match(/\r\n/g) ?? []).length; + if (crlf === 0) return false; + const bareLf = (text.match(/\n/g) ?? []).length - crlf; + return crlf >= bareLf; +} + +const JSON_CONFIG_MAX_BYTES = 10 * 1024 * 1024; + +function jsonValueEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + return a.every((value, index) => jsonValueEqual(value, b[index])); + } + if (isObject(a) && isObject(b)) { + const keys = Object.keys(a); + if (keys.length !== Object.keys(b).length) return false; + return keys.every((key) => Object.hasOwn(b, key) && jsonValueEqual(a[key], b[key])); + } + return false; +} + +function detectJsonIndent(body: string): { insertSpaces: boolean; tabSize: number } { + for (const line of body.split('\n')) { + const trimmed = line.trimStart(); + if (trimmed.length === 0 || trimmed.length === line.length) continue; + if (line.charCodeAt(0) === 0x09) return { insertSpaces: false, tabSize: 1 }; + return { insertSpaces: true, tabSize: line.length - trimmed.length }; + } + return { insertSpaces: true, tabSize: 2 }; +} + +function existingFileMode(path: string): number | undefined { + try { + return statSync(path).mode & 0o777; + } catch { + return undefined; + } +} + +type JsonUpsertOutcome = + | { kind: 'written' | 'overwritten' } + | { kind: 'declined'; reason: McpDeclineReason }; + +function upsertJsonMcpConfig( + configPath: string, + topLevelKey: string, + serverName: string, + entry: Record, +): JsonUpsertOutcome { + if (!existsSync(configPath)) { + writeJsonConfig(configPath, { [topLevelKey]: { [serverName]: entry } }); + return { kind: 'written' }; + } + let raw: string; + try { + raw = readFileSync(configPath, 'utf-8'); + } catch (err) { + debugNativeLoadFailure('json config read failed', err); + return { kind: 'declined', reason: 'unparseable' }; + } + if (raw.trim() === '') { + writeJsonConfig(configPath, { [topLevelKey]: { [serverName]: entry } }); + return { kind: 'written' }; + } + if (Buffer.byteLength(raw, 'utf-8') > JSON_CONFIG_MAX_BYTES) { + return { kind: 'declined', reason: 'oversize' }; + } + const tree = parseJsoncObjectTree(raw); + if (!tree) return { kind: 'declined', reason: 'unparseable' }; + if (countTopLevelKey(tree, topLevelKey) > 1) { + return { kind: 'declined', reason: 'duplicate-container' }; + } + + const root = getNodeValue(tree) as Record; + const container = root[topLevelKey]; + const existing = isObject(container) ? container[serverName] : undefined; + const entryExists = existing !== undefined; + if (entryExists && jsonValueEqual(existing, entry)) { + return { kind: 'overwritten' }; + } + + const hasBom = raw.charCodeAt(0) === 0xfeff; + const body = hasBom ? raw.slice(1) : raw; + const eol = body.includes('\r\n') ? '\r\n' : '\n'; + const edits = modifyJsonc(body, [topLevelKey, serverName], entry, { + formattingOptions: { ...detectJsonIndent(body), eol }, + }); + const newText = `${hasBom ? '\uFEFF' : ''}${applyJsoncEdits(body, edits)}`; + if (newText !== raw) { + atomicWriteFileSync(configPath, newText, { mode: existingFileMode(configPath) }); + } + return { kind: entryExists ? 'overwritten' : 'written' }; +} + +type TomlUpsertOutcome = + | { kind: 'written' | 'overwritten' } + | { kind: 'declined'; reason: McpDeclineReason }; + +function upsertTomlMcpConfig( + engine: TomlConfigEngine, + configPath: string, + topLevelKey: string, + serverName: string, + entry: Record, +): TomlUpsertOutcome { + let raw = ''; + if (existsSync(configPath)) { + try { + raw = readFileSync(configPath, 'utf-8'); + } catch (err) { + debugNativeLoadFailure('toml config read failed', err); + return { kind: 'declined', reason: 'unparseable' }; + } + } + const blank = raw.trim() === ''; + + if (engine.backend === 'fallback') { + if (!blank) return { kind: 'declined', reason: 'no-native-writer' }; + writeTomlConfig(configPath, { [topLevelKey]: { [serverName]: entry } }); + return { kind: 'written' }; + } + + const hasBom = raw.charCodeAt(0) === 0xfeff; + const body = hasBom ? raw.slice(1) : raw; + const crlfDominant = isCrlfDominant(body); + const wantTrailingNewline = blank || body.endsWith('\n'); + + let result: TomlUpsertResult; + try { + result = engine.upsertEntry(body, serverName, entry); + } catch (err) { + debugNativeLoadFailure('upsertEntry failed', err); + return { kind: 'declined', reason: 'unparseable' }; + } + + let text = result.text; + if (wantTrailingNewline) { + if (!text.endsWith('\n')) text = `${text}\n`; + } else { + text = text.replace(/\n+$/, ''); + } + if (crlfDominant) { + text = text.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); + } + const newText = `${hasBom ? '\uFEFF' : ''}${text}`; + + if (newText !== raw) { + atomicWriteFileSync(configPath, newText, { mode: existingFileMode(configPath) }); + } + return { kind: result.existed ? 'overwritten' : 'written' }; +} + type McpScope = 'user' | 'project' | 'both'; const writesUser = (s: McpScope) => s !== 'project'; @@ -185,10 +344,11 @@ export async function resolveSharingMode(opts: { export interface EditorMcpResult { editorId: EditorId; label: string; - action: 'written' | 'overwritten' | 'skipped-missing' | 'skipped-flag' | 'failed'; + action: 'written' | 'overwritten' | 'skipped-missing' | 'skipped-flag' | 'failed' | 'declined'; configPath: string; serverName: string; error?: string; + declineReason?: McpDeclineReason; configScope?: 'project'; } @@ -228,7 +388,7 @@ interface InitCommandResult { * Only set when `didGitInit` is also `true` AND no `.gitignore` was already * present at `projectRoot` — pre-existing files are never touched. */ rootGitignoreCreated: boolean; - mcpAction: 'written' | 'overwritten' | 'skipped-missing' | 'skipped-flag' | 'failed'; + mcpAction: 'written' | 'overwritten' | 'skipped-missing' | 'skipped-flag' | 'failed' | 'declined'; mcpPath: string; mcpError?: string; previewWarning?: string; @@ -451,28 +611,32 @@ export function writeEditorMcpConfig( }; } - let existing: unknown; + const captured: { + action: 'written' | 'overwritten' | 'declined'; + declineReason?: McpDeclineReason; + } = { action: 'written' }; let lockErr: Error | undefined; try { withFileLockSync( `${configPath}.lock`, () => { - const config: Record = - target.format === 'toml' ? readTomlConfig(configPath) : readJsonConfig(configPath); - const servers = (config[target.topLevelKey] as Record | undefined) ?? {}; - existing = servers[serverName]; - const nextConfig: Record = { - ...config, - [target.topLevelKey]: { - ...servers, - [serverName]: targetEntry, - }, - }; + const writePath = resolveHarnessWritePaths(configPath).writePath; + mkdirSync(dirname(writePath), { recursive: true }); if (target.format === 'toml') { - writeTomlConfig(configPath, nextConfig); - } else { - writeJsonConfig(configPath, nextConfig); + const tomlOutcome = upsertTomlMcpConfig( + getTomlConfigEngine(), + writePath, + target.topLevelKey, + serverName, + targetEntry, + ); + captured.action = tomlOutcome.kind; + if (tomlOutcome.kind === 'declined') captured.declineReason = tomlOutcome.reason; + return; } + const outcome = upsertJsonMcpConfig(writePath, target.topLevelKey, serverName, targetEntry); + captured.action = outcome.kind; + if (outcome.kind === 'declined') captured.declineReason = outcome.reason; }, { onWarn: (message, context) => @@ -494,10 +658,22 @@ export function writeEditorMcpConfig( }; } + if (captured.action === 'declined') { + return { + editorId: target.id, + label: target.label, + action: 'declined', + configPath, + serverName, + declineReason: captured.declineReason, + ...(configPathOverride !== undefined ? { configScope: 'project' as const } : {}), + }; + } + return { editorId: target.id, label: target.label, - action: existing !== undefined ? 'overwritten' : 'written', + action: captured.action, configPath, serverName, ...(configPathOverride !== undefined ? { configScope: 'project' as const } : {}), @@ -541,11 +717,29 @@ export function readExistingMcpEntry( return classified.kind === 'present' ? classified.entry : null; } +export type McpDeclineReason = + | 'unparseable' + | 'duplicate-container' + | 'oversize' + | 'no-native-writer'; + export type McpEntryClassification = | { kind: 'absent' } | { kind: 'no-entry' } | { kind: 'present'; entry: Record } - | { kind: 'corrupt'; error: string }; + | { kind: 'decline'; reason: McpDeclineReason }; + +function classifyContainer( + config: Record, + topLevelKey: string, + serverName: string, +): McpEntryClassification { + const servers = config[topLevelKey]; + if (!isObject(servers)) return { kind: 'no-entry' }; + const existing = servers[serverName]; + if (!isObject(existing)) return { kind: 'no-entry' }; + return { kind: 'present', entry: existing }; +} export function classifyExistingMcpEntry( target: EditorMcpTarget, @@ -561,27 +755,46 @@ export function classifyExistingMcpEntry( } if (!existsSync(configPath)) return { kind: 'absent' }; + try { + if (statSync(configPath).size > JSON_CONFIG_MAX_BYTES) { + return { kind: 'decline', reason: 'oversize' }; + } + } catch { + return { kind: 'decline', reason: 'unparseable' }; + } + let raw: string; try { raw = readFileSync(configPath, 'utf-8'); - } catch (err) { - return { kind: 'corrupt', error: err instanceof Error ? err.message : String(err) }; + } catch { + return { kind: 'decline', reason: 'unparseable' }; } if (raw.trim() === '') { - return { kind: 'corrupt', error: 'file is empty' }; + return { kind: 'absent' }; } - let config: Record; - try { - config = target.format === 'toml' ? readTomlConfig(configPath) : readJsonConfig(configPath); - } catch (err) { - return { kind: 'corrupt', error: err instanceof Error ? err.message : String(err) }; + const serverName = target.serverName(cwd); + + if (target.format === 'toml') { + let config: Record; + try { + config = getTomlConfigEngine().parseToObject(raw); + } catch { + return { kind: 'decline', reason: 'unparseable' }; + } + return classifyContainer(config, target.topLevelKey, serverName); } - const servers = config[target.topLevelKey]; - if (!isObject(servers)) return { kind: 'no-entry' }; - const existing = servers[target.serverName(cwd)]; - if (!isObject(existing)) return { kind: 'no-entry' }; - return { kind: 'present', entry: existing }; + + const tree = parseJsoncObjectTree(raw); + if (!tree) return { kind: 'decline', reason: 'unparseable' }; + if (countTopLevelKey(tree, target.topLevelKey) > 1) { + return { kind: 'decline', reason: 'duplicate-container' }; + } + return classifyContainer( + getNodeValue(tree) as Record, + target.topLevelKey, + serverName, + ); } export async function runInit(options: InitCommandOptions = {}): Promise { @@ -848,6 +1061,19 @@ function summarizeApplied( }; } +function declineReasonLabel(reason: McpDeclineReason | undefined): string { + switch (reason) { + case 'oversize': + return 'config too large to edit safely'; + case 'duplicate-container': + return 'duplicate server block'; + case 'no-native-writer': + return 'no format-preserving writer available'; + default: + return 'config not readable'; + } +} + export function formatInitResult(result: InitCommandResult, cwd: string): string { const lines: string[] = []; const anyWritten = result.editors.some( @@ -954,8 +1180,17 @@ export function formatInitResult(result: InitCommandResult, cwd: string): string ` ${labelWithScope}${pad}${displayPath} ${error('FAILED')}: ${editor.error}`, ); break; + case 'declined': + lines.push( + ` ${labelWithScope}${pad}${displayPath} left unchanged (${declineReasonLabel(editor.declineReason)})`, + ); + break; case 'skipped-flag': break; + default: { + const _exhaustive: never = editor.action; + void _exhaustive; + } } if (editor.editorId === 'claude' && result.launchJson) { lines.push(formatLaunchJsonSummary(result.launchJson)); diff --git a/packages/cli/src/commands/mcp-cross-harness-acceptance.test.ts b/packages/cli/src/commands/mcp-cross-harness-acceptance.test.ts new file mode 100644 index 000000000..ba55f37a8 --- /dev/null +++ b/packages/cli/src/commands/mcp-cross-harness-acceptance.test.ts @@ -0,0 +1,296 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parse as parseJsonc } from 'jsonc-parser'; +import { + createTomlConfigEngine, + setTomlConfigEngineForTesting, +} from '../native/toml-config-engine.ts'; +import { CHAIN_V1, EDITOR_TARGETS, type EditorId, type EditorMcpTarget } from './editors.ts'; +import { writeEditorMcpConfig } from './init.ts'; + +const PUBLISHED_CHAIN_ENTRY = { command: '/bin/sh', args: ['-l', '-c', CHAIN_V1] }; +const OPENCODE_ENTRY = { + type: 'local', + enabled: true, + command: ['/bin/sh', '-l', '-c', CHAIN_V1], +}; + +function targetForFile(id: EditorId, configPath: string): EditorMcpTarget { + return { ...EDITOR_TARGETS[id], configPath: () => configPath }; +} + +function write(id: EditorId, configPath: string) { + return writeEditorMcpConfig(targetForFile(id, configPath), '', { + mode: 'published', + skipAvailabilityCheck: true, + }); +} + +// biome-ignore lint/suspicious/noExplicitAny: structured nested access in tests. +function parseFor(id: EditorId, raw: string): any { + if (EDITOR_TARGETS[id].format === 'toml') { + const engine = createTomlConfigEngine(); + if (engine.backend !== 'native') { + throw new Error('native toml_edit addon must be built for the cross-harness acceptance gate'); + } + return engine.parseToObject(raw); + } + return parseJsonc(raw, [], { allowTrailingComma: true, disallowComments: false }); +} + +interface HarnessCase { + id: EditorId; + file: string; + fixture: string; + expectedEntry: Record; + comments: string[]; + byteContains?: string[]; +} + +const JSON_MCP_SERVERS_FIXTURE = `{ + "mcpServers": { + "linear": { "command": "linear-cmd", "args": ["--stdio"] } // sibling note + }, + "telemetry": false +} +`; + +const CASES: HarnessCase[] = [ + { + id: 'claude', + file: 'config.json', + fixture: JSON_MCP_SERVERS_FIXTURE, + expectedEntry: PUBLISHED_CHAIN_ENTRY, + comments: ['// dotfiles-managed config', '// sibling note', '/* trailing block comment */'], + }, + { + id: 'claude-desktop', + file: 'config.json', + fixture: JSON_MCP_SERVERS_FIXTURE, + expectedEntry: PUBLISHED_CHAIN_ENTRY, + comments: ['// dotfiles-managed config', '// sibling note', '/* trailing block comment */'], + }, + { + id: 'cursor', + file: 'mcp.json', + fixture: JSON_MCP_SERVERS_FIXTURE, + expectedEntry: PUBLISHED_CHAIN_ENTRY, + comments: ['// dotfiles-managed config', '// sibling note', '/* trailing block comment */'], + }, + { + id: 'opencode', + file: 'opencode.json', + fixture: `{ + "mcp": { + "linear": { "type": "local", "enabled": true, "command": ["linear-cmd"] } + }, + "theme": "dark" +} +`, + expectedEntry: OPENCODE_ENTRY, + comments: ['// opencode user config'], + }, + { + id: 'codex', + file: 'config.toml', + fixture: [ + '# my codex config — keep these comments', + 'model = "gpt-5"', + 'timeout = 30.0', + 'startup_timeout_ms = 9223372036854775807', + 'last_seen = 2026-06-26T12:34:56.123456Z', + '', + '[mcp_servers.linear]', + 'command = "linear-cmd" # sibling note', + '', + ].join('\n'), + expectedEntry: PUBLISHED_CHAIN_ENTRY, + comments: ['# my codex config — keep these comments', 'command = "linear-cmd" # sibling note'], + byteContains: [ + 'timeout = 30.0', + 'startup_timeout_ms = 9223372036854775807', + 'last_seen = 2026-06-26T12:34:56.123456Z', + ], + }, +]; + +describe('cross-harness FR1 acceptance matrix', () => { + let dir: string; + + beforeEach(() => { + const engine = createTomlConfigEngine(); + if (engine.backend !== 'native') { + throw new Error('native toml_edit addon must be built for the cross-harness acceptance gate'); + } + setTomlConfigEngineForTesting(engine); + }); + + afterEach(() => { + setTomlConfigEngineForTesting(null); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + function tempFile(name: string): string { + dir = mkdtempSync(join(tmpdir(), 'ok-acceptance-')); + return join(dir, name); + } + + for (const c of CASES) { + it(`${c.id}: only OK's entry changes — everything else is data-identical`, () => { + const configPath = tempFile(c.file); + writeFileSync(configPath, c.fixture); + + const result = write(c.id, configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + + for (const cm of c.comments) expect(after).toContain(cm); + for (const b of c.byteContains ?? []) expect(after).toContain(b); + + const beforeData = parseFor(c.id, c.fixture); + const afterData = parseFor(c.id, after); + const key = EDITOR_TARGETS[c.id].topLevelKey; + const container = afterData[key] as Record; + expect(container['open-knowledge']).toEqual(c.expectedEntry); + delete container['open-knowledge']; + expect(afterData).toEqual(beforeData); + + expect(readdirSync(dir).some((n) => n.includes('.broken-'))).toBe(false); + }); + } + + it('codex: preserves a leading BOM and CRLF line endings while adding only our entry', () => { + const configPath = tempFile('config.toml'); + const original = + '\uFEFF# bom+crlf config\r\nmodel = "gpt-5"\r\n\r\n[mcp_servers.other]\r\ncommand = "node"\r\n'; + writeFileSync(configPath, original); + + const result = write('codex', configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + expect(after.charCodeAt(0)).toBe(0xfeff); + expect(after.replace(/\r\n/g, '')).not.toContain('\n'); + expect(after).toContain('# bom+crlf config'); + + const parsed = parseFor('codex', after); + expect(parsed.mcp_servers.other).toEqual({ command: 'node' }); + expect(parsed.mcp_servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + }); + + it('claude: preserves a leading BOM on a JSON harness', () => { + const configPath = tempFile('config.json'); + const original = '\uFEFF{\n // keep me\n "mcpServers": {}\n}\n'; + writeFileSync(configPath, original); + + const result = write('claude', configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + expect(after.charCodeAt(0)).toBe(0xfeff); + expect(after).toContain('// keep me'); + expect( + (parseFor('claude', after).mcpServers as Record)['open-knowledge'], + ).toEqual(PUBLISHED_CHAIN_ENTRY); + }); +}); + +describe('surgical-text-splice counterfactual guard', () => { + let dir: string; + + beforeEach(() => { + const engine = createTomlConfigEngine(); + if (engine.backend !== 'native') { + throw new Error('native toml_edit addon must be built for the counterfactual guard'); + } + setTomlConfigEngineForTesting(engine); + }); + + afterEach(() => { + setTomlConfigEngineForTesting(null); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + function tempFile(name: string): string { + dir = mkdtempSync(join(tmpdir(), 'ok-counterfactual-')); + return join(dir, name); + } + + it('inline-table own-entry: updates in place, never appends a duplicate header', () => { + const configPath = tempFile('config.toml'); + const original = [ + '# codex with an inline OK entry', + 'model = "gpt-5"', + 'mcp_servers.open-knowledge = { command = "STALE", args = ["old"] }', + '', + '[mcp_servers.linear]', + 'command = "linear-cmd" # keep', + '', + ].join('\n'); + writeFileSync(configPath, original); + + const result = write('codex', configPath); + expect(result.action).toBe('overwritten'); + + const after = readFileSync(configPath, 'utf-8'); + const engine = createTomlConfigEngine(); + if (engine.backend !== 'native') throw new Error('native addon required'); + const parsed = engine.parseToObject(after) as { mcp_servers: Record }; + expect(after).not.toContain('STALE'); + expect(parsed.mcp_servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + expect(parsed.mcp_servers.linear).toEqual({ command: 'linear-cmd' }); + }); + + it('dotted-key form: inserts our entry without deleting sibling servers or root keys', () => { + const configPath = tempFile('config.toml'); + const original = [ + '# codex with dotted-key servers', + 'model = "gpt-5"', + 'profile.name = "default"', + 'mcp_servers.linear = { command = "linear-cmd", args = ["--stdio"] }', + 'mcp_servers.github = { command = "gh-cmd" }', + '', + ].join('\n'); + writeFileSync(configPath, original); + + const result = write('codex', configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + const engine = createTomlConfigEngine(); + if (engine.backend !== 'native') throw new Error('native addon required'); + // biome-ignore lint/suspicious/noExplicitAny: structured nested access in tests. + const parsed = engine.parseToObject(after) as any; + expect(parsed.model).toBe('gpt-5'); + expect(parsed.profile).toEqual({ name: 'default' }); + expect(parsed.mcp_servers.linear).toEqual({ command: 'linear-cmd', args: ['--stdio'] }); + expect(parsed.mcp_servers.github).toEqual({ command: 'gh-cmd' }); + expect(parsed.mcp_servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + }); +}); + +describe('decline carries no config contents', () => { + let dir: string; + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + function tempFile(name: string): string { + dir = mkdtempSync(join(tmpdir(), 'ok-decline-')); + return join(dir, name); + } + + it('declines an unparseable present config and the result leaks none of its bytes', () => { + const configPath = tempFile('config.json'); + const secret = 'SUPER_SECRET_TOKEN_xyz'; + writeFileSync(configPath, `{ "mcpServers": { "a": "${secret}" `); + + const result = write('claude', configPath); + expect(result.action).toBe('declined'); + expect(result.declineReason).toBe('unparseable'); + expect(readFileSync(configPath, 'utf-8')).toContain(secret); + expect(JSON.stringify(result)).not.toContain(secret); + }); +}); diff --git a/packages/cli/src/commands/mcp-decline-event.ts b/packages/cli/src/commands/mcp-decline-event.ts new file mode 100644 index 000000000..309a48ac9 --- /dev/null +++ b/packages/cli/src/commands/mcp-decline-event.ts @@ -0,0 +1,31 @@ +import type { McpDeclineReason } from './init.ts'; + +export type McpConfigDeclineScope = 'user' | 'project'; + +type McpConfigDeclineSurface = 'desktop-startup' | 'desktop-project-open' | 'desktop-firstlaunch'; + +interface McpConfigDeclineInput { + scope: McpConfigDeclineScope; + surface: McpConfigDeclineSurface; + editorId: string; + reason: McpDeclineReason; +} + +export interface McpConfigDeclineEvent { + event: 'mcp-config-decline'; + scope: McpConfigDeclineScope; + surface: McpConfigDeclineSurface; + editorId: string; + reason: McpDeclineReason; + [key: string]: unknown; +} + +export function buildMcpConfigDeclineEvent(input: McpConfigDeclineInput): McpConfigDeclineEvent { + return { + event: 'mcp-config-decline', + scope: input.scope, + surface: input.surface, + editorId: input.editorId, + reason: input.reason, + }; +} diff --git a/packages/cli/src/commands/mcp-json-surgical-write.test.ts b/packages/cli/src/commands/mcp-json-surgical-write.test.ts new file mode 100644 index 000000000..5dbd6021d --- /dev/null +++ b/packages/cli/src/commands/mcp-json-surgical-write.test.ts @@ -0,0 +1,300 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parse as parseJsonc } from 'jsonc-parser'; +import { CHAIN_V1, EDITOR_TARGETS, type EditorId, type EditorMcpTarget } from './editors.ts'; +import { writeEditorMcpConfig } from './init.ts'; + +function targetForFile(id: EditorId, configPath: string): EditorMcpTarget { + return { ...EDITOR_TARGETS[id], configPath: () => configPath }; +} + +function write(id: EditorId, configPath: string) { + return writeEditorMcpConfig(targetForFile(id, configPath), '', { + mode: 'published', + skipAvailabilityCheck: true, + }); +} + +const PUBLISHED_CHAIN_ENTRY = { command: '/bin/sh', args: ['-l', '-c', CHAIN_V1] }; +const OPENCODE_ENTRY = { + type: 'local', + enabled: true, + command: ['/bin/sh', '-l', '-c', CHAIN_V1], +}; + +function parseConfig(raw: string): Record { + return parseJsonc(raw, [], { allowTrailingComma: true, disallowComments: false }) as Record< + string, + unknown + >; +} + +describe('surgical JSON MCP write', () => { + let dir: string; + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + function tempFile(name: string): string { + dir = mkdtempSync(join(tmpdir(), 'ok-surgical-')); + return join(dir, name); + } + + for (const id of ['claude', 'claude-desktop', 'cursor'] as const) { + it(`${id}: inserts only our entry, preserving comments, siblings, and key order`, () => { + const configPath = tempFile('config.json'); + const original = `{ + "mcpServers": { + "existing-server": { + "command": "node", + "args": ["./srv.js"] // inline note + } + }, + "otherTopKey": 42 +} +`; + writeFileSync(configPath, original); + + const result = write(id, configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + expect(after).toContain('// hand-written header comment'); + expect(after).toContain('// inline note'); + expect(after).toContain('/* trailing block comment */'); + expect(after).toContain('"otherTopKey": 42'); + + const parsed = parseConfig(after); + const servers = parsed.mcpServers as Record; + expect(servers['existing-server']).toEqual({ command: 'node', args: ['./srv.js'] }); + expect(servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + expect(parsed.otherTopKey).toBe(42); + }); + } + + function indentOfKeyLine(text: string, key: string): string { + const line = text.split('\n').find((l) => l.includes(`"${key}"`)); + if (line === undefined) throw new Error(`key "${key}" not found in output`); + return line.slice(0, line.length - line.trimStart().length); + } + + it('matches a 4-space-indented config (does not force 2-space on our entry)', () => { + const configPath = tempFile('config.json'); + const original = [ + '{', + ' "mcpServers": {', + ' "existing-server": {', + ' "command": "node"', + ' }', + ' }', + '}', + '', + ].join('\n'); + writeFileSync(configPath, original); + + const result = write('cursor', configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + expect(indentOfKeyLine(after, 'open-knowledge')).toBe( + indentOfKeyLine(after, 'existing-server'), + ); + expect(indentOfKeyLine(after, 'open-knowledge')).toBe(' '); + const servers = parseConfig(after).mcpServers as Record; + expect(servers['existing-server']).toEqual({ command: 'node' }); + expect(servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + }); + + it('matches a tab-indented config (does not force spaces on our entry)', () => { + const configPath = tempFile('config.json'); + const original = ['{', '\t"mcpServers": {', '\t\t"existing-server": {}', '\t}', '}', ''].join( + '\n', + ); + writeFileSync(configPath, original); + + const result = write('cursor', configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + expect(indentOfKeyLine(after, 'open-knowledge')).toBe( + indentOfKeyLine(after, 'existing-server'), + ); + expect(indentOfKeyLine(after, 'open-knowledge')).toBe('\t\t'); + expect(after).toContain('\t\t"existing-server"'); + const servers = parseConfig(after).mcpServers as Record; + expect(servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + }); + + it.skipIf(process.platform === 'win32')( + 'preserves a user-tightened file mode (0600) on an in-place rewrite', + () => { + const configPath = tempFile('config.json'); + writeFileSync(configPath, '{\n "mcpServers": {}\n}\n'); + chmodSync(configPath, 0o600); + + const result = write('claude', configPath); + expect(result.action).toBe('written'); + + expect(statSync(configPath).mode & 0o777).toBe(0o600); + }, + ); + + it('preserves a leading UTF-8 BOM byte-for-byte', () => { + const configPath = tempFile('config.json'); + const original = `\uFEFF{ + "mcpServers": {} +} +`; + writeFileSync(configPath, original); + + const result = write('claude', configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + expect(after.charCodeAt(0)).toBe(0xfeff); + expect(after).toContain('// keep me'); + const parsed = parseConfig(after); + expect((parsed.mcpServers as Record)['open-knowledge']).toEqual( + PUBLISHED_CHAIN_ENTRY, + ); + }); + + it('preserves CRLF line endings on untouched lines and inserts our entry as CRLF', () => { + const configPath = tempFile('config.json'); + const original = + '{\r\n // crlf header\r\n "mcpServers": {\r\n "existing-server": { "command": "node", "args": ["./srv.js"] }\r\n }\r\n}\r\n'; + writeFileSync(configPath, original); + + const result = write('cursor', configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + expect(after.replace(/\r\n/g, '')).not.toContain('\n'); + expect(after).toContain('// crlf header'); + + const servers = parseConfig(after).mcpServers as Record; + expect(servers['existing-server']).toEqual({ command: 'node', args: ['./srv.js'] }); + expect(servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + }); + + it('opencode: inserts the array-command entry under `mcp`, preserving comments + siblings', () => { + const configPath = tempFile('opencode.json'); + const original = `{ + "mcp": { + "other": { "type": "local", "enabled": true, "command": ["node", "x.js"] } + } +} +`; + writeFileSync(configPath, original); + + const result = write('opencode', configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + expect(after).toContain('// opencode config'); + const parsed = parseConfig(after); + const mcp = parsed.mcp as Record; + expect(mcp.other).toEqual({ type: 'local', enabled: true, command: ['node', 'x.js'] }); + expect(mcp['open-knowledge']).toEqual(OPENCODE_ENTRY); + }); + + it('updating an existing entry rewrites only our slot, leaving siblings intact', () => { + const configPath = tempFile('config.json'); + const original = `{ + "mcpServers": { + "existing-server": { "command": "node", "args": ["./srv.js"] }, + "open-knowledge": { "command": "stale", "args": ["old"] } + } +} +`; + writeFileSync(configPath, original); + + const result = write('cursor', configPath); + expect(result.action).toBe('overwritten'); + + const after = readFileSync(configPath, 'utf-8'); + expect(after).toContain('// header'); + const servers = parseConfig(after).mcpServers as Record; + expect(servers['existing-server']).toEqual({ command: 'node', args: ['./srv.js'] }); + expect(servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + }); + + it('is a byte-identical no-op when our entry is already current', () => { + const configPath = tempFile('config.json'); + writeFileSync( + configPath, + `{ + "mcpServers": {} +} +`, + ); + const first = write('claude', configPath); + expect(first.action).toBe('written'); + const afterFirst = readFileSync(configPath, 'utf-8'); + + const second = write('claude', configPath); + expect(second.action).toBe('overwritten'); + expect(readFileSync(configPath, 'utf-8')).toBe(afterFirst); + }); + + it('never writes a backup sidecar beside a present, parseable config', () => { + const configPath = tempFile('config.json'); + const original = `{ + "mcpServers": { "existing-server": { "command": "node" } } +} +`; + writeFileSync(configPath, original); + + write('cursor', configPath); + + expect(existsSync(`${configPath}.ok-backup`)).toBe(false); + }); + + it('declines (oversize) and leaves the config byte-unchanged', () => { + const configPath = tempFile('config.json'); + const huge = 'x'.repeat(11 * 1024 * 1024); + const original = `{ "mcpServers": { "big": { "note": "${huge}" } } }`; + writeFileSync(configPath, original); + + const result = write('claude', configPath); + expect(result.action).toBe('declined'); + expect(result.declineReason).toBe('oversize'); + expect(readFileSync(configPath, 'utf-8')).toBe(original); + expect(existsSync(`${configPath}.ok-backup`)).toBe(false); + }); + + it('declines (duplicate-container) rather than editing one block arbitrarily', () => { + const configPath = tempFile('config.json'); + const original = `{ + "mcpServers": { "a": { "command": "x" } }, + "mcpServers": { "b": { "command": "y" } } +} +`; + writeFileSync(configPath, original); + + const result = write('claude', configPath); + expect(result.action).toBe('declined'); + expect(result.declineReason).toBe('duplicate-container'); + expect(readFileSync(configPath, 'utf-8')).toBe(original); + }); + + it('creates a fresh config when the file is absent', () => { + const configPath = tempFile('config.json'); + const result = write('cursor', configPath); + expect(result.action).toBe('written'); + const servers = parseConfig(readFileSync(configPath, 'utf-8')).mcpServers as Record< + string, + unknown + >; + expect(servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + }); +}); diff --git a/packages/cli/src/commands/mcp-symlink-write-through.test.ts b/packages/cli/src/commands/mcp-symlink-write-through.test.ts new file mode 100644 index 000000000..263d8ef8b --- /dev/null +++ b/packages/cli/src/commands/mcp-symlink-write-through.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { lstatSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createTomlConfigEngine, + setTomlConfigEngineForTesting, +} from '../native/toml-config-engine.ts'; +import { EDITOR_TARGETS, type EditorMcpTarget } from './editors.ts'; +import { writeEditorMcpConfig } from './init.ts'; + +const unix = process.platform !== 'win32'; +const dirs: string[] = []; + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + dirs.push(dir); + return dir; +} + +function targetForFile(base: EditorMcpTarget, configPath: string): EditorMcpTarget { + return { ...base, configPath: () => configPath }; +} + +function write(base: EditorMcpTarget, configPath: string) { + return writeEditorMcpConfig(targetForFile(base, configPath), '', { + mode: 'published', + skipAvailabilityCheck: true, + }); +} + +afterEach(() => { + setTomlConfigEngineForTesting(null); + while (dirs.length) rmSync(dirs.pop() as string, { recursive: true, force: true }); +}); + +describe('symlink write-through on the harness write path', () => { + it.skipIf(!unix)('writes a JSON harness through a symlink, leaving the symlink intact', () => { + const home = tempDir('ok-symlink-home-'); + const repo = tempDir('ok-symlink-repo-'); + const target = join(repo, 'mcp.json'); + const original = [ + '{', + ' // dotfiles-managed cursor config', + ' "mcpServers": {', + ' "existing": { "command": "x" }', + ' }', + '}', + '', + ].join('\n'); + writeFileSync(target, original); + const config = join(home, 'mcp.json'); + symlinkSync(target, config); + + const result = write(EDITOR_TARGETS.cursor, config); + expect(result.action).toBe('written'); + + expect(lstatSync(config).isSymbolicLink()).toBe(true); + const after = readFileSync(target, 'utf-8'); + expect(after).toContain('// dotfiles-managed cursor config'); + expect(after).toContain('"existing"'); + expect(after).toContain('open-knowledge'); + }); + + it.skipIf(!unix)( + 'writes the Codex TOML harness through a symlink, preserving comments on the real target', + () => { + const engine = createTomlConfigEngine(); + if (engine.backend !== 'native') { + throw new Error('native toml_edit addon must be built for the TOML write-through gate'); + } + setTomlConfigEngineForTesting(engine); + + const home = tempDir('ok-symlink-home-'); + const repo = tempDir('ok-symlink-repo-'); + const target = join(repo, 'config.toml'); + const original = [ + '# dotfiles-managed codex config', + 'model = "gpt-5"', + '', + '[mcp_servers.other]', + 'command = "other-cmd" # keep', + '', + ].join('\n'); + writeFileSync(target, original); + const config = join(home, 'config.toml'); + symlinkSync(target, config); + + const result = write(EDITOR_TARGETS.codex, config); + expect(result.action).toBe('written'); + + expect(lstatSync(config).isSymbolicLink()).toBe(true); + const after = readFileSync(target, 'utf-8'); + expect(after).toContain('# dotfiles-managed codex config'); + expect(after).toContain('command = "other-cmd" # keep'); + expect(after).toContain('[mcp_servers.open-knowledge]'); + }, + ); + + it.skipIf(!unix)('breaks a cyclic symlink into a regular file carrying our entry', () => { + const home = tempDir('ok-symlink-home-'); + symlinkSync('b.json', join(home, 'a.json')); + symlinkSync('a.json', join(home, 'b.json')); + const config = join(home, 'config.json'); + symlinkSync('a.json', config); + + const result = write(EDITOR_TARGETS.cursor, config); + expect(result.action).toBe('written'); + + expect(lstatSync(config).isSymbolicLink()).toBe(false); + expect(readFileSync(config, 'utf-8')).toContain('open-knowledge'); + }); +}); diff --git a/packages/cli/src/commands/mcp-toml-surgical-write.test.ts b/packages/cli/src/commands/mcp-toml-surgical-write.test.ts new file mode 100644 index 000000000..98a527815 --- /dev/null +++ b/packages/cli/src/commands/mcp-toml-surgical-write.test.ts @@ -0,0 +1,260 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createTomlConfigEngine, + setTomlConfigEngineForTesting, +} from '../native/toml-config-engine.ts'; +import { CHAIN_V1, EDITOR_TARGETS, type EditorMcpTarget } from './editors.ts'; +import { writeEditorMcpConfig } from './init.ts'; + +function codexTargetForFile(configPath: string): EditorMcpTarget { + return { ...EDITOR_TARGETS.codex, configPath: () => configPath }; +} + +function writeCodex(configPath: string) { + return writeEditorMcpConfig(codexTargetForFile(configPath), '', { + mode: 'published', + skipAvailabilityCheck: true, + }); +} + +const PUBLISHED_CHAIN_ENTRY = { command: '/bin/sh', args: ['-l', '-c', CHAIN_V1] }; + +// biome-ignore lint/suspicious/noExplicitAny: structured nested access in tests. +function parseToml(raw: string): any { + const engine = createTomlConfigEngine(); + if (engine.backend !== 'native') throw new Error('native addon required to parse'); + return engine.parseToObject(raw); +} + +describe('surgical TOML MCP write', () => { + let dir: string; + + beforeEach(() => { + const engine = createTomlConfigEngine(); + if (engine.backend !== 'native') { + throw new Error('native toml_edit addon must be built for the surgical TOML write gate'); + } + setTomlConfigEngineForTesting(engine); + }); + + afterEach(() => { + setTomlConfigEngineForTesting(null); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + function tempFile(name: string): string { + dir = mkdtempSync(join(tmpdir(), 'ok-toml-surgical-')); + return join(dir, name); + } + + it.skipIf(process.platform === 'win32')( + 'preserves a user-tightened file mode (0600) on an in-place rewrite', + () => { + const configPath = tempFile('config.toml'); + writeFileSync(configPath, '# my codex config\nmodel = "gpt-5"\n'); + chmodSync(configPath, 0o600); + + const result = writeCodex(configPath); + expect(result.action).toBe('written'); + + expect(statSync(configPath).mode & 0o777).toBe(0o600); + }, + ); + + it('inserts only our entry, preserving comments, siblings, key order, and value types', () => { + const configPath = tempFile('config.toml'); + const original = [ + '# hand-written header', + 'model = "gpt-5"', + 'approval_policy = "never"', + 'timeout = 30.0', + 'startup_timeout_ms = 9223372036854775807', + 'last_seen = 2026-06-26T12:34:56.123456Z', + 'server.host = "localhost"', + '', + '[mcp_servers.linear]', + 'command = "linear-cmd" # keep this note', + 'url = "https://linear.example"', + '', + ].join('\n'); + writeFileSync(configPath, original); + + const result = writeCodex(configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + expect(after).toContain('# hand-written header'); + expect(after).toContain('command = "linear-cmd" # keep this note'); + expect(after).toContain('timeout = 30.0'); + expect(after).toContain('startup_timeout_ms = 9223372036854775807'); + expect(after).toContain('last_seen = 2026-06-26T12:34:56.123456Z'); + expect(after).toContain('server.host = "localhost"'); + expect(after).toContain('[mcp_servers.open-knowledge]'); + + const parsed = parseToml(after); + expect(parsed.model).toBe('gpt-5'); + expect(parsed.timeout).toBe(30.0); + expect(parsed.mcp_servers.linear).toEqual({ + command: 'linear-cmd', + url: 'https://linear.example', + }); + expect(parsed.mcp_servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + }); + + it('appends our entry with the rest of the file byte-identical (only-additive)', () => { + const configPath = tempFile('config.toml'); + const original = '# my config\nmodel = "gpt-5"\n'; + writeFileSync(configPath, original); + + writeCodex(configPath); + const after = readFileSync(configPath, 'utf-8'); + expect(after.startsWith(original)).toBe(true); + expect(after.slice(original.length)).toContain('[mcp_servers.open-knowledge]'); + }); + + it('preserves a leading UTF-8 BOM byte-for-byte', () => { + const configPath = tempFile('config.toml'); + const original = '\uFEFF# bom config\nmodel = "gpt-5"\n'; + writeFileSync(configPath, original); + + const result = writeCodex(configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + expect(after.charCodeAt(0)).toBe(0xfeff); + expect(after).toContain('# bom config'); + expect(parseToml(after).mcp_servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + }); + + it('preserves CRLF line endings elsewhere and keeps our chain LF-internal', () => { + const configPath = tempFile('config.toml'); + const original = + '# crlf config\r\nmodel = "gpt-5"\r\n\r\n[mcp_servers.other]\r\ncommand = "node"\r\n'; + writeFileSync(configPath, original); + + const result = writeCodex(configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + expect(after.replace(/\r\n/g, '')).not.toContain('\n'); + expect(after).toContain('# crlf config'); + + const parsed = parseToml(after); + expect(parsed.mcp_servers.other).toEqual({ command: 'node' }); + expect(parsed.mcp_servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + const body = parsed.mcp_servers['open-knowledge'].args[2] as string; + expect(body).toBe(CHAIN_V1); + expect(body).not.toContain('\r'); + }); + + it('does not double the CR of a CRLF multi-line string sibling', () => { + const configPath = tempFile('config.toml'); + const original = + '# crlf config\r\nmodel = "gpt-5"\r\n\r\n[server]\r\nnotes = """\r\nline one\r\nline two\r\n"""\r\n'; + writeFileSync(configPath, original); + + const result = writeCodex(configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + expect(after).not.toContain('\r\r'); + expect(after).toContain('notes = """\r\nline one\r\nline two\r\n"""'); + const parsed = parseToml(after); + expect(parsed.server.notes).toBe('line one\r\nline two\r\n'); + }); + + it('keeps an LF-dominant file LF despite a stray CRLF (dominant EOL, not presence)', () => { + const configPath = tempFile('config.toml'); + const original = 'model = "gpt-5"\r\n# one stray crlf above, rest LF\nname = "ok"\n'; + writeFileSync(configPath, original); + + const result = writeCodex(configPath); + expect(result.action).toBe('written'); + + const after = readFileSync(configPath, 'utf-8'); + expect(after).not.toContain('\r'); + expect(parseToml(after).mcp_servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + }); + + it('preserves a config that lacks a trailing newline', () => { + const configPath = tempFile('config.toml'); + const original = '# no trailing newline\nmodel = "gpt-5"'; + writeFileSync(configPath, original); + + writeCodex(configPath); + const after = readFileSync(configPath, 'utf-8'); + expect(after.endsWith('\n')).toBe(false); + expect(after).toContain('# no trailing newline'); + expect(parseToml(after).mcp_servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + }); + + it('never writes a backup sidecar beside a present, parseable config', () => { + const configPath = tempFile('config.toml'); + const original = '# do not snapshot me\nmodel = "gpt-5"\n'; + writeFileSync(configPath, original); + + writeCodex(configPath); + expect(existsSync(`${configPath}.ok-backup`)).toBe(false); + }); + + it('updates an existing entry in place, preserving siblings, a hand-added key, and a comment', () => { + const configPath = tempFile('config.toml'); + const original = [ + '[mcp_servers.other]', + 'command = "other-cmd" # sibling note', + '', + '[mcp_servers.open-knowledge]', + '# interior note', + 'command = "/bin/sh"', + 'args = ["-l", "-c", "STALE"]', + 'enabled = false', + '', + ].join('\n'); + writeFileSync(configPath, original); + + const result = writeCodex(configPath); + expect(result.action).toBe('overwritten'); + + const after = readFileSync(configPath, 'utf-8'); + expect(after).toContain('command = "other-cmd" # sibling note'); + expect(after).toContain('# interior note'); + expect(after).toContain('enabled = false'); + expect(after).not.toContain('STALE'); + const parsed = parseToml(after); + expect(parsed.mcp_servers.other).toEqual({ command: 'other-cmd' }); + expect(parsed.mcp_servers['open-knowledge'].args).toEqual(['-l', '-c', CHAIN_V1]); + }); + + it('is a byte-identical no-op on an unchanged config (idempotent)', () => { + const configPath = tempFile('config.toml'); + writeFileSync(configPath, '# stable\nmodel = "gpt-5"\n'); + + writeCodex(configPath); + const afterFirst = readFileSync(configPath, 'utf-8'); + + const second = writeCodex(configPath); + expect(second.action).toBe('overwritten'); + expect(readFileSync(configPath, 'utf-8')).toBe(afterFirst); + }); + + it('creates a fresh config when none exists', () => { + const configPath = tempFile('config.toml'); + const result = writeCodex(configPath); + expect(result.action).toBe('written'); + const after = readFileSync(configPath, 'utf-8'); + expect(after.endsWith('\n')).toBe(true); + expect(parseToml(after).mcp_servers['open-knowledge']).toEqual(PUBLISHED_CHAIN_ENTRY); + expect(existsSync(`${configPath}.ok-backup`)).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/repair-mcp-configs.ts b/packages/cli/src/commands/repair-mcp-configs.ts index ef7eecf80..457ddee17 100644 --- a/packages/cli/src/commands/repair-mcp-configs.ts +++ b/packages/cli/src/commands/repair-mcp-configs.ts @@ -13,7 +13,7 @@ export interface RepairOutcome { scope: 'user' | 'project'; editorId: EditorId; configPath: string; - outcome: 'no-entry' | 'canonical' | 'repaired' | 'write-failed'; + outcome: 'no-entry' | 'canonical' | 'repaired' | 'write-failed' | 'declined'; error?: string; } @@ -162,6 +162,17 @@ function repairOne(opts: RepairOneOptions): RepairOutcome { return { ...base, outcome: 'write-failed', error }; } + if (result.action === 'declined') { + opts.logger({ + event: 'mcp-config-repair-declined', + scope: opts.scope, + editorId: opts.editorId, + configPath: opts.configPath, + reason: result.declineReason, + }); + return { ...base, outcome: 'declined' }; + } + return { ...base, outcome: 'repaired' }; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b4225290e..ff40f4ce7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,6 +24,7 @@ export { LAUNCH_UI_CHAIN_SENTINEL, LAUNCH_UI_CHAIN_V1, type LaunchJsonResult, + type McpDeclineReason, type McpEntryClassification, readExistingMcpEntry, scaffoldLaunchJson, @@ -31,6 +32,11 @@ export { writeEditorMcpConfig, writeUserMcpConfigs, } from './commands/init.ts'; +export { + buildMcpConfigDeclineEvent, + type McpConfigDeclineEvent, + type McpConfigDeclineScope, +} from './commands/mcp-decline-event.ts'; export { buildMcpConfigMigrateEvent, type McpConfigMigrateEvent, diff --git a/packages/cli/src/integrations/project-integration-writers.test.ts b/packages/cli/src/integrations/project-integration-writers.test.ts index 41197e35a..422bf9dda 100644 --- a/packages/cli/src/integrations/project-integration-writers.test.ts +++ b/packages/cli/src/integrations/project-integration-writers.test.ts @@ -89,6 +89,21 @@ describe('mcpConfigWriter', () => { expect(outcome.path).toBe(join(projectDir, '.cursor', 'mcp.json')); }); + test('reports "declined" with the reason when the present config is unparseable', () => { + const cursorMcp = join(projectDir, '.cursor', 'mcp.json'); + mkdirSync(join(projectDir, '.cursor'), { recursive: true }); + const malformed = '{ "mcpServers": { "open-knowledge": '; + writeFileSync(cursorMcp, malformed); + + const outcome = mcpConfigWriter.write(EDITOR_TARGETS.cursor, projectDir, {}); + + expect(outcome.action).toBe('declined'); + expect(outcome.reason).toBe('unparseable'); + expect(outcome.path).toBe(cursorMcp); + expect(outcome.error).toBeUndefined(); + expect(readFileSync(cursorMcp, 'utf-8')).toBe(malformed); + }); + test('never throws even when the target path environment is hostile', () => { writeFileSync(join(projectDir, '.mcp.json'), 'not-json'); writeFileSync(join(projectDir, '.cursor'), 'block'); diff --git a/packages/cli/src/integrations/project-integration-writers.ts b/packages/cli/src/integrations/project-integration-writers.ts index ad80cde9a..8ee684ec4 100644 --- a/packages/cli/src/integrations/project-integration-writers.ts +++ b/packages/cli/src/integrations/project-integration-writers.ts @@ -4,7 +4,7 @@ import { type EditorMcpTarget, type McpInstallOptions, } from '../commands/editors.ts'; -import { writeEditorMcpConfig } from '../commands/init.ts'; +import { type McpDeclineReason, writeEditorMcpConfig } from '../commands/init.ts'; import { writeProjectSkill } from './write-project-skill.ts'; type IntegrationId = 'mcp-config' | 'project-skill'; @@ -12,9 +12,10 @@ type IntegrationId = 'mcp-config' | 'project-skill'; export interface IntegrationWriteOutcome { readonly integration: IntegrationId; readonly editorId: EditorId; - readonly action: 'written' | 'overwritten' | 'skipped-unsupported' | 'failed'; + readonly action: 'written' | 'overwritten' | 'skipped-unsupported' | 'declined' | 'failed'; readonly path?: string; readonly error?: string; + readonly reason?: McpDeclineReason; } export interface ProjectIntegrationWriter { @@ -56,6 +57,15 @@ export const mcpConfigWriter: ProjectIntegrationWriter = { error: result.error ?? 'unknown failure', }; } + if (result.action === 'declined') { + return { + integration: 'mcp-config', + editorId: target.id, + action: 'declined', + path: result.configPath, + ...(result.declineReason !== undefined ? { reason: result.declineReason } : {}), + }; + } return { integration: 'mcp-config', editorId: target.id, diff --git a/packages/cli/src/native/load-native-config.test.ts b/packages/cli/src/native/load-native-config.test.ts new file mode 100644 index 000000000..e62833443 --- /dev/null +++ b/packages/cli/src/native/load-native-config.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from 'bun:test'; +import { fileURLToPath } from 'node:url'; +import { requireNativeConfigModule } from './load-native-config.ts'; + +function withCapturedStderr(fn: () => void): string { + const original = process.stderr.write.bind(process.stderr); + const priorEnv = process.env.OK_DEBUG_NATIVE; + let captured = ''; + process.env.OK_DEBUG_NATIVE = '1'; + // biome-ignore lint/suspicious/noExplicitAny: minimal stderr.write spy for the test. + process.stderr.write = ((chunk: any) => { + captured += String(chunk); + return true; + }) as typeof process.stderr.write; + try { + fn(); + } finally { + process.stderr.write = original; + if (priorEnv === undefined) delete process.env.OK_DEBUG_NATIVE; + else process.env.OK_DEBUG_NATIVE = priorEnv; + } + return captured; +} + +const BROKEN_BINARY_URL = 'file:///app/dist/cli.mjs'; + +function moduleNotFound(id: string): never { + throw Object.assign(new Error(`Cannot find module '${id}'`), { code: 'MODULE_NOT_FOUND' }); +} + +const BUNDLED_MODULE_URL = 'file:///app/dist/cli.mjs'; +const SENTINEL_BUNDLED = { source: 'bundled' }; +const SENTINEL_WORKSPACE = { source: 'workspace' }; + +function bundledLoaderPath(moduleUrl: string): string { + return fileURLToPath(moduleUrl).replace(/cli\.mjs$/, 'native/index.js'); +} + +describe('requireNativeConfigModule resolution order', () => { + test('loads the dist-relative bundle first when present', () => { + const expectedBundlePath = bundledLoaderPath(BUNDLED_MODULE_URL); + let workspaceTried = false; + + const mod = requireNativeConfigModule({ + moduleUrl: BUNDLED_MODULE_URL, + requireModule: (id) => { + if (id === expectedBundlePath) return SENTINEL_BUNDLED; + workspaceTried = true; + return SENTINEL_WORKSPACE; + }, + }); + + expect(mod).toBe(SENTINEL_BUNDLED); + expect(workspaceTried).toBe(false); + }); + + test('falls back to the workspace package when no bundled binary exists', () => { + const mod = requireNativeConfigModule({ + moduleUrl: BUNDLED_MODULE_URL, + requireModule: (id) => { + if (id === '@inkeep/open-knowledge-native-config') return SENTINEL_WORKSPACE; + throw new Error(`MODULE_NOT_FOUND: ${id}`); + }, + }); + + expect(mod).toBe(SENTINEL_WORKSPACE); + }); + + test('returns null when neither the bundle nor the workspace package resolves', () => { + const mod = requireNativeConfigModule({ + moduleUrl: BUNDLED_MODULE_URL, + requireModule: (id) => { + throw new Error(`MODULE_NOT_FOUND: ${id}`); + }, + }); + + expect(mod).toBeNull(); + }); + + test('computes the bundled path relative to the calling module dir', () => { + const requested: string[] = []; + + requireNativeConfigModule({ + moduleUrl: 'file:///somewhere/else/dist/index.mjs', + requireModule: (id) => { + requested.push(id); + if (id.endsWith('index.js')) return SENTINEL_BUNDLED; + throw new Error(`MODULE_NOT_FOUND: ${id}`); + }, + }); + + expect(requested).toContain('/somewhere/else/dist/native/index.js'); + }); +}); + +describe('broken-binary diagnostic (OK_DEBUG_NATIVE)', () => { + test('surfaces a present-but-broken binary, distinct from the silent no-binary case', () => { + const captured = withCapturedStderr(() => { + const mod = requireNativeConfigModule({ + moduleUrl: BROKEN_BINARY_URL, + requireModule: () => { + throw new Error('dlopen failed: wrong ELF class'); + }, + }); + expect(mod).toBeNull(); + }); + expect(captured).toContain('native-config'); + expect(captured).toContain('dlopen failed'); + }); + + test('stays silent for the no-binary case even under OK_DEBUG_NATIVE', () => { + const captured = withCapturedStderr(() => { + const mod = requireNativeConfigModule({ + moduleUrl: BROKEN_BINARY_URL, + requireModule: (id) => moduleNotFound(id), + }); + expect(mod).toBeNull(); + }); + expect(captured).toBe(''); + }); +}); diff --git a/packages/cli/src/native/load-native-config.ts b/packages/cli/src/native/load-native-config.ts new file mode 100644 index 000000000..67ead24d1 --- /dev/null +++ b/packages/cli/src/native/load-native-config.ts @@ -0,0 +1,45 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const NATIVE_CONFIG_PACKAGE = '@inkeep/open-knowledge-native-config'; + +function isModuleNotFound(err: unknown): boolean { + const code = + err && typeof err === 'object' && 'code' in err ? (err as { code?: unknown }).code : undefined; + return code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND'; +} + +export function debugNativeLoadFailure(context: string, err: unknown): void { + if (!process.env.OK_DEBUG_NATIVE) return; + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`[ok] native-config ${context}: ${message}\n`); +} + +const BUNDLED_LOADER_SUBPATH = ['native', 'index.js']; + +export interface NativeConfigResolver { + requireModule: (id: string) => unknown; + moduleUrl: string; +} + +export function requireNativeConfigModule( + resolver: Partial = {}, +): unknown | null { + const moduleUrl = resolver.moduleUrl ?? import.meta.url; + const requireModule = resolver.requireModule ?? createRequire(moduleUrl); + + try { + const here = dirname(fileURLToPath(moduleUrl)); + return requireModule(join(here, ...BUNDLED_LOADER_SUBPATH)); + } catch (err) { + if (!isModuleNotFound(err)) debugNativeLoadFailure('bundled loader failed to load', err); + } + + try { + return requireModule(NATIVE_CONFIG_PACKAGE); + } catch (err) { + if (!isModuleNotFound(err)) debugNativeLoadFailure('workspace addon failed to load', err); + return null; + } +} diff --git a/packages/cli/src/native/mcp-toml-edit-binding.test.ts b/packages/cli/src/native/mcp-toml-edit-binding.test.ts new file mode 100644 index 000000000..fe8c305ab --- /dev/null +++ b/packages/cli/src/native/mcp-toml-edit-binding.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +interface McpEditResult { + text: string; + changed: boolean; + existed: boolean; +} + +interface SymlinkWritePaths { + readPath?: string; + writePath: string; +} + +interface NativeMcpEditBinding { + upsertMcpServer(tomlText: string, serverName: string, entryJson: string): McpEditResult; + removeMcpServer(tomlText: string, serverName: string): McpEditResult; + resolveSymlinkWritePath(path: string): SymlinkWritePaths; +} + +const require = createRequire(import.meta.url); +const binding = require('@inkeep/open-knowledge-native-config') as NativeMcpEditBinding; + +const SERVER = 'open-knowledge'; +const ENTRY = JSON.stringify({ command: '/bin/sh', args: ['-l', '-c', 'run-ok'] }); + +describe('native mcp toml-edit binding', () => { + test('upserts a fresh entry, preserving a sibling and its comment', () => { + const input = '[mcp_servers.other]\ncommand = "other" # keep\n'; + const result = binding.upsertMcpServer(input, SERVER, ENTRY); + expect(result.changed).toBe(true); + expect(result.existed).toBe(false); + expect(typeof result.text).toBe('string'); + expect(result.text).toContain('[mcp_servers.open-knowledge]'); + expect(result.text).toContain('command = "other" # keep'); + }); + + test('reports existed=true when updating an entry that is already present', () => { + const input = '[mcp_servers.open-knowledge]\ncommand = "/old"\n'; + const result = binding.upsertMcpServer(input, SERVER, ENTRY); + expect(result.existed).toBe(true); + expect(result.changed).toBe(true); + expect(result.text).toContain('/bin/sh'); + }); + + test('a re-upsert of the canonical entry is a byte-identical no-op', () => { + const first = binding.upsertMcpServer('[other]\nx = 1\n', SERVER, ENTRY).text; + const second = binding.upsertMcpServer(first, SERVER, ENTRY); + expect(second.changed).toBe(false); + expect(second.text).toBe(first); + }); + + test('remove deletes only our entry and reports the change', () => { + const input = `[mcp_servers.other]\ncommand = "other"\n\n[mcp_servers.open-knowledge]\ncommand = "/bin/sh"\n`; + const result = binding.removeMcpServer(input, SERVER); + expect(result.changed).toBe(true); + expect(result.text).not.toContain('[mcp_servers.open-knowledge]'); + expect(result.text).toContain('[mcp_servers.other]'); + }); + + test('the binding throws across the boundary on malformed TOML', () => { + expect(() => binding.upsertMcpServer('a = = b', SERVER, ENTRY)).toThrow(); + }); + + test('resolveSymlinkWritePath marshals a real target back as both paths', () => { + const dir = mkdtempSync(join(tmpdir(), 'ok-resolve-')); + try { + const plain = join(dir, 'config.toml'); + writeFileSync(plain, 'x = 1\n'); + const resolved = binding.resolveSymlinkWritePath(plain); + expect(resolved.writePath).toBe(plain); + expect(resolved.readPath).toBe(plain); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('resolveSymlinkWritePath marshals a cyclic chain as an undefined readPath', () => { + const dir = mkdtempSync(join(tmpdir(), 'ok-resolve-')); + try { + symlinkSync('b.toml', join(dir, 'a.toml')); + symlinkSync('a.toml', join(dir, 'b.toml')); + const config = join(dir, 'config.toml'); + symlinkSync('a.toml', config); + const resolved = binding.resolveSymlinkWritePath(config); + expect(resolved.readPath).toBeUndefined(); + expect(resolved.writePath).toBe(config); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/native/symlink-resolve.test.ts b/packages/cli/src/native/symlink-resolve.test.ts new file mode 100644 index 000000000..82c95584d --- /dev/null +++ b/packages/cli/src/native/symlink-resolve.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { resolveHarnessWritePaths, type SymlinkWritePaths } from './symlink-resolve.ts'; + +interface NativeSymlinkBinding { + resolveSymlinkWritePath(path: string): { readPath?: string | null; writePath: string }; +} + +const require = createRequire(import.meta.url); +const nativeBinding = require('@inkeep/open-knowledge-native-config') as NativeSymlinkBinding; + +const unix = process.platform !== 'win32'; + +function describeResolver(label: string, resolve: (path: string) => SymlinkWritePaths) { + describe(`symlink write-path resolver (${label})`, () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'ok-symlink-resolve-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test('a regular file resolves to itself', () => { + const path = join(dir, 'config.toml'); + writeFileSync(path, 'x = 1\n'); + const resolved = resolve(path); + expect(resolved.writePath).toBe(path); + expect(resolved.readPath).toBe(path); + }); + + test('a not-yet-created file resolves to itself (first write)', () => { + const path = join(dir, 'config.toml'); + const resolved = resolve(path); + expect(resolved.writePath).toBe(path); + expect(resolved.readPath).toBe(path); + }); + + test.skipIf(!unix)( + 'follows a chain to its real target, keeping the target as both paths', + () => { + const targetDir = mkdtempSync(join(tmpdir(), 'ok-symlink-target-')); + try { + const target = join(targetDir, 'real-config.toml'); + writeFileSync(target, 'model = "x"\n'); + const link = join(dir, 'link.toml'); + const config = join(dir, 'config.toml'); + symlinkSync(target, link); // absolute target + symlinkSync('link.toml', config); // relative hop within the dir + + const resolved = resolve(config); + expect(resolved.writePath).toBe(target); + expect(resolved.readPath).toBe(target); + } finally { + rmSync(targetDir, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(!unix)('resolves a relative link against its own parent', () => { + const target = join(dir, 'target.toml'); + writeFileSync(target, 'model = "x"\n'); + const config = join(dir, 'config.toml'); + symlinkSync('target.toml', config); + + const resolved = resolve(config); + expect(resolved.writePath).toBe(target); + expect(resolved.readPath).toBe(target); + }); + + test.skipIf(!unix)('breaks a cycle: no read target, writes through the original path', () => { + symlinkSync('b.toml', join(dir, 'a.toml')); + symlinkSync('a.toml', join(dir, 'b.toml')); + const config = join(dir, 'config.toml'); + symlinkSync('a.toml', config); + + const resolved = resolve(config); + expect(resolved.readPath).toBeNull(); + expect(resolved.writePath).toBe(config); + }); + }); +} + +describeResolver('js fallback', (path) => resolveHarnessWritePaths(path, () => null)); +describeResolver('native', (path) => resolveHarnessWritePaths(path, () => nativeBinding)); + +describe('resolveHarnessWritePaths backend selection', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'ok-symlink-backend-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test('degrades to the JS mirror when the native binding throws', () => { + const path = join(dir, 'config.toml'); + writeFileSync(path, 'x = 1\n'); + const throwingBinding: NativeSymlinkBinding = { + resolveSymlinkWritePath() { + throw new Error('binding cannot execute'); + }, + }; + const resolved = resolveHarnessWritePaths(path, () => throwingBinding); + expect(resolved.writePath).toBe(path); + expect(resolved.readPath).toBe(path); + }); +}); diff --git a/packages/cli/src/native/symlink-resolve.ts b/packages/cli/src/native/symlink-resolve.ts new file mode 100644 index 000000000..ea6eeea1b --- /dev/null +++ b/packages/cli/src/native/symlink-resolve.ts @@ -0,0 +1,73 @@ +import { lstatSync, readlinkSync } from 'node:fs'; +import { dirname, isAbsolute, join } from 'node:path'; +import { debugNativeLoadFailure, requireNativeConfigModule } from './load-native-config.ts'; + +export interface SymlinkWritePaths { + readPath: string | null; + writePath: string; +} + +interface NativeSymlinkBinding { + resolveSymlinkWritePath(path: string): { readPath?: string | null; writePath: string }; +} + +function requireNativeSymlinkBinding(): NativeSymlinkBinding | null { + const mod = requireNativeConfigModule(); + return mod && typeof (mod as Partial).resolveSymlinkWritePath === 'function' + ? (mod as NativeSymlinkBinding) + : null; +} + +function resolveSymlinkWritePathsJs(path: string): SymlinkWritePaths { + let current = path; + const visited = new Set(); + + for (;;) { + let isSymlink: boolean; + try { + isSymlink = lstatSync(current).isSymbolicLink(); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { readPath: current, writePath: current }; + } + return { readPath: null, writePath: path }; + } + + if (!isSymlink) return { readPath: current, writePath: current }; + + if (visited.has(current)) return { readPath: null, writePath: path }; + visited.add(current); + + let target: string; + try { + target = readlinkSync(current); + } catch (err) { + debugNativeLoadFailure('readlinkSync threw during symlink walk', err); + return { readPath: null, writePath: path }; + } + current = isAbsolute(target) ? target : join(dirname(current), target); + } +} + +let cachedBinding: NativeSymlinkBinding | null | undefined; + +function cachedNativeBinding(): NativeSymlinkBinding | null { + if (cachedBinding === undefined) cachedBinding = requireNativeSymlinkBinding(); + return cachedBinding; +} + +export function resolveHarnessWritePaths( + configPath: string, + loadNative: () => NativeSymlinkBinding | null = cachedNativeBinding, +): SymlinkWritePaths { + const native = loadNative(); + if (native) { + try { + const resolved = native.resolveSymlinkWritePath(configPath); + return { readPath: resolved.readPath ?? null, writePath: resolved.writePath }; + } catch (err) { + debugNativeLoadFailure('resolveSymlinkWritePath threw', err); + } + } + return resolveSymlinkWritePathsJs(configPath); +} diff --git a/packages/cli/src/native/toml-config-engine.test.ts b/packages/cli/src/native/toml-config-engine.test.ts new file mode 100644 index 000000000..a4d67b227 --- /dev/null +++ b/packages/cli/src/native/toml-config-engine.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from 'bun:test'; +import { createTomlConfigEngine, type NativeTomlBinding } from './toml-config-engine.ts'; + +const CAPABLE_CASE = 'big = 9223372036854775807\nts = 2026-06-26T12:34:56.123456Z\n'; + +const NOOP_UPSERT: NativeTomlBinding['upsertMcpServer'] = () => ({ + text: '', + changed: false, + existed: false, +}); + +describe('createTomlConfigEngine', () => { + test('resolves the native backend and parses values smol-toml rejects', () => { + const engine = createTomlConfigEngine(); + expect(engine.backend).toBe('native'); + const parsed = engine.parseToObject(CAPABLE_CASE); + expect(parsed.big).toBeDefined(); + expect(parsed.ts).toBeDefined(); + }); + + test('the JS fallback rejects the same integer the native engine accepts', () => { + const fallback = createTomlConfigEngine(() => null); + expect(fallback.backend).toBe('fallback'); + expect(() => fallback.parseToObject(CAPABLE_CASE)).toThrow(); + }); + + test('the fallback still parses an ordinary config', () => { + const fallback = createTomlConfigEngine(() => null); + const parsed = fallback.parseToObject('[mcp_servers.other]\ncommand = "node"\n'); + expect(parsed.mcp_servers).toEqual({ other: { command: 'node' } }); + }); + + test('a binding that loads but fails its probe degrades to the fallback', () => { + const abiMismatch: NativeTomlBinding = { + parseTomlToJson: () => { + throw new Error('symbol not found'); + }, + upsertMcpServer: NOOP_UPSERT, + }; + const engine = createTomlConfigEngine(() => abiMismatch); + expect(engine.backend).toBe('fallback'); + }); + + test('a binding whose probe returns garbage degrades to the fallback', () => { + const wrongOutput: NativeTomlBinding = { + parseTomlToJson: () => 'not json at all', + upsertMcpServer: NOOP_UPSERT, + }; + expect(createTomlConfigEngine(() => wrongOutput).backend).toBe('fallback'); + }); + + test('a healthy injected binding drives the native engine', () => { + const fake: NativeTomlBinding = { + parseTomlToJson: (raw) => (raw.includes('probe') ? '{"probe":1}' : '{"injected":true}'), + upsertMcpServer: NOOP_UPSERT, + }; + const engine = createTomlConfigEngine(() => fake); + expect(engine.backend).toBe('native'); + expect(engine.parseToObject('anything')).toEqual({ injected: true }); + }); + + test('upsertEntry forwards to the binding and maps text + existed', () => { + let captured: { toml: string; name: string; json: string } | undefined; + const fake: NativeTomlBinding = { + parseTomlToJson: (raw) => (raw.includes('probe') ? '{"probe":1}' : '{}'), + upsertMcpServer: (toml, name, json) => { + captured = { toml, name, json }; + return { text: 'edited', changed: true, existed: true }; + }, + }; + const engine = createTomlConfigEngine(() => fake); + if (engine.backend !== 'native') throw new Error('expected the native engine'); + const result = engine.upsertEntry('x = 1\n', 'open-knowledge', { command: 'c' }); + expect(result).toEqual({ text: 'edited', existed: true }); + expect(captured).toEqual({ + toml: 'x = 1\n', + name: 'open-knowledge', + json: '{"command":"c"}', + }); + }); + + test('the real native engine upserts OK’s entry, preserving a sibling', () => { + const engine = createTomlConfigEngine(); + if (engine.backend !== 'native') throw new Error('native addon must be built for this gate'); + const input = '# keep\n[mcp_servers.other]\ncommand = "node"\n'; + const result = engine.upsertEntry(input, 'open-knowledge', { + command: '/bin/sh', + args: ['-l', '-c', 'run'], + }); + expect(result.existed).toBe(false); + expect(result.text).toContain('# keep'); + expect(result.text).toContain('[mcp_servers.other]'); + expect(result.text).toContain('[mcp_servers.open-knowledge]'); + const again = engine.upsertEntry(result.text, 'open-knowledge', { + command: '/bin/sh', + args: ['-l', '-c', 'run'], + }); + expect(again.existed).toBe(true); + expect(again.text).toBe(result.text); + }); + + test('both backends throw on genuinely-malformed TOML', () => { + expect(() => createTomlConfigEngine().parseToObject('a = = b')).toThrow(); + expect(() => createTomlConfigEngine(() => null).parseToObject('a = = b')).toThrow(); + }); + + test('both backends reject a non-table root', () => { + const arrayRoot: NativeTomlBinding = { + parseTomlToJson: (raw) => (raw.includes('probe') ? '{"probe":1}' : '[1,2,3]'), + upsertMcpServer: NOOP_UPSERT, + }; + expect(() => createTomlConfigEngine(() => arrayRoot).parseToObject('x')).toThrow(); + }); +}); diff --git a/packages/cli/src/native/toml-config-engine.ts b/packages/cli/src/native/toml-config-engine.ts new file mode 100644 index 000000000..5a18f71a3 --- /dev/null +++ b/packages/cli/src/native/toml-config-engine.ts @@ -0,0 +1,97 @@ +import { parse as parseToml } from 'smol-toml'; +import { isObject } from '../utils/is-object.ts'; +import { debugNativeLoadFailure, requireNativeConfigModule } from './load-native-config.ts'; + +interface NativeMcpEditResult { + text: string; + changed: boolean; + existed: boolean; +} + +export interface NativeTomlBinding { + parseTomlToJson(tomlText: string): string; + upsertMcpServer(tomlText: string, serverName: string, entryJson: string): NativeMcpEditResult; +} + +export interface TomlUpsertResult { + text: string; + existed: boolean; +} + +interface TomlConfigEngineBase { + parseToObject(raw: string): Record; +} + +interface NativeTomlConfigEngine extends TomlConfigEngineBase { + readonly backend: 'native'; + upsertEntry(raw: string, serverName: string, entry: Record): TomlUpsertResult; +} + +interface FallbackTomlConfigEngine extends TomlConfigEngineBase { + readonly backend: 'fallback'; +} + +export type TomlConfigEngine = NativeTomlConfigEngine | FallbackTomlConfigEngine; + +function requireNativeBinding(): NativeTomlBinding | null { + const mod = requireNativeConfigModule(); + return mod && typeof (mod as Partial).parseTomlToJson === 'function' + ? (mod as NativeTomlBinding) + : null; +} + +function probeBinding(binding: NativeTomlBinding): boolean { + try { + const probe = binding.parseTomlToJson('probe = 1'); + return typeof probe === 'string' && probe.includes('probe'); + } catch (err) { + debugNativeLoadFailure('addon loaded but probe failed', err); + return false; + } +} + +function assertTable(parsed: unknown): Record { + if (!isObject(parsed)) throw new Error('TOML root is not a table'); + return parsed; +} + +function makeNativeEngine(binding: NativeTomlBinding): NativeTomlConfigEngine { + return { + backend: 'native', + parseToObject(raw) { + return assertTable(JSON.parse(binding.parseTomlToJson(raw))); + }, + upsertEntry(raw, serverName, entry) { + const result = binding.upsertMcpServer(raw, serverName, JSON.stringify(entry)); + return { text: result.text, existed: result.existed }; + }, + }; +} + +function makeFallbackEngine(): FallbackTomlConfigEngine { + return { + backend: 'fallback', + parseToObject(raw) { + return assertTable(parseToml(raw)); + }, + }; +} + +export function createTomlConfigEngine( + loadNative: () => NativeTomlBinding | null = requireNativeBinding, +): TomlConfigEngine { + const native = loadNative(); + if (native && probeBinding(native)) return makeNativeEngine(native); + return makeFallbackEngine(); +} + +let cachedEngine: TomlConfigEngine | null = null; + +export function getTomlConfigEngine(): TomlConfigEngine { + if (cachedEngine === null) cachedEngine = createTomlConfigEngine(); + return cachedEngine; +} + +export function setTomlConfigEngineForTesting(engine: TomlConfigEngine | null): void { + cachedEngine = engine; +} diff --git a/packages/cli/tests/integration/mcp-json-write-bundled.test.ts b/packages/cli/tests/integration/mcp-json-write-bundled.test.ts new file mode 100644 index 000000000..85c5953d1 --- /dev/null +++ b/packages/cli/tests/integration/mcp-json-write-bundled.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'bun:test'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const distIndex = join(import.meta.dir, '..', '..', 'dist', 'index.mjs'); +const built = existsSync(distIndex); +if (!built) { + console.warn( + '[mcp-json-write-bundled] dist/index.mjs not built — skipping bundled-load guard. Run `bun run build:cli`.', + ); +} + +interface BundledCli { + writeEditorMcpConfig: ( + target: { configPath: () => string; [k: string]: unknown }, + cwd: string, + options: { mode: string; skipAvailabilityCheck: boolean }, + ) => { action: string }; + EDITOR_TARGETS: Record string; [k: string]: unknown }>; +} + +describe('surgical JSON write through the bundled CLI', () => { + it.skipIf(!built)('loads the bundled jsonc-parser and edits only our entry', async () => { + const mod = (await import(pathToFileURL(distIndex).href)) as unknown as BundledCli; + + const dir = mkdtempSync(join(tmpdir(), 'ok-bundled-')); + const configPath = join(dir, 'mcp.json'); + const original = `\uFEFF{ + "mcpServers": { + "existing": { "command": "node" } + } +} +`; + writeFileSync(configPath, original); + + const target = { ...mod.EDITOR_TARGETS.cursor, configPath: () => configPath }; + const result = mod.writeEditorMcpConfig(target, '', { + mode: 'published', + skipAvailabilityCheck: true, + }); + + const after = readFileSync(configPath, 'utf-8'); + try { + expect(result.action).toBe('written'); + expect(after.charCodeAt(0)).toBe(0xfeff); + expect(after).toContain('// user comment'); + expect(after).toContain('"existing"'); + expect(after).toContain('open-knowledge'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 6fc041f95..193e919e6 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -1,5 +1,8 @@ +import { createRequire } from 'node:module'; import { defineConfig } from 'tsdown'; +const jsoncParserEsmEntry = createRequire(import.meta.url).resolve('jsonc-parser/lib/esm/main.js'); + const dtsEmitFallbackNotice = '[rolldown-plugin-dts] Warning: Failed to emit declaration file'; const originalWarn = console.warn; console.warn = (...args: unknown[]) => { @@ -16,6 +19,14 @@ export default defineConfig({ dts: true, clean: true, minify: true, + plugins: [ + { + name: 'jsonc-parser-esm-entry', + resolveId(id) { + return id === 'jsonc-parser' ? jsoncParserEsmEntry : null; + }, + }, + ], inputOptions: (options) => { options.onLog = (level, log, defaultHandler) => { if ( @@ -44,7 +55,7 @@ export default defineConfig({ return options; }, deps: { - neverBundle: ['@parcel/watcher', '@napi-rs/keyring'], + neverBundle: ['@parcel/watcher', '@napi-rs/keyring', '@inkeep/open-knowledge-native-config'], alwaysBundle: [ /^@inquirer\/checkbox(\/|$)/, /^@inquirer\/password(\/|$)/, @@ -55,6 +66,7 @@ export default defineConfig({ /^@octokit\/rest(\/|$)/, /^cli-boxes(\/|$)/, /^commander(\/|$)/, + /^jsonc-parser(\/|$)/, /^just-bash(\/|$)/, /^picocolors(\/|$)/, /^picomatch(\/|$)/, diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index ec46c721b..8ee06c476 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -33,6 +33,14 @@ extraResources: # Resources/cli/dist/public/ (distinct consumer from the React bundle # copy above that the BrowserWindow loads from Contents/Resources/app/). # Two copies, deliberately — see SPEC §5 AC1.11 and OQ-22. + # Ships the whole bundled CLI, INCLUDING `cli/dist/native/` — the prebuilt + # native-config loader + `.node` the CLI build copies there (Option 3: bundle + # the addon into the CLI; reports/native-addon-npm-distribution/REPORT.md). The + # spawned `ok` subprocess resolves the addon dist-relative from there, so this + # one rule covers the bundled-CLI consumer; the per-package `cli/node_modules` + # copy + asarUnpack below stay for the desktop MAIN process's in-process load. + # The filter must not exclude `.node`/`.js`/`package.json` — guarded by + # tests/unit/electron-builder-cli-native-deps.test.ts. - from: "../cli/dist" to: "cli/dist" filter: @@ -90,6 +98,26 @@ extraResources: - from: "../../node_modules/@napi-rs/keyring-darwin-arm64" to: "cli/node_modules/@napi-rs/keyring-darwin-arm64" filter: ["**/*"] + # The bundled CLI AND the desktop main process both load + # `@inkeep/open-knowledge-native-config` at runtime — the Codex TOML + # harness-config write routes through its toml_edit addon. Like + # `@napi-rs/keyring` above, the bundled CLI's ESM resolver walks + # `cli/dist → cli → Resources → …` and never reaches + # `app.asar.unpacked/node_modules/`, so the addon must sit on the CLI's own + # path. Unlike keyring (a published package with a separate per-arch binary + # package), this is a workspace package whose napi-built loader + `.node` + # live in the package dir, so source it directly and filter to the runtime + # files — never the Rust `target/` tree. arm64-only matches the arm64-only + # DMG. When the binary is absent the load fails and the TOML write degrades + # to a non-destructive decline, never a lossy write. + # Coverage: tests/unit/electron-builder-cli-native-deps.test.ts. + - from: "../native-config" + to: "cli/node_modules/@inkeep/open-knowledge-native-config" + filter: + - "index.js" + - "index.d.ts" + - "package.json" + - "*.node" asarUnpack: - "**/*.node" @@ -111,6 +139,12 @@ asarUnpack: - "**/detect-libc/**" - "**/@napi-rs/keyring/**" - "**/@napi-rs/keyring-*/**" + # The desktop main process loads the addon in-process (the MCP repair sweep's + # Codex TOML write). Keep its napi loader and the `.node` it requires + # relatively co-located on the real filesystem — a loader left inside + # app.asar resolving `require('./native-config..node')` against an + # unpacked sibling is the fragile path; unpack the whole package dir instead. + - "**/@inkeep/open-knowledge-native-config/**" - "**/simple-git/**" # node-pty ships its native pty.node addon AND an extensionless `spawn-helper` # binary under prebuilds/-/. The `**/*.node` rule above unpacks diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 8d2b87f70..3644e942c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -27,6 +27,7 @@ "dependencies": { "@inkeep/open-knowledge": "workspace:*", "@inkeep/open-knowledge-core": "workspace:*", + "@inkeep/open-knowledge-native-config": "workspace:*", "@inkeep/open-knowledge-server": "workspace:*", "@napi-rs/keyring": "^1.3.0", "electron-updater": "6.8.4", diff --git a/packages/desktop/src/main/claude-readiness.ts b/packages/desktop/src/main/claude-readiness.ts index 8dfb5d064..d3d714e9e 100644 --- a/packages/desktop/src/main/claude-readiness.ts +++ b/packages/desktop/src/main/claude-readiness.ts @@ -1,3 +1,4 @@ +import type { McpEntryClassification } from '@inkeep/open-knowledge'; import type { ClaudeReadiness, CliReadiness } from '../shared/bridge-contract.ts'; import { getLogger } from './desktop-logger.ts'; @@ -14,7 +15,9 @@ export const CLAUDE_PROBE_ARGS: readonly string[] = cliProbeArgs('claude'); const PROBE_TIMEOUT_MS = 5000; -export type McpEntryKind = 'present' | 'absent' | 'no-entry' | 'corrupt'; +/** The classifications `classifyExistingMcpEntry` can return — derived from the + * CLI's authoritative union so a new kind can't silently drift this copy. */ +export type McpEntryKind = McpEntryClassification['kind']; /** Minimal child-process surface the probe drives — injected so the spawn is a * test seam. Custom method names avoid the EventEmitter overload friction of @@ -68,7 +71,7 @@ export function interpretClaudeProbe(code: number | null): ClaudeOnPath { } /** Only an actually-present `open-knowledge` entry counts as wired; absent / - * no-entry / corrupt all mean the terminal's `claude` would see no OK tools. */ + * no-entry / decline all mean the terminal's `claude` would see no OK tools. */ export function mcpStatusFromClassification(kind: McpEntryKind): McpWiringStatus { return kind === 'present' ? 'wired' : 'needs-rewire'; } diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index db2ad3b67..8116f5f97 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -771,6 +771,7 @@ function logAiIntegrationOutcomes(result: ProjectAiIntegrationsResult): number { integration: o.integration, action: o.action, ...(o.error !== undefined ? { error: o.error } : {}), + ...(o.reason !== undefined ? { reason: o.reason } : {}), })), }), ); @@ -843,6 +844,7 @@ async function openProject( cli: createProjectMcpReclaimCliSurface(), forceEnv: process.env.OK_M6B_FORCE ?? null, reclaimDisableEnv: process.env.OK_RECLAIM_DISABLE ?? null, + logger: { event: (payload) => getLogger('mcp-wiring').info(payload, payload.event) }, }).catch((err) => { console.warn('[main] project-mcp reclaim failed', { err: err instanceof Error ? err.message : String(err), @@ -1345,6 +1347,9 @@ function createProjectMcpReclaimCliSurface(): ProjectMcpReclaimCliSurface { if (result.action === 'failed') { return { action: 'failed', error: result.error }; } + if (result.action === 'declined') { + return { action: 'declined', reason: result.declineReason }; + } return { action: 'overwritten' }; }, }; @@ -1367,6 +1372,16 @@ function createMcpWiringOpts(opts: ArmMcpWiringOpts = {}) { reclaimDisableEnv: process.env.OK_RECLAIM_DISABLE ?? null, forceShow: opts.forceShow ?? false, immediateDispatchTarget: opts.immediateDispatchTarget, + logger: { + info: (msg: string, ctx?: object) => + getLogger('mcp-wiring').info((ctx ?? {}) as Record, msg), + warn: (msg: string, ctx?: object) => + getLogger('mcp-wiring').warn((ctx ?? {}) as Record, msg), + error: (msg: string, ctx?: object) => + getLogger('mcp-wiring').error((ctx ?? {}) as Record, msg), + event: (payload: { event: string; [k: string]: unknown }) => + getLogger('mcp-wiring').info(payload, payload.event), + }, }; } diff --git a/packages/desktop/src/main/mcp-wiring.test.ts b/packages/desktop/src/main/mcp-wiring.test.ts index 21905d72f..74fdfcfd8 100644 --- a/packages/desktop/src/main/mcp-wiring.test.ts +++ b/packages/desktop/src/main/mcp-wiring.test.ts @@ -1,9 +1,28 @@ -import { describe, expect, test } from 'bun:test'; +import { afterEach, describe, expect, test } from 'bun:test'; +import { + existsSync as fsExistsSync, + mkdirSync as fsMkdirSync, + readFileSync as fsReadFileSync, + renameSync as fsRenameSync, + unlinkSync as fsUnlinkSync, + writeFileSync as fsWriteFileSync, + mkdtempSync, + readdirSync, + rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { buildManagedServerEntry, + classifyExistingMcpEntry, type EditorMcpTarget, type McpEntryClassification, } from '@inkeep/open-knowledge'; +import { classifyExistingMcpEntry as classifyWithOverridableEngine } from '../../../cli/src/commands/init.ts'; +import { + createTomlConfigEngine, + setTomlConfigEngineForTesting, +} from '../../../cli/src/native/toml-config-engine.ts'; import type { McpWiringEditorId } from '../shared/ipc-channels.ts'; import { checkAndRepairMcpWiringOnStartup, @@ -55,7 +74,7 @@ function fakeTarget(id: McpWiringEditorId): EditorMcpTarget { interface BuildStartupCliOptions { classify: McpEntryClassification; - writeOutcome?: 'written' | 'overwritten' | 'failed'; + writeOutcome?: 'written' | 'overwritten' | 'failed' | 'declined'; writeError?: string; } @@ -158,6 +177,326 @@ describe('checkAndRepairMcpWiringOnStartup — migrate event ordering', () => { }); }); +describe('checkAndRepairMcpWiringOnStartup — non-destructive decline', () => { + const inertIpcMain = { handle() {}, removeHandler() {} } as unknown as Parameters< + typeof checkAndRepairMcpWiringOnStartup + >[0]['ipcMain']; + + test('declined config → no write, no rename, ok status, bounded decline signal', async () => { + const { cli, order } = buildStartupCli({ + classify: { kind: 'decline', reason: 'unparseable' }, + }); + const fs = memoryFs({ '/home/.config-for-claude.json': 'not-valid-json{' }); + const events: Array> = []; + const result = await checkAndRepairMcpWiringOnStartup({ + isPackaged: true, + executablePath: PACKAGED_EXE, + home: '/home', + platform: 'darwin', + ipcMain: inertIpcMain, + cli, + fs, + logger: { info() {}, warn() {}, error() {}, event: (e) => events.push(e) }, + }); + expect(result.status).toBe('ok'); + expect(order).toEqual([]); + expect(fs.files['/home/.config-for-claude.json']).toBe('not-valid-json{'); + expect(Object.keys(fs.files).some((p) => p.includes('.broken-'))).toBe(false); + const decline = events.find((e) => e.event === 'mcp-config-decline'); + expect(decline).toMatchObject({ + event: 'mcp-config-decline', + scope: 'user', + surface: 'desktop-startup', + editorId: 'claude', + reason: 'unparseable', + }); + expect(decline).not.toHaveProperty('configPath'); + }); + + test('a write that declines (read-then-write race) is not counted as repaired', async () => { + const { cli } = buildStartupCli({ + classify: { + kind: 'present', + entry: { command: 'npx', args: ['-y', '@inkeep/open-knowledge', 'mcp'] }, + }, + writeOutcome: 'declined', + }); + const result = await checkAndRepairMcpWiringOnStartup({ + isPackaged: true, + executablePath: PACKAGED_EXE, + home: '/home', + platform: 'darwin', + ipcMain: inertIpcMain, + cli, + logger: { info() {}, warn() {}, error() {}, event() {} }, + }); + expect(result.status).toBe('ok'); + expect(result).not.toHaveProperty('repairedEditors'); + }); + + describe('reset-repro — a valid i64 Codex config is never reset', () => { + let dir: string | undefined; + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + test('real classify on a 2^53+ integer TOML → byte-unchanged, no .broken, no-op', async () => { + dir = mkdtempSync(join(tmpdir(), 'ok-reset-repro-')); + const tomlPath = join(dir, 'config.toml'); + const original = [ + '# my codex config — keep my comments!', + 'model = "gpt-5"', + '', + '[mcp_servers.other]', + 'command = "node"', + 'startup_timeout_ms = 9223372036854775807', + '', + ].join('\n'); + fsWriteFileSync(tomlPath, original); + + const target: EditorMcpTarget = { + id: 'codex' as McpWiringEditorId, + label: 'Codex', + format: 'toml', + topLevelKey: 'mcp_servers', + serverName: () => 'open-knowledge', + configPath: () => tomlPath, + buildEntry: () => buildManagedServerEntry({ mode: 'published' }), + scope: 'global', + }; + + expect(classifyExistingMcpEntry(target, '', undefined, tomlPath)).toEqual({ + kind: 'no-entry', + }); + + const realFs: McpWiringFsOps = { + existsSync: (p) => fsExistsSync(p), + readFileSync: (p) => fsReadFileSync(p, 'utf8'), + writeFileSync: (p, c) => fsWriteFileSync(p, c), + mkdirSync: (p, o) => { + fsMkdirSync(p, o); + }, + renameSync: (from, to) => fsRenameSync(from, to), + unlinkSync: (p) => fsUnlinkSync(p), + }; + const writes: McpWiringEditorId[][] = []; + const cli: McpWiringCliSurface = { + detectInstalledEditors: () => ['codex' as McpWiringEditorId], + classifyExistingMcpEntry: () => classifyExistingMcpEntry(target, '', undefined, tomlPath), + readExistingMcpEntry: () => null, + allEditorIds: ['codex' as McpWiringEditorId], + editorTargets: { codex: target } as Record, + writeUserMcpConfigs: async ({ editors }) => { + writes.push([...editors]); + fsWriteFileSync(tomlPath, '{"mcp_servers":{"open-knowledge":{}}}'); + return editors.map((editorId) => ({ + editorId, + label: editorId, + action: 'written' as const, + configPath: tomlPath, + serverName: 'open-knowledge', + })); + }, + }; + + const events: Array> = []; + const result = await checkAndRepairMcpWiringOnStartup({ + isPackaged: true, + executablePath: PACKAGED_EXE, + home: dir, + platform: 'darwin', + ipcMain: inertIpcMain, + cli, + fs: realFs, + logger: { info() {}, warn() {}, error() {}, event: (e) => events.push(e) }, + }); + + expect(result.status).toBe('ok'); + expect(fsReadFileSync(tomlPath, 'utf8')).toBe(original); + expect(readdirSync(dir).some((name) => name.includes('.broken-'))).toBe(false); + expect(writes).toEqual([]); + expect(events.find((e) => e.event === 'mcp-config-decline')).toBeUndefined(); + }); + + test('real classify on a genuinely-malformed TOML → decline, byte-unchanged, no write, bounded signal', async () => { + dir = mkdtempSync(join(tmpdir(), 'ok-decline-sweep-')); + const tomlPath = join(dir, 'config.toml'); + const original = ['# keep my comments', 'model = "gpt-5"', 'broken = "unterminated', ''].join( + '\n', + ); + fsWriteFileSync(tomlPath, original); + + const target: EditorMcpTarget = { + id: 'codex' as McpWiringEditorId, + label: 'Codex', + format: 'toml', + topLevelKey: 'mcp_servers', + serverName: () => 'open-knowledge', + configPath: () => tomlPath, + buildEntry: () => buildManagedServerEntry({ mode: 'published' }), + scope: 'global', + }; + + expect(classifyExistingMcpEntry(target, '', undefined, tomlPath)).toEqual({ + kind: 'decline', + reason: 'unparseable', + }); + + const realFs: McpWiringFsOps = { + existsSync: (p) => fsExistsSync(p), + readFileSync: (p) => fsReadFileSync(p, 'utf8'), + writeFileSync: (p, c) => fsWriteFileSync(p, c), + mkdirSync: (p, o) => { + fsMkdirSync(p, o); + }, + renameSync: (from, to) => fsRenameSync(from, to), + unlinkSync: (p) => fsUnlinkSync(p), + }; + const writes: McpWiringEditorId[][] = []; + const cli: McpWiringCliSurface = { + detectInstalledEditors: () => ['codex' as McpWiringEditorId], + classifyExistingMcpEntry: () => classifyExistingMcpEntry(target, '', undefined, tomlPath), + readExistingMcpEntry: () => null, + allEditorIds: ['codex' as McpWiringEditorId], + editorTargets: { codex: target } as Record, + writeUserMcpConfigs: async ({ editors }) => { + writes.push([...editors]); + fsWriteFileSync(tomlPath, '{"mcp_servers":{"open-knowledge":{}}}'); + return editors.map((editorId) => ({ + editorId, + label: editorId, + action: 'written' as const, + configPath: tomlPath, + serverName: 'open-knowledge', + })); + }, + }; + + const events: Array> = []; + const result = await checkAndRepairMcpWiringOnStartup({ + isPackaged: true, + executablePath: PACKAGED_EXE, + home: dir, + platform: 'darwin', + ipcMain: inertIpcMain, + cli, + fs: realFs, + logger: { info() {}, warn() {}, error() {}, event: (e) => events.push(e) }, + }); + + expect(result.status).toBe('ok'); + expect(fsReadFileSync(tomlPath, 'utf8')).toBe(original); + expect(readdirSync(dir).some((name) => name.includes('.broken-'))).toBe(false); + expect(writes).toEqual([]); + const decline = events.find((e) => e.event === 'mcp-config-decline'); + expect(decline).toMatchObject({ + event: 'mcp-config-decline', + scope: 'user', + surface: 'desktop-startup', + editorId: 'codex', + reason: 'unparseable', + }); + expect(decline).not.toHaveProperty('configPath'); + }); + + test('fallback engine: the same valid i64 config is declined, never reset (off-macOS / no-prebuilt-binary host)', async () => { + dir = mkdtempSync(join(tmpdir(), 'ok-fallback-i64-')); + const tomlPath = join(dir, 'config.toml'); + const original = [ + '# my codex config — keep my comments!', + 'model = "gpt-5"', + '', + '[mcp_servers.other]', + 'command = "node"', + 'startup_timeout_ms = 9223372036854775807', + '', + ].join('\n'); + fsWriteFileSync(tomlPath, original); + + const target: EditorMcpTarget = { + id: 'codex' as McpWiringEditorId, + label: 'Codex', + format: 'toml', + topLevelKey: 'mcp_servers', + serverName: () => 'open-knowledge', + configPath: () => tomlPath, + buildEntry: () => buildManagedServerEntry({ mode: 'published' }), + scope: 'global', + }; + + const realFs: McpWiringFsOps = { + existsSync: (p) => fsExistsSync(p), + readFileSync: (p) => fsReadFileSync(p, 'utf8'), + writeFileSync: (p, c) => fsWriteFileSync(p, c), + mkdirSync: (p, o) => { + fsMkdirSync(p, o); + }, + renameSync: (from, to) => fsRenameSync(from, to), + unlinkSync: (p) => fsUnlinkSync(p), + }; + const writes: McpWiringEditorId[][] = []; + const cli: McpWiringCliSurface = { + detectInstalledEditors: () => ['codex' as McpWiringEditorId], + classifyExistingMcpEntry: () => + classifyWithOverridableEngine(target, '', undefined, tomlPath), + readExistingMcpEntry: () => null, + allEditorIds: ['codex' as McpWiringEditorId], + editorTargets: { codex: target } as Record, + writeUserMcpConfigs: async ({ editors }) => { + writes.push([...editors]); + fsWriteFileSync(tomlPath, '{"mcp_servers":{"open-knowledge":{}}}'); + return editors.map((editorId) => ({ + editorId, + label: editorId, + action: 'written' as const, + configPath: tomlPath, + serverName: 'open-knowledge', + })); + }, + }; + + const events: Array> = []; + try { + setTomlConfigEngineForTesting(createTomlConfigEngine(() => null)); + + expect(classifyWithOverridableEngine(target, '', undefined, tomlPath)).toEqual({ + kind: 'decline', + reason: 'unparseable', + }); + + const result = await checkAndRepairMcpWiringOnStartup({ + isPackaged: true, + executablePath: PACKAGED_EXE, + home: dir, + platform: 'darwin', + ipcMain: inertIpcMain, + cli, + fs: realFs, + logger: { info() {}, warn() {}, error() {}, event: (e) => events.push(e) }, + }); + + expect(result.status).toBe('ok'); + } finally { + setTomlConfigEngineForTesting(null); + } + + expect(fsReadFileSync(tomlPath, 'utf8')).toBe(original); + expect(readdirSync(dir).some((name) => name.includes('.broken-'))).toBe(false); + expect(writes).toEqual([]); + const decline = events.find((e) => e.event === 'mcp-config-decline'); + expect(decline).toMatchObject({ + event: 'mcp-config-decline', + scope: 'user', + surface: 'desktop-startup', + editorId: 'codex', + reason: 'unparseable', + }); + expect(decline).not.toHaveProperty('configPath'); + }); + }); +}); + describe('MCP status marker', () => { test('writes confirmed marker without cliPath', () => { const fs = memoryFs(); @@ -294,6 +633,69 @@ describe('runMcpWiringOnFirstLaunch — mid-session immediate dispatch', () => { }); }); + test('a declined editor is not recorded as configured and emits the decline signal', async () => { + const ipcMain = stubIpcMain(); + const wc = fakeWebContents(11); + const fs = memoryFs(); + const events: Array> = []; + const claude = fakeTarget('claude' as McpWiringEditorId); + const cursor = fakeTarget('cursor' as McpWiringEditorId); + const cli: McpWiringCliSurface = { + detectInstalledEditors: () => ['claude' as McpWiringEditorId, 'cursor' as McpWiringEditorId], + classifyExistingMcpEntry: () => ({ kind: 'absent' }) as McpEntryClassification, + readExistingMcpEntry: () => null, + allEditorIds: ['claude' as McpWiringEditorId, 'cursor' as McpWiringEditorId], + editorTargets: { claude, cursor } as Record, + writeUserMcpConfigs: async ({ editors }) => + editors.map((editorId) => + editorId === 'cursor' + ? { + editorId, + label: editorId, + action: 'declined' as const, + configPath: '/home/u/.cursor/mcp.json', + serverName: 'open-knowledge', + declineReason: 'unparseable' as const, + } + : { + editorId, + label: editorId, + action: 'written' as const, + configPath: '/home/u/.claude.json', + serverName: 'open-knowledge', + }, + ), + }; + runMcpWiringOnFirstLaunch( + buildWiringOpts({ + ipcMain, + cli, + fs, + immediateDispatchTarget: wc, + logger: { info() {}, warn() {}, error() {}, event: (e) => events.push(e) }, + }), + ); + + const confirm = ipcMain.handlers.get('ok:mcp-wiring:confirm'); + const result = await confirm?.({ sender: { id: 11 } }, { editorIds: ['claude', 'cursor'] }); + expect(result).toEqual({ ok: true }); + + expect(readMcpStatusMarker('/home/u', fs)).toMatchObject({ + configured: true, + editors: ['claude'], + }); + + const decline = events.find((e) => e.event === 'mcp-config-decline'); + expect(decline).toMatchObject({ + event: 'mcp-config-decline', + scope: 'user', + surface: 'desktop-firstlaunch', + editorId: 'cursor', + reason: 'unparseable', + }); + expect(decline).not.toHaveProperty('configPath'); + }); + test('confirm from a window other than the dispatch target is rejected', async () => { const ipcMain = stubIpcMain(); const wc = fakeWebContents(11); diff --git a/packages/desktop/src/main/mcp-wiring.ts b/packages/desktop/src/main/mcp-wiring.ts index caed74aff..12519825b 100644 --- a/packages/desktop/src/main/mcp-wiring.ts +++ b/packages/desktop/src/main/mcp-wiring.ts @@ -8,9 +8,11 @@ import { } from 'node:fs'; import { dirname, join } from 'node:path'; import { + buildMcpConfigDeclineEvent, buildMcpConfigMigrateEvent, type EditorMcpTarget, isEntryUpToDate, + type McpDeclineReason, type McpEntryClassification, } from '@inkeep/open-knowledge'; import type { IpcMain, IpcMainInvokeEvent } from 'electron'; @@ -159,20 +161,27 @@ export interface McpWiringCliSurface { Array<{ editorId: McpWiringEditorId; label: string; - action: 'written' | 'overwritten' | 'skipped-missing' | 'skipped-flag' | 'failed'; + action: + | 'written' + | 'overwritten' + | 'skipped-missing' + | 'skipped-flag' + | 'failed' + | 'declined'; configPath: string; serverName: string; error?: string; + declineReason?: McpDeclineReason; }> >; /** Look up an editor's existing MCP entry (format-aware). `null` when the * config file is absent or has no entry for this editor. The editorId * surface avoids a cross-package `EditorMcpTarget` type in this module. */ readExistingMcpEntry(editorId: McpWiringEditorId, home: string): Record | null; - /** Discriminated classification — distinguishes 'corrupt' (file exists but - * unparseable or blank/whitespace) from 'no-entry' (file parses, no entry - * under our server name) and 'absent'. Used by startup reclaim to - * move-aside + rewrite corrupt files instead of no-op'ing. */ + /** Discriminated classification — distinguishes 'decline' (file present but + * unparseable) from 'no-entry' (file parses, no entry under our server + * name), 'absent' (missing or blank), and 'present'. Drives the startup + * reclaim disposition per editor. */ classifyExistingMcpEntry(editorId: McpWiringEditorId, home: string): McpEntryClassification; allEditorIds: readonly McpWiringEditorId[]; /** `EDITOR_TARGETS[id]` keyed by editor. Imported directly from @@ -233,11 +242,8 @@ export function checkAndRepairMcpWiringOnStartup( cli, forceEnv, reclaimDisableEnv, - fs = defaultFsOps, - now, logger = DEFAULT_LOGGER, } = opts; - const nowDate = (): Date => (now ? now() : new Date()); if (reclaimDisableEnv === '1') return Promise.resolve({ status: 'skipped', reason: 'reclaim-disabled' }); if (platform !== 'darwin') return Promise.resolve({ status: 'skipped', reason: 'platform' }); @@ -251,7 +257,6 @@ export function checkAndRepairMcpWiringOnStartup( if (selectedEditors.length === 0) return Promise.resolve({ status: 'ok', checkedEditors: [] }); const editorsToRepair: McpWiringEditorId[] = []; - const corruptBackupFailures: Array<{ editor: McpWiringEditorId; error: string }> = []; for (const editor of selectedEditors) { let classification: McpEntryClassification; try { @@ -273,78 +278,41 @@ export function checkAndRepairMcpWiringOnStartup( continue; } - if (classification.kind === 'corrupt') { - let configPath: string; - try { - configPath = cli.editorTargets[editor]?.configPath('', home) ?? ''; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - corruptBackupFailures.push({ editor, error }); - logger.event({ - event: 'mcp-wiring-repair-backup-failed', - editor, - error, - }); - continue; - } - if (configPath === '') { - corruptBackupFailures.push({ editor, error: 'config path unresolvable' }); - logger.event({ - event: 'mcp-wiring-repair-backup-failed', - editor, - error: 'config path unresolvable', - }); - continue; - } - const stamp = nowDate().toISOString().replace(/[:.]/g, '-'); - const backupPath = `${configPath}.broken-${stamp}`; + if (classification.kind === 'decline') { + logger.event( + buildMcpConfigDeclineEvent({ + scope: 'user', + surface: 'desktop-startup', + editorId: editor, + reason: classification.reason, + }), + ); + continue; + } + + if (classification.kind === 'present') { + let migrateConfigPath = ''; try { - fs.renameSync(configPath, backupPath); - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - corruptBackupFailures.push({ editor, error }); - logger.event({ - event: 'mcp-wiring-repair-backup-failed', - editor, - configPath, - error, - }); - continue; - } - logger.event({ - event: 'mcp-wiring-repair-corrupt-backup', - editor, - configPath, - backupPath, - error: classification.error, - }); + migrateConfigPath = cli.editorTargets[editor]?.configPath('', home) ?? ''; + } catch {} + logger.event( + buildMcpConfigMigrateEvent({ + scope: 'user', + surface: 'desktop-startup', + editorId: editor, + configPath: migrateConfigPath, + priorEntry: classification.entry, + }), + ); editorsToRepair.push(editor); continue; } - let migrateConfigPath = ''; - try { - migrateConfigPath = cli.editorTargets[editor]?.configPath('', home) ?? ''; - } catch {} - logger.event( - buildMcpConfigMigrateEvent({ - scope: 'user', - surface: 'desktop-startup', - editorId: editor, - configPath: migrateConfigPath, - priorEntry: classification.entry, - }), - ); - editorsToRepair.push(editor); + const _exhaustive: never = classification; + return _exhaustive; } if (editorsToRepair.length === 0) { - if (corruptBackupFailures.length > 0) { - return Promise.resolve({ - status: 'failed', - failedEditors: corruptBackupFailures, - } satisfies McpStartupRepairResult); - } return Promise.resolve({ status: 'ok', checkedEditors: selectedEditors }); } @@ -355,6 +323,17 @@ export function checkAndRepairMcpWiringOnStartup( .filter((r) => r.action === 'failed') .map((r) => ({ editor: r.editorId, error: r.error })); for (const r of results) { + if (r.action === 'declined') { + logger.event( + buildMcpConfigDeclineEvent({ + scope: 'user', + surface: 'desktop-startup', + editorId: r.editorId, + reason: r.declineReason ?? 'unparseable', + }), + ); + continue; + } logger.event({ event: r.action === 'failed' ? 'mcp-wiring-repair-write-failed' : 'mcp-wiring-repair-repaired', @@ -363,12 +342,16 @@ export function checkAndRepairMcpWiringOnStartup( error: r.error ?? null, }); } - const allFailed = [...failed, ...corruptBackupFailures]; - if (allFailed.length > 0) - return { status: 'failed', failedEditors: allFailed } satisfies McpStartupRepairResult; + const repairedEditors = results + .filter((r) => r.action === 'written' || r.action === 'overwritten') + .map((r) => r.editorId); + if (failed.length > 0) + return { status: 'failed', failedEditors: failed } satisfies McpStartupRepairResult; + if (repairedEditors.length === 0) + return { status: 'ok', checkedEditors: selectedEditors } satisfies McpStartupRepairResult; return { status: 'repaired', - repairedEditors: editorsToRepair, + repairedEditors, } satisfies McpStartupRepairResult; }) .catch((err) => { @@ -380,10 +363,7 @@ export function checkAndRepairMcpWiringOnStartup( }); return { status: 'failed', - failedEditors: [ - ...editorsToRepair.map((editor) => ({ editor, error: message })), - ...corruptBackupFailures, - ], + failedEditors: editorsToRepair.map((editor) => ({ editor, error: message })), } satisfies McpStartupRepairResult; }); } @@ -531,6 +511,17 @@ export function runMcpWiringOnFirstLaunch(opts: RunMcpWiringOpts): RunMcpWiringH error: r.error ?? null, }); } + for (const r of results) { + if (r.action !== 'declined') continue; + logger.event( + buildMcpConfigDeclineEvent({ + scope: 'user', + surface: 'desktop-firstlaunch', + editorId: r.editorId, + reason: r.declineReason ?? 'unparseable', + }), + ); + } if (failedResults.length > 0) { logger.info('partial failure — marker not written; dialog will re-fire next boot'); logIpcError({ @@ -555,13 +546,16 @@ export function runMcpWiringOnFirstLaunch(opts: RunMcpWiringOpts): RunMcpWiringH }; } + const configuredEditors = results + .filter((r) => r.action === 'written' || r.action === 'overwritten') + .map((r) => r.editorId); try { writeMcpStatusMarker( home, { configured: true, configuredAt: nowDate().toISOString(), - editors: [...selectedEditors], + editors: configuredEditors, }, fs, ); @@ -579,7 +573,7 @@ export function runMcpWiringOnFirstLaunch(opts: RunMcpWiringOpts): RunMcpWiringH return { ok: false, error: message }; } - logger.info('configured', { editors: selectedEditors }); + logger.info('configured', { editors: configuredEditors }); return { ok: true }; }; diff --git a/packages/desktop/src/main/project-mcp-reclaim.test.ts b/packages/desktop/src/main/project-mcp-reclaim.test.ts index a9be875d5..5023a730a 100644 --- a/packages/desktop/src/main/project-mcp-reclaim.test.ts +++ b/packages/desktop/src/main/project-mcp-reclaim.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { buildManagedServerEntry, type EditorMcpTarget, + type McpDeclineReason, type McpEntryClassification, } from '@inkeep/open-knowledge'; import type { McpWiringEditorId } from '../shared/ipc-channels.ts'; @@ -34,7 +35,11 @@ function buildCli( target: EditorMcpTarget; classification?: McpEntryClassification; readThrows?: Error; - writeOutcome?: { action: 'overwritten' | 'failed'; error?: string }; + writeOutcome?: { + action: 'overwritten' | 'declined' | 'failed'; + reason?: McpDeclineReason; + error?: string; + }; } >, ): { cli: ProjectMcpReclaimCliSurface; writes: string[] } { @@ -170,7 +175,7 @@ describe('checkAndRepairProjectMcpOnProjectOpen', () => { expect(writes).toEqual([]); }); - test('incompatible entry → reclaimed (write occurs, no rename)', async () => { + test('incompatible entry → reclaimed (write occurs in place, no rename)', async () => { const { cli, writes } = buildCli({ claude: { target: fakeTarget('claude' as McpWiringEditorId, '/p/.mcp.json'), @@ -180,19 +185,55 @@ describe('checkAndRepairProjectMcpOnProjectOpen', () => { }, }, }); - const renames: Array<{ oldPath: string; newPath: string }> = []; const r = await checkAndRepairProjectMcpOnProjectOpen({ projectDir: '/p', executablePath: EXE, isPackaged: true, platform: 'darwin', cli, - backupFs: { renameSync: (o, n) => renames.push({ oldPath: o, newPath: n }) }, }); expect(r.status).toBe('done'); if (r.status === 'done') expect(r.perEditor[0]?.status).toBe('reclaimed'); expect(writes).toEqual(['claude']); - expect(renames).toEqual([]); + }); + + test('write declines (read-then-write race) → declined, not a false reclaimed', async () => { + const { cli, writes } = buildCli({ + claude: { + target: fakeTarget('claude' as McpWiringEditorId, '/p/.mcp.json'), + classification: { + kind: 'present', + entry: { command: 'npx', args: ['-y', '@inkeep/open-knowledge', 'mcp'] }, + }, + writeOutcome: { action: 'declined', reason: 'unparseable' }, + }, + }); + const events: Array> = []; + const r = await checkAndRepairProjectMcpOnProjectOpen({ + projectDir: '/p', + executablePath: EXE, + isPackaged: true, + platform: 'darwin', + cli, + logger: { event: (e) => events.push(e) }, + }); + expect(r.status).toBe('done'); + if (r.status === 'done') { + const e = r.perEditor.find((p) => p.editor === 'claude'); + expect(e?.status).toBe('declined'); + if (e?.status === 'declined') expect(e.reason).toBe('unparseable'); + } + expect(writes).toEqual(['claude']); + expect(events.some((e) => e.event === 'project-mcp-reclaim-reclaimed')).toBe(false); + const decline = events.find((e) => e.event === 'mcp-config-decline'); + expect(decline).toMatchObject({ + event: 'mcp-config-decline', + scope: 'project', + surface: 'desktop-project-open', + editorId: 'claude', + reason: 'unparseable', + }); + expect(decline).not.toHaveProperty('configPath'); }); test('incompatible entry emits mcp-config-migrate before the write', async () => { @@ -238,14 +279,13 @@ describe('checkAndRepairProjectMcpOnProjectOpen', () => { }); }); - test('corrupt file → renamed to .broken- sidecar then fresh write', async () => { + test('declined (unparseable) file → left untouched, no write, no rename, decline signal', async () => { const { cli, writes } = buildCli({ claude: { target: fakeTarget('claude' as McpWiringEditorId, '/p/.mcp.json'), - classification: { kind: 'corrupt', error: 'file is empty' }, + classification: { kind: 'decline', reason: 'unparseable' }, }, }); - const renames: Array<{ oldPath: string; newPath: string }> = []; const events: Array> = []; const r = await checkAndRepairProjectMcpOnProjectOpen({ projectDir: '/p', @@ -253,55 +293,58 @@ describe('checkAndRepairProjectMcpOnProjectOpen', () => { isPackaged: true, platform: 'darwin', cli, - backupFs: { renameSync: (o, n) => renames.push({ oldPath: o, newPath: n }) }, - now: () => new Date('2026-05-19T20:00:00.000Z'), logger: { event: (e) => events.push(e) }, }); expect(r.status).toBe('done'); if (r.status === 'done') { const e = r.perEditor[0]; - expect(e?.status).toBe('reclaimed-from-corrupt'); - if (e?.status === 'reclaimed-from-corrupt') { - expect(e.backupPath).toBe('/p/.mcp.json.broken-2026-05-19T20-00-00-000Z'); + expect(e?.status).toBe('declined'); + if (e?.status === 'declined') { + expect(e.reason).toBe('unparseable'); + expect(e.configPath).toBe('/p/.mcp.json'); } } - expect(renames).toEqual([ - { oldPath: '/p/.mcp.json', newPath: '/p/.mcp.json.broken-2026-05-19T20-00-00-000Z' }, - ]); - expect(writes).toEqual(['claude']); - expect(events.some((e) => e.event === 'project-mcp-reclaim-corrupt-backup')).toBe(true); - expect(events.some((e) => e.event === 'project-mcp-reclaim-reclaimed-from-corrupt')).toBe(true); + expect(writes).toEqual([]); + expect(events.some((e) => e.event === 'project-mcp-reclaim-corrupt-backup')).toBe(false); + expect(events.some((e) => e.event === 'project-mcp-reclaim-reclaimed-from-corrupt')).toBe( + false, + ); + const decline = events.find((e) => e.event === 'mcp-config-decline'); + expect(decline).toMatchObject({ + event: 'mcp-config-decline', + scope: 'project', + surface: 'desktop-project-open', + editorId: 'claude', + reason: 'unparseable', + }); + expect(decline).not.toHaveProperty('configPath'); }); - test('corrupt file with backup rename failure → failed, no write attempted', async () => { + test('a declined editor does not block a sibling reclaim in the same sweep', async () => { const { cli, writes } = buildCli({ claude: { target: fakeTarget('claude' as McpWiringEditorId, '/p/.mcp.json'), - classification: { kind: 'corrupt', error: 'invalid JSON' }, + classification: { kind: 'decline', reason: 'unparseable' }, + }, + cursor: { + target: fakeTarget('cursor' as McpWiringEditorId, '/p/.cursor/mcp.json'), + classification: { kind: 'present', entry: { command: 'old' } }, + writeOutcome: { action: 'overwritten' }, }, }); - const events: Array> = []; const r = await checkAndRepairProjectMcpOnProjectOpen({ projectDir: '/p', executablePath: EXE, isPackaged: true, platform: 'darwin', cli, - backupFs: { - renameSync: () => { - throw new Error('EROFS: read-only filesystem'); - }, - }, - logger: { event: (e) => events.push(e) }, }); expect(r.status).toBe('done'); if (r.status === 'done') { - const e = r.perEditor[0]; - expect(e?.status).toBe('failed'); - if (e?.status === 'failed') expect(e.error).toContain('EROFS'); + expect(r.perEditor.find((e) => e.editor === 'claude')?.status).toBe('declined'); + expect(r.perEditor.find((e) => e.editor === 'cursor')?.status).toBe('reclaimed'); } - expect(writes).toEqual([]); - expect(events.some((e) => e.event === 'project-mcp-reclaim-backup-failed')).toBe(true); + expect(writes).toEqual(['cursor']); }); test('write failure surfaces as failed entry', async () => { diff --git a/packages/desktop/src/main/project-mcp-reclaim.ts b/packages/desktop/src/main/project-mcp-reclaim.ts index 3e6e96cae..c78762244 100644 --- a/packages/desktop/src/main/project-mcp-reclaim.ts +++ b/packages/desktop/src/main/project-mcp-reclaim.ts @@ -1,9 +1,10 @@ -import { renameSync as fsRenameSync } from 'node:fs'; import { join } from 'node:path'; import { + buildMcpConfigDeclineEvent, buildMcpConfigMigrateEvent, type EditorMcpTarget, isEntryUpToDate, + type McpDeclineReason, type McpEntryClassification, truncatePriorEntry, } from '@inkeep/open-knowledge'; @@ -24,9 +25,9 @@ type ProjectMcpReclaimPerEditor = | { editor: McpWiringEditorId; status: 'reclaimed'; configPath: string } | { editor: McpWiringEditorId; - status: 'reclaimed-from-corrupt'; + status: 'declined'; configPath: string; - backupPath: string; + reason: McpDeclineReason; } | { editor: McpWiringEditorId; status: 'failed'; configPath: string; error: string } | { editor: McpWiringEditorId; status: 'unsupported'; reason: string }; @@ -43,28 +44,16 @@ export interface ProjectMcpReclaimCliSurface { projectDir: string, projectPath: string, ): McpEntryClassification; + /** Project-scope variant: rewrites the entry at `projectPath` with the canonical + * shape. `declined` is the guest-ownership outcome when a read-then-write race + * surfaces a present config the write path won't edit (the classify pre-pass + * saw it as reclaimable, but the lock-time read no longer parses / is oversized + * / has a duplicate container). */ writeProjectMcpConfig(opts: { editorId: McpWiringEditorId; projectDir: string; projectPath: string; - }): { action: 'overwritten' | 'failed'; error?: string }; -} - -interface CorruptBackupFs { - renameSync(oldPath: string, newPath: string): void; -} - -const defaultBackupFs: CorruptBackupFs = { - renameSync: (oldPath, newPath) => { - fsRenameSync(oldPath, newPath); - }, -}; - -function moveCorruptAside(configPath: string, now: Date, fs: CorruptBackupFs): string { - const stamp = now.toISOString().replace(/[:.]/g, '-'); - const backupPath = `${configPath}.broken-${stamp}`; - fs.renameSync(configPath, backupPath); - return backupPath; + }): { action: 'overwritten' | 'declined' | 'failed'; reason?: McpDeclineReason; error?: string }; } interface CheckAndRepairProjectMcpOpts { @@ -76,8 +65,6 @@ interface CheckAndRepairProjectMcpOpts { forceEnv?: string | null | undefined; reclaimDisableEnv?: string | null | undefined; logger?: ProjectMcpReclaimLogger; - backupFs?: CorruptBackupFs; - now?: () => Date; } export async function checkAndRepairProjectMcpOnProjectOpen( @@ -92,10 +79,7 @@ export async function checkAndRepairProjectMcpOnProjectOpen( forceEnv, reclaimDisableEnv, logger = DEFAULT_LOGGER, - backupFs = defaultBackupFs, - now, } = opts; - const nowDate = (): Date => (now ? now() : new Date()); if (reclaimDisableEnv === '1') return { status: 'skipped', reason: 'reclaim-disabled' }; if (platform !== 'darwin') return { status: 'skipped', reason: 'platform' }; if (!isPackaged && forceEnv !== '1') return { status: 'skipped', reason: 'dev-mode' }; @@ -165,42 +149,39 @@ export async function checkAndRepairProjectMcpOnProjectOpen( continue; } - let backupPath: string | null = null; - if (classification.kind === 'corrupt') { - try { - backupPath = moveCorruptAside(projectPath, nowDate(), backupFs); - logger.event({ - event: 'project-mcp-reclaim-corrupt-backup', - editor, - configPath: projectPath, - backupPath, - error: classification.error, - }); - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - perEditor.push({ editor, status: 'failed', configPath: projectPath, error }); - logger.event({ - event: 'project-mcp-reclaim-backup-failed', - editor, - configPath: projectPath, - error, - }); - continue; - } - } - - if (classification.kind === 'present') { + if (classification.kind === 'decline') { + perEditor.push({ + editor, + status: 'declined', + configPath: projectPath, + reason: classification.reason, + }); logger.event( - buildMcpConfigMigrateEvent({ + buildMcpConfigDeclineEvent({ scope: 'project', surface: 'desktop-project-open', editorId: editor, - configPath: projectPath, - priorEntry: classification.entry, + reason: classification.reason, }), ); + continue; + } + + if (classification.kind !== 'present') { + const _exhaustive: never = classification; + return _exhaustive; } + logger.event( + buildMcpConfigMigrateEvent({ + scope: 'project', + surface: 'desktop-project-open', + editorId: editor, + configPath: projectPath, + priorEntry: classification.entry, + }), + ); + const writeResult = cli.writeProjectMcpConfig({ editorId: editor, projectDir, @@ -222,22 +203,20 @@ export async function checkAndRepairProjectMcpOnProjectOpen( continue; } - if (backupPath !== null) { - perEditor.push({ - editor, - status: 'reclaimed-from-corrupt', - configPath: projectPath, - backupPath, - }); - logger.event({ - event: 'project-mcp-reclaim-reclaimed-from-corrupt', - editor, - configPath: projectPath, - backupPath, - }); + if (writeResult.action === 'declined') { + const reason: McpDeclineReason = writeResult.reason ?? 'unparseable'; + perEditor.push({ editor, status: 'declined', configPath: projectPath, reason }); + logger.event( + buildMcpConfigDeclineEvent({ + scope: 'project', + surface: 'desktop-project-open', + editorId: editor, + reason, + }), + ); continue; } - if (classification.kind !== 'present') continue; + const { priorCommand, priorArgs } = truncatePriorEntry(classification.entry); perEditor.push({ editor, status: 'reclaimed', configPath: projectPath }); logger.event({ diff --git a/packages/desktop/tests/main/claude-readiness.test.ts b/packages/desktop/tests/main/claude-readiness.test.ts index 3dc1ab889..58077fb05 100644 --- a/packages/desktop/tests/main/claude-readiness.test.ts +++ b/packages/desktop/tests/main/claude-readiness.test.ts @@ -66,10 +66,10 @@ describe('mcpStatusFromClassification', () => { test('present → wired', () => { expect(mcpStatusFromClassification('present')).toBe('wired'); }); - test('absent / no-entry / corrupt → needs-rewire', () => { + test('absent / no-entry / decline → needs-rewire', () => { expect(mcpStatusFromClassification('absent')).toBe('needs-rewire'); expect(mcpStatusFromClassification('no-entry')).toBe('needs-rewire'); - expect(mcpStatusFromClassification('corrupt')).toBe('needs-rewire'); + expect(mcpStatusFromClassification('decline')).toBe('needs-rewire'); }); }); diff --git a/packages/desktop/tests/smoke/native-config-e2e.md b/packages/desktop/tests/smoke/native-config-e2e.md new file mode 100644 index 000000000..1b95cf50a --- /dev/null +++ b/packages/desktop/tests/smoke/native-config-e2e.md @@ -0,0 +1,64 @@ +# native-config packaged-load smoke — runbook + +**Purpose.** Verify US-010 AC5 — the prebuilt `native-config` (`toml_edit`) addon that the CLI bundles into `dist/native/` actually loads and round-trips from the shipped layout: the npm CLI tarball, and the packaged macOS `.app`/`.dmg`. + +**What is committable + automated vs. deferred.** + +| Check | Fidelity | Where | +|---|---|---| +| Bundle is produced + loadable + round-trips (parse / upsert / symlink) from `packages/cli/dist/native/` | Real (runs in `bun run check`) | `packages/desktop/tests/unit/verify-native-config-driver.test.mjs` (end-to-end test) | +| Driver maps `.dmg`/`.app`/dir inputs + exit codes | Real (unit) | same test file | +| Loads from a packaged `.app`/`.dmg` shipped layout | Real, but needs a built artifact | this runbook, Step 2 (`verify-native-config-in-packaged-dmg.mjs`) | +| Loads **in the Electron main process** (hardened runtime + asar) | Deferred to signed-DMG QA | this runbook, Step 3 | + +The addon is pure computation over a napi N-API surface that is ABI-stable across Node and Electron, so the Node-load checks (Steps 1–2) are a faithful proof of the bundle + layout + loadability. Step 3 (the in-Electron-process confirmation) is the only part that genuinely needs a packaged runtime, and it is gated on a built DMG. + +--- + +## Step 1 — Pre-package (creds-free, runs in CI) + +```bash +bun run build # turbo builds native-config -> cli; copies into dist/native +node scripts/verify-native-config-in-packaged-dmg.mjs packages/cli/dist +# Expect: "verify-native-config: OK — backend=native nativeDir=.../dist/native durationMs=N" and exit 0 +``` + +`bun run check` runs the same load end-to-end via the driver test, so a regression that breaks the bundle (a missing `build:native` step, a `type: module` collision, a missing `package.json` in `dist/native/`) fails the gate. + +Exit codes: `0` loaded + round-tripped · `1` found but failed to load · `2` bad args · `3` no `dist/native` loader found. + +--- + +## Step 2 — Packaged artifact (a built `.app` or `.dmg`) + +After an unsigned or signed build, point the driver at the artifact: + +```bash +bun run --cwd packages/desktop build:mac:unsigned +node scripts/verify-native-config-in-packaged-dmg.mjs \ + packages/desktop/dist-desktop/OpenKnowledge-arm64.dmg +# Expect: "verify-native-config: OK — backend=native ..." and exit 0 +``` + +The driver mounts the DMG read-only, copies the first `.app`, detaches, and loads `Contents/Resources/cli/dist/native/index.js` under Node. A non-zero exit means the bundle did not ship into the `.app` — check the electron-builder `from: ../cli/dist` rule and `cli` build output. + +--- + +## Step 3 — In-Electron-process confirmation (deferred — needs a built DMG) + +The Node-load checks above do not exercise the Electron main process's own resolution path. To confirm the addon loads in-process inside the packaged app: + +1. Launch the packaged app from `/Applications/`. +2. Open a project so the MCP repair sweep runs (it loads the addon in-process via `writeUserMcpConfigs -> getTomlConfigEngine`). +3. Register OK into a Codex `~/.codex/config.toml` containing a comment + a 64-bit integer; confirm the comment + value survive (format-preserving write = the native engine ran). If the comment is stripped/reflowed, the desktop main fell back to the JS path — investigate `app.asar.unpacked/node_modules/@inkeep/open-knowledge-native-config` and the `dist/native` bundle. + +This step needs a built (ideally signed) DMG and a real Codex install, so it runs at release/QA time, not in the per-PR gate. The non-destructive fallback (D11) keeps a missing/failed addon safe — Codex registration simply degrades, never corrupts — so a Step-3 miss is a fidelity regression, not a data-loss one. + +--- + +## Related + +- [`scripts/verify-native-config-in-packaged-dmg.mjs`](../../../../scripts/verify-native-config-in-packaged-dmg.mjs) — the driver. +- [`packages/desktop/tests/unit/electron-builder-cli-native-deps.test.ts`](../unit/electron-builder-cli-native-deps.test.ts) — asserts the bundle + per-package copies ship. +- [`packages/desktop/tests/smoke/keyring-e2e.md`](./keyring-e2e.md) — the signed-DMG keyring runbook this mirrors. +- `reports/native-addon-npm-distribution/REPORT.md` — why the binaries are bundled into the CLI tarball (Option 3). diff --git a/packages/desktop/tests/unit/electron-builder-cli-native-deps.test.ts b/packages/desktop/tests/unit/electron-builder-cli-native-deps.test.ts index a4103be3b..08009cd32 100644 --- a/packages/desktop/tests/unit/electron-builder-cli-native-deps.test.ts +++ b/packages/desktop/tests/unit/electron-builder-cli-native-deps.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse } from 'yaml'; @@ -36,6 +36,31 @@ function readExtraResourceTargets(): string[] { } } +type ExtraResourceRule = { from?: string; to?: string; filter?: string[] | string }; + +function readExtraResources(): ExtraResourceRule[] { + try { + const cfg = parse(readFileSync(builderYml, 'utf8')) as { extraResources?: ExtraResourceRule[] }; + return cfg.extraResources ?? []; + } catch { + return []; + } +} + +function readAsarUnpack(): string[] { + try { + const cfg = parse(readFileSync(builderYml, 'utf8')) as { asarUnpack?: string[] }; + return cfg.asarUnpack ?? []; + } catch { + return []; + } +} + +function asFilterList(filter: string[] | string | undefined): string[] { + if (Array.isArray(filter)) return filter; + return filter ? [filter] : []; +} + describe('bundled CLI can resolve tsdown neverBundle native addons', () => { const neverBundle = readNeverBundle(); const targets = readExtraResourceTargets(); @@ -80,3 +105,92 @@ describe('bundled CLI can resolve tsdown neverBundle native addons', () => { } }); }); + +describe('@inkeep/open-knowledge-native-config ships its napi loader + platform binary', () => { + const NATIVE_CONFIG = '@inkeep/open-knowledge-native-config'; + const nativeConfigDir = resolve(desktopRoot, '..', 'native-config'); + + test('an extraResources rule copies the addon into cli/node_modules shipping loader AND binary', () => { + const rule = readExtraResources().find((r) => r.to === `cli/node_modules/${NATIVE_CONFIG}`); + expect( + rule, + `electron-builder.yml has no extraResources rule copying ${NATIVE_CONFIG} into ` + + 'cli/node_modules. The bundled CLI cannot resolve the toml_edit addon from ' + + 'cli/dist/ → the Codex TOML write degrades to a non-destructive decline.', + ).toBeDefined(); + const filter = asFilterList(rule?.filter); + expect(filter).toContain('index.js'); + expect( + filter.includes('*.node'), + `The ${NATIVE_CONFIG} extraResources filter must include '*.node' — without the ` + + "platform binary the loader is shipped but require('./.node') throws.", + ).toBe(true); + }); + + test('asarUnpack unpacks the addon for the in-process desktop main consumer', () => { + expect(readAsarUnpack()).toContain(`**/${NATIVE_CONFIG}/**`); + }); + + test('the addon source dir exists at the extraResources `from` path', () => { + expect(existsSync(nativeConfigDir)).toBe(true); + expect(existsSync(resolve(nativeConfigDir, 'package.json'))).toBe(true); + }); + + test('the napi-built loader + a platform binary exist after a build', () => { + const loader = resolve(nativeConfigDir, 'index.js'); + const nodeBinaries = existsSync(nativeConfigDir) + ? readdirSync(nativeConfigDir).filter((f) => f.endsWith('.node')) + : []; + if (!existsSync(loader) || nodeBinaries.length === 0) { + console.warn( + `[electron-builder-cli-native-deps] SKIP: ${NATIVE_CONFIG} not built ` + + `(no index.js / *.node in ${nativeConfigDir}). Run \`bun run build\` first; ` + + 'the gate builds it upstream of this tier.', + ); + return; + } + expect(nodeBinaries.length).toBeGreaterThan(0); + if (process.platform === 'darwin' && process.arch === 'arm64') { + expect(nodeBinaries).toContain('native-config.darwin-arm64.node'); + } + }); +}); + +describe('@inkeep/open-knowledge-native-config ships bundled in cli/dist/native', () => { + const cliDist = resolve(desktopRoot, '..', 'cli', 'dist'); + + test('the cli/dist extraResources rule does not filter out the native bundle', () => { + const rule = readExtraResources().find((r) => r.to === 'cli/dist'); + expect( + rule, + 'electron-builder.yml must copy ../cli/dist into the packaged app so the ' + + 'bundled native-config (cli/dist/native) reaches the spawned CLI subprocess.', + ).toBeDefined(); + const filter = asFilterList(rule?.filter); + expect(filter).toContain('**/*'); + for (const excluded of ['!**/*.node', '!**/*.js', '!**/package.json', '!**/native/**']) { + expect( + filter.includes(excluded), + `the cli/dist filter must not exclude '${excluded}' — it would strip the bundled addon.`, + ).toBe(false); + } + }); + + test('the bundled loader + platform binary exist in cli/dist/native after a build', () => { + const nativeBundle = resolve(cliDist, 'native'); + const loader = resolve(nativeBundle, 'index.js'); + const pkgJson = resolve(nativeBundle, 'package.json'); + const nodeBinaries = existsSync(nativeBundle) + ? readdirSync(nativeBundle).filter((f) => f.endsWith('.node')) + : []; + if (!existsSync(loader) || nodeBinaries.length === 0) { + console.warn( + '[electron-builder-cli-native-deps] SKIP: cli/dist/native not built ' + + `(no index.js / *.node in ${nativeBundle}). Run \`bun run build\` first.`, + ); + return; + } + expect(existsSync(pkgJson)).toBe(true); + expect(nodeBinaries.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/desktop/tests/unit/verify-native-config-driver.test.mjs b/packages/desktop/tests/unit/verify-native-config-driver.test.mjs new file mode 100644 index 000000000..f2d790523 --- /dev/null +++ b/packages/desktop/tests/unit/verify-native-config-driver.test.mjs @@ -0,0 +1,193 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + classifyInputPath, + loadAndRoundTrip, + parseArgs, + resolveBundledNativeDirInDir, + runDriver, +} from '../../../../scripts/verify-native-config-in-packaged-dmg.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const cliDist = resolve(__dirname, '../../../cli/dist'); + +describe('parseArgs', () => { + test('accepts a single positional', () => { + expect(parseArgs(['node', 'script', '/Applications/OpenKnowledge.app']).inputPath).toBe( + '/Applications/OpenKnowledge.app', + ); + }); + + test('rejects zero positionals', () => { + expect(() => parseArgs(['node', 'script'])).toThrow(/Usage:/); + }); + + test('rejects multiple positionals', () => { + expect(() => parseArgs(['node', 'script', 'a', 'b'])).toThrow(/Usage:/); + }); +}); + +describe('classifyInputPath', () => { + test('recognises .dmg / .app case-insensitively', () => { + expect(classifyInputPath('/tmp/foo.dmg')).toBe('dmg'); + expect(classifyInputPath('/tmp/Foo.DMG')).toBe('dmg'); + expect(classifyInputPath('/Applications/Foo.app')).toBe('app'); + }); + + test('treats anything else as a directory', () => { + expect(classifyInputPath('/tmp/some/dir')).toBe('dir'); + expect(classifyInputPath('packages/cli/dist')).toBe('dir'); + }); +}); + +describe('resolveBundledNativeDirInDir', () => { + test('finds the loader at /dist/native', () => { + const existsSyncMock = mock((p) => p === '/proj/dist/native/index.js'); + expect(resolveBundledNativeDirInDir('/proj', { existsSync: existsSyncMock })).toBe( + '/proj/dist/native', + ); + }); + + test('finds the loader when is itself the native dir', () => { + const existsSyncMock = mock((p) => p === '/proj/native/index.js'); + expect(resolveBundledNativeDirInDir('/proj', { existsSync: existsSyncMock })).toBe( + '/proj/native', + ); + }); + + test('returns null when no candidate holds a loader', () => { + expect(resolveBundledNativeDirInDir('/proj', { existsSync: () => false })).toBeNull(); + }); +}); + +describe('loadAndRoundTrip', () => { + test('ok:true when the binding round-trips parse/upsert/symlink', () => { + const fakeBinding = { + parseTomlToJson: () => '{"probe":1}', + upsertMcpServer: () => ({ + text: '[mcp_servers.open-knowledge]\n', + changed: true, + existed: false, + }), + resolveSymlinkWritePath: (p) => ({ writePath: p }), + }; + const result = loadAndRoundTrip('/bundle/native', { + requireModule: () => fakeBinding, + now: () => 0, + }); + expect(result.ok).toBe(true); + expect(result.backend).toBe('native'); + }); + + test('ok:false when the loader throws', () => { + const result = loadAndRoundTrip('/bundle/native', { + requireModule: () => { + throw new Error('Cannot find native binding'); + }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain('native binding'); + }); + + test('ok:false when a binding fn returns the wrong shape', () => { + const fakeBinding = { + parseTomlToJson: () => '{"probe":1}', + upsertMcpServer: () => ({ changed: true }), // missing text + resolveSymlinkWritePath: (p) => ({ writePath: p }), + }; + const result = loadAndRoundTrip('/bundle/native', { requireModule: () => fakeBinding }); + expect(result.ok).toBe(false); + expect(result.error).toContain('upsertMcpServer'); + }); +}); + +describe('runDriver (full orchestration)', () => { + function fakeDeps(overrides = {}) { + const messages = { stdout: [], stderr: [] }; + return { + writeStream: (s) => messages.stdout.push(s), + errStream: (s) => messages.stderr.push(s), + existsSync: () => true, + loadAndRoundTrip: () => ({ ok: true, backend: 'native', nativeDir: '/x', durationMs: 3 }), + ...overrides, + messages, + }; + } + + test('exit 2 on bad argv', async () => { + const deps = fakeDeps(); + expect(await runDriver(['node', 'script'], deps)).toBe(2); + expect(deps.messages.stderr.join('')).toContain('Usage:'); + }); + + test('exit 3 when no bundled native dir is found in a directory input', async () => { + const deps = fakeDeps({ existsSync: () => false }); + expect(await runDriver(['node', 'script', '/tmp/proj'], deps)).toBe(3); + expect(deps.messages.stderr.join('')).toContain('no bundled native loader'); + }); + + test('exit 0 when the bundled addon loads + round-trips', async () => { + const deps = fakeDeps(); + expect(await runDriver(['node', 'script', '/tmp/proj'], deps)).toBe(0); + expect(deps.messages.stdout.join('')).toContain('OK'); + expect(deps.messages.stdout.join('')).toContain('native'); + }); + + test('exit 1 when the addon is found but fails to load', async () => { + const deps = fakeDeps({ + loadAndRoundTrip: () => ({ ok: false, error: 'dlopen failed', nativeDir: '/x' }), + }); + expect(await runDriver(['node', 'script', '/tmp/proj'], deps)).toBe(1); + expect(deps.messages.stderr.join('')).toContain('dlopen failed'); + }); + + test('.app input resolves the Contents/Resources/cli/dist/native layout', async () => { + let probed = ''; + const deps = fakeDeps({ + existsSync: (p) => { + probed = p; + return true; + }, + }); + expect(await runDriver(['node', 'script', '/Applications/OpenKnowledge.app'], deps)).toBe(0); + expect(probed).toContain('Contents/Resources/cli/dist/native/index.js'); + }); + + test('.dmg input mounts, copies the .app, and detaches', async () => { + const runCommand = mock(async () => {}); + const cpMock = mock(async () => {}); + const deps = fakeDeps({ + runCommand, + cp: cpMock, + mkdtemp: mock(async () => '/tmp/ok-nc-fake'), + rm: mock(async () => {}), + listAppsInMount: mock(async () => ['OpenKnowledge.app']), + existsSync: () => true, + }); + expect(await runDriver(['node', 'script', '/tmp/build.dmg'], deps)).toBe(0); + const cmds = runCommand.mock.calls.map((c) => c[0]); + expect(cmds.filter((c) => c === 'hdiutil').length).toBeGreaterThanOrEqual(2); + expect(cpMock).toHaveBeenCalled(); + }); +}); + +describe('real bundle (end-to-end, requires a built CLI)', () => { + test('loads + round-trips the actual packages/cli/dist/native bundle', async () => { + if (!existsSync(resolve(cliDist, 'native', 'index.js'))) { + console.warn( + '[verify-native-config-driver] SKIP: packages/cli/dist/native not built. ' + + 'Run `bun run build` (the gate builds it upstream of this tier).', + ); + return; + } + const messages = []; + const code = await runDriver(['node', 'script', cliDist], { + writeStream: (s) => messages.push(s), + errStream: (s) => messages.push(s), + }); + expect(code).toBe(0); + expect(messages.join('')).toContain('backend=native'); + }); +}); diff --git a/packages/native-config/.gitignore b/packages/native-config/.gitignore new file mode 100644 index 000000000..e073ded1d --- /dev/null +++ b/packages/native-config/.gitignore @@ -0,0 +1,8 @@ +# Rust build output +/target + +# napi-rs build artifacts (regenerated by `napi build`; the cross-platform +# prebuilt binaries are produced and published by the addon's own CI matrix). +*.node +/index.js +/index.d.ts diff --git a/packages/native-config/Cargo.lock b/packages/native-config/Cargo.lock new file mode 100644 index 000000000..1a0dab952 --- /dev/null +++ b/packages/native-config/Cargo.lock @@ -0,0 +1,496 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "ctor" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "napi" +version = "3.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b41bda2ac390efb5e8d22025d925ccc3f3807d8c1bea6d19b36127247c4b8f83" +dependencies = [ + "bitflags", + "ctor", + "futures", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", +] + +[[package]] +name = "napi-build" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" + +[[package]] +name = "napi-derive" +version = "3.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61d66f70256ad5aef58659966064471d0ad90e2897bc36a5a5e0389c85aabc1e" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "5.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b4b08f15eed7a2a20c3f4c6314013fc3ac890a3afa9892b594485299ebdb2d" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400" +dependencies = [ + "libloading", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open-knowledge-native-config" +version = "0.0.0" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "serde_json", + "tempfile", + "toml_edit", + "toml_writer", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.24.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01f2eadbbc6b377a847be05f60791ef1058d9f696ecb51d2c07fe911d8569d8e" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/native-config/Cargo.toml b/packages/native-config/Cargo.toml new file mode 100644 index 000000000..53d36f207 --- /dev/null +++ b/packages/native-config/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "open-knowledge-native-config" +version = "0.0.0" +edition = "2021" +license = "GPL-3.0-or-later" +publish = false +description = "Native (toml_edit) parse/classify and format-preserving edit engine for OpenKnowledge harness MCP-config writes." + +[lib] +crate-type = ["cdylib", "lib"] + +[dependencies] +napi = { version = "3", default-features = false, features = ["napi6"] } +napi-derive = "3" +toml_edit = "0.24.0" +# toml_edit's string encoder (re-exported here) so OK's own multi-line chain +# serializes as a single-line escaped basic string rather than toml_edit's +# default `"""` multi-line form; keeps every newline in the document structural +# for the JS EOL wrapper. Already in the tree as a toml_edit dependency. +toml_writer = "1" +# preserve_order keeps a fresh entry's keys in their JS-supplied order +# (command, args) instead of alphabetizing them; the backing indexmap is +# already in the tree via toml_edit, so this pulls in no new crate. +serde_json = { version = "1", features = ["preserve_order"] } + +[build-dependencies] +napi-build = "2" + +[dev-dependencies] +# Symlink-resolution conformance tests build real symlink chains under a +# throwaway directory; dev-only, never linked into the shipped addon. +tempfile = "3" + +[profile.release] +lto = true +strip = "symbols" + +# Standalone Cargo workspace so the crate never gets adopted by a parent +# workspace that happens to sit above the Bun monorepo. +[workspace] diff --git a/packages/native-config/build.rs b/packages/native-config/build.rs new file mode 100644 index 000000000..e4d638e97 --- /dev/null +++ b/packages/native-config/build.rs @@ -0,0 +1,14 @@ +fn main() { + napi_build::setup(); + + // `napi_build::setup()` only applies `-undefined dynamic_lookup` to the + // cdylib artifact. The unit-test binary links the same crate, so without + // this the N-API host symbols it references (but never calls in a test + // process) would be unresolved at link time on macOS. Allowing dynamic + // lookup lets `cargo test` link; the symbols are never dereferenced because + // the tests exercise only the pure projection helpers. + #[cfg(target_os = "macos")] + { + println!("cargo:rustc-link-arg=-Wl,-undefined,dynamic_lookup"); + } +} diff --git a/packages/native-config/package.json b/packages/native-config/package.json new file mode 100644 index 000000000..a70aee7c6 --- /dev/null +++ b/packages/native-config/package.json @@ -0,0 +1,35 @@ +{ + "name": "@inkeep/open-knowledge-native-config", + "version": "0.0.0", + "license": "GPL-3.0-or-later", + "private": true, + "description": "Native toml_edit parse/classify + format-preserving edit engine for OpenKnowledge harness MCP-config writes.", + "main": "index.js", + "types": "index.d.ts", + "napi": { + "binaryName": "native-config", + "targets": [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc" + ] + }, + "files": [ + "index.js", + "index.d.ts", + "*.node" + ], + "scripts": { + "build": "napi build --platform --release", + "build:debug": "napi build --platform", + "test": "cargo test" + }, + "devDependencies": { + "@napi-rs/cli": "^3.7.2" + } +} diff --git a/packages/native-config/src/document_helpers.rs b/packages/native-config/src/document_helpers.rs new file mode 100644 index 000000000..a1408c9bf --- /dev/null +++ b/packages/native-config/src/document_helpers.rs @@ -0,0 +1,90 @@ +//! Decor-preserving `toml_edit` table primitives, ported from Codex's +//! `core/src/config/edit/document_helpers.rs` (Apache-2.0). These are the +//! building blocks the insert-only upsert engine stands on: they migrate an +//! inline table to an explicit one *in place* without discarding the keys it +//! already holds, and they seed intermediate tables as implicit so a fresh +//! `[mcp_servers.]` renders with the canonical implicit-parent header. +//! +//! Codex's bulk `replace_mcp_servers` (which prunes siblings and regenerates the +//! whole `[mcp_servers]` subtree) is deliberately NOT ported — the engine here +//! reconciles a single entry per key and never touches siblings. + +use toml_edit::{InlineTable, Item, Table}; + +/// A table that exists only to host nested headers — its own `[header]` line is +/// suppressed on serialize unless it gains direct leaf children. Used for the +/// `mcp_servers` parent so a fresh entry renders as `[mcp_servers.]`. +pub fn new_implicit_table() -> Table { + let mut table = Table::new(); + table.set_implicit(true); + table +} + +/// Rebuild an inline table as a standard table, carrying each value across with +/// its decor but dropping the inline trailing-space suffix so the migrated keys +/// land on their own lines cleanly. +pub fn table_from_inline(inline: &InlineTable) -> Table { + let mut table = new_implicit_table(); + for (key, value) in inline.iter() { + let mut value = value.clone(); + value.decor_mut().set_suffix(""); + table.insert(key, Item::Value(value)); + } + table +} + +/// Resolve `item` to a mutable standard table, migrating an inline table or a +/// bare value in place. Returns `None` only for items that cannot host keys +/// (e.g. an array of tables), which the caller treats as "leave untouched". +pub fn ensure_table_for_write(item: &mut Item) -> Option<&mut Table> { + match item { + Item::Table(_) => {} + Item::Value(value) => { + let replacement = if let Some(inline) = value.as_inline_table() { + table_from_inline(inline) + } else { + new_implicit_table() + }; + *item = Item::Table(replacement); + } + Item::None => { + *item = Item::Table(new_implicit_table()); + } + _ => return None, + } + item.as_table_mut() +} + +/// Copy the formatting (whitespace + comments) of an existing node onto its +/// replacement so an only-additive edit preserves the user's surrounding decor. +/// Recurses into tables key-by-key, matching Codex's `preserve_decor`. +pub fn preserve_decor(existing: &Item, replacement: &mut Item) { + match (existing, replacement) { + (Item::Table(existing_table), Item::Table(replacement_table)) => { + replacement_table + .decor_mut() + .clone_from(existing_table.decor()); + for (key, existing_item) in existing_table.iter() { + if let (Some(existing_key), Some(mut replacement_key)) = + (existing_table.key(key), replacement_table.key_mut(key)) + { + replacement_key + .leaf_decor_mut() + .clone_from(existing_key.leaf_decor()); + replacement_key + .dotted_decor_mut() + .clone_from(existing_key.dotted_decor()); + } + if let Some(replacement_item) = replacement_table.get_mut(key) { + preserve_decor(existing_item, replacement_item); + } + } + } + (Item::Value(existing_value), Item::Value(replacement_value)) => { + replacement_value + .decor_mut() + .clone_from(existing_value.decor()); + } + _ => {} + } +} diff --git a/packages/native-config/src/lib.rs b/packages/native-config/src/lib.rs new file mode 100644 index 000000000..d3a48a928 --- /dev/null +++ b/packages/native-config/src/lib.rs @@ -0,0 +1,96 @@ +//! N-API surface for OpenKnowledge's native harness-config engine. +//! +//! This crate exists because every maintained JavaScript TOML library destroys +//! comments and reflows formatting on a round-trip, and OpenKnowledge must add +//! only its own `[mcp_servers.open-knowledge]` entry to a user's Codex config +//! without touching anything else. `toml_edit` is a format-preserving document +//! model; this addon wraps the slice of it the write/classify spine needs. +//! +//! The real logic lives in N-API-free modules so it stays unit-testable under a +//! plain `cargo test`; the functions here are thin marshalling wrappers. + +use napi_derive::napi; + +mod document_helpers; +mod mcp_edit; +mod path_resolve; +mod toml_json; + +use std::path::Path; + +/// Parse TOML text and return its data projected to a JSON string. +/// +/// Throws a JS exception only for genuinely-unparseable input. See +/// `toml_json::parse_toml_to_json` for the capability and lossiness contract. +#[napi] +pub fn parse_toml_to_json(toml_text: String) -> napi::Result { + toml_json::parse_toml_to_json(&toml_text).map_err(napi::Error::from_reason) +} + +/// The serialized document, whether the edit actually changed it (so the JS +/// write spine can skip a write and its backup on a no-op), and whether OK's +/// entry already existed (so the spine labels register-vs-update without +/// re-parsing). +#[napi(object)] +pub struct McpEditResult { + pub text: String, + pub changed: bool, + pub existed: bool, +} + +/// Insert or update only `[mcp_servers.]` from a JSON object of the +/// entry's managed keys, preserving every other document token. Throws only for +/// unparseable TOML or a non-object entry payload. +#[napi] +pub fn upsert_mcp_server( + toml_text: String, + server_name: String, + entry_json: String, +) -> napi::Result { + let outcome = mcp_edit::upsert_mcp_server(&toml_text, &server_name, &entry_json) + .map_err(napi::Error::from_reason)?; + Ok(McpEditResult { + text: outcome.text, + changed: outcome.changed, + existed: outcome.existed, + }) +} + +/// Remove only `[mcp_servers.]`, never the surrounding table. +/// Throws only for unparseable TOML. +// Exposed for the future `ok uninstall` flow; NativeTomlBinding intentionally omits it until then. +#[napi] +pub fn remove_mcp_server(toml_text: String, server_name: String) -> napi::Result { + let outcome = + mcp_edit::remove_mcp_server(&toml_text, &server_name).map_err(napi::Error::from_reason)?; + Ok(McpEditResult { + text: outcome.text, + changed: outcome.changed, + existed: outcome.existed, + }) +} + +/// Where to read the existing config from and where to write the updated one +/// after following any symlink chain. `read_path` is absent when the chain +/// cycles or can't be resolved, in which case `write_path` is the original path +/// and writing a fresh file there breaks the link. +#[napi(object)] +pub struct SymlinkWritePaths { + pub read_path: Option, + pub write_path: String, +} + +/// Resolve `path`'s symlink chain to its real write target so the caller writes +/// through a dotfile-managed symlink instead of replacing it. Never touches the +/// filesystem beyond reading link metadata. +#[napi] +pub fn resolve_symlink_write_path(path: String) -> napi::Result { + let resolved = path_resolve::resolve_symlink_write_paths(Path::new(&path)) + .map_err(|err| napi::Error::from_reason(err.to_string()))?; + Ok(SymlinkWritePaths { + read_path: resolved + .read_path + .map(|p| p.to_string_lossy().into_owned()), + write_path: resolved.write_path.to_string_lossy().into_owned(), + }) +} diff --git a/packages/native-config/src/mcp_edit.rs b/packages/native-config/src/mcp_edit.rs new file mode 100644 index 000000000..fa96e886e --- /dev/null +++ b/packages/native-config/src/mcp_edit.rs @@ -0,0 +1,446 @@ +//! Insert-only, format-preserving upsert of a single `[mcp_servers.]` +//! entry. Unlike Codex's bulk `replace_mcp_servers` (which regenerates the +//! whole table and prunes any server not in its input), this engine touches +//! only the named entry: it inserts a fresh explicit table when absent and +//! reconciles per key in place when present, so sibling servers, surrounding +//! comments, value types, and any keys a user hand-added to OpenKnowledge's own +//! entry are all preserved. The whole document is the user's; OpenKnowledge owns +//! only its one entry's managed keys. +//! +//! N-API-free so it stays unit-testable under a plain `cargo test`; `lib.rs` +//! holds the thin marshalling wrappers. + +use serde_json::Value as JsonValue; +use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value as TomlValue}; +use toml_writer::ToTomlValue as _; + +use crate::document_helpers::{ensure_table_for_write, new_implicit_table, preserve_decor}; + +/// Result of an edit: the serialized document, whether it actually changed, and +/// whether OpenKnowledge's entry already existed before the edit. `changed` is +/// computed by comparing the serialized document before and after, so an entry +/// that already matches (or a remove of an absent entry) reports `false` and the +/// caller can skip the write. `existed` lets the write spine label the result +/// register-vs-update without re-parsing the document JS-side. +pub struct UpsertOutcome { + pub text: String, + pub changed: bool, + pub existed: bool, +} + +/// Upsert `[mcp_servers.]` from a JSON object of the desired +/// managed keys. Absent entries are inserted as a fresh explicit table; present +/// entries are reconciled key-by-key in place. Sibling servers are never read, +/// pruned, or re-serialized. +pub fn upsert_mcp_server( + toml_text: &str, + server_name: &str, + entry_json: &str, +) -> Result { + let mut doc = parse_document(toml_text)?; + let before = doc.to_string(); + let desired = json_object_to_table(entry_json)?; + + let existed = doc + .as_table() + .get("mcp_servers") + .and_then(Item::as_table_like) + .is_some_and(|table| table.contains_key(server_name)); + + if !doc.as_table().contains_key("mcp_servers") { + doc.as_table_mut() + .insert("mcp_servers", Item::Table(new_implicit_table())); + } + + let mcp_item = doc + .as_table_mut() + .get_mut("mcp_servers") + .ok_or_else(|| "mcp_servers missing after insert".to_string())?; + + // A whole-table inline `mcp_servers = { ... }` is upserted in place so the + // other servers keep their inline form; migrating it to explicit headers + // would re-serialize siblings we don't own. + let is_inline_whole = mcp_item + .as_value() + .is_some_and(TomlValue::is_inline_table); + + if is_inline_whole { + let inline = mcp_item + .as_value_mut() + .and_then(TomlValue::as_inline_table_mut) + .ok_or_else(|| "mcp_servers inline table vanished".to_string())?; + upsert_into_inline(inline, server_name, &desired); + } else { + let mcp_table = ensure_table_for_write(mcp_item) + .ok_or_else(|| "mcp_servers is not a table".to_string())?; + upsert_into_table(mcp_table, server_name, &desired); + } + + let after = doc.to_string(); + Ok(UpsertOutcome { + changed: after != before, + existed, + text: after, + }) +} + +/// Remove `[mcp_servers.]`, deleting only that entry. The +/// `[mcp_servers]` table itself (and every sibling) is left intact even when the +/// removed entry was the last one. Removing an absent entry is a byte-identical +/// no-op. +pub fn remove_mcp_server(toml_text: &str, server_name: &str) -> Result { + let mut doc = parse_document(toml_text)?; + let before = doc.to_string(); + + let present = doc + .as_table() + .get("mcp_servers") + .and_then(Item::as_table_like) + .is_some_and(|table| table.contains_key(server_name)); + + if present { + if let Some(table) = doc + .as_table_mut() + .get_mut("mcp_servers") + .and_then(Item::as_table_like_mut) + { + table.remove(server_name); + } + } + + let after = doc.to_string(); + Ok(UpsertOutcome { + changed: after != before, + existed: present, + text: after, + }) +} + +fn parse_document(toml_text: &str) -> Result { + toml_text + .parse::() + .map_err(|_| "invalid TOML".to_string()) +} + +/// Reconcile the desired keys into an explicit-table-hosted entry, migrating an +/// inline or scalar value in place and preserving existing decor + hand-added +/// keys on update; insert a fresh explicit table when absent. +fn upsert_into_table(mcp_table: &mut Table, server_name: &str, desired: &Table) { + if !mcp_table.contains_key(server_name) { + mcp_table.insert(server_name, Item::Table(desired.clone())); + return; + } + + let was_table = matches!(mcp_table.get(server_name), Some(Item::Table(_))); + { + let Some(existing_item) = mcp_table.get_mut(server_name) else { + return; + }; + let Some(entry_table) = ensure_table_for_write(existing_item) else { + return; + }; + // The entry owns a header line of its own; without this an inline->table + // migration would render headerless and orphan our keys. + entry_table.set_implicit(false); + for (key, value_item) in desired.iter() { + let mut replacement = value_item.clone(); + if let Some(existing) = entry_table.get(key) { + preserve_decor(existing, &mut replacement); + } + entry_table[key] = replacement; + } + } + + // A migrated inline/dotted key carries a trailing-space decor from its old + // `name = ` form; cleared so the new header renders as + // `[mcp_servers.]`, not `[mcp_servers. ]`. An entry that was + // already an explicit table keeps its decor (and any leading comment). + if !was_table { + if let Some(mut key) = mcp_table.key_mut(server_name) { + key.leaf_decor_mut().set_suffix(""); + key.dotted_decor_mut().set_suffix(""); + } + } +} + +/// Upsert into a whole-table inline `mcp_servers = { ... }` without disturbing +/// the inline representation of sibling servers. +fn upsert_into_inline(mcp_inline: &mut InlineTable, server_name: &str, desired: &Table) { + let desired_inline = desired.clone().into_inline_table(); + match mcp_inline + .get_mut(server_name) + .and_then(TomlValue::as_inline_table_mut) + { + Some(existing_inline) => { + for (key, value) in desired_inline.iter() { + if let Some(existing) = existing_inline.get_mut(key) { + let mut replacement = value.clone(); + replacement.decor_mut().clone_from(existing.decor()); + *existing = replacement; + } else { + existing_inline.insert(key, value.clone()); + } + } + } + None => { + mcp_inline.insert(server_name, TomlValue::InlineTable(desired_inline)); + } + } +} + +fn json_object_to_table(entry_json: &str) -> Result { + let value: JsonValue = + serde_json::from_str(entry_json).map_err(|_| "invalid entry JSON".to_string())?; + let JsonValue::Object(map) = value else { + return Err("entry JSON must be an object".to_string()); + }; + let mut table = Table::new(); + table.set_implicit(false); + for (key, item) in &map { + table.insert(key, Item::Value(json_to_toml_value(item))); + } + Ok(table) +} + +fn json_to_toml_value(value: &JsonValue) -> TomlValue { + match value { + // TOML has no null; OpenKnowledge's entry never carries one, so the + // empty-string projection is a safe last resort, not a real path. + JsonValue::Null => TomlValue::from(""), + JsonValue::Bool(b) => TomlValue::from(*b), + JsonValue::Number(number) => number_to_toml_value(number), + JsonValue::String(s) => single_line_string_value(s), + JsonValue::Array(items) => { + let mut array = Array::new(); + for item in items { + array.push(json_to_toml_value(item)); + } + TomlValue::Array(array) + } + JsonValue::Object(map) => { + let mut inline = InlineTable::new(); + for (key, item) in map { + inline.insert(key, json_to_toml_value(item)); + } + TomlValue::InlineTable(inline) + } + } +} + +/// Render `s` as a single-line escaped TOML basic string value. +/// +/// toml_edit's default serializes a string that contains newlines as a +/// multi-line basic string (`"""…"""`) with real newlines in the output. +/// OpenKnowledge's own entry carries a multi-line resolver chain; rendering it +/// single-line-escaped (matching the prior smol-toml writer) keeps every newline +/// in the serialized document *structural*, so the JS write wrapper can re-apply +/// a file's CRLF convention without rewriting the line endings inside the chain +/// string. We parse the rendered literal back because toml_edit preserves a +/// parsed representation verbatim and exposes no public setter for a value's repr. +fn single_line_string_value(s: &str) -> TomlValue { + let literal = toml_writer::TomlStringBuilder::new(s).as_basic().to_toml_value(); + let doc: DocumentMut = format!("v = {literal}\n") + .parse() + .expect("an escaped basic string is always valid TOML"); + let mut value = doc["v"] + .as_value() + .expect("the parsed item is a value") + .clone(); + // Drop the ` ` prefix the `v = ` scaffold left on the value, so it carries + // the same empty decor a directly-constructed value would (array elements + // render `["-l", …]`, not `[ "-l", …]`). + value.decor_mut().clear(); + value +} + +fn number_to_toml_value(number: &serde_json::Number) -> TomlValue { + if let Some(int) = number.as_i64() { + TomlValue::from(int) + } else if let Some(float) = number.as_f64() { + TomlValue::from(float) + } else { + TomlValue::from(number.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ENTRY: &str = r#"{"command":"/bin/sh","args":["-l","-c","run-ok"]}"#; + + fn parse(text: &str) -> DocumentMut { + text.parse::().expect("output must be valid TOML") + } + + #[test] + fn inserts_fresh_entry_into_empty_document() { + let out = upsert_mcp_server("", "open-knowledge", ENTRY).unwrap(); + assert!(out.changed); + assert!(!out.existed); + assert!(out.text.contains("[mcp_servers.open-knowledge]")); + let doc = parse(&out.text); + assert_eq!( + doc["mcp_servers"]["open-knowledge"]["command"].as_str(), + Some("/bin/sh") + ); + } + + #[test] + fn fresh_insert_preserves_existing_content_and_comments() { + let input = "# my config\nmodel = \"gpt-5\"\n\n[mcp_servers.other]\ncommand = \"other\" # keep\n"; + let out = upsert_mcp_server(input, "open-knowledge", ENTRY).unwrap(); + assert!(out.changed); + assert!(out.text.starts_with("# my config\nmodel = \"gpt-5\"\n")); + assert!(out.text.contains("[mcp_servers.other]")); + assert!(out.text.contains("command = \"other\" # keep")); + assert!(out.text.contains("[mcp_servers.open-knowledge]")); + } + + #[test] + fn update_preserves_interior_comment_handadded_key_and_siblings() { + let input = r#"[mcp_servers.other] +command = "other-cmd" + +[mcp_servers.open-knowledge] +# interior note +command = "/bin/sh" +args = ["-l", "-c", "OLD"] +enabled = false +"#; + let out = upsert_mcp_server(input, "open-knowledge", ENTRY).unwrap(); + assert!(out.changed); + assert!(out.existed); + assert!(out.text.contains("[mcp_servers.other]")); + assert!(out.text.contains("command = \"other-cmd\"")); + assert!(out.text.contains("# interior note")); + assert!(out.text.contains("enabled = false")); + assert!(out.text.contains("run-ok")); + assert!(!out.text.contains("OLD")); + } + + #[test] + fn reupsert_of_canonical_entry_is_byte_identical_noop() { + let first = upsert_mcp_server("[other]\nx = 1\n", "open-knowledge", ENTRY) + .unwrap() + .text; + let second = upsert_mcp_server(&first, "open-knowledge", ENTRY).unwrap(); + assert!(!second.changed); + assert_eq!(second.text, first); + } + + #[test] + fn upsert_changes_only_our_entry_byte_for_byte() { + let input = r#"# header comment +title = "My Config" +ratio = 1.0 +server.host = "localhost" +inline = { a = 1, b = 2 } + +[mcp_servers.open-knowledge] +command = "/old/sh" +args = ["-l", "-c", "run-ok"] +"#; + let out = upsert_mcp_server(input, "open-knowledge", ENTRY).unwrap(); + let expected = input.replace("/old/sh", "/bin/sh"); + assert_eq!(out.text, expected); + assert!(out.changed); + // 1.0 must remain a float, not retype to 1. + assert!(out.text.contains("ratio = 1.0")); + } + + #[test] + fn remove_deletes_only_our_entry_keeps_table_and_siblings() { + let input = r#"[mcp_servers.other] +command = "other-cmd" # sibling + +[mcp_servers.open-knowledge] +command = "/bin/sh" +args = ["-l", "-c", "run-ok"] +"#; + let out = remove_mcp_server(input, "open-knowledge").unwrap(); + assert!(out.changed); + assert!(out.existed); + assert!(!out.text.contains("[mcp_servers.open-knowledge]")); + assert!(out.text.contains("[mcp_servers.other]")); + assert!(out.text.contains("command = \"other-cmd\" # sibling")); + } + + #[test] + fn remove_absent_entry_is_byte_identical_noop() { + let input = "[mcp_servers.other]\ncommand = \"other\"\n"; + let out = remove_mcp_server(input, "open-knowledge").unwrap(); + assert!(!out.changed); + assert!(!out.existed); + assert_eq!(out.text, input); + } + + #[test] + fn migrates_inline_ok_entry_to_explicit_table_preserving_siblings() { + let input = r#"mcp_servers.other = { command = "other-cmd" } +mcp_servers.open-knowledge = { command = "/old/sh" } +"#; + let out = upsert_mcp_server(input, "open-knowledge", ENTRY).unwrap(); + assert!(out.changed); + assert!(out.text.contains("[mcp_servers.open-knowledge]")); + assert!(out.text.contains("mcp_servers.other = { command = \"other-cmd\" }")); + let doc = parse(&out.text); + assert_eq!( + doc["mcp_servers"]["open-knowledge"]["command"].as_str(), + Some("/bin/sh") + ); + assert!(out.text.contains("run-ok")); + } + + #[test] + fn upsert_into_whole_inline_mcp_servers_preserves_siblings_inline() { + let input = "mcp_servers = { other = { command = \"other-cmd\" } }\n"; + let out = upsert_mcp_server(input, "open-knowledge", ENTRY).unwrap(); + assert!(out.changed); + let doc = parse(&out.text); + assert_eq!( + doc["mcp_servers"]["other"]["command"].as_str(), + Some("other-cmd") + ); + assert_eq!( + doc["mcp_servers"]["open-knowledge"]["command"].as_str(), + Some("/bin/sh") + ); + assert!(out.text.contains("mcp_servers = {")); + } + + #[test] + fn renders_a_multiline_value_as_a_single_line_escaped_string() { + // OK's resolver chain spans many lines; it must serialize as one escaped + // basic string so every newline in the document is structural (the JS + // EOL wrapper re-applies CRLF to structural lines only). A `"""` form + // with real interior newlines would be corrupted by that conversion. + let entry = + r##"{"command":"/bin/sh","args":["-l","-c","# ok-mcp-v1\nexec npx mcp\nexit 127"]}"##; + let out = upsert_mcp_server("", "open-knowledge", entry).unwrap(); + assert!(!out.text.contains("\"\"\"")); + assert!(out.text.contains(r##""# ok-mcp-v1\nexec npx mcp\nexit 127""##)); + // The decoded value still round-trips with its real newlines intact. + let doc = parse(&out.text); + let body = doc["mcp_servers"]["open-knowledge"]["args"][2] + .as_str() + .expect("third arg is a string"); + assert_eq!(body, "# ok-mcp-v1\nexec npx mcp\nexit 127"); + } + + #[test] + fn rejects_non_object_entry_json() { + assert!(upsert_mcp_server("", "open-knowledge", "[1,2,3]").is_err()); + assert!(upsert_mcp_server("", "open-knowledge", "not json").is_err()); + } + + #[test] + fn rejects_malformed_toml() { + assert!(upsert_mcp_server("not = valid = toml", "open-knowledge", ENTRY).is_err()); + assert!(remove_mcp_server("not = valid = toml", "open-knowledge").is_err()); + } +} + +#[cfg(test)] +#[path = "mcp_edit_conformance_tests.rs"] +mod conformance_tests; diff --git a/packages/native-config/src/mcp_edit_conformance_tests.rs b/packages/native-config/src/mcp_edit_conformance_tests.rs new file mode 100644 index 000000000..fe968c9b3 --- /dev/null +++ b/packages/native-config/src/mcp_edit_conformance_tests.rs @@ -0,0 +1,243 @@ +//! Conformance suite ported from Codex's `core/src/config/edit_tests.rs` +//! (Apache-2.0). Codex's own edit tests are OpenKnowledge's invariant floor: +//! the TOML-generic cases (comment/decor preservation, implicit-parent +//! rendering, auto-quoting, int-vs-float and large-integer fidelity, no-op and +//! byte-stability) are the "cases we are not thinking about." Codex's +//! schema-specific cases are re-expressed against OpenKnowledge's single +//! `[mcp_servers.open-knowledge]` entry (register / deregister / prune-only-our +//! -keys) rather than copied verbatim, since OpenKnowledge inserts one entry +//! instead of regenerating the whole table. +//! +//! BOM and CRLF are deliberately NOT asserted here: `toml_edit` strips a leading +//! BOM and normalizes CRLF to LF on serialize, so byte-level encoding fidelity +//! is the wrapper's job on the JS write spine, not this document engine's. + +use super::*; +use toml_edit::DocumentMut; + +const PUBLISHED_ENTRY: &str = r#"{"command":"/bin/sh","args":["-l","-c","run-ok"]}"#; + +fn parse(text: &str) -> DocumentMut { + text.parse::() + .expect("engine output must be valid TOML") +} + +fn upsert(text: &str, entry: &str) -> UpsertOutcome { + upsert_mcp_server(text, "open-knowledge", entry).expect("upsert must succeed") +} + +// ─── TOML-generic invariants ───────────────────────────────────────────────── + +#[test] +fn fresh_insert_renders_implicit_parent_header_not_bare_table() { + let out = upsert("", PUBLISHED_ENTRY); + assert!(out.text.contains("[mcp_servers.open-knowledge]")); + // The parent must stay implicit — a bare `[mcp_servers]` header would be a + // spurious empty table we never asked for. + assert!(!out.text.contains("[mcp_servers]")); +} + +#[test] +fn auto_quotes_server_name_that_is_not_a_bare_key() { + let out = upsert_mcp_server("", "weird.name", PUBLISHED_ENTRY).expect("upsert"); + assert!(out.text.contains("[mcp_servers.\"weird.name\"]")); + let doc = parse(&out.text); + assert_eq!( + doc["mcp_servers"]["weird.name"]["command"].as_str(), + Some("/bin/sh") + ); +} + +#[test] +fn preserves_sibling_large_integer_through_a_write() { + // smol-toml throws on this i64 (the throw that mis-classified a real config + // as corrupt); toml_edit preserves it byte-for-byte across an only-additive + // write. + let input = "big = 9223372036854775807\n"; + let out = upsert(input, PUBLISHED_ENTRY); + assert!(out.text.starts_with("big = 9223372036854775807\n")); + assert!(out.changed); +} + +#[test] +fn preserves_sibling_microsecond_datetime_through_a_write() { + let input = "ts = 2026-06-26T12:34:56.123456Z\n"; + let out = upsert(input, PUBLISHED_ENTRY); + assert!(out.text.starts_with("ts = 2026-06-26T12:34:56.123456Z\n")); +} + +#[test] +fn preserves_sibling_integer_valued_float_through_a_write() { + let input = "timeout = 30.0\n"; + let out = upsert(input, PUBLISHED_ENTRY); + // 30.0 must not retype to 30. + assert!(out.text.contains("timeout = 30.0")); +} + +#[test] +fn preserves_inline_sibling_and_its_comment_on_register() { + let input = "[mcp_servers]\n# keep me\nother = { command = \"cmd\" }\n"; + let out = upsert(input, PUBLISHED_ENTRY); + assert!(out.changed); + assert!(out.text.contains("# keep me")); + assert!(out.text.contains("other = { command = \"cmd\" }")); + assert!(out.text.contains("[mcp_servers.open-knowledge]")); + let doc = parse(&out.text); + assert_eq!(doc["mcp_servers"]["other"]["command"].as_str(), Some("cmd")); +} + +#[test] +fn round_trips_a_to_b_to_a_byte_stably() { + let v1 = r#"{"command":"/bin/sh","args":["-l","-c","ONE"]}"#; + let v2 = r#"{"command":"/bin/sh","args":["-l","-c","TWO"]}"#; + let first = upsert("approval_policy = \"never\"\n", v1).text; + let second = upsert(&first, v2); + assert!(second.changed); + let third = upsert(&second.text, v1); + assert!(third.changed); + assert_eq!(third.text, first); +} + +#[test] +fn removing_only_entry_keeps_an_explicit_mcp_servers_table() { + // Unlike Codex's clears-empty-table behavior, OpenKnowledge owns only its + // own entry, never the surrounding table, so an explicit `[mcp_servers]` + // survives even after the last managed server is removed. + let input = "[mcp_servers]\nopen-knowledge = { command = \"x\" }\n"; + let out = remove_mcp_server(input, "open-knowledge").expect("remove"); + assert!(out.changed); + assert!(out.text.contains("[mcp_servers]")); + assert!(!out.text.contains("open-knowledge")); +} + +#[test] +fn remove_on_a_config_without_the_table_is_a_noop_and_creates_nothing() { + let input = "model = \"gpt-5\"\n"; + let out = remove_mcp_server(input, "open-knowledge").expect("remove"); + assert!(!out.changed); + assert_eq!(out.text, input); + assert!(!out.text.contains("mcp_servers")); +} + +#[test] +fn single_element_args_array_stays_an_array() { + let out = upsert("", r#"{"command":"x","args":["only"]}"#); + let doc = parse(&out.text); + let args = doc["mcp_servers"]["open-knowledge"]["args"] + .as_array() + .expect("args must serialize as an array"); + assert_eq!(args.len(), 1); + assert_eq!(args.get(0).and_then(|v| v.as_str()), Some("only")); +} + +#[test] +fn preserves_a_dotted_root_key_on_register() { + let input = "server.host = \"localhost\"\n"; + let out = upsert(input, PUBLISHED_ENTRY); + assert!(out.text.starts_with("server.host = \"localhost\"\n")); +} + +// ─── OpenKnowledge-schema register / deregister / prune ────────────────────── + +#[test] +fn registers_the_published_chain_shape_round_tripping_a_multiline_arg() { + // The real published entry's third arg is a multi-line resolver script; it + // must survive the document round-trip with its content intact. + let chain = "# ok-mcp-v1\nexec npx -y @inkeep/open-knowledge@latest mcp"; + let entry = serde_json::json!({ + "command": "/bin/sh", + "args": ["-l", "-c", chain], + }) + .to_string(); + let out = upsert("", &entry); + let doc = parse(&out.text); + let server = &doc["mcp_servers"]["open-knowledge"]; + assert_eq!(server["command"].as_str(), Some("/bin/sh")); + assert_eq!(server["args"][0].as_str(), Some("-l")); + let body = server["args"][2].as_str().expect("third arg is a string"); + assert!(body.contains("# ok-mcp-v1")); + assert!(body.contains("@inkeep/open-knowledge@latest mcp")); +} + +#[test] +fn registers_into_an_existing_config_preserving_root_keys() { + let input = "model = \"gpt-5\"\napproval_policy = \"never\"\n"; + let out = upsert(input, PUBLISHED_ENTRY); + assert!(out.text.starts_with("model = \"gpt-5\"\napproval_policy = \"never\"\n")); + assert!(out.text.contains("[mcp_servers.open-knowledge]")); +} + +#[test] +fn registers_alongside_a_sibling_server_and_its_comments() { + let input = r#"[mcp_servers.linear] +name = "linear" +# keep this note +url = "https://linear.example" +"#; + let out = upsert(input, PUBLISHED_ENTRY); + assert!(out.text.contains("[mcp_servers.linear]")); + assert!(out.text.contains("# keep this note")); + assert!(out.text.contains("url = \"https://linear.example\"")); + assert!(out.text.contains("[mcp_servers.open-knowledge]")); +} + +#[test] +fn registers_an_entry_carrying_an_env_subtable() { + let entry = r#"{"command":"node","args":["mcp"],"env":{"OK_TOKEN":"abc"}}"#; + let out = upsert("", entry); + let doc = parse(&out.text); + assert_eq!( + doc["mcp_servers"]["open-knowledge"]["env"]["OK_TOKEN"].as_str(), + Some("abc") + ); +} + +#[test] +fn update_reconciles_managed_keys_but_never_prunes_hand_added_keys() { + let input = r#"[mcp_servers.open-knowledge] +command = "/bin/sh" +args = ["-l", "-c", "OLD"] +enabled = false +"#; + let out = upsert(input, PUBLISHED_ENTRY); + assert!(out.changed); + assert!(out.text.contains("run-ok")); + assert!(!out.text.contains("OLD")); + // A key the user added to our own entry is theirs to keep. + assert!(out.text.contains("enabled = false")); +} + +#[test] +fn deregisters_keeping_table_siblings_and_their_comments() { + let input = r#"[mcp_servers.other] +command = "other-cmd" # sibling note + +[mcp_servers.open-knowledge] +command = "/bin/sh" +args = ["-l", "-c", "run-ok"] +"#; + let out = remove_mcp_server(input, "open-knowledge").expect("remove"); + assert!(out.changed); + assert!(!out.text.contains("[mcp_servers.open-knowledge]")); + assert!(out.text.contains("[mcp_servers.other]")); + assert!(out.text.contains("command = \"other-cmd\" # sibling note")); +} + +#[test] +fn register_does_not_disturb_an_unrelated_profile_table() { + let input = r#"profile = "team" + +[profiles.team] +model = "gpt-5" +sandbox_mode = "strict" +"#; + let out = upsert(input, PUBLISHED_ENTRY); + assert!(out.text.contains("[profiles.team]")); + assert!(out.text.contains("sandbox_mode = \"strict\"")); + let doc = parse(&out.text); + assert_eq!( + doc["profiles"]["team"]["model"].as_str(), + Some("gpt-5"), + "an unrelated profile table must be untouched" + ); +} diff --git a/packages/native-config/src/path_resolve.rs b/packages/native-config/src/path_resolve.rs new file mode 100644 index 000000000..d9d5b958a --- /dev/null +++ b/packages/native-config/src/path_resolve.rs @@ -0,0 +1,185 @@ +//! Symlink write-through resolution, ported from Codex's +//! `utils/path-utils/src/lib.rs` `resolve_symlink_write_paths` (Apache-2.0). +//! +//! A dotfile-managed harness config is often a symlink into a stow/chezmoi +//! repo. OpenKnowledge's current write replaces that symlink with a regular +//! file and orphans the repo copy; this resolves the chain to its real target +//! so the caller writes *through* the symlink instead. The actual atomic +//! tmp+rename stays on OpenKnowledge's existing write spine — this module only +//! decides *where* to write. +//! +//! Codex's version normalizes the root through its `AbsolutePathBuf`; OpenKnowledge +//! always passes an absolute config path, so that step is dropped. Relative +//! symlink targets are still resolved against the link's parent, and a chain +//! that cycles (or whose metadata can't be read) falls back to the original +//! path with no read target — the caller then writes a fresh regular file there, +//! breaking the cycle. + +use std::collections::HashSet; +use std::io; +use std::path::{Path, PathBuf}; + +/// Where to read the existing config from and where to write the new one. +/// +/// `read_path` is `None` when the chain could not be safely resolved (a cycle +/// or a link/metadata error); in that case `write_path` is the original path, +/// and writing a regular file there intentionally breaks the broken link. +pub struct SymlinkWritePaths { + pub read_path: Option, + pub write_path: PathBuf, +} + +/// Follow `path`'s symlink chain to the first non-symlink target, guarding +/// against cycles via a visited set. There is no fixed max-hop count. +pub fn resolve_symlink_write_paths(path: &Path) -> io::Result { + let root = path.to_path_buf(); + let mut current = root.clone(); + let mut visited = HashSet::new(); + + loop { + let meta = match std::fs::symlink_metadata(¤t) { + Ok(meta) => meta, + // A not-yet-created target is a normal first-write: write there. + Err(err) if err.kind() == io::ErrorKind::NotFound => { + return Ok(SymlinkWritePaths { + read_path: Some(current.clone()), + write_path: current, + }); + } + Err(_) => return Ok(broken_chain(root)), + }; + + if !meta.file_type().is_symlink() { + return Ok(SymlinkWritePaths { + read_path: Some(current.clone()), + write_path: current, + }); + } + + // Re-seeing a path means the chain loops back on itself. + if !visited.insert(current.clone()) { + return Ok(broken_chain(root)); + } + + let target = match std::fs::read_link(¤t) { + Ok(target) => target, + Err(_) => return Ok(broken_chain(root)), + }; + + current = if target.is_absolute() { + target + } else if let Some(parent) = current.parent() { + parent.join(target) + } else { + return Ok(broken_chain(root)); + }; + } +} + +fn broken_chain(root: PathBuf) -> SymlinkWritePaths { + SymlinkWritePaths { + read_path: None, + write_path: root, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + /// Mirror of OpenKnowledge's tmp+rename atomic write so the symlink tests + /// exercise the same replace-vs-write-through behavior the real spine does: + /// renaming a fresh file onto a path replaces a symlink there, but renaming + /// onto a resolved real target leaves the original symlink intact. + fn atomic_write(path: &Path, contents: &str) { + let parent = path.parent().expect("write path has a parent"); + let tmp = tempfile::NamedTempFile::new_in(parent).expect("tmp file"); + fs::write(tmp.path(), contents).expect("write tmp"); + tmp.persist(path).expect("rename onto target"); + } + + #[test] + fn regular_file_resolves_to_itself() { + let dir = tempdir().expect("tmpdir"); + let path = dir.path().join("config.toml"); + fs::write(&path, "x = 1\n").expect("seed"); + + let resolved = resolve_symlink_write_paths(&path).expect("resolve"); + assert_eq!(resolved.read_path.as_deref(), Some(path.as_path())); + assert_eq!(resolved.write_path, path); + } + + #[test] + fn missing_file_resolves_to_itself() { + let dir = tempdir().expect("tmpdir"); + let path = dir.path().join("config.toml"); + + let resolved = resolve_symlink_write_paths(&path).expect("resolve"); + assert_eq!(resolved.read_path.as_deref(), Some(path.as_path())); + assert_eq!(resolved.write_path, path); + } + + #[cfg(unix)] + #[test] + fn writes_through_symlink_chain_to_real_target() { + use std::os::unix::fs::symlink; + + let home = tempdir().expect("home"); + let target_dir = tempdir().expect("target dir"); + let target_path = target_dir.path().join("config.toml"); + let link_path = home.path().join("config-link.toml"); + let config_path = home.path().join("config.toml"); + + symlink(&target_path, &link_path).expect("link -> target"); + symlink("config-link.toml", &config_path).expect("config -> link"); + + let resolved = resolve_symlink_write_paths(&config_path).expect("resolve"); + assert_eq!(resolved.write_path, target_path); + assert_eq!(resolved.read_path.as_deref(), Some(target_path.as_path())); + + atomic_write(&resolved.write_path, "model = \"x\"\n"); + + let meta = fs::symlink_metadata(&config_path).expect("config metadata"); + assert!( + meta.file_type().is_symlink(), + "the user's symlink must survive a write-through" + ); + assert_eq!( + fs::read_to_string(&target_path).expect("read target"), + "model = \"x\"\n" + ); + } + + #[cfg(unix)] + #[test] + fn breaks_cycle_into_regular_file() { + use std::os::unix::fs::symlink; + + let home = tempdir().expect("home"); + let link_a = home.path().join("a.toml"); + let link_b = home.path().join("b.toml"); + let config_path = home.path().join("config.toml"); + + symlink("b.toml", &link_a).expect("a -> b"); + symlink("a.toml", &link_b).expect("b -> a"); + symlink("a.toml", &config_path).expect("config -> a"); + + let resolved = resolve_symlink_write_paths(&config_path).expect("resolve"); + assert!(resolved.read_path.is_none()); + assert_eq!(resolved.write_path, config_path); + + atomic_write(&resolved.write_path, "model = \"x\"\n"); + + let meta = fs::symlink_metadata(&config_path).expect("config metadata"); + assert!( + !meta.file_type().is_symlink(), + "a cyclic chain must collapse to a regular file" + ); + assert_eq!( + fs::read_to_string(&config_path).expect("read config"), + "model = \"x\"\n" + ); + } +} diff --git a/packages/native-config/src/toml_json.rs b/packages/native-config/src/toml_json.rs new file mode 100644 index 000000000..c287714d1 --- /dev/null +++ b/packages/native-config/src/toml_json.rs @@ -0,0 +1,130 @@ +//! Pure (N-API-free) TOML parse + JSON projection used by the read/classify +//! path. Kept free of any `napi` types so it is directly unit-testable under a +//! plain `cargo test` binary; the thin `#[napi]` wrapper lives in `lib.rs`. + +use serde_json::{Map, Number, Value as JsonValue}; +use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value as TomlValue}; + +/// Parse TOML text and project it to a compact JSON string. +/// +/// Returns `Err(reason)` only for genuinely-unparseable input. A capable parser +/// (`toml_edit`) accepts 64-bit integers past the JS safe-integer boundary and +/// microsecond/offset datetimes that the JS `smol-toml` parser rejects, so a +/// valid harness config is never mis-flagged on the classify path. The error +/// reason is a fixed string — never an echo of the file's bytes. +/// +/// The projection is intentionally lossy for *write* fidelity (JSON has no +/// integer/float distinction or datetime type); it feeds only the read/classify +/// path, which compares structural shape and our own entry's value. Format- +/// preserving writes use the document model directly, not this projection. +pub fn parse_toml_to_json(toml_text: &str) -> Result { + let doc = toml_text + .parse::() + .map_err(|_| "invalid TOML".to_string())?; + let value = table_to_json(doc.as_table()); + serde_json::to_string(&value).map_err(|_| "serialize failed".to_string()) +} + +fn item_to_json(item: &Item) -> JsonValue { + match item { + Item::None => JsonValue::Null, + Item::Value(value) => value_to_json(value), + Item::Table(table) => table_to_json(table), + Item::ArrayOfTables(array) => { + JsonValue::Array(array.iter().map(table_to_json).collect()) + } + } +} + +fn table_to_json(table: &Table) -> JsonValue { + let mut map = Map::new(); + for (key, item) in table.iter() { + map.insert(key.to_string(), item_to_json(item)); + } + JsonValue::Object(map) +} + +fn inline_table_to_json(table: &InlineTable) -> JsonValue { + let mut map = Map::new(); + for (key, value) in table.iter() { + map.insert(key.to_string(), value_to_json(value)); + } + JsonValue::Object(map) +} + +fn array_to_json(array: &Array) -> JsonValue { + JsonValue::Array(array.iter().map(value_to_json).collect()) +} + +fn value_to_json(value: &TomlValue) -> JsonValue { + match value { + TomlValue::String(s) => JsonValue::String(s.value().to_string()), + TomlValue::Integer(i) => JsonValue::Number(Number::from(*i.value())), + // A non-finite float has no JSON representation; project it as null + // rather than fail the whole parse. Real configs never carry one. + TomlValue::Float(f) => Number::from_f64(*f.value()) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null), + TomlValue::Boolean(b) => JsonValue::Bool(*b.value()), + TomlValue::Datetime(dt) => JsonValue::String(dt.value().to_string()), + TomlValue::Array(a) => array_to_json(a), + TomlValue::InlineTable(t) => inline_table_to_json(t), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_integers_past_the_js_safe_boundary() { + // `9223372036854775807` (i64::MAX) and `9007199254740993` (2^53 + 1) + // both exceed Number.MAX_SAFE_INTEGER; smol-toml throws on them, which + // is the throw that mis-classified a valid config as corrupt. + let toml = "big = 9223372036854775807\njust_over = 9007199254740993\n"; + let json = parse_toml_to_json(toml).expect("capable parser must accept i64"); + assert!(json.contains("9223372036854775807")); + assert!(json.contains("9007199254740993")); + } + + #[test] + fn accepts_microsecond_datetime() { + let toml = "ts = 2026-06-26T12:34:56.123456Z\n"; + let json = parse_toml_to_json(toml).expect("must accept microsecond datetime"); + assert!(json.contains("2026-06-26T12:34:56.123456Z")); + } + + #[test] + fn projects_nested_tables_and_arrays() { + let toml = "[mcp_servers.open-knowledge]\ncommand = \"/bin/sh\"\nargs = [\"-l\", \"-c\"]\n"; + let json = parse_toml_to_json(toml).unwrap(); + let parsed: JsonValue = serde_json::from_str(&json).unwrap(); + let entry = &parsed["mcp_servers"]["open-knowledge"]; + assert_eq!(entry["command"], JsonValue::String("/bin/sh".into())); + assert_eq!(entry["args"][0], JsonValue::String("-l".into())); + assert_eq!(entry["args"][1], JsonValue::String("-c".into())); + } + + #[test] + fn projects_inline_table_entry() { + let toml = "mcp_servers = { \"open-knowledge\" = { command = \"npx\" } }\n"; + let json = parse_toml_to_json(toml).unwrap(); + let parsed: JsonValue = serde_json::from_str(&json).unwrap(); + assert_eq!( + parsed["mcp_servers"]["open-knowledge"]["command"], + JsonValue::String("npx".into()) + ); + } + + #[test] + fn rejects_genuinely_malformed_toml() { + let err = parse_toml_to_json("not = valid = toml = at = all").unwrap_err(); + assert_eq!(err, "invalid TOML"); + } + + #[test] + fn empty_input_projects_to_empty_object() { + assert_eq!(parse_toml_to_json("").unwrap(), "{}"); + assert_eq!(parse_toml_to_json(" \n\t\n").unwrap(), "{}"); + } +} diff --git a/packages/native-config/turbo.json b/packages/native-config/turbo.json new file mode 100644 index 000000000..612053b80 --- /dev/null +++ b/packages/native-config/turbo.json @@ -0,0 +1,14 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "inputs": ["Cargo.toml", "Cargo.lock", "build.rs", "src/**/*.rs", "package.json"], + "outputs": ["index.js", "index.d.ts", "*.node"] + }, + "test": { + "dependsOn": [], + "inputs": ["Cargo.toml", "Cargo.lock", "build.rs", "src/**/*.rs"], + "outputs": [] + } + } +} diff --git a/scripts/generate-third-party-notices.mjs b/scripts/generate-third-party-notices.mjs index 76873894b..3ed550b02 100644 --- a/scripts/generate-third-party-notices.mjs +++ b/scripts/generate-third-party-notices.mjs @@ -382,6 +382,160 @@ function apacheEntry(e) { return lines.join('\n'); } +function vendoredCodexEntry() { + return [ + '### OpenAI Codex (vendored into `packages/native-config`)', + 'Homepage: https://github.com/openai/codex', + '', + 'Copyright 2025 OpenAI', + '', + "The native harness-config addon contains Rust code derived from OpenAI Codex's `toml_edit`-based config-edit implementation, adapted to an insert-only single-entry upsert. The Apache License, Version 2.0 reproduced above applies. Derived files and their upstream origins:", + '', + '- `src/document_helpers.rs` — from `codex-rs/core/src/config/edit/document_helpers.rs`', + '- `src/mcp_edit.rs` — adapted from `codex-rs/core/src/config/edit.rs` (insert-only, not `replace_mcp_servers`)', + '- `src/path_resolve.rs` — from `codex-rs/utils/path-utils/src/lib.rs`', + '- `src/mcp_edit_conformance_tests.rs` — ported from `codex-rs/core/src/config/edit_tests.rs`', + '', + 'NOTICE:', + '', + '```', + 'OpenAI Codex', + 'Copyright 2025 OpenAI', + '```', + ].join('\n'); +} + +const NATIVE_CONFIG_CRATE = 'open-knowledge-native-config'; +const NATIVE_CONFIG_CARGO_LOCK = join(REPO_ROOT, 'packages', 'native-config', 'Cargo.lock'); + +const RUST_RUNTIME_CRATES = { + bitflags: { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/bitflags/bitflags' }, + 'cfg-if': { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/rust-lang/cfg-if' }, + ctor: { spdx: 'Apache-2.0 OR MIT', repo: 'https://github.com/mmastrac/linktime' }, + equivalent: { spdx: 'Apache-2.0 OR MIT', repo: 'https://github.com/indexmap-rs/equivalent' }, + futures: { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/rust-lang/futures-rs' }, + 'futures-channel': { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/rust-lang/futures-rs' }, + 'futures-core': { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/rust-lang/futures-rs' }, + 'futures-executor': { + spdx: 'MIT OR Apache-2.0', + repo: 'https://github.com/rust-lang/futures-rs', + }, + 'futures-io': { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/rust-lang/futures-rs' }, + 'futures-sink': { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/rust-lang/futures-rs' }, + 'futures-task': { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/rust-lang/futures-rs' }, + 'futures-util': { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/rust-lang/futures-rs' }, + hashbrown: { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/rust-lang/hashbrown' }, + indexmap: { spdx: 'Apache-2.0 OR MIT', repo: 'https://github.com/indexmap-rs/indexmap' }, + itoa: { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/dtolnay/itoa' }, + libloading: { spdx: 'ISC', repo: 'https://github.com/nagisa/rust_libloading' }, + memchr: { spdx: 'Unlicense OR MIT', repo: 'https://github.com/BurntSushi/memchr' }, + napi: { spdx: 'MIT', repo: 'https://github.com/napi-rs/napi-rs' }, + 'napi-sys': { spdx: 'MIT', repo: 'https://github.com/napi-rs/napi-rs' }, + 'nohash-hasher': { + spdx: 'Apache-2.0 OR MIT', + repo: 'https://github.com/paritytech/nohash-hasher', + }, + 'pin-project-lite': { + spdx: 'Apache-2.0 OR MIT', + repo: 'https://github.com/taiki-e/pin-project-lite', + }, + 'rustc-hash': { spdx: 'Apache-2.0 OR MIT', repo: 'https://github.com/rust-lang/rustc-hash' }, + serde: { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/serde-rs/serde' }, + serde_core: { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/serde-rs/serde' }, + serde_json: { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/serde-rs/json' }, + slab: { spdx: 'MIT', repo: 'https://github.com/tokio-rs/slab' }, + toml_datetime: { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/toml-rs/toml' }, + toml_edit: { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/toml-rs/toml' }, + toml_parser: { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/toml-rs/toml' }, + toml_writer: { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/toml-rs/toml' }, + 'windows-link': { spdx: 'MIT OR Apache-2.0', repo: 'https://github.com/microsoft/windows-rs' }, + winnow: { spdx: 'MIT', repo: 'https://github.com/winnow-rs/winnow' }, + zmij: { spdx: 'MIT', repo: 'https://github.com/dtolnay/zmij' }, +}; + +const RUST_COMPILE_TIME_CRATES = new Set([ + 'convert_case', + 'futures-macro', + 'napi-build', + 'napi-derive', + 'napi-derive-backend', + 'proc-macro2', + 'quote', + 'semver', + 'serde_derive', + 'syn', + 'unicode-ident', + 'unicode-segmentation', +]); + +const RUST_DEV_CRATES = new Set([ + 'errno', + 'fastrand', + 'getrandom', + 'libc', + 'linux-raw-sys', + 'once_cell', + 'r-efi', + 'rustix', + 'tempfile', + 'windows-sys', +]); + +function parseCargoLockPackages(lockPath) { + const text = readFileSync(lockPath, 'utf8'); + const pkgs = []; + for (const block of text.split('[[package]]').slice(1)) { + const name = /^\s*name\s*=\s*"([^"]+)"/m.exec(block)?.[1]; + const version = /^\s*version\s*=\s*"([^"]+)"/m.exec(block)?.[1]; + if (name && version) pkgs.push({ name, version }); + } + return pkgs; +} + +function bundledRustCratesSection() { + if (!existsSync(NATIVE_CONFIG_CARGO_LOCK)) { + throw new Error( + `Cargo.lock not found at ${NATIVE_CONFIG_CARGO_LOCK} — cannot attribute the ` + + 'native-config addon Rust crates.', + ); + } + const pkgs = parseCargoLockPackages(NATIVE_CONFIG_CARGO_LOCK).filter( + (p) => p.name !== NATIVE_CONFIG_CRATE, + ); + + const unclassified = []; + for (const { name } of pkgs) { + const matches = + Number(name in RUST_RUNTIME_CRATES) + + Number(RUST_COMPILE_TIME_CRATES.has(name)) + + Number(RUST_DEV_CRATES.has(name)); + if (matches !== 1) unclassified.push(`${name} (in ${matches} of 3 sets)`); + } + if (unclassified.length > 0) { + throw new Error( + 'native-config Cargo.lock has crates not classified exactly once in ' + + 'scripts/generate-third-party-notices.mjs (RUST_RUNTIME_CRATES / ' + + `RUST_COMPILE_TIME_CRATES / RUST_DEV_CRATES):\n ${unclassified.join('\n ')}\n` + + 'Classify each: runtime-linked → attribute in RUST_RUNTIME_CRATES; ' + + 'proc-macro/build → RUST_COMPILE_TIME_CRATES; test-only → RUST_DEV_CRATES.', + ); + } + + const runtime = pkgs + .filter((p) => p.name in RUST_RUNTIME_CRATES) + .sort((a, b) => byteCompare(a.name, b.name) || byteCompare(a.version, b.version)); + + const lines = [ + "The native harness-config addon's `.node` binaries statically link the Rust crates below. Each is redistributed under the license shown; every license here is MIT, Apache-2.0, or ISC, whose full texts are reproduced elsewhere in this document (for dual- or multi-licensed crates OpenKnowledge elects a reproduced license). Compile-time-only crates (proc-macros, build scripts) and test-only dev-dependencies are not listed — their code is not present in the distributed binary. Versions track `packages/native-config/Cargo.lock`.", + '', + ]; + for (const { name, version } of runtime) { + const meta = RUST_RUNTIME_CRATES[name]; + lines.push(`- \`${name}@${version}\` — ${meta.spdx} — ${meta.repo}`); + } + return lines.join('\n'); +} + function build() { const collected = collectClosure(); @@ -448,18 +602,21 @@ function build() { push('```', LICENSE_TEXTS.lgpl3, '```', ''); hr(); - if (grouped.has('Apache-2.0')) { - push('## Apache License, Version 2.0', ''); - push( - 'Each package in this section is licensed under the Apache License, Version 2.0. The full text of the license is reproduced once below and applies to every entry; per-package `NOTICE` file content is reproduced inline with each entry.', - '', - ); - push('```', LICENSE_TEXTS.apache, '```', ''); - for (const e of grouped.get('Apache-2.0')) { - push(apacheEntry(e), ''); - } - hr(); + push('## Apache License, Version 2.0', ''); + push( + 'The packages and vendored source in this section are licensed under the Apache License, Version 2.0. The full text of the license is reproduced once below and applies to every entry; per-package `NOTICE` file content is reproduced inline with each entry.', + '', + ); + push('```', LICENSE_TEXTS.apache, '```', ''); + for (const e of grouped.get('Apache-2.0') || []) { + push(apacheEntry(e), ''); } + push(vendoredCodexEntry(), ''); + hr(); + + push('## Bundled Rust crates (native-config addon)', ''); + push(bundledRustCratesSection(), ''); + hr(); if (grouped.has('MIT')) { push('## MIT License', '');