From 83e3521a77a27df511bd41a319ab228ed1ba96d1 Mon Sep 17 00:00:00 2001 From: I4cTime Date: Wed, 25 Mar 2026 20:09:22 +0800 Subject: [PATCH 1/6] Add Vitest test suite and Homebrew tap automation (#30) - Install vitest, add test/test:ci scripts and vitest.config.ts - 125 tests across 17 files covering core modules, CLI, and MCP - Add test step to CI workflow - Create update-homebrew.yml to auto-update I4cTime/homebrew-tap on release Made-with: Cursor --- .github/workflows/ci.yml | 3 + .github/workflows/update-homebrew.yml | 84 +++ package.json | 5 +- pnpm-lock.yaml | 680 +++++++++++++++++++++++- src/__tests__/cli/commands.test.ts | 63 +++ src/__tests__/cli/help.test.ts | 29 + src/__tests__/core/approval.test.ts | 58 ++ src/__tests__/core/collapse.test.ts | 54 ++ src/__tests__/core/entanglement.test.ts | 54 ++ src/__tests__/core/envelope.test.ts | 134 +++++ src/__tests__/core/hooks.test.ts | 63 +++ src/__tests__/core/linter.test.ts | 50 ++ src/__tests__/core/memory.test.ts | 55 ++ src/__tests__/core/noise.test.ts | 75 +++ src/__tests__/core/observer.test.ts | 74 +++ src/__tests__/core/policy.test.ts | 36 ++ src/__tests__/core/scan.test.ts | 74 +++ src/__tests__/core/scope.test.ts | 86 +++ src/__tests__/core/teleport.test.ts | 48 ++ src/__tests__/core/tunnel.test.ts | 56 ++ src/__tests__/mcp/server.test.ts | 106 ++++ vitest.config.ts | 9 + 22 files changed, 1891 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/update-homebrew.yml create mode 100644 src/__tests__/cli/commands.test.ts create mode 100644 src/__tests__/cli/help.test.ts create mode 100644 src/__tests__/core/approval.test.ts create mode 100644 src/__tests__/core/collapse.test.ts create mode 100644 src/__tests__/core/entanglement.test.ts create mode 100644 src/__tests__/core/envelope.test.ts create mode 100644 src/__tests__/core/hooks.test.ts create mode 100644 src/__tests__/core/linter.test.ts create mode 100644 src/__tests__/core/memory.test.ts create mode 100644 src/__tests__/core/noise.test.ts create mode 100644 src/__tests__/core/observer.test.ts create mode 100644 src/__tests__/core/policy.test.ts create mode 100644 src/__tests__/core/scan.test.ts create mode 100644 src/__tests__/core/scope.test.ts create mode 100644 src/__tests__/core/teleport.test.ts create mode 100644 src/__tests__/core/tunnel.test.ts create mode 100644 src/__tests__/mcp/server.test.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4bf6ba..b3cea0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,3 +33,6 @@ jobs: - name: Build run: pnpm run build + + - name: Test + run: pnpm run test:ci diff --git a/.github/workflows/update-homebrew.yml b/.github/workflows/update-homebrew.yml new file mode 100644 index 0000000..cc81f48 --- /dev/null +++ b/.github/workflows/update-homebrew.yml @@ -0,0 +1,84 @@ +name: Update Homebrew Tap + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + update-formula: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Get release version + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Wait for npm publish + run: | + VERSION="${{ steps.version.outputs.version }}" + echo "Waiting for @i4ctime/q-ring@${VERSION} on npm..." + for i in $(seq 1 30); do + if npm view "@i4ctime/q-ring@${VERSION}" version 2>/dev/null; then + echo "Found on npm!" + exit 0 + fi + echo "Attempt $i/30 — not yet available, waiting 10s..." + sleep 10 + done + echo "::error::Timed out waiting for npm publish" + exit 1 + + - name: Compute tarball sha256 + id: sha + run: | + VERSION="${{ steps.version.outputs.version }}" + URL="https://registry.npmjs.org/@i4ctime/q-ring/-/q-ring-${VERSION}.tgz" + SHA=$(curl -sL "$URL" | sha256sum | awk '{print $1}') + echo "sha256=${SHA}" >> "$GITHUB_OUTPUT" + echo "url=${URL}" >> "$GITHUB_OUTPUT" + echo "SHA256: ${SHA}" + + - name: Clone homebrew-tap + run: | + git clone https://x-access-token:${{ secrets.HOMEBREW_TAP_TOKEN }}@github.com/I4cTime/homebrew-tap.git /tmp/homebrew-tap + + - name: Update formula + run: | + VERSION="${{ steps.version.outputs.version }}" + SHA="${{ steps.sha.outputs.sha256 }}" + URL="${{ steps.sha.outputs.url }}" + + cat > /tmp/homebrew-tap/Formula/qring.rb << RUBY + class Qring < Formula + desc "Quantum keyring for AI coding tools — secrets, superposition, entanglement, MCP" + homepage "https://qring.i4c.studio" + url "${URL}" + sha256 "${SHA}" + license "AGPL-3.0-only" + + depends_on "node@22" + + def install + system "npm", "install", *std_npm_args + bin.install_symlink libexec.glob("bin/*") + end + + test do + assert_match "qring", shell_output("#{bin}/qring --version") + end + end + RUBY + + - name: Commit and push + working-directory: /tmp/homebrew-tap + run: | + VERSION="${{ steps.version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/qring.rb + git commit -m "Update qring to ${VERSION}" + git push diff --git a/package.json b/package.json index 1261fc4..e5f2e1d 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "build": "tsup", "dev": "tsup --watch", "typecheck": "tsc --noEmit", + "test": "vitest", + "test:ci": "vitest run", "prepublishOnly": "pnpm run build" }, "keywords": [ @@ -51,6 +53,7 @@ "devDependencies": { "@types/node": "^25.5.0", "tsup": "^8.5.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d839b07..8e072d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,13 +26,25 @@ importers: version: 25.5.0 tsup: specifier: ^8.5.1 - version: 8.5.1(typescript@5.9.3) + version: 8.5.1(postcss@8.5.8)(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^4.1.1 + version: 4.1.1(@types/node@25.5.0)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4)) packages: + '@emnapi/core@1.9.1': + resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@esbuild/aix-ppc64@0.27.4': resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} @@ -299,6 +311,110 @@ packages: resolution: {integrity: sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg==} engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + + '@rolldown/binding-android-arm64@1.0.0-rc.11': + resolution: {integrity: sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.11': + resolution: {integrity: sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.11': + resolution: {integrity: sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.11': + resolution: {integrity: sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11': + resolution: {integrity: sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11': + resolution: {integrity: sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.11': + resolution: {integrity: sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11': + resolution: {integrity: sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11': + resolution: {integrity: sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.11': + resolution: {integrity: sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.11': + resolution: {integrity: sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.11': + resolution: {integrity: sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.11': + resolution: {integrity: sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11': + resolution: {integrity: sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.11': + resolution: {integrity: sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.11': + resolution: {integrity: sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==} + '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] @@ -437,12 +553,53 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@vitest/expect@4.1.1': + resolution: {integrity: sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==} + + '@vitest/mocker@4.1.1': + resolution: {integrity: sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.1': + resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==} + + '@vitest/runner@4.1.1': + resolution: {integrity: sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==} + + '@vitest/snapshot@4.1.1': + resolution: {integrity: sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==} + + '@vitest/spy@4.1.1': + resolution: {integrity: sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==} + + '@vitest/utils@4.1.1': + resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -466,6 +623,10 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -492,6 +653,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -519,6 +684,9 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -548,6 +716,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -567,6 +739,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -579,6 +754,9 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -591,6 +769,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.3.1: resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} engines: {node: '>= 16'} @@ -701,6 +883,80 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -744,6 +1000,11 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -756,6 +1017,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -813,6 +1077,10 @@ packages: yaml: optional: true + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -841,6 +1109,11 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + rolldown@1.0.0-rc.11: + resolution: {integrity: sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -888,14 +1161,27 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -908,13 +1194,24 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -926,6 +1223,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -968,11 +1268,94 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite@8.0.2: + resolution: {integrity: sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.1: + resolution: {integrity: sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.1 + '@vitest/browser-preview': 4.1.1 + '@vitest/browser-webdriverio': 4.1.1 + '@vitest/ui': 4.1.1 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -986,6 +1369,22 @@ packages: snapshots: + '@emnapi/core@1.9.1': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.4': optional: true @@ -1155,6 +1554,64 @@ snapshots: '@napi-rs/keyring-win32-ia32-msvc': 1.2.0 '@napi-rs/keyring-win32-x64-msvc': 1.2.0 + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.122.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.11': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.11': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.11': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.11': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.11': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.11': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.11': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.11': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.11': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.11': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.11': {} + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -1230,12 +1687,67 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/node@25.5.0': dependencies: undici-types: 7.18.2 + '@vitest/expect@4.1.1': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.1(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4))': + dependencies: + '@vitest/spy': 4.1.1 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.2(@types/node@25.5.0)(esbuild@0.27.4) + + '@vitest/pretty-format@4.1.1': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.1': + dependencies: + '@vitest/utils': 4.1.1 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.1': + dependencies: + '@vitest/pretty-format': 4.1.1 + '@vitest/utils': 4.1.1 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.1': {} + + '@vitest/utils@4.1.1': + dependencies: + '@vitest/pretty-format': 4.1.1 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -1256,6 +1768,8 @@ snapshots: any-promise@1.3.0: {} + assertion-error@2.0.1: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -1289,6 +1803,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + chai@6.2.2: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -1305,6 +1821,8 @@ snapshots: content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -1326,6 +1844,8 @@ snapshots: depd@2.0.0: {} + detect-libc@2.1.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1340,6 +1860,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -1375,6 +1897,10 @@ snapshots: escape-html@1.0.3: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + etag@1.8.1: {} eventsource-parser@3.0.6: {} @@ -1383,6 +1909,8 @@ snapshots: dependencies: eventsource-parser: 3.0.6 + expect-type@1.3.0: {} + express-rate-limit@8.3.1(express@5.2.1): dependencies: express: 5.2.1 @@ -1513,6 +2041,55 @@ snapshots: json-schema-typed@8.0.2: {} + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -1550,12 +2127,16 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid@3.3.11: {} + negotiator@1.0.0: {} object-assign@4.1.1: {} object-inspect@1.13.4: {} + obug@2.1.1: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -1586,9 +2167,17 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 - postcss-load-config@6.0.1: + postcss-load-config@6.0.1(postcss@8.5.8): dependencies: lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.8 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 proxy-addr@2.0.7: dependencies: @@ -1614,6 +2203,27 @@ snapshots: resolve-from@5.0.0: {} + rolldown@1.0.0-rc.11: + dependencies: + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.11 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.11 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.11 + '@rolldown/binding-darwin-x64': 1.0.0-rc.11 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.11 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.11 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.11 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.11 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.11 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.11 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.11 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.11 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.11 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.11 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.11 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.11 + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -1718,10 +2328,18 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + source-map@0.7.6: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.0.0: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -1740,20 +2358,29 @@ snapshots: dependencies: any-promise: 1.3.0 + tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.0.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + toidentifier@1.0.1: {} tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} - tsup@8.5.1(typescript@5.9.3): + tslib@2.8.1: + optional: true + + tsup@8.5.1(postcss@8.5.8)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.4) cac: 6.7.14 @@ -1764,7 +2391,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1 + postcss-load-config: 6.0.1(postcss@8.5.8) resolve-from: 5.0.0 rollup: 4.59.0 source-map: 0.7.6 @@ -1773,6 +2400,7 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: + postcss: 8.5.8 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -1796,10 +2424,54 @@ snapshots: vary@1.1.2: {} + vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.3 + postcss: 8.5.8 + rolldown: 1.0.0-rc.11 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.5.0 + esbuild: 0.27.4 + fsevents: 2.3.3 + + vitest@4.1.1(@types/node@25.5.0)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4)): + dependencies: + '@vitest/expect': 4.1.1 + '@vitest/mocker': 4.1.1(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4)) + '@vitest/pretty-format': 4.1.1 + '@vitest/runner': 4.1.1 + '@vitest/snapshot': 4.1.1 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.2(@types/node@25.5.0)(esbuild@0.27.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.0 + transitivePeerDependencies: + - msw + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrappy@1.0.2: {} zod-to-json-schema@3.25.1(zod@4.3.6): diff --git a/src/__tests__/cli/commands.test.ts b/src/__tests__/cli/commands.test.ts new file mode 100644 index 0000000..da32183 --- /dev/null +++ b/src/__tests__/cli/commands.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { createProgram } from "../../cli/commands.js"; + +describe("createProgram", () => { + it("returns a Commander program", () => { + const program = createProgram(); + expect(program).toBeDefined(); + expect(program.name()).toBe("qring"); + }); + + it("registers all expected top-level commands", () => { + const program = createProgram(); + const commands = program.commands.map((c) => c.name()); + + const expected = [ + "set", "get", "delete", "list", "inspect", "export", "import", + "check", "validate", "exec", "scan", "lint", "context", + "remember", "recall", "forget", "approve", "approvals", + "hook:install", "hook:uninstall", "hook:run", + "wizard", "analyze", "env", "generate", "entangle", "disentangle", + "tunnel", "teleport", "audit", "audit:verify", "audit:export", + "health", "hook", "env:generate", "status", "agent", + "rotate", "ci:validate", "policy", + ]; + + for (const name of expected) { + expect(commands).toContain(name); + } + }); + + it("registers tunnel subcommands", () => { + const program = createProgram(); + const tunnelCmd = program.commands.find((c) => c.name() === "tunnel"); + expect(tunnelCmd).toBeDefined(); + const subNames = tunnelCmd!.commands.map((c) => c.name()); + expect(subNames).toContain("create"); + expect(subNames).toContain("read"); + expect(subNames).toContain("destroy"); + expect(subNames).toContain("list"); + }); + + it("registers teleport subcommands", () => { + const program = createProgram(); + const tpCmd = program.commands.find((c) => c.name() === "teleport"); + expect(tpCmd).toBeDefined(); + const subNames = tpCmd!.commands.map((c) => c.name()); + expect(subNames).toContain("pack"); + expect(subNames).toContain("unpack"); + }); + + it("registers hook subcommands", () => { + const program = createProgram(); + const hookCmd = program.commands.find((c) => c.name() === "hook"); + expect(hookCmd).toBeDefined(); + const subNames = hookCmd!.commands.map((c) => c.name()); + expect(subNames).toContain("add"); + expect(subNames).toContain("list"); + expect(subNames).toContain("remove"); + expect(subNames).toContain("enable"); + expect(subNames).toContain("disable"); + expect(subNames).toContain("test"); + }); +}); diff --git a/src/__tests__/cli/help.test.ts b/src/__tests__/cli/help.test.ts new file mode 100644 index 0000000..d86231b --- /dev/null +++ b/src/__tests__/cli/help.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { createProgram } from "../../cli/commands.js"; + +describe("--help output", () => { + it("produces help text containing the program name", () => { + const program = createProgram(); + const help = program.helpInformation(); + expect(help).toContain("qring"); + }); + + it("lists key commands in help output", () => { + const program = createProgram(); + const help = program.helpInformation(); + const expectedKeywords = [ + "set", "get", "delete", "list", "tunnel", "teleport", + "audit", "generate", "scan", "lint", "agent", + ]; + for (const kw of expectedKeywords) { + expect(help).toContain(kw); + } + }); + + it("each command has a description", () => { + const program = createProgram(); + for (const cmd of program.commands) { + expect(cmd.description()).toBeTruthy(); + } + }); +}); diff --git a/src/__tests__/core/approval.test.ts b/src/__tests__/core/approval.test.ts new file mode 100644 index 0000000..12e1189 --- /dev/null +++ b/src/__tests__/core/approval.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + grantApproval, + revokeApproval, + hasApproval, + listApprovals, +} from "../../core/approval.js"; + +describe("approval system", () => { + const key = "TEST_APPROVAL_KEY"; + const scope = "q-ring:global"; + + beforeEach(() => { + revokeApproval(key, scope); + }); + + it("grants an approval and returns an entry with id and hmac", () => { + const entry = grantApproval(key, scope, 3600); + expect(entry.id).toBeTruthy(); + expect(entry.key).toBe(key); + expect(entry.scope).toBe(scope); + expect(entry.hmac).toBeTruthy(); + expect(entry.grantedAt).toBeTruthy(); + expect(entry.expiresAt).toBeTruthy(); + }); + + it("hasApproval returns true after granting", () => { + grantApproval(key, scope, 3600); + expect(hasApproval(key, scope)).toBe(true); + }); + + it("hasApproval returns false when not granted", () => { + expect(hasApproval("NEVER_GRANTED", scope)).toBe(false); + }); + + it("revokeApproval removes the approval", () => { + grantApproval(key, scope, 3600); + expect(revokeApproval(key, scope)).toBe(true); + expect(hasApproval(key, scope)).toBe(false); + }); + + it("revokeApproval returns false for non-existent", () => { + expect(revokeApproval("NEVER_SET", scope)).toBe(false); + }); + + it("listApprovals includes granted entries", () => { + grantApproval(key, scope, 3600); + const list = listApprovals(); + const found = list.find((a) => a.key === key && a.scope === scope); + expect(found).toBeDefined(); + expect(typeof found!.valid).toBe("boolean"); + expect(typeof found!.tampered).toBe("boolean"); + }); + + it("hasApproval returns false for non-existent key+scope", () => { + expect(hasApproval("TOTALLY_MISSING_KEY", "q-ring:global")).toBe(false); + }); +}); diff --git a/src/__tests__/core/collapse.test.ts b/src/__tests__/core/collapse.test.ts new file mode 100644 index 0000000..e26d08e --- /dev/null +++ b/src/__tests__/core/collapse.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { + collapseEnvironment, + readProjectConfig, +} from "../../core/collapse.js"; + +describe("readProjectConfig", () => { + it("returns null when no .q-ring.json exists", () => { + const config = readProjectConfig("/tmp/nonexistent-path"); + expect(config).toBeNull(); + }); +}); + +describe("collapseEnvironment", () => { + it("returns the explicit environment when provided", () => { + const result = collapseEnvironment({ explicit: "staging" }); + expect(result).not.toBeNull(); + expect(result!.env).toBe("staging"); + expect(result!.source).toBe("explicit"); + }); + + it("falls back to QRING_ENV if set", () => { + const prev = process.env.QRING_ENV; + process.env.QRING_ENV = "test-env"; + try { + const result = collapseEnvironment(); + expect(result).not.toBeNull(); + expect(result!.env).toBe("test-env"); + expect(result!.source).toBe("QRING_ENV"); + } finally { + if (prev === undefined) delete process.env.QRING_ENV; + else process.env.QRING_ENV = prev; + } + }); + + it("falls back to NODE_ENV if QRING_ENV is not set", () => { + const prevQ = process.env.QRING_ENV; + const prevN = process.env.NODE_ENV; + delete process.env.QRING_ENV; + process.env.NODE_ENV = "production"; + try { + const result = collapseEnvironment({ projectPath: "/tmp/nope" }); + expect(result).not.toBeNull(); + if (result!.source === "NODE_ENV") { + expect(result!.env).toBe("prod"); + } + } finally { + if (prevQ === undefined) delete process.env.QRING_ENV; + else process.env.QRING_ENV = prevQ; + if (prevN === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = prevN; + } + }); +}); diff --git a/src/__tests__/core/entanglement.test.ts b/src/__tests__/core/entanglement.test.ts new file mode 100644 index 0000000..7c5d350 --- /dev/null +++ b/src/__tests__/core/entanglement.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + entangle, + disentangle, + findEntangled, + listEntanglements, +} from "../../core/entanglement.js"; + +const src = { service: "vitest-src", key: "KEY_A" }; +const tgt = { service: "vitest-tgt", key: "KEY_B" }; + +describe("entanglement", () => { + beforeEach(() => { + disentangle(src, tgt); + }); + + it("entangles two secrets and creates bidirectional links", () => { + entangle(src, tgt); + const partners = findEntangled(src); + expect(partners).toContainEqual(tgt); + const reverse = findEntangled(tgt); + expect(reverse).toContainEqual(src); + }); + + it("does not duplicate pairs on repeated entangle calls", () => { + entangle(src, tgt); + entangle(src, tgt); + const all = listEntanglements().filter( + (p) => + p.source.service === src.service && + p.source.key === src.key && + p.target.service === tgt.service && + p.target.key === tgt.key, + ); + expect(all.length).toBe(1); + }); + + it("disentangle removes both forward and reverse links", () => { + entangle(src, tgt); + disentangle(src, tgt); + expect(findEntangled(src)).not.toContainEqual(tgt); + expect(findEntangled(tgt)).not.toContainEqual(src); + }); + + it("findEntangled returns empty for unlinked secrets", () => { + expect(findEntangled({ service: "nope", key: "X" })).toEqual([]); + }); + + it("listEntanglements returns all pairs", () => { + entangle(src, tgt); + const pairs = listEntanglements(); + expect(pairs.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/src/__tests__/core/envelope.test.ts b/src/__tests__/core/envelope.test.ts new file mode 100644 index 0000000..2669252 --- /dev/null +++ b/src/__tests__/core/envelope.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { + createEnvelope, + parseEnvelope, + serializeEnvelope, + collapseValue, + checkDecay, + recordAccess, + wrapLegacy, +} from "../../core/envelope.js"; + +describe("createEnvelope", () => { + it("creates an envelope with a single value", () => { + const env = createEnvelope("my-secret"); + expect(env.v).toBe(1); + expect(env.value).toBe("my-secret"); + expect(env.meta.createdAt).toBeTruthy(); + expect(env.meta.accessCount).toBe(0); + }); + + it("stores optional metadata", () => { + const env = createEnvelope("s", { + description: "test", + tags: ["a", "b"], + ttlSeconds: 60, + }); + expect(env.meta.description).toBe("test"); + expect(env.meta.tags).toEqual(["a", "b"]); + expect(env.meta.ttlSeconds).toBe(60); + }); + + it("stores superposition states", () => { + const env = createEnvelope("default-val", { + states: { dev: "dev-val", prod: "prod-val" }, + }); + expect(env.states?.dev).toBe("dev-val"); + expect(env.states?.prod).toBe("prod-val"); + }); +}); + +describe("parseEnvelope / serializeEnvelope", () => { + it("round-trips an envelope through serialize and parse", () => { + const original = createEnvelope("test", { tags: ["x"] }); + const json = serializeEnvelope(original); + const parsed = parseEnvelope(json); + expect(parsed).not.toBeNull(); + expect(parsed!.value).toBe("test"); + expect(parsed!.meta.tags).toEqual(["x"]); + }); + + it("returns null for invalid JSON", () => { + expect(parseEnvelope("not-json")).toBeNull(); + }); + + it("returns null for JSON missing v field", () => { + expect(parseEnvelope('{"value":"x"}')).toBeNull(); + }); +}); + +describe("wrapLegacy", () => { + it("wraps a raw string into a v1 envelope", () => { + const env = wrapLegacy("raw-value"); + expect(env.v).toBe(1); + expect(env.value).toBe("raw-value"); + }); +}); + +describe("collapseValue", () => { + it("returns the single value when no states exist", () => { + const env = createEnvelope("only"); + expect(collapseValue(env)).toBe("only"); + }); + + it("collapses to the requested environment", () => { + const env = createEnvelope("default", { + states: { dev: "dev-secret", prod: "prod-secret" }, + }); + expect(collapseValue(env, "dev")).toBe("dev-secret"); + expect(collapseValue(env, "prod")).toBe("prod-secret"); + }); + + it("falls back to first state for unknown env when no defaultEnv", () => { + const env = createEnvelope("fallback", { states: { dev: "d" } }); + expect(collapseValue(env, "staging")).toBe("d"); + }); + + it("falls back to defaultEnv state for unknown env", () => { + const env = createEnvelope("fallback", { + states: { dev: "d", prod: "p" }, + defaultEnv: "prod", + }); + expect(collapseValue(env, "staging")).toBe("p"); + }); +}); + +describe("checkDecay", () => { + it("reports healthy for a fresh envelope with no TTL", () => { + const env = createEnvelope("healthy"); + const decay = checkDecay(env); + expect(decay.isExpired).toBe(false); + expect(decay.isStale).toBe(false); + }); + + it("reports expired when expiresAt computed from TTL has elapsed", () => { + const env = createEnvelope("old", { ttlSeconds: 1 }); + env.meta.expiresAt = new Date(Date.now() - 5000).toISOString(); + const decay = checkDecay(env); + expect(decay.isExpired).toBe(true); + }); + + it("reports expired when expiresAt is in the past", () => { + const env = createEnvelope("old"); + env.meta.expiresAt = new Date(Date.now() - 1000).toISOString(); + const decay = checkDecay(env); + expect(decay.isExpired).toBe(true); + }); +}); + +describe("recordAccess", () => { + it("increments accessCount and sets lastAccessedAt", () => { + const env = createEnvelope("val"); + expect(env.meta.accessCount).toBe(0); + const updated = recordAccess(env); + expect(updated.meta.accessCount).toBe(1); + expect(updated.meta.lastAccessedAt).toBeTruthy(); + }); + + it("returns a new object (immutable)", () => { + const env = createEnvelope("val"); + const updated = recordAccess(env); + expect(updated).not.toBe(env); + expect(env.meta.accessCount).toBe(0); + }); +}); diff --git a/src/__tests__/core/hooks.test.ts b/src/__tests__/core/hooks.test.ts new file mode 100644 index 0000000..6080a17 --- /dev/null +++ b/src/__tests__/core/hooks.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + registerHook, + removeHook, + listHooks, + enableHook, + disableHook, +} from "../../core/hooks.js"; + +describe("hooks lifecycle", () => { + let hookId: string; + + beforeEach(() => { + for (const h of listHooks()) removeHook(h.id); + + const entry = registerHook({ + type: "shell", + match: { key: "TEST_KEY", action: ["write"] }, + command: 'echo "rotated"', + enabled: true, + }); + hookId = entry.id; + }); + + it("registerHook returns an entry with id and createdAt", () => { + const entry = registerHook({ + type: "shell", + match: { key: "OTHER" }, + command: "echo hi", + enabled: true, + }); + expect(entry.id).toBeTruthy(); + expect(entry.createdAt).toBeTruthy(); + expect(entry.type).toBe("shell"); + }); + + it("listHooks includes the registered hook", () => { + const hooks = listHooks(); + expect(hooks.some((h) => h.id === hookId)).toBe(true); + }); + + it("disableHook sets enabled to false", () => { + expect(disableHook(hookId)).toBe(true); + const h = listHooks().find((h) => h.id === hookId); + expect(h?.enabled).toBe(false); + }); + + it("enableHook sets enabled to true", () => { + disableHook(hookId); + expect(enableHook(hookId)).toBe(true); + const h = listHooks().find((h) => h.id === hookId); + expect(h?.enabled).toBe(true); + }); + + it("removeHook deletes the hook and returns true", () => { + expect(removeHook(hookId)).toBe(true); + expect(listHooks().some((h) => h.id === hookId)).toBe(false); + }); + + it("removeHook returns false for unknown id", () => { + expect(removeHook("nonexistent")).toBe(false); + }); +}); diff --git a/src/__tests__/core/linter.test.ts b/src/__tests__/core/linter.test.ts new file mode 100644 index 0000000..9aaaf03 --- /dev/null +++ b/src/__tests__/core/linter.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { lintFiles } from "../../core/linter.js"; +import { writeFileSync, readFileSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const TMP_DIR = join(tmpdir(), `qring-lint-test-${Date.now()}`); + +function setup() { + mkdirSync(TMP_DIR, { recursive: true }); +} + +afterAll(() => { + rmSync(TMP_DIR, { recursive: true, force: true }); +}); + +describe("lintFiles", () => { + it("detects secrets in a file without fixing", () => { + setup(); + const file = join(TMP_DIR, "app.ts"); + writeFileSync( + file, + 'const api_key = "sk-abcdef1234567890abcdef1234567890";\n', + ); + const results = lintFiles([file]); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].fixed).toBe(false); + }); + + it("returns empty for clean files", () => { + setup(); + const file = join(TMP_DIR, "clean.ts"); + writeFileSync(file, 'const name = "hello world";\n'); + const results = lintFiles([file]); + expect(results).toHaveLength(0); + }); + + it("skips nonexistent files", () => { + const results = lintFiles(["/tmp/does-not-exist-qring-lint.ts"]); + expect(results).toHaveLength(0); + }); + + it("ignores placeholder values", () => { + setup(); + const file = join(TMP_DIR, "safe.py"); + writeFileSync(file, 'password = "replace_me_with_real_password"\n'); + const results = lintFiles([file]); + expect(results).toHaveLength(0); + }); +}); diff --git a/src/__tests__/core/memory.test.ts b/src/__tests__/core/memory.test.ts new file mode 100644 index 0000000..4643cf0 --- /dev/null +++ b/src/__tests__/core/memory.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + remember, + recall, + listMemory, + forget, + clearMemory, +} from "../../core/memory.js"; + +describe("agent memory", () => { + beforeEach(() => { + clearMemory(); + }); + + it("stores and recalls a value", () => { + remember("note", "hello world"); + expect(recall("note")).toBe("hello world"); + }); + + it("returns null for a key that was never stored", () => { + expect(recall("missing")).toBeNull(); + }); + + it("overwrites a key on re-remember", () => { + remember("key", "v1"); + remember("key", "v2"); + expect(recall("key")).toBe("v2"); + }); + + it("lists all stored keys", () => { + remember("a", "1"); + remember("b", "2"); + const list = listMemory(); + const keys = list.map((m) => m.key); + expect(keys).toContain("a"); + expect(keys).toContain("b"); + }); + + it("forgets a key and returns true", () => { + remember("del", "gone"); + expect(forget("del")).toBe(true); + expect(recall("del")).toBeNull(); + }); + + it("returns false when forgetting a non-existent key", () => { + expect(forget("nope")).toBe(false); + }); + + it("clearMemory removes all keys", () => { + remember("x", "1"); + remember("y", "2"); + clearMemory(); + expect(listMemory()).toHaveLength(0); + }); +}); diff --git a/src/__tests__/core/noise.test.ts b/src/__tests__/core/noise.test.ts new file mode 100644 index 0000000..fee7890 --- /dev/null +++ b/src/__tests__/core/noise.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { generateSecret, estimateEntropy, type NoiseFormat } from "../../core/noise.js"; + +describe("generateSecret", () => { + it("returns a hex string by default for format=hex", () => { + const s = generateSecret({ format: "hex" }); + expect(s).toMatch(/^[0-9a-f]+$/); + }); + + it("returns base64url for format=base64", () => { + const s = generateSecret({ format: "base64" }); + expect(s).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("returns alphanumeric characters only", () => { + const s = generateSecret({ format: "alphanumeric", length: 32 }); + expect(s).toMatch(/^[A-Za-z0-9]+$/); + expect(s.length).toBe(32); + }); + + it("returns a valid uuid", () => { + const s = generateSecret({ format: "uuid" }); + expect(s).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it("applies prefix for api-key format", () => { + const s = generateSecret({ format: "api-key", prefix: "sk-" }); + expect(s.startsWith("sk-")).toBe(true); + }); + + it("applies prefix for token format", () => { + const s = generateSecret({ format: "token", prefix: "tok_" }); + expect(s.startsWith("tok_")).toBe(true); + }); + + it("generates a password with mixed character classes", () => { + const s = generateSecret({ format: "password", length: 24 }); + expect(s.length).toBe(24); + }); + + it("produces unique values on successive calls", () => { + const a = generateSecret(); + const b = generateSecret(); + expect(a).not.toBe(b); + }); + + const formats: NoiseFormat[] = [ + "hex", "base64", "alphanumeric", "uuid", "api-key", "token", "password", + ]; + for (const fmt of formats) { + it(`format=${fmt} returns a non-empty string`, () => { + expect(generateSecret({ format: fmt }).length).toBeGreaterThan(0); + }); + } +}); + +describe("estimateEntropy", () => { + it("returns 0 for an empty string", () => { + expect(estimateEntropy("")).toBe(0); + }); + + it("returns higher entropy for random strings than repetitive ones", () => { + const low = estimateEntropy("aaaaaaaaaaaa"); + const high = estimateEntropy("aB3$xZ9!qW7@"); + expect(high).toBeGreaterThan(low); + }); + + it("returns a finite positive number for normal input", () => { + const e = estimateEntropy("test-secret-123"); + expect(e).toBeGreaterThan(0); + expect(Number.isFinite(e)).toBe(true); + }); +}); diff --git a/src/__tests__/core/observer.test.ts b/src/__tests__/core/observer.test.ts new file mode 100644 index 0000000..f2764d5 --- /dev/null +++ b/src/__tests__/core/observer.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { + logAudit, + queryAudit, + verifyAuditChain, + exportAudit, + detectAnomalies, +} from "../../core/observer.js"; + +describe("observer / audit log", () => { + it("logAudit does not throw", () => { + expect(() => + logAudit({ + action: "read", + key: "VITEST_KEY", + scope: "q-ring:global", + source: "cli", + }), + ).not.toThrow(); + }); + + it("queryAudit returns an array", () => { + const events = queryAudit(); + expect(Array.isArray(events)).toBe(true); + }); + + it("queryAudit can filter by key", () => { + logAudit({ + action: "write", + key: "VITEST_FILTER_KEY", + scope: "q-ring:global", + source: "cli", + }); + const events = queryAudit({ key: "VITEST_FILTER_KEY" }); + expect(events.length).toBeGreaterThanOrEqual(1); + for (const e of events) { + expect(e.key).toBe("VITEST_FILTER_KEY"); + } + }); + + it("queryAudit can filter by action", () => { + const events = queryAudit({ action: "read" }); + for (const e of events) { + expect(e.action).toBe("read"); + } + }); + + it("queryAudit respects limit", () => { + const events = queryAudit({ limit: 2 }); + expect(events.length).toBeLessThanOrEqual(2); + }); + + it("verifyAuditChain returns a VerifyResult", () => { + const result = verifyAuditChain(); + expect(typeof result.totalEvents).toBe("number"); + expect(typeof result.validEvents).toBe("number"); + expect(typeof result.intact).toBe("boolean"); + }); + + it("exportAudit returns a string", () => { + const output = exportAudit(); + expect(typeof output).toBe("string"); + }); + + it("exportAudit json format returns valid JSON", () => { + const output = exportAudit({ format: "json" }); + expect(() => JSON.parse(output)).not.toThrow(); + }); + + it("detectAnomalies returns an array", () => { + const anomalies = detectAnomalies(); + expect(Array.isArray(anomalies)).toBe(true); + }); +}); diff --git a/src/__tests__/core/policy.test.ts b/src/__tests__/core/policy.test.ts new file mode 100644 index 0000000..b8c5ede --- /dev/null +++ b/src/__tests__/core/policy.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + checkToolPolicy, + checkExecPolicy, + checkKeyReadPolicy, + getPolicySummary, + clearPolicyCache, +} from "../../core/policy.js"; + +describe("policy (no .q-ring.json)", () => { + beforeEach(() => { + clearPolicyCache(); + }); + + it("checkToolPolicy allows all tools when no policy exists", () => { + const decision = checkToolPolicy("get_secret", "/tmp/no-policy"); + expect(decision.allowed).toBe(true); + }); + + it("checkExecPolicy allows all commands when no policy exists", () => { + const decision = checkExecPolicy("node app.js", "/tmp/no-policy"); + expect(decision.allowed).toBe(true); + }); + + it("checkKeyReadPolicy allows all keys when no policy exists", () => { + const decision = checkKeyReadPolicy("ANY_KEY", undefined, "/tmp/no-policy"); + expect(decision.allowed).toBe(true); + }); + + it("getPolicySummary returns no policies", () => { + const summary = getPolicySummary("/tmp/no-policy"); + expect(summary.hasMcpPolicy).toBe(false); + expect(summary.hasExecPolicy).toBe(false); + expect(summary.hasSecretPolicy).toBe(false); + }); +}); diff --git a/src/__tests__/core/scan.test.ts b/src/__tests__/core/scan.test.ts new file mode 100644 index 0000000..3683485 --- /dev/null +++ b/src/__tests__/core/scan.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { scanCodebase } from "../../core/scan.js"; +import { writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const TMP_DIR = join(tmpdir(), `qring-scan-test-${Date.now()}`); + +function setup() { + mkdirSync(TMP_DIR, { recursive: true }); +} + +afterAll(() => { + rmSync(TMP_DIR, { recursive: true, force: true }); +}); + +describe("scanCodebase", () => { + it("returns empty for a directory with no secrets", () => { + setup(); + writeFileSync(join(TMP_DIR, "clean.ts"), 'const x = "hello";\n'); + const results = scanCodebase(TMP_DIR); + expect(results.filter((r) => r.file.includes("clean.ts"))).toHaveLength(0); + }); + + it("detects a hardcoded API key", () => { + setup(); + writeFileSync( + join(TMP_DIR, "leaky.ts"), + 'const api_key = "sk-abcdef1234567890abcdef1234567890";\n', + ); + const results = scanCodebase(TMP_DIR); + const hit = results.find((r) => r.file.includes("leaky.ts")); + expect(hit).toBeDefined(); + expect(hit!.keyName.toLowerCase()).toContain("api_key"); + }); + + it("ignores placeholder values", () => { + setup(); + writeFileSync( + join(TMP_DIR, "placeholder.ts"), + 'const secret = "your_api_key_placeholder_here";\n', + ); + const results = scanCodebase(TMP_DIR); + const hit = results.find((r) => r.file.includes("placeholder.ts")); + expect(hit).toBeUndefined(); + }); + + it("ignores short values", () => { + setup(); + writeFileSync( + join(TMP_DIR, "short.ts"), + 'const token = "abc";\n', + ); + const results = scanCodebase(TMP_DIR); + const hit = results.find((r) => r.file.includes("short.ts")); + expect(hit).toBeUndefined(); + }); + + it("returns an empty array for a nonexistent directory", () => { + const results = scanCodebase("/tmp/does-not-exist-qring"); + expect(results).toEqual([]); + }); + + it("detects ghp_ prefixed tokens", () => { + setup(); + writeFileSync( + join(TMP_DIR, "github.js"), + 'const token = "ghp_abcdef1234567890abcdef12345678901234";\n', + ); + const results = scanCodebase(TMP_DIR); + const hit = results.find((r) => r.file.includes("github.js")); + expect(hit).toBeDefined(); + }); +}); diff --git a/src/__tests__/core/scope.test.ts b/src/__tests__/core/scope.test.ts new file mode 100644 index 0000000..e73550e --- /dev/null +++ b/src/__tests__/core/scope.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { + globalService, + projectService, + teamService, + orgService, + resolveScope, + parseServiceName, +} from "../../core/scope.js"; + +describe("service name helpers", () => { + it("globalService returns q-ring:global", () => { + expect(globalService()).toBe("q-ring:global"); + }); + + it("projectService includes a hash of the path", () => { + const s = projectService("/home/user/project"); + expect(s.startsWith("q-ring:project:")).toBe(true); + expect(s.length).toBeGreaterThan("q-ring:project:".length); + }); + + it("teamService includes the team id", () => { + expect(teamService("team-42")).toBe("q-ring:team:team-42"); + }); + + it("orgService includes the org id", () => { + expect(orgService("org-99")).toBe("q-ring:org:org-99"); + }); +}); + +describe("resolveScope", () => { + it("returns global scope when scope is global", () => { + const result = resolveScope({ scope: "global" }); + expect(result[0].scope).toBe("global"); + }); + + it("returns project scope with projectPath", () => { + const result = resolveScope({ scope: "project", projectPath: "/tmp/p" }); + expect(result[0].scope).toBe("project"); + }); + + it("throws for project scope without projectPath", () => { + expect(() => resolveScope({ scope: "project" })).toThrow(); + }); + + it("throws for team scope without teamId", () => { + expect(() => resolveScope({ scope: "team" })).toThrow(); + }); + + it("throws for org scope without orgId", () => { + expect(() => resolveScope({ scope: "org" })).toThrow(); + }); + + it("builds a cascade when no scope is specified", () => { + const result = resolveScope({ + projectPath: "/tmp/p", + teamId: "t1", + orgId: "o1", + }); + expect(result.length).toBeGreaterThanOrEqual(2); + const scopes = result.map((r) => r.scope); + expect(scopes).toContain("global"); + }); +}); + +describe("parseServiceName", () => { + it("parses a global service string", () => { + const r = parseServiceName("q-ring:global"); + expect(r.scope).toBe("global"); + }); + + it("parses a project service string", () => { + const r = parseServiceName("q-ring:project:abc123"); + expect(r.scope).toBe("project"); + }); + + it("parses a team service string", () => { + const r = parseServiceName("q-ring:team:my-team"); + expect(r.scope).toBe("team"); + }); + + it("falls back to global for unknown patterns", () => { + const r = parseServiceName("unknown-service"); + expect(r.scope).toBe("global"); + }); +}); diff --git a/src/__tests__/core/teleport.test.ts b/src/__tests__/core/teleport.test.ts new file mode 100644 index 0000000..b5d94a3 --- /dev/null +++ b/src/__tests__/core/teleport.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { teleportPack, teleportUnpack } from "../../core/teleport.js"; + +describe("teleport pack/unpack", () => { + const secrets = [ + { key: "API_KEY", value: "sk-abc123", scope: "project" }, + { key: "DB_PASS", value: "p@ssw0rd" }, + ]; + const passphrase = "test-passphrase-42"; + + it("packs secrets into a non-empty base64 string", () => { + const bundle = teleportPack(secrets, passphrase); + expect(bundle.length).toBeGreaterThan(0); + }); + + it("round-trips secrets through pack and unpack", () => { + const bundle = teleportPack(secrets, passphrase); + const payload = teleportUnpack(bundle, passphrase); + expect(payload.secrets).toHaveLength(2); + expect(payload.secrets[0].key).toBe("API_KEY"); + expect(payload.secrets[0].value).toBe("sk-abc123"); + expect(payload.secrets[1].key).toBe("DB_PASS"); + expect(payload.secrets[1].value).toBe("p@ssw0rd"); + expect(payload.exportedAt).toBeTruthy(); + }); + + it("preserves scope metadata", () => { + const bundle = teleportPack(secrets, passphrase); + const payload = teleportUnpack(bundle, passphrase); + expect(payload.secrets[0].scope).toBe("project"); + expect(payload.secrets[1].scope).toBeUndefined(); + }); + + it("throws on wrong passphrase", () => { + const bundle = teleportPack(secrets, passphrase); + expect(() => teleportUnpack(bundle, "wrong-password")).toThrow(); + }); + + it("throws on corrupted bundle", () => { + expect(() => teleportUnpack("not-a-bundle", passphrase)).toThrow(); + }); + + it("handles empty secrets array", () => { + const bundle = teleportPack([], passphrase); + const payload = teleportUnpack(bundle, passphrase); + expect(payload.secrets).toHaveLength(0); + }); +}); diff --git a/src/__tests__/core/tunnel.test.ts b/src/__tests__/core/tunnel.test.ts new file mode 100644 index 0000000..30cc349 --- /dev/null +++ b/src/__tests__/core/tunnel.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { + tunnelCreate, + tunnelRead, + tunnelDestroy, + tunnelList, +} from "../../core/tunnel.js"; + +describe("tunnel lifecycle", () => { + it("creates a tunnel and returns an id starting with tun_", () => { + const id = tunnelCreate("secret-val"); + expect(id.startsWith("tun_")).toBe(true); + }); + + it("reads back the tunneled value", () => { + const id = tunnelCreate("read-me"); + expect(tunnelRead(id)).toBe("read-me"); + }); + + it("returns null for a non-existent tunnel id", () => { + expect(tunnelRead("tun_nonexistent")).toBeNull(); + }); + + it("destroys a tunnel and subsequent reads return null", () => { + const id = tunnelCreate("destroy-me"); + expect(tunnelDestroy(id)).toBe(true); + expect(tunnelRead(id)).toBeNull(); + }); + + it("returns false when destroying a non-existent tunnel", () => { + expect(tunnelDestroy("tun_nope")).toBe(false); + }); + + it("lists active tunnels", () => { + const id = tunnelCreate("listed"); + const list = tunnelList(); + const found = list.find((t) => t.id === id); + expect(found).toBeDefined(); + expect(found!.accessCount).toBe(0); + }); + + it("respects maxReads and self-destructs", () => { + const id = tunnelCreate("once", { maxReads: 1 }); + expect(tunnelRead(id)).toBe("once"); + expect(tunnelRead(id)).toBeNull(); + }); + + it("tracks accessCount across reads", () => { + const id = tunnelCreate("counted", { maxReads: 5 }); + tunnelRead(id); + tunnelRead(id); + const list = tunnelList(); + const entry = list.find((t) => t.id === id); + expect(entry?.accessCount).toBe(2); + }); +}); diff --git a/src/__tests__/mcp/server.test.ts b/src/__tests__/mcp/server.test.ts new file mode 100644 index 0000000..957f76c --- /dev/null +++ b/src/__tests__/mcp/server.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import { createMcpServer } from "../../mcp/server.js"; + +const EXPECTED_TOOLS = [ + "get_secret", + "list_secrets", + "set_secret", + "delete_secret", + "has_secret", + "export_secrets", + "import_dotenv", + "check_project", + "env_generate", + "inspect_secret", + "detect_environment", + "generate_secret", + "entangle_secrets", + "disentangle_secrets", + "tunnel_create", + "tunnel_read", + "tunnel_list", + "tunnel_destroy", + "teleport_pack", + "teleport_unpack", + "audit_log", + "detect_anomalies", + "health_check", + "validate_secret", + "list_providers", + "register_hook", + "list_hooks", + "remove_hook", + "exec_with_secrets", + "scan_codebase_for_secrets", + "get_project_context", + "agent_remember", + "agent_recall", + "agent_forget", + "lint_files", + "analyze_secrets", + "status_dashboard", + "agent_scan", + "verify_audit_chain", + "export_audit", + "rotate_secret", + "ci_validate_secrets", + "check_policy", + "get_policy_summary", +] as const; + +function getRegisteredToolNames(server: any): string[] { + for (const key of Object.getOwnPropertyNames(server)) { + const val = server[key]; + if (val instanceof Map) { + const firstEntry = val.values().next().value; + if (firstEntry && typeof firstEntry === "object" && "description" in firstEntry) { + return [...val.keys()]; + } + } + if (val && typeof val === "object" && !(val instanceof Map) && !Array.isArray(val)) { + const subKeys = Object.keys(val); + if (subKeys.length > 10 && subKeys.includes("get_secret")) { + return subKeys; + } + } + } + return []; +} + +describe("createMcpServer", () => { + it("returns an McpServer instance", () => { + const server = createMcpServer(); + expect(server).toBeDefined(); + }); + + it(`registers all ${EXPECTED_TOOLS.length} expected tools`, () => { + const server = createMcpServer(); + const names = getRegisteredToolNames(server); + + if (names.length === 0) { + const allKeys = Object.getOwnPropertyNames(server); + expect.soft(names.length, `Could not find tools registry. Server keys: ${allKeys.join(", ")}`).toBeGreaterThan(0); + return; + } + + expect(names.length).toBe(EXPECTED_TOOLS.length); + + for (const name of EXPECTED_TOOLS) { + expect(names, `Missing tool: ${name}`).toContain(name); + } + }); + + it("has no unexpected tools registered", () => { + const server = createMcpServer(); + const names = getRegisteredToolNames(server); + + if (names.length === 0) return; + + for (const name of names) { + expect( + (EXPECTED_TOOLS as readonly string[]).includes(name), + `Unexpected tool: ${name}`, + ).toBe(true); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..f4c22f3 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/__tests__/**/*.test.ts"], + environment: "node", + testTimeout: 10_000, + }, +}); From 36d0bf35f186a4d967006e585d282bac03c39c1e Mon Sep 17 00:00:00 2001 From: I4cTime Date: Wed, 25 Mar 2026 20:12:33 +0800 Subject: [PATCH 2/6] chore: bump version to v0.9.4 (#31) Made-with: Cursor --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- server.json | 4 ++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b004ed8..f8dfdf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## [0.9.4] — 2026-03-25 + +### Added +- **Vitest test suite** — 125 tests across 17 files covering core modules (noise, envelope, tunnel, teleport, entanglement, scan, linter, memory, hooks, approval, observer, policy, collapse, scope), CLI command registration, and MCP tool registration (all 44 tools verified). +- **CI test step** — `pnpm run test:ci` added to `ci.yml` workflow after the build step. +- **Homebrew tap** — `I4cTime/homebrew-tap` repo with `Formula/qring.rb` for `brew tap I4cTime/tap && brew install qring`. +- **Homebrew auto-update workflow** — `update-homebrew.yml` triggers on release, waits for npm availability, computes sha256, and pushes the updated formula automatically. + ## [0.9.3] — 2026-03-25 ### Changed diff --git a/package.json b/package.json index e5f2e1d..260ad52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@i4ctime/q-ring", - "version": "0.9.3", + "version": "0.9.4", "mcpName": "io.github.I4cTime/q-ring", "description": "Quantum keyring for AI coding tools — Cursor, Kiro, Claude Code. Secrets, superposition, entanglement, MCP.", "type": "module", diff --git a/server.json b/server.json index 2495ad0..83ed757 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/I4cTime/quantum_ring", "source": "github" }, - "version": "0.9.3", + "version": "0.9.4", "packages": [ { "registryType": "npm", "identifier": "@i4ctime/q-ring", - "version": "0.9.3", + "version": "0.9.4", "transport": { "type": "stdio" } From d740e1da5d90ebb42dc31c97dfef1bdb70f7eecc Mon Sep 17 00:00:00 2001 From: I4cTime Date: Thu, 26 Mar 2026 21:20:00 +0800 Subject: [PATCH 3/6] feat: Cursor marketplace plugin + Homebrew/plugin docs (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Cursor marketplace plugin and update README/web with Homebrew + plugin info - Create cursor-plugin/ with plugin.json manifest, 3 rules, 4 skills, 2 agents, 5 commands, hooks.json, .mcp.json, and README - Add .cursor-plugin/marketplace.json at repo root for monorepo discovery - Update README.md with Homebrew install option and Cursor Plugin section - Add Homebrew tab to web Hero and docs install commands - Create CursorPlugin.tsx homepage section component - Add Plugin nav link, update Footer version to v0.9.4 - Add Cursor Plugin step to docs page - Remove beforeShellExecution hook (causes circular block with Cursor metadata) Made-with: Cursor * fix: resolve picomatch audit + update changelogs for v0.9.5 - Add pnpm override for picomatch >=4.0.4 (ReDoS + method injection) - Add v0.9.5 entry to CHANGELOG.md (Cursor plugin, Homebrew docs, audit fix) - Sync web changelog with v0.9.2–v0.9.5 entries Made-with: Cursor --- .cursor-plugin/marketplace.json | 28 ++++ CHANGELOG.md | 21 +++ README.md | 24 ++- cursor-plugin/.cursor-plugin/plugin.json | 24 +++ cursor-plugin/.mcp.json | 9 ++ cursor-plugin/README.md | 102 +++++++++++++ cursor-plugin/agents/security-auditor.md | 46 ++++++ cursor-plugin/commands/health-check.md | 33 +++++ cursor-plugin/commands/rotate-expired.md | 30 ++++ cursor-plugin/commands/setup-project.md | 42 ++++++ cursor-plugin/hooks/hooks.json | 15 ++ cursor-plugin/rules/env-file-safety.mdc | 13 ++ cursor-plugin/rules/qring-workflow.mdc | 13 ++ .../skills/project-onboarding/SKILL.md | 85 +++++++++++ package.json | 5 + pnpm-lock.yaml | 23 +-- web/app/changelog/page.tsx | 39 +++++ web/app/docs/page.tsx | 56 +++++++ web/app/page.tsx | 2 + web/components/CursorPlugin.tsx | 138 ++++++++++++++++++ web/components/Footer.tsx | 2 +- web/components/Hero.tsx | 1 + web/components/Nav.tsx | 1 + 23 files changed, 738 insertions(+), 14 deletions(-) create mode 100644 .cursor-plugin/marketplace.json create mode 100644 cursor-plugin/.cursor-plugin/plugin.json create mode 100644 cursor-plugin/.mcp.json create mode 100644 cursor-plugin/README.md create mode 100644 cursor-plugin/agents/security-auditor.md create mode 100644 cursor-plugin/commands/health-check.md create mode 100644 cursor-plugin/commands/rotate-expired.md create mode 100644 cursor-plugin/commands/setup-project.md create mode 100644 cursor-plugin/hooks/hooks.json create mode 100644 cursor-plugin/rules/env-file-safety.mdc create mode 100644 cursor-plugin/rules/qring-workflow.mdc create mode 100644 cursor-plugin/skills/project-onboarding/SKILL.md create mode 100644 web/components/CursorPlugin.tsx diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..674ba08 --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,28 @@ +{ + "name": "i4ctime-plugins", + "owner": { + "name": "I4cTime", + "email": "publiclaststewart@gmail.com" + }, + "metadata": { + "description": "Cursor plugins by I4cTime — quantum-secured secret management for AI agents" + }, + "plugins": [ + { + "name": "qring", + "source": "cursor-plugin", + "description": "Quantum keyring for AI agents — manage secrets, scan for leaks, rotate keys, and enforce policy directly from Cursor.", + "version": "1.0.0", + "keywords": [ + "secrets", + "keyring", + "security", + "mcp", + "api-keys", + "dotenv", + "credential-management" + ], + "logo": "https://raw.githubusercontent.com/I4cTime/quantum_ring/main/web/public/assets/logo.png" + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index f8dfdf2..52c1fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to this project will be documented in this file. +## [0.9.5] — 2026-03-26 + +### Added +- **Cursor marketplace plugin** — `cursor-plugin/` with 3 rules, 4 skills, 2 agents, 5 commands, 2 hooks, MCP connector, and marketplace manifest. Surfaces all 44 MCP tools through IDE-native components. +- **Marketplace discovery** — `.cursor-plugin/marketplace.json` at repo root for monorepo-based plugin resolution. +- **Web: Cursor Plugin section** — homepage component with animated cards for all plugin components. +- **Web: Plugin nav link** — "Plugin" added to site navigation. +- **Web: Homebrew install tabs** — Homebrew option added to Hero and docs install commands. +- **README: Cursor Plugin section** — table summarizing all plugin components with marketplace install instructions. +- **README: Homebrew install** — `brew install i4ctime/tap/qring` added to Installation section. + +### Changed +- **Web: docs page** — Step 4 added for Cursor Plugin with component grid and manual install terminal. +- **Web: Footer** — version updated from v0.9.1 to v0.9.4. + +### Fixed +- **beforeShellExecution hook removed** — Cursor injects base64 metadata into hook commands, causing a circular block. Shell command warnings moved to the `secret-hygiene` rule instead. + +### Security +- **picomatch >=4.0.4** — pnpm override added to resolve ReDoS vulnerability (GHSA-c2c7-rcm5-vvqj) and method injection (GHSA-3v7f-55p6-f55p) in `tsup > tinyglobby > picomatch`. + ## [0.9.4] — 2026-03-25 ### Added diff --git a/README.md b/README.md index 3c5a2d1..3188aaa 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,17 @@ Stop pasting API keys into plain-text `.env` files or wrestling with clunky secr q-ring is designed to be installed globally so it's available anywhere in your terminal. Pick your favorite package manager: ```bash -# npm -npm install -g @i4ctime/q-ring - # pnpm (recommended) pnpm add -g @i4ctime/q-ring +# npm +npm install -g @i4ctime/q-ring + # yarn yarn global add @i4ctime/q-ring + +# Homebrew (macOS / Linux) +brew install i4ctime/tap/qring ``` ## ⚡ Quick Start @@ -794,6 +797,21 @@ Add to `~/.claude/claude_desktop_config.json`: } ``` +## Cursor Plugin + +The **q-ring Cursor Plugin** brings quantum secret management directly into your IDE with rules, skills, agents, commands, hooks, and a built-in MCP connector. + +| Component | What it does | +|-----------|-------------| +| **3 Rules** | Always-on guidance: never hardcode secrets, use q-ring for all ops, warn about `.env` files | +| **4 Skills** | Auto-triggered by context: secret management, scanning, rotation, project onboarding | +| **2 Agents** | `security-auditor` (proactive monitoring) and `secret-ops` (day-to-day assistant) | +| **5 Commands** | `/qring:scan-secrets`, `/qring:health-check`, `/qring:rotate-expired`, `/qring:setup-project`, `/qring:teleport-secrets` | +| **2 Hooks** | `afterFileEdit` (lint scan), `sessionStart` (project context) | +| **MCP Connector** | Auto-connects to `qring-mcp` via stdio — all 44 tools available | + +Install from the Cursor marketplace or see [`cursor-plugin/README.md`](cursor-plugin/README.md) for manual setup. + ## Architecture ``` diff --git a/cursor-plugin/.cursor-plugin/plugin.json b/cursor-plugin/.cursor-plugin/plugin.json new file mode 100644 index 0000000..5c93ea0 --- /dev/null +++ b/cursor-plugin/.cursor-plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "name": "qring", + "description": "Quantum keyring for AI agents — manage secrets, scan for leaks, rotate keys, and enforce policy directly from Cursor.", + "version": "1.0.0", + "author": { + "name": "I4cTime" + }, + "homepage": "https://qring.i4c.studio", + "repository": "https://github.com/I4cTime/quantum_ring", + "license": "AGPL-3.0", + "keywords": [ + "secrets", + "keyring", + "security", + "mcp", + "api-keys", + "dotenv", + "credential-management", + "secret-scanning", + "secret-rotation", + "governance" + ], + "logo": "https://raw.githubusercontent.com/I4cTime/quantum_ring/main/web/public/assets/logo.png" +} diff --git a/cursor-plugin/.mcp.json b/cursor-plugin/.mcp.json new file mode 100644 index 0000000..a90cfef --- /dev/null +++ b/cursor-plugin/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "q-ring": { + "command": "qring-mcp", + "args": [], + "env": {} + } + } +} diff --git a/cursor-plugin/README.md b/cursor-plugin/README.md new file mode 100644 index 0000000..5f7ad7e --- /dev/null +++ b/cursor-plugin/README.md @@ -0,0 +1,102 @@ +# q-ring Cursor Plugin + +Quantum keyring for AI agents — manage secrets, scan for leaks, rotate keys, and enforce policy directly from Cursor. + +## Prerequisites + +Install the q-ring CLI and MCP server globally: + +```bash +# npm +npm install -g @i4ctime/q-ring + +# Homebrew +brew install i4ctime/tap/qring +``` + +Verify the MCP server is available: + +```bash +qring-mcp --help +``` + +## What's Included + +### Rules (always active) + +| Rule | Purpose | +|------|---------| +| `secret-hygiene` | Never hardcode secrets — always use q-ring | +| `qring-workflow` | Use q-ring MCP tools for all secret operations | +| `env-file-safety` | Warn about `.env` files, suggest importing into q-ring | + +### Skills (auto-triggered by context) + +| Skill | Triggers On | +|-------|-------------| +| `secret-management` | Mentions of secrets, API keys, tokens, credentials, `.env` files | +| `secret-scanning` | Requests to scan, lint, audit, or find leaked credentials | +| `secret-rotation` | Expired keys, rotation, validation, CI checks | +| `project-onboarding` | New project setup, manifests, environment detection | + +### Agents (specialized personas) + +| Agent | Purpose | +|-------|---------| +| `security-auditor` | Proactive security monitoring — audit trails, anomalies, governance | +| `secret-ops` | Day-to-day secret management — store, share, organize | + +### Commands (explicit invocations) + +| Command | Action | +|---------|--------| +| `/qring:scan-secrets` | Scan codebase for hardcoded secrets, offer auto-fix | +| `/qring:health-check` | Full health report — decay, anomalies, audit integrity | +| `/qring:rotate-expired` | Find and rotate expired/stale credentials | +| `/qring:setup-project` | Initialize q-ring — manifest, imports, hooks | +| `/qring:teleport-secrets` | Encrypted cross-machine secret transfer | + +### Hooks (automatic) + +| Event | Behavior | +|-------|----------| +| `afterFileEdit` | Scan edited files for hardcoded secrets | +| `sessionStart` | Load project context and note expired secrets | + +### MCP Server + +The plugin connects to the `qring-mcp` server via stdio transport, providing access to all 44 q-ring MCP tools for secret management, scanning, rotation, auditing, and governance. + +## Installation + +Install from the Cursor marketplace, or clone the plugin directory: + +```bash +# From the quantum_ring repo +cp -r cursor-plugin/ ~/.cursor/plugins/qring/ +``` + +## Configuration + +The plugin works out of the box with default settings. For project-specific configuration, create a `.q-ring.json` manifest in your project root: + +```json +{ + "env": "dev", + "defaultEnv": "dev", + "branchMap": { + "main": "prod", + "develop": "dev" + }, + "secrets": { + "DATABASE_URL": { "required": true, "description": "PostgreSQL connection string" }, + "API_KEY": { "required": true, "provider": "openai" } + } +} +``` + +Or run `/qring:setup-project` to create one interactively. + +## License + +MIT — Part of the [q-ring](https://github.com/I4cTime/quantum_ring) project. diff --git a/cursor-plugin/agents/security-auditor.md b/cursor-plugin/agents/security-auditor.md new file mode 100644 index 0000000..cc8bc86 --- /dev/null +++ b/cursor-plugin/agents/security-auditor.md @@ -0,0 +1,46 @@ +--- +name: security-auditor +description: Proactive security monitoring agent that audits secret access, verifies integrity, detects anomalies, and enforces governance policy. +--- + +# Security Auditor + +You are a security-focused agent for q-ring. Your job is to proactively monitor the health and integrity of the project's secret management. + +## Capabilities + +You have access to these q-ring MCP tools: + +- `health_check` — assess decay, staleness, and anomalies across all secrets +- `audit_log` — query the tamper-evident audit trail (filter by key, action, source, time) +- `verify_audit_chain` — verify the SHA-256 hash chain has not been tampered with +- `detect_anomalies` — flag burst reads, unusual-hour access, new sources, and tampering +- `scan_codebase_for_secrets` — scan the project for hardcoded credentials +- `check_policy` — verify an action is allowed by governance policy +- `get_policy_summary` — show the full policy configuration +- `export_audit` — export audit events as JSONL, JSON, or CSV + +## Behavior + +1. **Start with a health check.** Call `health_check` to get the overall status. Report expired, stale, and healthy counts. + +2. **Verify audit integrity.** Call `verify_audit_chain` to confirm the hash chain is intact. If broken, report the break point and affected event. + +3. **Detect anomalies.** Call `detect_anomalies` to find suspicious patterns: + - **Burst reads** — many reads of the same key in a short window + - **Unusual-hour access** — reads outside normal working hours + - **New source** — access from a previously unseen source + - **Tampering** — audit entries with invalid hashes + +4. **Scan for leaks.** Call `scan_codebase_for_secrets` on the project directory and report any hardcoded credentials found. + +5. **Check governance.** If a `.q-ring.json` policy exists, call `get_policy_summary` and verify that denied tools, keys, and tags are properly configured. + +6. **Generate a report.** Summarize findings with counts and severity levels: + - Critical: tampered audit chain, hardcoded secrets, expired credentials + - Warning: stale secrets, anomalous access patterns + - Info: healthy secret counts, policy status + +## Tone + +Be direct and factual. Present findings as a structured report. Recommend specific remediation actions for each issue found. diff --git a/cursor-plugin/commands/health-check.md b/cursor-plugin/commands/health-check.md new file mode 100644 index 0000000..7e8638d --- /dev/null +++ b/cursor-plugin/commands/health-check.md @@ -0,0 +1,33 @@ +--- +name: health-check +description: Run a comprehensive health check on all secrets — decay, anomalies, and audit integrity. +--- + +# Health Check + +Run a full health assessment of all secrets managed by q-ring. + +## Usage + +Invoke via `/qring:health-check` + +## Workflow + +1. Call `health_check` to assess all secrets: + - Count healthy, stale (>75% lifetime), and expired secrets + - Note any secrets without decay tracking + +2. Call `detect_anomalies` to check for suspicious access patterns: + - Burst reads on a single key + - Access at unusual hours + - Access from new/unknown sources + +3. Call `verify_audit_chain` to confirm the audit log has not been tampered with: + - Report total events and valid count + - If broken, show the break point + +4. Present a summary report: + - Secrets: X healthy, Y stale, Z expired + - Anomalies: count and type + - Audit chain: intact or broken at event N + - Recommendations for any issues found diff --git a/cursor-plugin/commands/rotate-expired.md b/cursor-plugin/commands/rotate-expired.md new file mode 100644 index 0000000..938e47d --- /dev/null +++ b/cursor-plugin/commands/rotate-expired.md @@ -0,0 +1,30 @@ +--- +name: rotate-expired +description: Find expired and stale secrets and attempt automatic rotation via their providers. +--- + +# Rotate Expired Secrets + +Find all expired or stale secrets and attempt to rotate them. + +## Usage + +Invoke via `/qring:rotate-expired` + +## Workflow + +1. Call `health_check` to identify expired and stale secrets. + +2. If no expired or stale secrets exist, report that everything is healthy. + +3. For each expired secret: + a. Call `validate_secret` to confirm it is actually invalid + b. Call `rotate_secret` to attempt provider-native rotation + c. Report the result: rotated (with provider name) or failed (with reason) + +4. For stale secrets (>75% lifetime), list them as warnings with remaining time. + +5. Present a summary: + - Rotated: N secrets successfully refreshed + - Failed: N secrets could not be rotated (manual intervention needed) + - Stale: N secrets approaching expiry diff --git a/cursor-plugin/commands/setup-project.md b/cursor-plugin/commands/setup-project.md new file mode 100644 index 0000000..9d5a71a --- /dev/null +++ b/cursor-plugin/commands/setup-project.md @@ -0,0 +1,42 @@ +--- +name: setup-project +description: Initialize q-ring for a project — detect environment, create manifest, import .env, set up hooks. +--- + +# Setup Project + +Initialize q-ring for the current project with a manifest, environment detection, and secret import. + +## Usage + +Invoke via `/qring:setup-project` + +## Workflow + +1. Call `detect_environment` to determine the current environment context (dev, staging, prod). + +2. Check if a `.q-ring.json` manifest already exists: + - If yes, call `check_project` to validate it and report missing or expired secrets + - If no, guide the user through creating one + +3. Create a `.q-ring.json` manifest with: + - `env` and `defaultEnv` set to the detected environment + - `branchMap` mapping git branches to environments + - `secrets` declaring required credentials with descriptions and providers + - Optional `policy` section for governance + +4. Check for existing `.env` files: + - If found, offer to import via `import_dotenv` with `skipExisting: true` + - Verify `.env` is in `.gitignore` + +5. Call `env_generate` to produce a `.env` from the manifest. + +6. Offer to register hooks via `register_hook`: + - Shell hooks for restarting services on credential changes + - HTTP hooks for deployment notifications + +7. Summarize what was set up: + - Manifest location and declared secrets + - Imported secrets count + - Registered hooks + - Detected environment diff --git a/cursor-plugin/hooks/hooks.json b/cursor-plugin/hooks/hooks.json new file mode 100644 index 0000000..2fa0e0b --- /dev/null +++ b/cursor-plugin/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "hooks": { + "afterFileEdit": [ + { + "command": "Scan the edited file for hardcoded secrets using the lint_files MCP tool. If secrets are found, warn the user and offer to auto-fix." + } + ], + "sessionStart": [ + { + "command": "Call get_project_context via the q-ring MCP to understand the project's secret landscape. Silently note any expired or stale secrets for later mention." + } + ] + } +} diff --git a/cursor-plugin/rules/env-file-safety.mdc b/cursor-plugin/rules/env-file-safety.mdc new file mode 100644 index 0000000..72c1e30 --- /dev/null +++ b/cursor-plugin/rules/env-file-safety.mdc @@ -0,0 +1,13 @@ +--- +description: Warn about .env files and suggest importing into q-ring for secure management. +globs: "**/.env*" +--- + +# .env File Safety + +When a `.env` file is open or being edited: + +1. **Suggest importing** — offer to run `import_dotenv` to migrate all key-value pairs into q-ring where they are encrypted in the OS keychain. +2. **Check .gitignore** — verify that `.env*` patterns are in `.gitignore`. If not, warn the user immediately. +3. **Prefer manifest-driven generation** — if the project has a `.q-ring.json` manifest, suggest using `env_generate` to produce `.env` files on-demand from q-ring instead of maintaining them manually. +4. **Never add secrets** to `.env` files directly. Use `qring set KEY value` and then `qring env:generate` to produce the file. diff --git a/cursor-plugin/rules/qring-workflow.mdc b/cursor-plugin/rules/qring-workflow.mdc new file mode 100644 index 0000000..caa1577 --- /dev/null +++ b/cursor-plugin/rules/qring-workflow.mdc @@ -0,0 +1,13 @@ +--- +description: Use q-ring MCP tools for all secret operations — never access the OS keychain directly. +alwaysApply: true +--- + +# q-ring Workflow + +- Use q-ring MCP tools (`get_secret`, `set_secret`, `list_secrets`, etc.) for **all** secret operations. Never call OS keychain APIs directly. +- Before working with a project's secrets for the first time, call `get_project_context` to understand what secrets exist, their scopes, and any governance policy. +- If the project has a `.q-ring.json` file, call `check_policy` before performing tool or exec actions to respect governance rules. +- After writing or deleting a secret, remind the user that q-ring maintains a tamper-evident audit trail accessible via `audit_log`. +- For ephemeral values (one-time tokens, OTPs), use `tunnel_create` instead of `set_secret` — tunnels are memory-only and self-destruct. +- For sharing secrets across machines, use `teleport_pack` / `teleport_unpack` — never paste raw credentials into chat or files. diff --git a/cursor-plugin/skills/project-onboarding/SKILL.md b/cursor-plugin/skills/project-onboarding/SKILL.md new file mode 100644 index 0000000..732d60a --- /dev/null +++ b/cursor-plugin/skills/project-onboarding/SKILL.md @@ -0,0 +1,85 @@ +--- +name: project-onboarding +description: Set up q-ring for a new project — create manifests, detect environments, import secrets, configure hooks and policy. Use when the user starts a new project, mentions .q-ring.json, project manifest, environment detection, or initial secret setup. +--- + +# Project Onboarding + +## When to Use + +Activate when the user: +- Starts a new project and needs secret management +- Asks about `.q-ring.json` configuration +- Wants to detect or configure environments +- Needs to set up hooks for secret change notifications +- Asks about governance policy + +## Workflow + +### 1. Detect environment + +Call `detect_environment` to determine the current context. Sources checked in order: +1. Explicit `QRING_ENV` +2. `NODE_ENV` (mapped: `production` -> `prod`, `development` -> `dev`) +3. Git branch + `.q-ring.json` `branchMap` (supports globs like `release/*`) +4. `.q-ring.json` `defaultEnv` + +### 2. Check existing state + +Call `check_project` to validate against any existing `.q-ring.json` manifest. This reports: +- Required secrets that are present, missing, expired, or stale +- Overall project readiness + +### 3. Create or update the manifest + +Help the user create a `.q-ring.json` file with: + +```json +{ + "env": "dev", + "defaultEnv": "dev", + "branchMap": { + "main": "prod", + "develop": "dev", + "release/*": "staging" + }, + "secrets": { + "DATABASE_URL": { "required": true, "description": "PostgreSQL connection string" }, + "API_KEY": { "required": true, "provider": "openai", "description": "OpenAI API key" } + }, + "policy": { + "mcp": { "denyTools": ["exec_with_secrets"] }, + "secrets": { "maxTtlSeconds": 2592000 } + } +} +``` + +### 4. Import existing secrets + +If the project has `.env` files, offer to import them with `import_dotenv`. Use `skipExisting: true` to avoid overwriting. + +### 5. Generate .env from manifest + +Call `env_generate` to produce a `.env` file from the manifest with all declared secrets resolved from q-ring. + +### 6. Set up hooks + +Call `register_hook` to set up notifications when secrets change: +- Shell hook: restart dev server on DB credential change +- HTTP hook: notify a deployment webhook +- Signal hook: send SIGHUP to a running process + +### 7. Configure policy + +Guide the user through the `policy` section of `.q-ring.json`: +- **mcp**: `allowTools` / `denyTools`, `readableKeys` / `deniedKeys`, `deniedTags` +- **exec**: `allowCommands` / `denyCommands`, `maxRuntimeSeconds`, `allowNetwork` +- **secrets**: `requireApprovalForTags`, `maxTtlSeconds` + +Call `get_policy_summary` to verify the policy is loaded correctly. + +## Best Practices + +- Always create a `.q-ring.json` manifest for team projects — it serves as documentation and validation +- Use `branchMap` to automatically detect environments from git branches +- Set `required: true` on critical secrets so `check_project` catches missing credentials diff --git a/package.json b/package.json index 260ad52..dca2ab8 100644 --- a/package.json +++ b/package.json @@ -55,5 +55,10 @@ "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^4.1.1" + }, + "pnpm": { + "overrides": { + "picomatch": ">=4.0.4" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e072d2..c676580 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + picomatch: '>=4.0.4' + importers: .: @@ -793,7 +796,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: ^3 || ^4 + picomatch: '>=4.0.4' peerDependenciesMeta: picomatch: optional: true @@ -1044,8 +1047,8 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pirates@4.0.7: @@ -1953,9 +1956,9 @@ snapshots: fast-uri@3.1.0: {} - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 finalhandler@2.1.1: dependencies: @@ -2155,7 +2158,7 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} pirates@4.0.7: {} @@ -2366,8 +2369,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinyrainbow@3.1.0: {} @@ -2427,7 +2430,7 @@ snapshots: vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.4): dependencies: lightningcss: 1.32.0 - picomatch: 4.0.3 + picomatch: 4.0.4 postcss: 8.5.8 rolldown: 1.0.0-rc.11 tinyglobby: 0.2.15 @@ -2450,7 +2453,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.4 diff --git a/web/app/changelog/page.tsx b/web/app/changelog/page.tsx index d42bfda..164e4c1 100644 --- a/web/app/changelog/page.tsx +++ b/web/app/changelog/page.tsx @@ -16,6 +16,45 @@ interface ChangelogEntry { } const changelog: ChangelogEntry[] = [ + { + version: "0.9.5", + date: "2026-03-26", + highlights: [ + { type: "added", text: "Cursor marketplace plugin — 3 rules, 4 skills, 2 agents, 5 commands, 2 hooks, MCP connector. All 44 tools surfaced through IDE-native components" }, + { type: "added", text: "Marketplace discovery — `.cursor-plugin/marketplace.json` for monorepo-based plugin resolution" }, + { type: "added", text: "Web: Cursor Plugin homepage section, plugin nav link, Homebrew install tabs in Hero and docs" }, + { type: "added", text: "README: Cursor Plugin section and Homebrew install option" }, + { type: "fixed", text: "Removed beforeShellExecution hook — caused circular block with Cursor metadata injection" }, + { type: "security", text: "picomatch >=4.0.4 override — resolves ReDoS and method injection vulnerabilities in tsup > tinyglobby > picomatch" }, + ], + }, + { + version: "0.9.4", + date: "2026-03-25", + highlights: [ + { type: "added", text: "Vitest test suite — 125 tests across 17 files covering core, CLI, and MCP (all 44 tools verified)" }, + { type: "added", text: "CI test step — `pnpm run test:ci` added to ci.yml workflow" }, + { type: "added", text: "Homebrew tap — `brew install i4ctime/tap/qring` with auto-update workflow on release" }, + ], + }, + { + version: "0.9.3", + date: "2026-03-25", + highlights: [ + { type: "changed", text: "Custom domain — site now served at `qring.i4c.studio`" }, + { type: "changed", text: "Funding — Ko-fi slug updated; favicon metadata added" }, + { type: "changed", text: "Deploy workflow — CNAME file persists across gh-pages deploys" }, + ], + }, + { + version: "0.9.2", + date: "2026-03-24", + highlights: [ + { type: "changed", text: "README — improved intro, fixed badges, corrected MCP tool count from 31 to 44" }, + { type: "changed", text: "Docs page — added descriptions to every CLI command" }, + { type: "changed", text: "Repo settings — CODEOWNERS, branch rulesets, disabled default CodeQL" }, + ], + }, { version: "0.9.1", date: "2026-03-24", diff --git a/web/app/docs/page.tsx b/web/app/docs/page.tsx index 00b27a4..043ccae 100644 --- a/web/app/docs/page.tsx +++ b/web/app/docs/page.tsx @@ -16,6 +16,7 @@ const installCmds = [ { pm: "npm", cmd: "npm install -g @i4ctime/q-ring" }, { pm: "yarn", cmd: "yarn global add @i4ctime/q-ring" }, { pm: "bun", cmd: "bun add -g @i4ctime/q-ring" }, + { pm: "brew", cmd: "brew install i4ctime/tap/qring" }, ]; const cliReference = [ @@ -640,6 +641,61 @@ export default function DocsPage() { + {/* Step 4: Cursor Plugin */} + +
+

+ + 4 + + Cursor Plugin +

+

+ The q-ring Cursor Plugin brings + quantum secret management directly into your IDE. Install from the + Cursor marketplace or copy manually: +

+ +
+
+
+

3 Rules

+

Always-on guidance for secret hygiene, q-ring workflow, and .env safety

+
+
+

4 Skills

+

Auto-triggered: secret management, scanning, rotation, onboarding

+
+
+

2 Agents

+

Security auditor and day-to-day secret ops assistant

+
+
+

5 Commands

+

Scan, health-check, rotate, setup, and teleport

+
+
+

2 Hooks

+

After file edit and session start automation

+
+
+

MCP Connector

+

Auto-connects to qring-mcp — all 44 tools available

+
+
+
+ + +
+                  # From the quantum_ring repo
+                  {"\n"}
+                  ${" "}
+                  cp -r cursor-plugin/ ~/.cursor/plugins/qring/
+                
+
+
+
+

CLI Complete Reference diff --git a/web/app/page.tsx b/web/app/page.tsx index 87b1bbd..54d3d93 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -6,6 +6,7 @@ import QuickStart from "@/components/QuickStart"; import McpSection from "@/components/McpSection"; import Architecture from "@/components/Architecture"; import AgentMode from "@/components/AgentMode"; +import CursorPlugin from "@/components/CursorPlugin"; import Dashboard from "@/components/Dashboard"; import Footer from "@/components/Footer"; import WebGLBackground from "@/components/WebGLBackground"; @@ -23,6 +24,7 @@ export default function Home() { +