From 9b336e9a3d35dd708586ca8bda6a405007644c62 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Sun, 10 May 2026 10:48:47 +0200 Subject: [PATCH] :sparkles: Add nrepl-eval script and skill --- .opencode/skills/nrepl-eval/SKILL.md | 120 +++++++++++++ backend/src/app/main.clj | 5 +- package.json | 7 +- pnpm-lock.yaml | 196 +++++++++++--------- tools/nrepl-eval.mjs | 259 +++++++++++++++++++++++++++ 5 files changed, 494 insertions(+), 93 deletions(-) create mode 100644 .opencode/skills/nrepl-eval/SKILL.md create mode 100755 tools/nrepl-eval.mjs diff --git a/.opencode/skills/nrepl-eval/SKILL.md b/.opencode/skills/nrepl-eval/SKILL.md new file mode 100644 index 0000000000..2cf44d88c7 --- /dev/null +++ b/.opencode/skills/nrepl-eval/SKILL.md @@ -0,0 +1,120 @@ +--- +name: nrepl-eval +description: Evaluate Clojure code via nREPL using the standalone tools/nrepl-eval.mjs CLI tool. +--- + +# nREPL Eval + +Evaluate Clojure (or ClojureScript) code via a running nREPL server using +`tools/nrepl-eval.mjs` — a standalone CLI application. + +Session state (defs, in-ns, etc.) persists across invocations via a stored +session ID, so you can build up state incrementally. + +## Usage + +```bash +node tools/nrepl-eval.mjs [options] [] +``` + +The tool is also executable directly: +```bash +./tools/nrepl-eval.mjs [options] [] +``` + +## Options + +| Flag | Description | Default | +|------|-------------|---------| +| `-p, --port PORT` | nREPL server port | `6064` | +| `-H, --host HOST` | nREPL server host | `127.0.0.1` | +| `-t, --timeout MS` | Timeout in milliseconds | `120000` | +| `--reset-session` | Discard stored session and start fresh | — | +| `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — | +| `-h, --help` | Show help message | — | + +## When to Use + +Use this tool when you need to: + +1. **Evaluate Clojure code** during development — test functions, inspect + state, or run experiments against a running Clojure process. +2. **Verify that edited files compile** — require namespaces with `:reload` + to pick up changes. +3. **Inspect the last exception** after a failed evaluation — use `-e` to + print the error stored in `*e`. + +## Workflow + +### 1. Session management + +Sessions are persisted to `/tmp/penpot-nrepl-session--`. State +carries across calls automatically: + +```bash +./tools/nrepl-eval.mjs '(def x 42)' +./tools/nrepl-eval.mjs 'x' +# => 42 +``` + +Reset the session to start fresh: + +```bash +./tools/nrepl-eval.mjs --reset-session '(def x 0)' +``` + +### 2. Evaluate code + +**Single expression (inline) — uses default port 6064:** +```bash +./tools/nrepl-eval.mjs '(+ 1 2 3)' +``` + +**Multiple expressions via heredoc (recommended — avoids escaping issues):** +```bash +./tools/nrepl-eval.mjs <<'EOF' +(def x 10) +(+ x 20) +EOF +``` + +**Override with a different port:** +```bash +./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)' +``` + +### 3. Inspect last exception + +After code throws an error, retrieve the full exception details: + +```bash +./tools/nrepl-eval.mjs -e +``` + +## Common Patterns + +**Require a namespace with reload:** +```bash +./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)" +``` + +**Test a function:** +```bash +./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)" +``` + +**Long-running operation with custom timeout:** +```bash +./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)" +``` + +## Key Principles + +- **Default port is 6064** — just pass code directly, no `-p` needed when + your nREPL server is on 6064. Use `-p ` for a different port. +- **Always use `:reload`** when requiring namespaces to pick up file changes. +- **Session is reused** across invocations — defs, in-ns, and var bindings + persist. Use `--reset-session` to clear. +- **Do not start any server** — the tool connects to an existing nREPL + server, it is not the agent's responsibility to start the nREPL server + (assume the server is already running on the specified port). diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 9bcc96519a..7dac2b59f6 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -659,9 +659,8 @@ [& _args] (try (let [p (promise)] - (when (contains? cf/flags :nrepl-server) - (l/inf :hint "start nrepl server" :port 6064) - (nrepl/start-server :bind "0.0.0.0" :port 6064)) + (l/inf :hint "start nrepl server" :port 6064) + (nrepl/start-server :bind "0.0.0.0" :port 6064) (start) (deref p)) diff --git a/package.json b/package.json index 176a30c5da..67031cc6ef 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,10 @@ "fmt": "./scripts/fmt" }, "devDependencies": { - "@github/copilot": "^1.0.43", - "@types/node": "^25.6.0", + "@github/copilot": "^1.0.44", + "@types/node": "^25.6.2", "esbuild": "^0.28.0", - "opencode-ai": "^1.14.40" + "nrepl-client": "^0.3.0", + "opencode-ai": "^1.14.46" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7699645c22..f019e4a575 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,20 @@ importers: .: devDependencies: '@github/copilot': - specifier: ^1.0.43 - version: 1.0.43 + specifier: ^1.0.44 + version: 1.0.44 '@types/node': - specifier: ^25.6.0 - version: 25.6.0 + specifier: ^25.6.2 + version: 25.6.2 esbuild: specifier: ^0.28.0 version: 0.28.0 + nrepl-client: + specifier: ^0.3.0 + version: 0.3.0 opencode-ai: - specifier: ^1.14.40 - version: 1.14.40 + specifier: ^1.14.46 + version: 1.14.46 packages: @@ -179,118 +182,128 @@ packages: cpu: [x64] os: [win32] - '@github/copilot-darwin-arm64@1.0.43': - resolution: {integrity: sha512-VMaWfoUwIt19TGzmvTv/In5ITgFWfu91ZILt4Lb77gSmVbwbs+DahP2lbvM1s/GtGwOknwhOJLp4q2WMgK3CoQ==} + '@github/copilot-darwin-arm64@1.0.44': + resolution: {integrity: sha512-9NqA5sT2spmNsehxhs51GhXRZIZga5nq+WcMl4LG2QrUPJRDwvHf1bDKqETJUBbYvBY8jONGuTKMRofkMI68YQ==} cpu: [arm64] os: [darwin] hasBin: true - '@github/copilot-darwin-x64@1.0.43': - resolution: {integrity: sha512-lCxg75zbgtWggb1p+IHhlhmulQj7BKIc+9pUsTwJ3Mjt51kiUYmD6LF2BD1/Ed4M3GMumSu4UTSIv9pL96n0Wg==} + '@github/copilot-darwin-x64@1.0.44': + resolution: {integrity: sha512-QPD8KtXx07SIKILGBl4JDhPyL2Qo0FMmaTYVxR6nkyHkHnFPsUZD6VWGR+T/KMLkcUXFM85Xc1ba9Y27s4nRrQ==} cpu: [x64] os: [darwin] hasBin: true - '@github/copilot-linux-arm64@1.0.43': - resolution: {integrity: sha512-Jr1rvt/Syz5oVSiU53keRKEV8f5xTLkmiB2qasAV6Emk7K82/BZW5HfW8cDadfHnlAS1+UVPpoO+8ykgx4dikw==} + '@github/copilot-linux-arm64@1.0.44': + resolution: {integrity: sha512-Z8ScIUP433xS18f68NP9jM9zW320Xzpi2wf7Nig/VyfrwupBy25UTezydQMT0KQHLWTEleHOPcYnASY3HgJXnQ==} cpu: [arm64] os: [linux] hasBin: true - '@github/copilot-linux-x64@1.0.43': - resolution: {integrity: sha512-uyWyPpcwMC1tIHJTA1OPzIm1i/Eeku0NjFzPYnFvpfGug9pkL4xcUr8A2UNl8cVvxq/VQMwtYckV3G12ySJTFw==} + '@github/copilot-linux-x64@1.0.44': + resolution: {integrity: sha512-KUl6lvJt0HNKaXSx0T0bIWJ3rvrGwgZYMlkDfqMbuMnZatEQJbjPwxmL/IDfp/c0DyKd7K+ajl17wHYcN/hJIQ==} cpu: [x64] os: [linux] hasBin: true - '@github/copilot-win32-arm64@1.0.43': - resolution: {integrity: sha512-vY3rwkW1h7ixSqYd9XT/xGjxkepvQVJK2q1sYhSPBN4Wvuk37fRjN7ox3QU1lhpxlYQMc/g+ZR58u5cx31n1lw==} + '@github/copilot-win32-arm64@1.0.44': + resolution: {integrity: sha512-JVJxZJwAc95ZfapgOXjNFwSqrWlvC3heo128L+CDkdZ6lwpD1dTGMHT/6rMMEeo3xjZmMm8tiynfwsHLDgTtvQ==} cpu: [arm64] os: [win32] hasBin: true - '@github/copilot-win32-x64@1.0.43': - resolution: {integrity: sha512-AjGsebDbJmBxe9FFf2McSN5r2Joi8cFY879lxaQaXxfGjzOHblzvbhflbsX9x2GnvCfDdDiow413WvOGqKDH8g==} + '@github/copilot-win32-x64@1.0.44': + resolution: {integrity: sha512-Yj3KQ/DqwS50PwRtyQITX2mWIVZeJeX+y0faVSMwUUzG1qxmMcme7wimhKOyc4LSV11DpgVB9MSiBw2xys7iww==} cpu: [x64] os: [win32] hasBin: true - '@github/copilot@1.0.43': - resolution: {integrity: sha512-2FO825Aq4bwmHcXVyplW+CpZaJFUYjqtqjBGnueM31gu4ufn6ReurzB2swBQ6bn4Pquyy2KeodMRPpT6JaLMhw==} + '@github/copilot@1.0.44': + resolution: {integrity: sha512-wr/GmNOUaJK/giJK5abyB1oTpEowgFKLi+NJnlyAymKiK/GKCaRlJqiX23H2RetM8vD2hDYUFUFm9lTCooGy0g==} hasBin: true - '@types/node@25.6.0': - resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + + bencode@2.0.3: + resolution: {integrity: sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==} esbuild@0.28.0: resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} engines: {node: '>=18'} hasBin: true - opencode-ai@1.14.40: - resolution: {integrity: sha512-2Iwqe3wLdAGWxWGnTAPeOv7QNcm4stuWL2VQ7FM6OxKRE0h9zGoKtJ/bnX193hR6/QH81goOrocKfamRk4pM/A==} + nrepl-client@0.3.0: + resolution: {integrity: sha512-EcROXUrzlGHKOdu/E/5WB0OESCI0iGHhdXeYk9cULYtd72eFJrM/Q1umvjTBfKWlT62y76cnyLG/3CmSCqT12w==} + + opencode-ai@1.14.46: + resolution: {integrity: sha512-BX3xG3B/t85LpaVEZilbszOfP5aXc4C9e8oRBLX3rPsnnPXweKQZYfz9pn8/kfOz+8rOYuXGrBpJ86UxH/Le+Q==} hasBin: true - opencode-darwin-arm64@1.14.40: - resolution: {integrity: sha512-dVItdZeaJw9xRtpVKQ0K9sigcaf8p/3nr3hW/NxJY3I/L+op29Wly5jnH0sdxq05gV/cGmJ0BTJzoYizXDDoKg==} + opencode-darwin-arm64@1.14.46: + resolution: {integrity: sha512-KFUZxvEjYHz4cecbbHKJ3utc61iq4GYPjGDEM/GZ9x885FV0xQwhjQXVSQgd1gy/bj/3tFWzVLho3GC6Ogy03A==} cpu: [arm64] os: [darwin] - opencode-darwin-x64-baseline@1.14.40: - resolution: {integrity: sha512-ab2vqJmTPG48U2/6xZvPcZ+HdQBHFt6rpS0w2gURJobXHWgbW+uytfX4MCBprunS3K1DL7qoVWIgJRRu3YEMzQ==} + opencode-darwin-x64-baseline@1.14.46: + resolution: {integrity: sha512-3OZgCnPZZ19fI1Ju0EHTM7kHPIwadD58OBFSLhCAdJLH00OGRpbpoqxy24gbCm9LnompFjp2DnEQ+KA6+RFIeQ==} cpu: [x64] os: [darwin] - opencode-darwin-x64@1.14.40: - resolution: {integrity: sha512-Mcfh2LP/QA3LJ03m6OF1FNCMO/O0PsT94Mw7NXbcmi6g+nQ6I2vHensIjKtTJM3LSmosOlTduxdv/b82AVFt+w==} + opencode-darwin-x64@1.14.46: + resolution: {integrity: sha512-+cC9EoVeoU2yLWIq7835QQginkTE8S7rrjMRJk543ZZ+S0yHsrnhEQVkgqhX+Yzncb8Subt2wISF7yROAzq7zA==} cpu: [x64] os: [darwin] - opencode-linux-arm64-musl@1.14.40: - resolution: {integrity: sha512-5COW6bDCCbM/BZcrkiVR91VqrP9vD8J4uS7cHLPbdQHaDYETxOhHWgsYnp9JXWEbcDi6lBaxapj11RZPyUJziw==} + opencode-linux-arm64-musl@1.14.46: + resolution: {integrity: sha512-2DmmX4WuqrKdEo/rxK/ARA39fCecco53F0/v8t7gk+njbe5fWrKYrfbtL3OaGr8LUsQolny5O05I0tjBsGcTUQ==} cpu: [arm64] os: [linux] - opencode-linux-arm64@1.14.40: - resolution: {integrity: sha512-nqsnGxV237XWCbaMtFXOiN1ndukNAhAmbe5dnDThSFOKcgwMs6VKuhHa+iOpQL1r6FZ5eE3SU6IKgscVM/eAAQ==} + opencode-linux-arm64@1.14.46: + resolution: {integrity: sha512-gahGbcQNJ6CAXWeG06wW/U2S+GB2ccmhvp4bAXDZeRm6Liw7M4NzOZX9H7sboTGtcYTnVouBPvoKPaX59qv9rA==} cpu: [arm64] os: [linux] - opencode-linux-x64-baseline-musl@1.14.40: - resolution: {integrity: sha512-l0xfAHVy2klD181UbKUjeMVltqEn+9DTP/bZ9ZyDuiFbVNeKfwiU7HMkPw5GFnSnlQTUrDHniXMwgBcI5yrRcA==} + opencode-linux-x64-baseline-musl@1.14.46: + resolution: {integrity: sha512-lMJiPsb8b+aGjqXdAmfu9f5HyTAS6Cfk8O1GieZFu06pi8kO9oiJ6wPyQwwL8IM6J2ssNF4PxloHgqeNQI7kEg==} cpu: [x64] os: [linux] - opencode-linux-x64-baseline@1.14.40: - resolution: {integrity: sha512-XmXJHicRNylnGz/nzJCmsXy6mozjmrrXHntrrS3DWgq8UiSdg9wB2Hsq+1EDbPjzF2NzQaVi6/9/AyERuxW2iA==} + opencode-linux-x64-baseline@1.14.46: + resolution: {integrity: sha512-oq6Px+0epCwk2nZn54EUDtxccGb9JlSnF1stAm4oKUeKg/G76hGEbCgg4x3TNiIrpA8QnS9grRlh1oFYIl/efg==} cpu: [x64] os: [linux] - opencode-linux-x64-musl@1.14.40: - resolution: {integrity: sha512-AU8SAE4aOaGxzTDcv95GU8vrWeiYOzPVEt5LfcqhmOmhRiuN0jnDBO/gl62SITkGyu/sSes554L4UqkqkglC7g==} + opencode-linux-x64-musl@1.14.46: + resolution: {integrity: sha512-rUmQHsrlIWcLBmefiom71DWhRzQ0oSqjgvF2Xvg5ySs0GKklRzMB/HcS2ccIydqv02ImL94FbqVVXSZ2lnw4IQ==} cpu: [x64] os: [linux] - opencode-linux-x64@1.14.40: - resolution: {integrity: sha512-Cb+keGDsjo1wyOJ2Kf+KOZWlJUs1fD/VmjYepl9Fv3KGqWQNhSOH/4kiwj3KHWwEMWroCtw5KnAFykiOgbsz8A==} + opencode-linux-x64@1.14.46: + resolution: {integrity: sha512-qxFHxbyP3dCFX6vibX71JfVntEbzE7rSMmot6EwbdDB0vq+tcahFEZ+/KVC7qV3zL3ZbDAMsx8OiZknI7Cscmw==} cpu: [x64] os: [linux] - opencode-windows-arm64@1.14.40: - resolution: {integrity: sha512-049+Z8G4o10OO7s5dGIVIZ31DMR+0JlAWCM/XR+VmBlbPeZYRKL/9Q2dM0Ljw11ekZ1qbZfGlK7xUEHkwvy8fQ==} + opencode-windows-arm64@1.14.46: + resolution: {integrity: sha512-SmeKzxFNiiF9s9l0lj1CxIZesE+m0VvBX5GuW5CHAlVqNLjmQeW3X8qhHmXelvm9ujGxAudjmXBUlZTkBkH9FQ==} cpu: [arm64] os: [win32] - opencode-windows-x64-baseline@1.14.40: - resolution: {integrity: sha512-6JKSyc8oyCw4ZLMZ38l9/M7iQYfMF7qnc3d+P9dpfx80FiqHwWBxgYaGEaNHkQmoiTPVc39EsjaOiiBOLGZOQA==} + opencode-windows-x64-baseline@1.14.46: + resolution: {integrity: sha512-6rehvrK+KCqUUKBKFnjpk6YchpiH3TNJdOYTmg+QcRirynyhNjs83F7h29vr+WbSnImMCPYFeRfBAf51yLiAmQ==} cpu: [x64] os: [win32] - opencode-windows-x64@1.14.40: - resolution: {integrity: sha512-0+La4i8fj+E+kj6hu8sQKfjbHm6VgbbSiZ6vo3fYqU4+Ex2UIyNa31A3rWOQ9EFbmKQ8jb2PmfBDvDE0ihJvRw==} + opencode-windows-x64@1.14.46: + resolution: {integrity: sha512-m9LPvvNV9UvgYc5AbzR+hBBD2wPx+bitjbBTXNx/7s+WE/5DhlaHsxVQPePrA9bZ8XAO0EndRnDjvCrLLrkxWA==} cpu: [x64] os: [win32] + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} @@ -374,37 +387,39 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true - '@github/copilot-darwin-arm64@1.0.43': + '@github/copilot-darwin-arm64@1.0.44': optional: true - '@github/copilot-darwin-x64@1.0.43': + '@github/copilot-darwin-x64@1.0.44': optional: true - '@github/copilot-linux-arm64@1.0.43': + '@github/copilot-linux-arm64@1.0.44': optional: true - '@github/copilot-linux-x64@1.0.43': + '@github/copilot-linux-x64@1.0.44': optional: true - '@github/copilot-win32-arm64@1.0.43': + '@github/copilot-win32-arm64@1.0.44': optional: true - '@github/copilot-win32-x64@1.0.43': + '@github/copilot-win32-x64@1.0.44': optional: true - '@github/copilot@1.0.43': + '@github/copilot@1.0.44': optionalDependencies: - '@github/copilot-darwin-arm64': 1.0.43 - '@github/copilot-darwin-x64': 1.0.43 - '@github/copilot-linux-arm64': 1.0.43 - '@github/copilot-linux-x64': 1.0.43 - '@github/copilot-win32-arm64': 1.0.43 - '@github/copilot-win32-x64': 1.0.43 + '@github/copilot-darwin-arm64': 1.0.44 + '@github/copilot-darwin-x64': 1.0.44 + '@github/copilot-linux-arm64': 1.0.44 + '@github/copilot-linux-x64': 1.0.44 + '@github/copilot-win32-arm64': 1.0.44 + '@github/copilot-win32-x64': 1.0.44 - '@types/node@25.6.0': + '@types/node@25.6.2': dependencies: undici-types: 7.19.2 + bencode@2.0.3: {} + esbuild@0.28.0: optionalDependencies: '@esbuild/aix-ppc64': 0.28.0 @@ -434,55 +449,62 @@ snapshots: '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 - opencode-ai@1.14.40: + nrepl-client@0.3.0: + dependencies: + bencode: 2.0.3 + tree-kill: 1.2.2 + + opencode-ai@1.14.46: optionalDependencies: - opencode-darwin-arm64: 1.14.40 - opencode-darwin-x64: 1.14.40 - opencode-darwin-x64-baseline: 1.14.40 - opencode-linux-arm64: 1.14.40 - opencode-linux-arm64-musl: 1.14.40 - opencode-linux-x64: 1.14.40 - opencode-linux-x64-baseline: 1.14.40 - opencode-linux-x64-baseline-musl: 1.14.40 - opencode-linux-x64-musl: 1.14.40 - opencode-windows-arm64: 1.14.40 - opencode-windows-x64: 1.14.40 - opencode-windows-x64-baseline: 1.14.40 + opencode-darwin-arm64: 1.14.46 + opencode-darwin-x64: 1.14.46 + opencode-darwin-x64-baseline: 1.14.46 + opencode-linux-arm64: 1.14.46 + opencode-linux-arm64-musl: 1.14.46 + opencode-linux-x64: 1.14.46 + opencode-linux-x64-baseline: 1.14.46 + opencode-linux-x64-baseline-musl: 1.14.46 + opencode-linux-x64-musl: 1.14.46 + opencode-windows-arm64: 1.14.46 + opencode-windows-x64: 1.14.46 + opencode-windows-x64-baseline: 1.14.46 - opencode-darwin-arm64@1.14.40: + opencode-darwin-arm64@1.14.46: optional: true - opencode-darwin-x64-baseline@1.14.40: + opencode-darwin-x64-baseline@1.14.46: optional: true - opencode-darwin-x64@1.14.40: + opencode-darwin-x64@1.14.46: optional: true - opencode-linux-arm64-musl@1.14.40: + opencode-linux-arm64-musl@1.14.46: optional: true - opencode-linux-arm64@1.14.40: + opencode-linux-arm64@1.14.46: optional: true - opencode-linux-x64-baseline-musl@1.14.40: + opencode-linux-x64-baseline-musl@1.14.46: optional: true - opencode-linux-x64-baseline@1.14.40: + opencode-linux-x64-baseline@1.14.46: optional: true - opencode-linux-x64-musl@1.14.40: + opencode-linux-x64-musl@1.14.46: optional: true - opencode-linux-x64@1.14.40: + opencode-linux-x64@1.14.46: optional: true - opencode-windows-arm64@1.14.40: + opencode-windows-arm64@1.14.46: optional: true - opencode-windows-x64-baseline@1.14.40: + opencode-windows-x64-baseline@1.14.46: optional: true - opencode-windows-x64@1.14.40: + opencode-windows-x64@1.14.46: optional: true + tree-kill@1.2.2: {} + undici-types@7.19.2: {} diff --git a/tools/nrepl-eval.mjs b/tools/nrepl-eval.mjs new file mode 100755 index 0000000000..45da2445de --- /dev/null +++ b/tools/nrepl-eval.mjs @@ -0,0 +1,259 @@ +#!/usr/bin/env node +import nreplClient from "nrepl-client"; +import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs"; +import path from "path"; +import os from "os"; + +const DEFAULT_TIMEOUT = 120000; + +// ============================================================================ +// Session persistence +// ============================================================================ + +function sessionFilePath(host, port) { + return path.join(os.tmpdir(), `penpot-nrepl-session-${host}-${port}`); +} + +function readSession(host, port) { + const fp = sessionFilePath(host, port); + try { + return readFileSync(fp, "utf8").trim() || null; + } catch { + return null; + } +} + +function writeSession(host, port, id) { + writeFileSync(sessionFilePath(host, port), id, "utf8"); +} + +function deleteSession(host, port) { + const fp = sessionFilePath(host, port); + if (existsSync(fp)) unlinkSync(fp); +} + +// ============================================================================ +// nREPL helpers (promisified) +// ============================================================================ + +function nreplSend(con, msg) { + return new Promise((resolve, reject) => { + con.send(msg, (err, messages) => { + if (err) { + const text = Array.isArray(err) + ? err.map((e) => (e && e.message) || String(e)).join("; ") + : String(err); + reject(new Error(text)); + } else { + resolve(messages || []); + } + }); + }); +} + +function nreplEval({ host, port, code, sessionId, timeout = DEFAULT_TIMEOUT }) { + return new Promise((resolve, reject) => { + const con = nreplClient.connect({ host, port }); + let finished = false; + + const finish = (err, result) => { + if (finished) return; + finished = true; + clearTimeout(timer); + try { con.end(); } catch (_) {} + if (err) reject(err); + else resolve(result); + }; + + const timer = setTimeout(() => { + finish(new Error(`nREPL eval timed out after ${timeout}ms`)); + }, timeout); + + con.on("error", (err) => { + finish(err); + }); + + con.once("connect", async () => { + try { + let sid = sessionId; + + if (!sid) { + const msgs = await nreplSend(con, { op: "clone" }); + const m = msgs.find((m) => m["new-session"]); + if (!m) throw new Error("Clone response missing new-session"); + sid = m["new-session"]; + } + + const messages = await nreplSend(con, { op: "eval", code, session: sid }); + finish(null, { messages, sessionId: sid }); + } catch (err) { + finish(err); + } + }); + }); +} + +// ============================================================================ +// Output formatting +// ============================================================================ + +function formatEvalMessages(messages) { + const lines = []; + let hasContent = false; + for (const msg of messages) { + if (msg.out) { + lines.push(msg.out); + hasContent = true; + } + if (msg.err) { + lines.push(`[ERROR] ${msg.err}`); + hasContent = true; + } + if (msg.value) { + const ns = msg.ns ? ` (ns: ${msg.ns})` : ""; + lines.push(`=> ${msg.value}${ns}`); + hasContent = true; + } + } + if (!hasContent) { + const statuses = messages.map((m) => m.status).filter(Boolean).flat(); + return `Evaluation completed. Status: ${statuses.join(", ") || "done"}`; + } + return lines.join("\n"); +} + +// ============================================================================ +// CLI argument parsing +// ============================================================================ + +function parseArgs(argv) { + const args = { + port: 6064, + host: "127.0.0.1", + timeout: DEFAULT_TIMEOUT, + help: false, + resetSession: false, + lastError: false, + code: null, + }; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "-p" || a === "--port") { + const val = argv[++i]; + if (val === undefined) { console.error("Error: --port requires a value."); process.exit(1); } + args.port = parseInt(val, 10); + } else if (a === "-H" || a === "--host") { + const val = argv[++i]; + if (val === undefined) { console.error("Error: --host requires a value."); process.exit(1); } + args.host = val; + } else if (a === "-t" || a === "--timeout") { + const val = argv[++i]; + if (val === undefined) { console.error("Error: --timeout requires a value."); process.exit(1); } + args.timeout = parseInt(val, 10); + } else if (a === "--reset-session") { + args.resetSession = true; + } else if (a === "-e" || a === "--last-error") { + args.lastError = true; + } else if (a === "-h" || a === "--help") { + args.help = true; + } else { + if (args.code === null) { + args.code = a; + } else { + args.code += " " + a; + } + } + } + + return args; +} + +function printHelp() { + const bin = path.basename(process.argv[1]); + console.log(`Usage: ${bin} [options] [] + +Evaluate Clojure code via a running nREPL server. Session state (defs, in-ns) +persists across invocations via a stored session ID. + +Options: + -p, --port PORT nREPL port (default: 6064) + -H, --host HOST nREPL host (default: 127.0.0.1) + -t, --timeout MILLISECONDS Timeout in milliseconds (default: 120000) + --reset-session Discard stored session and start fresh + -e, --last-error Evaluate *e to retrieve the last exception + -h, --help Show this help message + +Examples: + ${bin} '(def x 42)' + ${bin} 'x' + ${bin} --reset-session '(def x 0)' + ${bin} --last-error + ${bin} <<'EOF' + (def x 10) + (+ x 20) + EOF`); +} + +function readStdin() { + return new Promise((resolve) => { + if (process.stdin.isTTY) { + resolve(""); + return; + } + let data = ""; + process.stdin.setEncoding("utf-8"); + process.stdin.on("data", (chunk) => { data += chunk; }); + process.stdin.on("end", () => { resolve(data.trim()); }); + }); +} + +// ============================================================================ +// Main +// ============================================================================ + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + printHelp(); + return; + } + + if (isNaN(args.port) || args.port < 1 || args.port > 65535) { + console.error("Error: invalid port number."); + process.exit(1); + } + + if (args.resetSession) { + deleteSession(args.host, args.port); + } + + let code = args.lastError ? "*e" : args.code; + if (!code) { + code = await readStdin(); + } + + if (!code) { + console.error("Error: No code provided. Pass code as an argument or pipe it via stdin."); + process.exit(1); + } + + const storedSession = readSession(args.host, args.port); + + const { messages, sessionId } = await nreplEval({ + host: args.host, + port: args.port, + code, + sessionId: storedSession, + timeout: args.timeout, + }); + + writeSession(args.host, args.port, sessionId); + console.log(formatEvalMessages(messages)); +} + +main().catch((err) => { + console.error(`Error: ${err.message || err}`); + process.exit(1); +});