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', '');