diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0649e8a8..4485dabb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: - 'app/**' - 'component/**' - 'connection/**' + - 'cli/**' - 'sonar-project.properties' - 'Dockerfile' - '.github/workflows/ci.yml' @@ -16,6 +17,7 @@ on: - 'app/**' - 'component/**' - 'connection/**' + - 'cli/**' - 'sonar-project.properties' - 'Dockerfile' - '.github/workflows/ci.yml' @@ -120,6 +122,7 @@ jobs: app/package-lock.json component/package-lock.json connection/package-lock.json + cli/package-lock.json - name: Install dependencies run: | @@ -129,11 +132,14 @@ jobs: COMP_CI_PID=$! npm ci --prefix connection & CONN_CI_PID=$! + npm ci --prefix cli & + CLI_CI_PID=$! FAIL=0 wait $APP_CI_PID || FAIL=1 wait $COMP_CI_PID || FAIL=1 wait $CONN_CI_PID || FAIL=1 + wait $CLI_CI_PID || FAIL=1 exit $FAIL - name: Run all tests in parallel @@ -144,11 +150,14 @@ jobs: COMP_PID=$! cd connection && npm run test:coverage & CONN_PID=$! + cd cli && npm run test:coverage & + CLI_PID=$! FAIL=0 wait $APP_PID || FAIL=1 wait $COMP_PID || FAIL=1 wait $CONN_PID || FAIL=1 + wait $CLI_PID || FAIL=1 exit $FAIL env: POSTGRES_HOST: localhost @@ -170,6 +179,7 @@ jobs: app/coverage/lcov.info component/coverage/lcov.info connection/coverage/lcov.info + cli/coverage/lcov.info # ── Job 2: E2E tests — 5 shards with isolated containers ────────────────── # Each shard gets its own runner + Testcontainers (no shared state). diff --git a/.gitignore b/.gitignore index 382e6637..78c51559 100644 --- a/.gitignore +++ b/.gitignore @@ -64,4 +64,8 @@ scripts/claude-setup.sh # Fumadocs generated docs/.source -docs/.next \ No newline at end of file +docs/.next + +# CLI build output +cli/dist/ +.neoboard.local \ No newline at end of file diff --git a/bin/neoboard b/bin/neoboard new file mode 100755 index 00000000..d093e923 --- /dev/null +++ b/bin/neoboard @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import("../cli/dist/index.js"); diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 00000000..b23a17d1 --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,1802 @@ +{ + "name": "@neoboard/cli", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@neoboard/cli", + "version": "0.0.1", + "dependencies": { + "chalk": "^5.0.0", + "commander": "^13.0.0", + "dotenv": "^17.0.0", + "ora": "^8.0.0" + }, + "bin": { + "neoboard": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "@vitest/coverage-v8": "^4.1.2", + "typescript": "~5.9.3", + "vitest": "^4.1.2" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", + "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.2", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.2", + "vitest": "4.1.2" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz", + "integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "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" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "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 + } + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "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-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "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.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "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 + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 00000000..2515b810 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,28 @@ +{ + "name": "@neoboard/cli", + "version": "0.0.1", + "private": true, + "type": "module", + "bin": { + "neoboard": "./dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "chalk": "^5.0.0", + "commander": "^13.0.0", + "dotenv": "^17.0.0", + "ora": "^8.0.0" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "@vitest/coverage-v8": "^4.1.2", + "typescript": "~5.9.3", + "vitest": "^4.1.2" + } +} diff --git a/cli/src/__tests__/commands/db/dump.test.ts b/cli/src/__tests__/commands/db/dump.test.ts new file mode 100644 index 00000000..88d5e20d --- /dev/null +++ b/cli/src/__tests__/commands/db/dump.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../lib/exec.js", () => ({ + run: vi.fn(() => "-- SQL dump"), +})); + +vi.mock("../../../lib/config.js", () => ({ + paths: { root: "/project" }, + readProjectConfig: vi.fn(() => ({ + ports: { postgres: 5432 }, + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + })), + getMode: vi.fn(() => "docker"), +})); + +vi.mock("../../../lib/output.js", () => ({ + success: vi.fn(), + createSpinner: vi.fn(() => ({ + start: vi.fn(), + succeed: vi.fn(), + fail: vi.fn(), + })), +})); + +vi.mock("node:fs", () => ({ + writeFileSync: vi.fn(), + statSync: vi.fn(() => ({ size: 2048 })), +})); + +import { run } from "../../../lib/exec.js"; +import { getMode } from "../../../lib/config.js"; +import { writeFileSync } from "node:fs"; +import { runDbDump } from "../../../commands/db/dump.js"; + +const mockRun = vi.mocked(run); +const mockGetMode = vi.mocked(getMode); +const mockWriteFileSync = vi.mocked(writeFileSync); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetMode.mockReturnValue("docker"); +}); + +describe("runDbDump", () => { + it("dumps via docker exec in docker mode", async () => { + await runDbDump({}); + expect(mockRun).toHaveBeenCalledWith( + expect.stringContaining("docker exec neoboard-postgres pg_dump"), + ); + }); + + it("dumps via local pg_dump in local mode", async () => { + mockGetMode.mockReturnValue("local"); + await runDbDump({}); + expect(mockRun).toHaveBeenCalledWith( + expect.stringContaining("pg_dump -h localhost"), + ); + }); + + it("uses custom output path", async () => { + await runDbDump({ output: "/tmp/backup.sql" }); + expect(mockWriteFileSync).toHaveBeenCalledWith( + "/tmp/backup.sql", + "-- SQL dump", + ); + }); + + it("generates timestamped default filename", async () => { + await runDbDump({}); + const path = mockWriteFileSync.mock.calls[0][0] as string; + expect(path).toMatch( + /neoboard-dump-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.sql$/, + ); + }); + + it("passes --data-only flag", async () => { + await runDbDump({ dataOnly: true }); + expect(mockRun).toHaveBeenCalledWith( + expect.stringContaining("--data-only"), + ); + }); + + it("writes sql output to file", async () => { + await runDbDump({}); + expect(mockWriteFileSync).toHaveBeenCalledWith( + expect.any(String), + "-- SQL dump", + ); + }); +}); diff --git a/cli/src/__tests__/commands/db/migrate.test.ts b/cli/src/__tests__/commands/db/migrate.test.ts new file mode 100644 index 00000000..0943843b --- /dev/null +++ b/cli/src/__tests__/commands/db/migrate.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../lib/exec.js", () => ({ + run: vi.fn(), +})); + +vi.mock("../../../lib/docker.js", () => ({ + dockerExec: vi.fn(), +})); + +vi.mock("../../../lib/config.js", () => ({ + paths: { + journalPath: "/project/app/drizzle/migrations/meta/_journal.json", + appDir: "/project/app", + }, + getMode: vi.fn(() => "local"), +})); + +vi.mock("../../../lib/output.js", () => ({ + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + createSpinner: vi.fn(() => ({ + start: vi.fn(), + succeed: vi.fn(), + fail: vi.fn(), + })), +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +import { run } from "../../../lib/exec.js"; +import { dockerExec } from "../../../lib/docker.js"; +import { getMode } from "../../../lib/config.js"; +import { info, warn } from "../../../lib/output.js"; +import { existsSync, readFileSync } from "node:fs"; +import { + showMigrationStatus, + showDryRun, + runDbMigrate, +} from "../../../commands/db/migrate.js"; + +const mockRun = vi.mocked(run); +const mockDockerExec = vi.mocked(dockerExec); +const mockGetMode = vi.mocked(getMode); +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); + +const SAMPLE_JOURNAL = JSON.stringify({ + version: "7", + entries: [ + { idx: 0, tag: "0000_wooden_zeigeist", when: 1700000000000 }, + { idx: 1, tag: "0001_rapid_iron_monger", when: 1700100000000 }, + ], +}); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetMode.mockReturnValue("local"); +}); + +describe("showMigrationStatus", () => { + it("displays migration entries", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(SAMPLE_JOURNAL); + showMigrationStatus(); + expect(info).toHaveBeenCalledWith("Migrations: 2 available"); + }); + + it("warns when no journal found", () => { + mockExistsSync.mockReturnValue(false); + showMigrationStatus(); + expect(warn).toHaveBeenCalledWith("No migration journal found."); + }); +}); + +describe("showDryRun", () => { + it("shows pending migrations without applying", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(SAMPLE_JOURNAL); + showDryRun(); + expect(info).toHaveBeenCalledWith("Would apply 2 migration(s):"); + expect(mockRun).not.toHaveBeenCalled(); + }); +}); + +describe("runDbMigrate", () => { + it("shows status when --status flag set", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(SAMPLE_JOURNAL); + await runDbMigrate({ status: true }); + expect(info).toHaveBeenCalledWith("Migrations: 2 available"); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it("shows dry run when --dry-run flag set", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(SAMPLE_JOURNAL); + await runDbMigrate({ dryRun: true }); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it("runs migrations in local mode", async () => { + await runDbMigrate({}); + expect(mockRun).toHaveBeenCalledWith("npx drizzle-kit migrate", { + cwd: "/project/app", + }); + }); + + it("runs migrations via docker exec in docker mode", async () => { + mockGetMode.mockReturnValue("docker"); + await runDbMigrate({}); + expect(mockDockerExec).toHaveBeenCalledWith( + "neoboard-app", + "npx drizzle-kit migrate", + ); + }); + + it("prints backup warning", async () => { + await runDbMigrate({}); + expect(info).toHaveBeenCalledWith( + expect.stringContaining("neoboard db dump"), + ); + }); + + it("warns about --to flag limitation", async () => { + await runDbMigrate({ to: "1.0.0" }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("--to 1.0.0")); + }); +}); diff --git a/cli/src/__tests__/commands/db/reset.test.ts b/cli/src/__tests__/commands/db/reset.test.ts new file mode 100644 index 00000000..79463134 --- /dev/null +++ b/cli/src/__tests__/commands/db/reset.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("node:fs", () => ({ + readFileSync: vi.fn( + () => + "DATABASE_URL=postgresql://neoboard:neoboard@localhost:5432/neoboard\n", + ), +})); + +vi.mock("../../../lib/exec.js", () => ({ + run: vi.fn(), +})); + +vi.mock("../../../lib/docker.js", () => ({ + dockerExec: vi.fn(), +})); + +vi.mock("../../../lib/config.js", () => ({ + paths: { envFile: "/project/app/.env.local" }, + readProjectConfig: vi.fn(() => ({ + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + })), + getMode: vi.fn(() => "docker"), +})); + +vi.mock("../../../lib/output.js", () => ({ + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), + createSpinner: vi.fn(() => ({ + start: vi.fn(), + succeed: vi.fn(), + fail: vi.fn(), + })), +})); + +vi.mock("../../../lib/prompt.js", () => ({ + confirm: vi.fn(async () => true), +})); + +vi.mock("../../../commands/db/migrate.js", () => ({ + runDbMigrate: vi.fn(), +})); + +vi.mock("../../../commands/db/seed.js", () => ({ + runDbSeed: vi.fn(), +})); + +import { readFileSync } from "node:fs"; +import { run } from "../../../lib/exec.js"; +import { dockerExec } from "../../../lib/docker.js"; +import { getMode } from "../../../lib/config.js"; +import { error as logError } from "../../../lib/output.js"; +import { confirm } from "../../../lib/prompt.js"; +import { runDbMigrate } from "../../../commands/db/migrate.js"; +import { runDbSeed } from "../../../commands/db/seed.js"; +import { runDbReset } from "../../../commands/db/reset.js"; + +const mockReadFileSync = vi.mocked(readFileSync); +const mockRun = vi.mocked(run); +const mockDockerExec = vi.mocked(dockerExec); +const mockGetMode = vi.mocked(getMode); +const mockConfirm = vi.mocked(confirm); +const mockRunDbMigrate = vi.mocked(runDbMigrate); +const mockRunDbSeed = vi.mocked(runDbSeed); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetMode.mockReturnValue("docker"); + mockConfirm.mockResolvedValue(true); + mockReadFileSync.mockReturnValue( + "DATABASE_URL=postgresql://neoboard:neoboard@localhost:5432/neoboard\n", + ); + process.exitCode = undefined; +}); + +describe("runDbReset", () => { + it("refuses on non-localhost DATABASE_URL", async () => { + mockReadFileSync.mockReturnValue( + "DATABASE_URL=postgresql://neoboard:neoboard@prod-db.example.com:5432/neoboard\n", + ); + await runDbReset(); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining("prod-db.example.com"), + ); + expect(mockDockerExec).not.toHaveBeenCalled(); + }); + + it("prompts for confirmation", async () => { + await runDbReset(); + expect(mockConfirm).toHaveBeenCalledWith(expect.stringContaining("DROP")); + }); + + it("aborts when user declines", async () => { + mockConfirm.mockResolvedValue(false); + await runDbReset(); + expect(mockDockerExec).not.toHaveBeenCalled(); + }); + + it("skips confirmation with --force", async () => { + await runDbReset({ force: true }); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockDockerExec).toHaveBeenCalled(); + }); + + it("drops and creates database in docker mode", async () => { + await runDbReset({ force: true }); + expect(mockDockerExec).toHaveBeenCalledWith( + "neoboard-postgres", + expect.stringContaining("DROP DATABASE"), + ); + expect(mockDockerExec).toHaveBeenCalledWith( + "neoboard-postgres", + expect.stringContaining("CREATE DATABASE"), + ); + }); + + it("uses local psql in local mode", async () => { + mockGetMode.mockReturnValue("local"); + await runDbReset({ force: true }); + expect(mockRun).toHaveBeenCalledWith( + expect.stringContaining("psql -h localhost"), + ); + }); + + it("replays migrations after reset", async () => { + await runDbReset({ force: true }); + expect(mockRunDbMigrate).toHaveBeenCalledWith({}); + }); + + it("seeds after migration by default", async () => { + await runDbReset({ force: true }); + expect(mockRunDbSeed).toHaveBeenCalled(); + }); + + it("skips seed with --no-seed", async () => { + await runDbReset({ force: true, noSeed: true }); + expect(mockRunDbSeed).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/__tests__/commands/db/seed.test.ts b/cli/src/__tests__/commands/db/seed.test.ts new file mode 100644 index 00000000..24855627 --- /dev/null +++ b/cli/src/__tests__/commands/db/seed.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../lib/exec.js", () => ({ + run: vi.fn(), +})); + +vi.mock("../../../lib/docker.js", () => ({ + dockerExec: vi.fn(), +})); + +vi.mock("../../../lib/config.js", () => ({ + paths: { root: "/project" }, + readProjectConfig: vi.fn(() => ({ + neo4j: { user: "neo4j", password: "neoboard123" }, + seed: { + script: "scripts/seed-demo.mjs", + neo4j_cypher: "docker/neo4j/init.cypher", + }, + })), +})); + +vi.mock("../../../lib/output.js", () => ({ + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + createSpinner: vi.fn(() => ({ + start: vi.fn(), + succeed: vi.fn(), + fail: vi.fn(), + })), +})); + +import { run } from "../../../lib/exec.js"; +import { dockerExec } from "../../../lib/docker.js"; +import { + seedNeo4j, + seedPostgres, + runDbSeed, +} from "../../../commands/db/seed.js"; + +const mockRun = vi.mocked(run); +const mockDockerExec = vi.mocked(dockerExec); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("seedNeo4j", () => { + it("seeds when database is empty", async () => { + // First call: count query returns 0 + mockDockerExec.mockReturnValueOnce("c\n0"); + // Second call: cypher-shell seed + mockDockerExec.mockReturnValueOnce("ok"); + + await seedNeo4j(); + expect(mockDockerExec).toHaveBeenCalledTimes(2); + expect(mockDockerExec).toHaveBeenLastCalledWith( + "neoboard-neo4j", + expect.stringContaining("-f /var/lib/neo4j/import/init.cypher"), + ); + }); + + it("skips when database has nodes", async () => { + mockDockerExec.mockReturnValue("c\n42"); + await seedNeo4j(); + // Only the count query, no seed + expect(mockDockerExec).toHaveBeenCalledTimes(1); + }); +}); + +describe("seedPostgres", () => { + it("runs seed script", async () => { + await seedPostgres(); + expect(mockRun).toHaveBeenCalledWith( + "node /project/scripts/seed-demo.mjs", + { cwd: "/project" }, + ); + }); +}); + +describe("runDbSeed", () => { + it("seeds both by default", async () => { + mockDockerExec.mockReturnValue("c\n0"); + await runDbSeed(); + // Neo4j count + Neo4j seed + PG seed + expect(mockDockerExec).toHaveBeenCalled(); + expect(mockRun).toHaveBeenCalled(); + }); + + it("seeds only neo4j with --neo4j flag", async () => { + mockDockerExec.mockReturnValue("c\n0"); + await runDbSeed({ neo4j: true }); + expect(mockDockerExec).toHaveBeenCalled(); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it("seeds only postgres with --demo flag", async () => { + await runDbSeed({ demo: true }); + expect(mockRun).toHaveBeenCalled(); + // No Neo4j exec calls + expect(mockDockerExec).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/__tests__/commands/demo.test.ts b/cli/src/__tests__/commands/demo.test.ts new file mode 100644 index 00000000..c1f5c8ed --- /dev/null +++ b/cli/src/__tests__/commands/demo.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../commands/setup.js", () => ({ + runSetup: vi.fn(), +})); + +vi.mock("../../commands/db/seed.js", () => ({ + runDbSeed: vi.fn(), +})); + +vi.mock("../../lib/output.js", () => ({ + success: vi.fn(), + banner: vi.fn(), +})); + +import { runSetup } from "../../commands/setup.js"; +import { runDbSeed } from "../../commands/db/seed.js"; +import { banner } from "../../lib/output.js"; +import { runDemo } from "../../commands/demo.js"; + +const mockRunSetup = vi.mocked(runSetup); +const mockRunDbSeed = vi.mocked(runDbSeed); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runDemo", () => { + it("calls setup then seed", async () => { + await runDemo(); + expect(mockRunSetup).toHaveBeenCalledBefore(mockRunDbSeed); + }); + + it("passes mode to setup", async () => { + await runDemo({ mode: "local" }); + expect(mockRunSetup).toHaveBeenCalledWith({ mode: "local" }); + }); + + it("seeds both neo4j and demo data", async () => { + await runDemo(); + expect(mockRunDbSeed).toHaveBeenCalledWith({ neo4j: true, demo: true }); + }); + + it("shows login credentials", async () => { + await runDemo(); + expect(banner).toHaveBeenCalledWith( + expect.arrayContaining([expect.stringContaining("admin@neoboard.local")]), + ); + }); +}); diff --git a/cli/src/__tests__/commands/dev.test.ts b/cli/src/__tests__/commands/dev.test.ts new file mode 100644 index 00000000..9281837b --- /dev/null +++ b/cli/src/__tests__/commands/dev.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../lib/exec.js", () => ({ + spawn: vi.fn(() => ({ + kill: vi.fn(), + on: vi.fn(), + })), +})); + +vi.mock("../../lib/config.js", () => ({ + paths: { appDir: "/project/app" }, + getMode: vi.fn(() => "local"), +})); + +vi.mock("../../lib/output.js", () => ({ + info: vi.fn(), +})); + +import { spawn } from "../../lib/exec.js"; +import { getMode } from "../../lib/config.js"; +import { info } from "../../lib/output.js"; +import { runDev } from "../../commands/dev.js"; + +const mockSpawn = vi.mocked(spawn); +const mockGetMode = vi.mocked(getMode); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetMode.mockReturnValue("local"); +}); + +describe("runDev", () => { + it("prints info message in docker mode without spawning", async () => { + mockGetMode.mockReturnValue("docker"); + await runDev(); + expect(info).toHaveBeenCalledWith(expect.stringContaining("Docker mode")); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it("spawns npm run dev in local mode", async () => { + const mockChild = { + kill: vi.fn(), + on: vi.fn((_event: string, cb: () => void) => { + // Immediately close to resolve the promise + if (_event === "close") cb(); + }), + }; + mockSpawn.mockReturnValue(mockChild as ReturnType); + + await runDev(); + expect(mockSpawn).toHaveBeenCalledWith("npm", ["run", "dev"], { + cwd: "/project/app", + }); + }); +}); diff --git a/cli/src/__tests__/commands/doctor.test.ts b/cli/src/__tests__/commands/doctor.test.ts new file mode 100644 index 00000000..53314373 --- /dev/null +++ b/cli/src/__tests__/commands/doctor.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../lib/exec.js", () => ({ + runOrNull: vi.fn(), +})); + +vi.mock("../../lib/ports.js", () => ({ + isPortAvailable: vi.fn(), +})); + +vi.mock("../../lib/config.js", () => ({ + paths: { appDir: "/project/app", envFile: "/project/app/.env.local" }, + readProjectConfig: vi.fn(() => ({ + ports: { app: 3000, postgres: 5432, neo4j_http: 7474, neo4j_bolt: 7687 }, + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + neo4j: { user: "neo4j", password: "neoboard123" }, + seed: { + script: "scripts/seed-demo.mjs", + neo4j_cypher: "docker/neo4j/init.cypher", + }, + })), +})); + +vi.mock("../../lib/output.js", () => ({ + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), +})); + +import { runOrNull } from "../../lib/exec.js"; +import { isPortAvailable } from "../../lib/ports.js"; +import { existsSync } from "node:fs"; +import { + checkDockerRunning, + checkDockerComposeV2, + checkNodeVersion, + checkPortAvailable, + checkNodeModulesExist, + checkEnvFileExists, + runDoctor, + printResults, +} from "../../commands/doctor.js"; +import { success, warn, error as logError } from "../../lib/output.js"; + +const mockRunOrNull = vi.mocked(runOrNull); +const mockIsPortAvailable = vi.mocked(isPortAvailable); +const mockExistsSync = vi.mocked(existsSync); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("checkDockerRunning", () => { + it("returns ok when docker info succeeds", () => { + mockRunOrNull.mockReturnValue("ok"); + const result = checkDockerRunning(); + expect(result.status).toBe("ok"); + }); + + it("returns fail when docker info fails", () => { + mockRunOrNull.mockReturnValue(null); + const result = checkDockerRunning(); + expect(result.status).toBe("fail"); + }); +}); + +describe("checkDockerComposeV2", () => { + it("returns ok for v2", () => { + mockRunOrNull.mockReturnValue("Docker Compose version v2.24.0"); + expect(checkDockerComposeV2().status).toBe("ok"); + }); + + it("returns fail when not available", () => { + mockRunOrNull.mockReturnValue(null); + expect(checkDockerComposeV2().status).toBe("fail"); + }); +}); + +describe("checkNodeVersion", () => { + it("returns ok for current node (>= 20)", () => { + const result = checkNodeVersion(); + const major = parseInt(process.version.slice(1), 10); + expect(result.status).toBe(major >= 20 ? "ok" : "fail"); + }); +}); + +describe("checkPortAvailable", () => { + it("returns ok when port is free", async () => { + mockIsPortAvailable.mockResolvedValue(true); + const result = await checkPortAvailable(3000, "App"); + expect(result.status).toBe("ok"); + expect(result.name).toBe("Port 3000 (App)"); + }); + + it("returns warn when port is in use", async () => { + mockIsPortAvailable.mockResolvedValue(false); + const result = await checkPortAvailable(5432, "PostgreSQL"); + expect(result.status).toBe("warn"); + }); +}); + +describe("checkNodeModulesExist", () => { + it("returns ok when node_modules exists", () => { + mockExistsSync.mockReturnValue(true); + expect(checkNodeModulesExist().status).toBe("ok"); + }); + + it("returns warn when missing", () => { + mockExistsSync.mockReturnValue(false); + expect(checkNodeModulesExist().status).toBe("warn"); + }); +}); + +describe("checkEnvFileExists", () => { + it("returns ok when .env.local exists", () => { + mockExistsSync.mockReturnValue(true); + expect(checkEnvFileExists().status).toBe("ok"); + }); + + it("returns warn when missing", () => { + mockExistsSync.mockReturnValue(false); + expect(checkEnvFileExists().status).toBe("warn"); + }); +}); + +describe("runDoctor", () => { + it("returns all check results", async () => { + mockRunOrNull.mockReturnValue("Docker Compose version v2.24.0"); + mockIsPortAvailable.mockResolvedValue(true); + mockExistsSync.mockReturnValue(true); + + const results = await runDoctor(); + // 3 sync checks + 4 port checks + 2 file checks = 9 + expect(results.length).toBe(9); + expect(results.every((r) => r.status === "ok")).toBe(true); + }); +}); + +describe("printResults", () => { + it("calls success for ok results", () => { + printResults([{ name: "test", status: "ok", message: "all good" }]); + expect(success).toHaveBeenCalledWith("all good"); + }); + + it("calls warn for warn results", () => { + printResults([{ name: "test", status: "warn", message: "careful" }]); + expect(warn).toHaveBeenCalledWith("careful"); + }); + + it("calls error for fail results and returns true", () => { + const hasFailure = printResults([ + { name: "test", status: "fail", message: "broken" }, + ]); + expect(logError).toHaveBeenCalledWith("broken"); + expect(hasFailure).toBe(true); + }); + + it("returns false when no failures", () => { + const hasFailure = printResults([ + { name: "a", status: "ok", message: "ok" }, + { name: "b", status: "warn", message: "warn" }, + ]); + expect(hasFailure).toBe(false); + }); +}); diff --git a/cli/src/__tests__/commands/env.test.ts b/cli/src/__tests__/commands/env.test.ts new file mode 100644 index 00000000..0c4a71e7 --- /dev/null +++ b/cli/src/__tests__/commands/env.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock("node:crypto", () => ({ + randomBytes: vi.fn(() => ({ + toString: () => "a".repeat(64), + })), +})); + +vi.mock("../../lib/config.js", () => ({ + paths: { + envFile: "/project/app/.env.local", + envExample: "/project/.env.example", + }, + readProjectConfig: vi.fn(() => ({ + ports: { app: 3000, postgres: 5432 }, + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + })), + getMode: vi.fn(() => "local"), +})); + +vi.mock("../../lib/output.js", () => ({ + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + banner: vi.fn(), +})); + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { getMode } from "../../lib/config.js"; +import { info, error as logError } from "../../lib/output.js"; +import { validateEnv, generateEnvFile, runEnv } from "../../commands/env.js"; + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); +const mockWriteFileSync = vi.mocked(writeFileSync); +const mockGetMode = vi.mocked(getMode); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetMode.mockReturnValue("local"); +}); + +describe("validateEnv", () => { + it("reports missing when file does not exist", () => { + mockExistsSync.mockReturnValue(false); + const result = validateEnv(); + expect(result.ok).toBe(false); + expect(result.missing).toContain("(file does not exist)"); + }); + + it("passes when all required vars present", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + "DATABASE_URL=postgres://...\nENCRYPTION_KEY=abc\nNEXTAUTH_SECRET=def\nNEXTAUTH_URL=http://localhost:3000\n", + ); + const result = validateEnv(); + expect(result.ok).toBe(true); + expect(result.missing).toEqual([]); + }); + + it("reports specific missing vars", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue("DATABASE_URL=postgres://...\n"); + const result = validateEnv(); + expect(result.ok).toBe(false); + expect(result.missing).toContain("ENCRYPTION_KEY"); + expect(result.missing).toContain("NEXTAUTH_SECRET"); + }); + + it("ignores comments and blank lines", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + "# comment\n\nDATABASE_URL=x\nENCRYPTION_KEY=x\nNEXTAUTH_SECRET=x\nNEXTAUTH_URL=x\n", + ); + expect(validateEnv().ok).toBe(true); + }); +}); + +describe("generateEnvFile", () => { + it("generates file when none exists", () => { + mockExistsSync.mockReturnValue(false); + generateEnvFile(); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + const content = mockWriteFileSync.mock.calls[0][1] as string; + expect(content).toContain("DATABASE_URL="); + expect(content).toContain("ENCRYPTION_KEY="); + expect(content).toContain("NEXTAUTH_SECRET="); + expect(content).toContain("ADMIN_BOOTSTRAP_TOKEN="); + }); + + it("skips when file exists and no regenerate flag", () => { + mockExistsSync.mockReturnValue(true); + generateEnvFile(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("overwrites when regenerate is true", () => { + mockExistsSync.mockReturnValue(true); + generateEnvFile({ regenerate: true }); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + }); + + it("builds DATABASE_URL from config", () => { + mockExistsSync.mockReturnValue(false); + generateEnvFile(); + const content = mockWriteFileSync.mock.calls[0][1] as string; + expect(content).toContain( + "DATABASE_URL=postgresql://neoboard:neoboard@localhost:5432/neoboard", + ); + }); +}); + +describe("runEnv", () => { + it("exits early in docker mode", async () => { + mockGetMode.mockReturnValue("docker"); + await runEnv({}); + expect(info).toHaveBeenCalledWith(expect.stringContaining("Docker mode")); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("validates when --validate flag is set", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue("DATABASE_URL=x\n"); + await runEnv({ validate: true }); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining("Missing variables"), + ); + }); + + it("generates env file by default", async () => { + mockExistsSync.mockReturnValue(false); + await runEnv({}); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); +}); diff --git a/cli/src/__tests__/commands/init.test.ts b/cli/src/__tests__/commands/init.test.ts new file mode 100644 index 00000000..4ceecb5d --- /dev/null +++ b/cli/src/__tests__/commands/init.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock("../../lib/exec.js", () => ({ + run: vi.fn(), +})); + +vi.mock("../../lib/config.js", () => ({ + paths: { + root: "/project", + appDir: "/project/app", + projectConfig: "/project/neoboard.config.json", + }, + readProjectConfig: vi.fn(() => ({ + ports: { app: 3000, postgres: 5432, neo4j_http: 7474, neo4j_bolt: 7687 }, + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + neo4j: { user: "neo4j", password: "neoboard123" }, + seed: { + script: "scripts/seed-demo.mjs", + neo4j_cypher: "docker/neo4j/init.cypher", + }, + })), + writeLocalConfig: vi.fn(), +})); + +vi.mock("../../lib/output.js", () => ({ + info: vi.fn(), + success: vi.fn(), + createSpinner: vi.fn(() => ({ + start: vi.fn(), + succeed: vi.fn(), + })), +})); + +vi.mock("../../commands/env.js", () => ({ + generateEnvFile: vi.fn(), +})); + +import { existsSync, writeFileSync } from "node:fs"; +import { run } from "../../lib/exec.js"; +import { writeLocalConfig } from "../../lib/config.js"; +import { generateEnvFile } from "../../commands/env.js"; +import { runInit } from "../../commands/init.js"; + +const mockExistsSync = vi.mocked(existsSync); +const mockWriteFileSync = vi.mocked(writeFileSync); +const mockRun = vi.mocked(run); +const mockWriteLocalConfig = vi.mocked(writeLocalConfig); +const mockGenerateEnvFile = vi.mocked(generateEnvFile); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runInit", () => { + it("creates config files with docker mode by default", async () => { + mockExistsSync.mockReturnValue(false); + await runInit(); + expect(mockWriteFileSync).toHaveBeenCalledWith( + "/project/neoboard.config.json", + expect.stringContaining('"ports"'), + ); + expect(mockWriteLocalConfig).toHaveBeenCalledWith({ mode: "docker" }); + }); + + it("skips config creation when already exists", async () => { + mockExistsSync.mockReturnValue(true); + await runInit(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("sets local mode when specified", async () => { + mockExistsSync.mockReturnValue(false); + await runInit({ mode: "local" }); + expect(mockWriteLocalConfig).toHaveBeenCalledWith({ mode: "local" }); + }); + + it("installs deps in local mode", async () => { + mockExistsSync.mockReturnValue(false); + await runInit({ mode: "local" }); + expect(mockRun).toHaveBeenCalledWith("npm install", { cwd: "/project" }); + expect(mockRun).toHaveBeenCalledWith("npm install", { + cwd: "/project/app", + }); + }); + + it("generates env file in local mode", async () => { + mockExistsSync.mockReturnValue(false); + await runInit({ mode: "local" }); + expect(mockGenerateEnvFile).toHaveBeenCalled(); + }); + + it("does not install deps or generate env in docker mode", async () => { + mockExistsSync.mockReturnValue(false); + await runInit({ mode: "docker" }); + expect(mockRun).not.toHaveBeenCalled(); + expect(mockGenerateEnvFile).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/__tests__/commands/setup.test.ts b/cli/src/__tests__/commands/setup.test.ts new file mode 100644 index 00000000..b77b350e --- /dev/null +++ b/cli/src/__tests__/commands/setup.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../commands/init.js", () => ({ + runInit: vi.fn(), +})); + +vi.mock("../../commands/start.js", () => ({ + runStart: vi.fn(), +})); + +vi.mock("../../lib/output.js", () => ({ + success: vi.fn(), +})); + +import { runInit } from "../../commands/init.js"; +import { runStart } from "../../commands/start.js"; +import { runSetup } from "../../commands/setup.js"; + +const mockRunInit = vi.mocked(runInit); +const mockRunStart = vi.mocked(runStart); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runSetup", () => { + it("calls init then start", async () => { + await runSetup(); + expect(mockRunInit).toHaveBeenCalledBefore(mockRunStart); + }); + + it("passes mode to init", async () => { + await runSetup({ mode: "local" }); + expect(mockRunInit).toHaveBeenCalledWith({ mode: "local" }); + }); +}); diff --git a/cli/src/__tests__/commands/start.test.ts b/cli/src/__tests__/commands/start.test.ts new file mode 100644 index 00000000..727b2dbb --- /dev/null +++ b/cli/src/__tests__/commands/start.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../lib/docker.js", () => ({ + composeUp: vi.fn(), + isPgReady: vi.fn(() => true), + isNeo4jReady: vi.fn(() => true), +})); + +vi.mock("../../lib/health.js", () => ({ + waitForHealth: vi.fn(), +})); + +vi.mock("../../lib/config.js", () => ({ + readProjectConfig: vi.fn(() => ({ + ports: { app: 3000, postgres: 5432, neo4j_http: 7474, neo4j_bolt: 7687 }, + })), + getMode: vi.fn(() => "docker"), +})); + +vi.mock("../../lib/output.js", () => ({ + info: vi.fn(), + success: vi.fn(), + banner: vi.fn(), +})); + +vi.mock("../../commands/doctor.js", () => ({ + runDoctor: vi.fn(async () => []), + printResults: vi.fn(() => false), +})); + +vi.mock("../../commands/db/migrate.js", () => ({ + runDbMigrate: vi.fn(), +})); + +import { composeUp } from "../../lib/docker.js"; +import { waitForHealth } from "../../lib/health.js"; +import { getMode } from "../../lib/config.js"; +import { printResults } from "../../commands/doctor.js"; +import { runDbMigrate } from "../../commands/db/migrate.js"; +import { runStart } from "../../commands/start.js"; + +const mockComposeUp = vi.mocked(composeUp); +const mockWaitForHealth = vi.mocked(waitForHealth); +const mockPrintResults = vi.mocked(printResults); +const mockRunDbMigrate = vi.mocked(runDbMigrate); +const mockGetMode = vi.mocked(getMode); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetMode.mockReturnValue("docker"); + mockPrintResults.mockReturnValue(false); +}); + +describe("runStart", () => { + it("runs doctor checks first", async () => { + await runStart(); + expect(printResults).toHaveBeenCalled(); + }); + + it("aborts if doctor finds failures", async () => { + mockPrintResults.mockReturnValue(true); + await runStart(); + expect(mockComposeUp).not.toHaveBeenCalled(); + }); + + it("starts containers with full stack in docker mode", async () => { + await runStart(); + expect(mockComposeUp).toHaveBeenCalledWith({ full: true }); + }); + + it("starts only DB containers in local mode", async () => { + mockGetMode.mockReturnValue("local"); + await runStart(); + expect(mockComposeUp).toHaveBeenCalledWith({ full: false }); + }); + + it("waits for health checks", async () => { + await runStart(); + expect(mockWaitForHealth).toHaveBeenCalledTimes(2); + }); + + it("runs migrations after health checks pass", async () => { + await runStart(); + expect(mockRunDbMigrate).toHaveBeenCalledWith({}); + }); +}); diff --git a/cli/src/__tests__/commands/status.test.ts b/cli/src/__tests__/commands/status.test.ts new file mode 100644 index 00000000..089d6320 --- /dev/null +++ b/cli/src/__tests__/commands/status.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../lib/docker.js", () => ({ + composePs: vi.fn(() => [ + { name: "neoboard-postgres", state: "running", status: "Up" }, + { name: "neoboard-neo4j", state: "running", status: "Up" }, + ]), + isPgReady: vi.fn(() => true), + isNeo4jReady: vi.fn(() => true), +})); + +vi.mock("../../lib/exec.js", () => ({ + runOrNull: vi.fn(() => "200"), +})); + +vi.mock("../../lib/config.js", () => ({ + paths: { + journalPath: "/project/app/drizzle/migrations/meta/_journal.json", + root: "/project", + }, + getMode: vi.fn(() => "docker"), + readProjectConfig: vi.fn(() => ({ + ports: { app: 3000, postgres: 5432, neo4j_http: 7474, neo4j_bolt: 7687 }, + })), +})); + +vi.mock("../../lib/output.js", () => ({ + info: vi.fn(), +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn((p: string) => { + if (p.includes("_journal.json")) { + return JSON.stringify({ + entries: [{ idx: 0, tag: "0000_wooden_zeigeist" }], + }); + } + if (p.includes("package.json")) { + return JSON.stringify({ version: "0.0.1" }); + } + return "{}"; + }), +})); + +import { composePs, isPgReady, isNeo4jReady } from "../../lib/docker.js"; +import { info } from "../../lib/output.js"; +import { runStatus } from "../../commands/status.js"; + +const mockComposePs = vi.mocked(composePs); +const mockIsPgReady = vi.mocked(isPgReady); +const mockIsNeo4jReady = vi.mocked(isNeo4jReady); + +beforeEach(() => { + vi.clearAllMocks(); + mockComposePs.mockReturnValue([ + { name: "neoboard-postgres", state: "running", status: "Up" }, + { name: "neoboard-neo4j", state: "running", status: "Up" }, + ]); + mockIsPgReady.mockReturnValue(true); + mockIsNeo4jReady.mockReturnValue(true); +}); + +describe("runStatus", () => { + it("displays mode and version", async () => { + await runStatus(); + expect(info).toHaveBeenCalledWith(expect.stringContaining("docker")); + expect(info).toHaveBeenCalledWith(expect.stringContaining("0.0.1")); + }); + + it("shows container count", async () => { + await runStatus(); + expect(info).toHaveBeenCalledWith(expect.stringContaining("2 containers")); + }); + + it("shows healthy services", async () => { + await runStatus(); + expect(info).toHaveBeenCalledWith( + expect.stringContaining("PostgreSQL healthy"), + ); + expect(info).toHaveBeenCalledWith( + expect.stringContaining("Neo4j healthy"), + ); + }); + + it("shows stopped services", async () => { + mockIsPgReady.mockReturnValue(false); + mockIsNeo4jReady.mockReturnValue(false); + await runStatus(); + expect(info).toHaveBeenCalledWith( + expect.stringContaining("PostgreSQL stopped"), + ); + expect(info).toHaveBeenCalledWith( + expect.stringContaining("Neo4j stopped"), + ); + }); + + it("shows migration status", async () => { + await runStatus(); + expect(info).toHaveBeenCalledWith(expect.stringContaining("1 applied")); + }); + + it("shows no containers when none running", async () => { + mockComposePs.mockReturnValue([]); + await runStatus(); + expect(info).toHaveBeenCalledWith(expect.stringContaining("no containers")); + }); +}); diff --git a/cli/src/__tests__/commands/stop.test.ts b/cli/src/__tests__/commands/stop.test.ts new file mode 100644 index 00000000..08a7ac6b --- /dev/null +++ b/cli/src/__tests__/commands/stop.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../lib/docker.js", () => ({ + composeDown: vi.fn(), +})); + +vi.mock("../../lib/output.js", () => ({ + success: vi.fn(), +})); + +import { composeDown } from "../../lib/docker.js"; +import { runStop } from "../../commands/stop.js"; + +const mockComposeDown = vi.mocked(composeDown); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runStop", () => { + it("calls composeDown", async () => { + await runStop(); + expect(mockComposeDown).toHaveBeenCalledWith({ volumes: undefined }); + }); + + it("passes volumes flag through", async () => { + await runStop({ volumes: true }); + expect(mockComposeDown).toHaveBeenCalledWith({ volumes: true }); + }); +}); diff --git a/cli/src/__tests__/lib/config.test.ts b/cli/src/__tests__/lib/config.test.ts new file mode 100644 index 00000000..4f49e086 --- /dev/null +++ b/cli/src/__tests__/lib/config.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { + findProjectRoot, + readProjectConfig, + readLocalConfig, + writeLocalConfig, + _setRootForTesting, +} from "../../lib/config.js"; + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); +const mockWriteFileSync = vi.mocked(writeFileSync); + +beforeEach(() => { + vi.clearAllMocks(); + _setRootForTesting(null); +}); + +describe("findProjectRoot", () => { + it("finds root when package.json has name neoboard", () => { + mockExistsSync.mockImplementation((p) => { + return p === "/projects/neoboard/package.json"; + }); + mockReadFileSync.mockReturnValue(JSON.stringify({ name: "neoboard" })); + expect(findProjectRoot("/projects/neoboard/cli/src")).toBe( + "/projects/neoboard", + ); + }); + + it("walks up directories until found", () => { + mockExistsSync.mockImplementation((p) => { + return ( + p === "/a/b/c/package.json" || + p === "/a/b/package.json" || + p === "/a/package.json" + ); + }); + mockReadFileSync.mockImplementation((p) => { + if (p === "/a/package.json") return JSON.stringify({ name: "neoboard" }); + return JSON.stringify({ name: "other" }); + }); + expect(findProjectRoot("/a/b/c")).toBe("/a"); + }); + + it("throws when no project root found", () => { + mockExistsSync.mockReturnValue(false); + expect(() => findProjectRoot("/nowhere")).toThrow( + "Could not find NeoBoard project root", + ); + }); +}); + +describe("readProjectConfig", () => { + beforeEach(() => { + _setRootForTesting("/project"); + }); + + it("returns default config when file missing", () => { + mockExistsSync.mockReturnValue(false); + const config = readProjectConfig(); + expect(config.ports.app).toBe(3000); + expect(config.postgres.user).toBe("neoboard"); + expect(config.neo4j.user).toBe("neo4j"); + }); + + it("parses config from file", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + ports: { app: 4000, postgres: 5433, neo4j_http: 7475, neo4j_bolt: 7688 }, + postgres: { user: "custom", password: "pass", database: "mydb" }, + neo4j: { user: "admin", password: "secret" }, + seed: { script: "seed.mjs", neo4j_cypher: "init.cypher" }, + }), + ); + const config = readProjectConfig(); + expect(config.ports.app).toBe(4000); + expect(config.postgres.user).toBe("custom"); + }); + + it("returns default on invalid json", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue("not json"); + const config = readProjectConfig(); + expect(config.ports.app).toBe(3000); + }); +}); + +describe("readLocalConfig", () => { + beforeEach(() => { + _setRootForTesting("/project"); + }); + + it("returns default config when file missing", () => { + mockExistsSync.mockReturnValue(false); + const config = readLocalConfig(); + expect(config.mode).toBe("docker"); + }); + + it("parses local config from file", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ mode: "local" })); + const config = readLocalConfig(); + expect(config.mode).toBe("local"); + }); +}); + +describe("writeLocalConfig", () => { + it("writes config as formatted json", () => { + _setRootForTesting("/project"); + writeLocalConfig({ mode: "local" }); + expect(mockWriteFileSync).toHaveBeenCalledWith( + expect.stringContaining(".neoboard.local"), + JSON.stringify({ mode: "local" }, null, 2) + "\n", + ); + }); +}); diff --git a/cli/src/__tests__/lib/docker.test.ts b/cli/src/__tests__/lib/docker.test.ts new file mode 100644 index 00000000..2599cce4 --- /dev/null +++ b/cli/src/__tests__/lib/docker.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../lib/exec.js", () => ({ + run: vi.fn(), + runOrNull: vi.fn(), +})); + +vi.mock("../../lib/config.js", () => ({ + paths: { + root: "/project", + dockerDir: "/project/docker", + }, + readProjectConfig: vi.fn(() => ({ + ports: { app: 3000, postgres: 5432, neo4j_http: 7474, neo4j_bolt: 7687 }, + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + neo4j: { user: "neo4j", password: "neoboard123" }, + seed: { + script: "scripts/seed-demo.mjs", + neo4j_cypher: "docker/neo4j/init.cypher", + }, + })), +})); + +import { run, runOrNull } from "../../lib/exec.js"; +import { + isDockerRunning, + isComposeV2, + composeFile, + composeUp, + composeDown, + composePs, + dockerExec, + isPgReady, + isNeo4jReady, +} from "../../lib/docker.js"; + +const mockRun = vi.mocked(run); +const mockRunOrNull = vi.mocked(runOrNull); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("isDockerRunning", () => { + it("returns true when docker info succeeds", () => { + mockRunOrNull.mockReturnValue("some output"); + expect(isDockerRunning()).toBe(true); + }); + + it("returns false when docker info fails", () => { + mockRunOrNull.mockReturnValue(null); + expect(isDockerRunning()).toBe(false); + }); +}); + +describe("isComposeV2", () => { + it("returns true for v2 output", () => { + mockRunOrNull.mockReturnValue("Docker Compose version v2.24.0"); + expect(isComposeV2()).toBe(true); + }); + + it("returns false when compose not available", () => { + mockRunOrNull.mockReturnValue(null); + expect(isComposeV2()).toBe(false); + }); + + it("returns false for v1 output", () => { + mockRunOrNull.mockReturnValue("docker-compose version 1.29.0"); + expect(isComposeV2()).toBe(false); + }); +}); + +describe("composeFile", () => { + it("returns dev compose file by default", () => { + expect(composeFile()).toBe("/project/docker/docker-compose.yml"); + }); + + it("returns full compose file when full=true", () => { + expect(composeFile(true)).toBe("/project/docker/docker-compose.full.yml"); + }); +}); + +describe("composeUp", () => { + it("runs docker compose up with dev file", () => { + composeUp(); + expect(mockRun).toHaveBeenCalledWith( + "docker compose -f /project/docker/docker-compose.yml up -d --build", + { cwd: "/project" }, + ); + }); + + it("uses full compose file when full=true", () => { + composeUp({ full: true }); + expect(mockRun).toHaveBeenCalledWith( + "docker compose -f /project/docker/docker-compose.full.yml up -d --build", + { cwd: "/project" }, + ); + }); +}); + +describe("composeDown", () => { + it("runs docker compose down", () => { + composeDown(); + expect(mockRun).toHaveBeenCalledWith( + "docker compose -f /project/docker/docker-compose.yml down", + { cwd: "/project" }, + ); + }); + + it("adds -v flag when volumes=true", () => { + composeDown({ volumes: true }); + expect(mockRun).toHaveBeenCalledWith( + "docker compose -f /project/docker/docker-compose.yml down -v", + { cwd: "/project" }, + ); + }); +}); + +describe("composePs", () => { + it("parses json output into container info", () => { + mockRunOrNull.mockReturnValue( + '{"Name":"neoboard-postgres","State":"running","Status":"Up 5 minutes"}\n' + + '{"Name":"neoboard-neo4j","State":"running","Status":"Up 5 minutes"}', + ); + const result = composePs(); + expect(result).toEqual([ + { name: "neoboard-postgres", state: "running", status: "Up 5 minutes" }, + { name: "neoboard-neo4j", state: "running", status: "Up 5 minutes" }, + ]); + }); + + it("returns empty array when command fails", () => { + mockRunOrNull.mockReturnValue(null); + expect(composePs()).toEqual([]); + }); + + it("returns empty array on invalid json", () => { + mockRunOrNull.mockReturnValue("not json"); + expect(composePs()).toEqual([]); + }); +}); + +describe("dockerExec", () => { + it("runs command in container", () => { + mockRun.mockReturnValue("output"); + const result = dockerExec("neoboard-postgres", "pg_isready"); + expect(result).toBe("output"); + expect(mockRun).toHaveBeenCalledWith( + "docker exec neoboard-postgres pg_isready", + ); + }); +}); + +describe("isPgReady", () => { + it("returns true when pg_isready succeeds", () => { + mockRunOrNull.mockReturnValue("accepting connections"); + expect(isPgReady()).toBe(true); + }); + + it("returns false when pg_isready fails", () => { + mockRunOrNull.mockReturnValue(null); + expect(isPgReady()).toBe(false); + }); +}); + +describe("isNeo4jReady", () => { + it("returns true when cypher-shell succeeds", () => { + mockRunOrNull.mockReturnValue("1"); + expect(isNeo4jReady()).toBe(true); + }); + + it("returns false when cypher-shell fails", () => { + mockRunOrNull.mockReturnValue(null); + expect(isNeo4jReady()).toBe(false); + }); +}); diff --git a/cli/src/__tests__/lib/exec.test.ts b/cli/src/__tests__/lib/exec.test.ts new file mode 100644 index 00000000..993a5903 --- /dev/null +++ b/cli/src/__tests__/lib/exec.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { run, runOrNull, spawn, ExecError } from "../../lib/exec.js"; + +vi.mock("node:child_process", () => ({ + execSync: vi.fn(), + spawn: vi.fn(), +})); + +import { execSync, spawn as nodeSpawn } from "node:child_process"; + +const mockExecSync = vi.mocked(execSync); +const mockSpawn = vi.mocked(nodeSpawn); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("run", () => { + it("returns trimmed stdout on success", () => { + mockExecSync.mockReturnValue(" hello world \n"); + expect(run("echo hello")).toBe("hello world"); + }); + + it("passes cwd and env options", () => { + mockExecSync.mockReturnValue("ok"); + const env = { ...process.env, FOO: "bar" }; + run("test-cmd", { cwd: "/tmp", env }); + expect(mockExecSync).toHaveBeenCalledWith( + "test-cmd", + expect.objectContaining({ + cwd: "/tmp", + env, + }), + ); + }); + + it("throws ExecError on failure", () => { + const err = Object.assign(new Error("fail"), { + status: 42, + stderr: "bad stuff", + }); + mockExecSync.mockImplementation(() => { + throw err; + }); + try { + run("bad-cmd"); + expect.unreachable("should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(ExecError); + const execErr = e as ExecError; + expect(execErr.cmd).toBe("bad-cmd"); + expect(execErr.exitCode).toBe(42); + expect(execErr.stderr).toBe("bad stuff"); + } + }); + + it("defaults exitCode to 1 when status is undefined", () => { + const err = Object.assign(new Error("fail"), { stderr: "" }); + mockExecSync.mockImplementation(() => { + throw err; + }); + try { + run("fail-cmd"); + expect.unreachable("should have thrown"); + } catch (e) { + expect((e as ExecError).exitCode).toBe(1); + } + }); +}); + +describe("runOrNull", () => { + it("returns stdout on success", () => { + mockExecSync.mockReturnValue("result"); + expect(runOrNull("echo ok")).toBe("result"); + }); + + it("returns null on failure", () => { + mockExecSync.mockImplementation(() => { + throw new Error("fail"); + }); + expect(runOrNull("bad-cmd")).toBeNull(); + }); +}); + +describe("spawn", () => { + it("calls child_process.spawn with inherited stdio by default", () => { + const fakeChild = {} as ReturnType; + mockSpawn.mockReturnValue(fakeChild); + const result = spawn("npm", ["run", "dev"]); + expect(result).toBe(fakeChild); + expect(mockSpawn).toHaveBeenCalledWith("npm", ["run", "dev"], { + stdio: "inherit", + }); + }); + + it("allows overriding spawn options", () => { + const fakeChild = {} as ReturnType; + mockSpawn.mockReturnValue(fakeChild); + spawn("npm", ["test"], { cwd: "/app" }); + expect(mockSpawn).toHaveBeenCalledWith("npm", ["test"], { + stdio: "inherit", + cwd: "/app", + }); + }); +}); diff --git a/cli/src/__tests__/lib/health.test.ts b/cli/src/__tests__/lib/health.test.ts new file mode 100644 index 00000000..dbd130ef --- /dev/null +++ b/cli/src/__tests__/lib/health.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../lib/output.js", () => ({ + createSpinner: vi.fn(() => ({ + start: vi.fn(), + succeed: vi.fn(), + fail: vi.fn(), + })), +})); + +import { waitForHealth } from "../../lib/health.js"; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe("waitForHealth", () => { + it("resolves immediately when check passes on first try", async () => { + const check = vi.fn(() => true); + await waitForHealth({ check, label: "test-service" }); + expect(check).toHaveBeenCalledTimes(1); + }); + + it("polls until check passes", async () => { + let callCount = 0; + const check = vi.fn(() => { + callCount++; + return callCount >= 3; + }); + + const promise = waitForHealth({ + check, + label: "test-service", + interval: 100, + }); + + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(100); + + await promise; + expect(check).toHaveBeenCalledTimes(3); + }); + + it("throws on timeout", async () => { + const check = vi.fn(() => false); + const promise = waitForHealth({ + check, + label: "test-service", + interval: 100, + timeout: 250, + }); + + // Attach the rejection handler BEFORE advancing timers + const rejection = expect(promise).rejects.toThrow( + "Timeout waiting for test-service", + ); + + // Now advance past the timeout + await vi.advanceTimersByTimeAsync(300); + + await rejection; + }); +}); diff --git a/cli/src/__tests__/lib/output.test.ts b/cli/src/__tests__/lib/output.test.ts new file mode 100644 index 00000000..69b63acc --- /dev/null +++ b/cli/src/__tests__/lib/output.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + +beforeEach(() => { + logSpy.mockClear(); +}); + +// Import after spy is set up +import { info, warn, error, success, banner } from "../../lib/output.js"; + +describe("info", () => { + it("logs a message", () => { + info("test message"); + expect(logSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe("warn", () => { + it("logs a warning with prefix", () => { + warn("test warning"); + expect(logSpy).toHaveBeenCalledTimes(1); + const output = logSpy.mock.calls[0][0] as string; + expect(output).toContain("WARN"); + }); +}); + +describe("error", () => { + it("logs an error with prefix", () => { + error("test error"); + expect(logSpy).toHaveBeenCalledTimes(1); + const output = logSpy.mock.calls[0][0] as string; + expect(output).toContain("ERROR"); + }); +}); + +describe("success", () => { + it("logs a success message with checkmark", () => { + success("done"); + expect(logSpy).toHaveBeenCalledTimes(1); + const output = logSpy.mock.calls[0][0] as string; + expect(output).toContain("\u2714"); + }); +}); + +describe("banner", () => { + it("prints boxed output", () => { + banner(["Line 1", "Line 2"]); + // Top border + 2 lines + bottom border = 4 calls + expect(logSpy).toHaveBeenCalledTimes(4); + }); + + it("pads lines to equal width", () => { + banner(["Short", "Much longer line"]); + const line1 = logSpy.mock.calls[1][0] as string; + const line2 = logSpy.mock.calls[2][0] as string; + // Both content lines should have the same length + expect(line1.length).toBe(line2.length); + }); +}); diff --git a/cli/src/__tests__/lib/ports.test.ts b/cli/src/__tests__/lib/ports.test.ts new file mode 100644 index 00000000..d943fda4 --- /dev/null +++ b/cli/src/__tests__/lib/ports.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockServer = { + once: vi.fn(), + listen: vi.fn(), + close: vi.fn(), +}; + +vi.mock("node:net", () => ({ + createServer: vi.fn(() => mockServer), +})); + +import { isPortAvailable } from "../../lib/ports.js"; + +beforeEach(() => { + vi.clearAllMocks(); + mockServer.once.mockReset(); + mockServer.listen.mockReset(); + mockServer.close.mockReset(); +}); + +describe("isPortAvailable", () => { + it("returns true when port is free", async () => { + mockServer.once.mockImplementation((event: string, cb: () => void) => { + if (event === "listening") { + // Simulate successful listen + setTimeout(() => cb(), 0); + } + return mockServer; + }); + mockServer.close.mockImplementation((cb: () => void) => cb()); + + const result = await isPortAvailable(3000); + expect(result).toBe(true); + expect(mockServer.listen).toHaveBeenCalledWith(3000, "127.0.0.1"); + }); + + it("returns false when port is in use", async () => { + mockServer.once.mockImplementation((event: string, cb: () => void) => { + if (event === "error") { + setTimeout(() => cb(), 0); + } + return mockServer; + }); + + const result = await isPortAvailable(3000); + expect(result).toBe(false); + }); +}); diff --git a/cli/src/__tests__/program.test.ts b/cli/src/__tests__/program.test.ts new file mode 100644 index 00000000..63d72fad --- /dev/null +++ b/cli/src/__tests__/program.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Command } from "commander"; + +// We test the program structure without executing real commands. +// Import the program export to inspect its configuration. + +vi.mock("node:fs", () => ({ + readFileSync: vi.fn(() => JSON.stringify({ version: "0.0.1" })), +})); + +// Prevent actual command execution +vi.mock("../commands/doctor.js", () => ({ + runDoctor: vi.fn(async () => []), + printResults: vi.fn(() => false), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("CLI program", () => { + let program: Command; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../index.js"); + program = mod.program; + }); + + it("has correct name", () => { + expect(program.name()).toBe("neoboard"); + }); + + it("has a version set", () => { + expect(program.version()).toBe("0.0.1"); + }); + + it("registers top-level commands", () => { + const commandNames = program.commands.map((c) => c.name()); + expect(commandNames).toContain("init"); + expect(commandNames).toContain("start"); + expect(commandNames).toContain("stop"); + expect(commandNames).toContain("dev"); + expect(commandNames).toContain("setup"); + expect(commandNames).toContain("status"); + expect(commandNames).toContain("doctor"); + expect(commandNames).toContain("demo"); + expect(commandNames).toContain("env"); + expect(commandNames).toContain("db"); + }); + + it("registers db subcommands", () => { + const dbCmd = program.commands.find((c) => c.name() === "db"); + expect(dbCmd).toBeDefined(); + const subNames = dbCmd!.commands.map((c) => c.name()); + expect(subNames).toContain("migrate"); + expect(subNames).toContain("reset"); + expect(subNames).toContain("seed"); + expect(subNames).toContain("dump"); + }); + + it("init has --mode option", () => { + const initCmd = program.commands.find((c) => c.name() === "init"); + const opts = initCmd!.options.map((o) => o.long); + expect(opts).toContain("--mode"); + }); + + it("env has --regenerate and --validate options", () => { + const envCmd = program.commands.find((c) => c.name() === "env"); + const opts = envCmd!.options.map((o) => o.long); + expect(opts).toContain("--regenerate"); + expect(opts).toContain("--validate"); + }); + + it("db migrate has --status, --to, --dry-run options", () => { + const dbCmd = program.commands.find((c) => c.name() === "db"); + const migrateCmd = dbCmd!.commands.find((c) => c.name() === "migrate"); + const opts = migrateCmd!.options.map((o) => o.long); + expect(opts).toContain("--status"); + expect(opts).toContain("--to"); + expect(opts).toContain("--dry-run"); + }); + + it("db dump has --output and --data-only options", () => { + const dbCmd = program.commands.find((c) => c.name() === "db"); + const dumpCmd = dbCmd!.commands.find((c) => c.name() === "dump"); + const opts = dumpCmd!.options.map((o) => o.long); + expect(opts).toContain("--output"); + expect(opts).toContain("--data-only"); + }); + + it("db reset has --no-seed and --force options", () => { + const dbCmd = program.commands.find((c) => c.name() === "db"); + const resetCmd = dbCmd!.commands.find((c) => c.name() === "reset"); + const opts = resetCmd!.options.map((o) => o.long); + expect(opts).toContain("--no-seed"); + expect(opts).toContain("--force"); + }); + + it("stop has --volumes option", () => { + const stopCmd = program.commands.find((c) => c.name() === "stop"); + const opts = stopCmd!.options.map((o) => o.long); + expect(opts).toContain("--volumes"); + }); +}); diff --git a/cli/src/commands/db/dump.ts b/cli/src/commands/db/dump.ts new file mode 100644 index 00000000..fcbd2d15 --- /dev/null +++ b/cli/src/commands/db/dump.ts @@ -0,0 +1,44 @@ +import { writeFileSync, statSync } from "node:fs"; +import { run } from "../../lib/exec.js"; +import { paths, readProjectConfig, getMode } from "../../lib/config.js"; +import { success, createSpinner } from "../../lib/output.js"; + +function defaultFilename(): string { + const now = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + return `neoboard-dump-${now}.sql`; +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export async function runDbDump(opts: { + output?: string; + dataOnly?: boolean; +}): Promise { + const config = readProjectConfig(); + const outPath = opts.output ?? `${paths.root}/${defaultFilename()}`; + const dataFlag = opts.dataOnly ? " --data-only" : ""; + + const spinner = createSpinner("Dumping database..."); + spinner.start(); + + const mode = getMode(); + let sql: string; + if (mode === "docker") { + sql = run( + `docker exec neoboard-postgres pg_dump -U ${config.postgres.user} ${config.postgres.database}${dataFlag}`, + ); + } else { + sql = run( + `pg_dump -h localhost -p ${config.ports.postgres} -U ${config.postgres.user} ${config.postgres.database}${dataFlag}`, + ); + } + + writeFileSync(outPath, sql); + const size = statSync(outPath).size; + spinner.succeed(`Backup saved to ${outPath} (${formatSize(size)})`); + success("Database dump complete"); +} diff --git a/cli/src/commands/db/migrate.ts b/cli/src/commands/db/migrate.ts new file mode 100644 index 00000000..70de2d94 --- /dev/null +++ b/cli/src/commands/db/migrate.ts @@ -0,0 +1,88 @@ +import { existsSync, readFileSync } from "node:fs"; +import { run } from "../../lib/exec.js"; +import { dockerExec } from "../../lib/docker.js"; +import { paths, getMode } from "../../lib/config.js"; +import { info, success, warn, createSpinner } from "../../lib/output.js"; + +interface JournalEntry { + idx: number; + tag: string; + when: number; +} + +interface Journal { + version: string; + entries: JournalEntry[]; +} + +function readJournal(): Journal | null { + if (!existsSync(paths.journalPath)) return null; + try { + return JSON.parse(readFileSync(paths.journalPath, "utf-8")); + } catch { + return null; + } +} + +export function showMigrationStatus(): void { + const journal = readJournal(); + if (!journal) { + warn("No migration journal found."); + return; + } + + info(`Migrations: ${journal.entries.length} available`); + for (const entry of journal.entries) { + const date = new Date(entry.when).toISOString().slice(0, 10); + info(` ${entry.idx}: ${entry.tag} (${date})`); + } +} + +export function showDryRun(): void { + const journal = readJournal(); + if (!journal) { + warn("No migration journal found."); + return; + } + info(`Would apply ${journal.entries.length} migration(s):`); + for (const entry of journal.entries) { + info(` - ${entry.tag}`); + } +} + +export async function runDbMigrate(opts: { + status?: boolean; + to?: string; + dryRun?: boolean; +}): Promise { + if (opts.status) { + showMigrationStatus(); + return; + } + + if (opts.dryRun) { + showDryRun(); + return; + } + + info("Tip: Run 'neoboard db dump' to backup before migrating"); + + if (opts.to) { + warn( + `--to ${opts.to}: Drizzle Kit applies all pending migrations. Version validation is not yet supported.`, + ); + } + + const spinner = createSpinner("Running migrations..."); + spinner.start(); + + const mode = getMode(); + if (mode === "docker") { + dockerExec("neoboard-app", "npx drizzle-kit migrate"); + } else { + run("npx drizzle-kit migrate", { cwd: paths.appDir }); + } + + spinner.succeed("Migrations applied"); + success("Database is up to date"); +} diff --git a/cli/src/commands/db/reset.ts b/cli/src/commands/db/reset.ts new file mode 100644 index 00000000..2cdffa00 --- /dev/null +++ b/cli/src/commands/db/reset.ts @@ -0,0 +1,89 @@ +import { readFileSync } from "node:fs"; +import { run } from "../../lib/exec.js"; +import { dockerExec } from "../../lib/docker.js"; +import { paths, readProjectConfig, getMode } from "../../lib/config.js"; +import { + info, + success, + error as logError, + createSpinner, +} from "../../lib/output.js"; +import { confirm } from "../../lib/prompt.js"; +import { runDbMigrate } from "./migrate.js"; +import { runDbSeed } from "./seed.js"; + +function getDatabaseHost(): string { + try { + const content = readFileSync(paths.envFile, "utf-8"); + const match = content.match(/DATABASE_URL=.*@([^:/]+)/); + return match?.[1] ?? "localhost"; + } catch { + return "localhost"; + } +} + +function isLocalhost(host: string): boolean { + return host === "localhost" || host === "127.0.0.1"; +} + +export async function runDbReset(opts?: { + noSeed?: boolean; + force?: boolean; +}): Promise { + const host = getDatabaseHost(); + if (!isLocalhost(host)) { + logError( + `Refusing to reset: DATABASE_URL points to '${host}' (not localhost). This command only works on local databases.`, + ); + process.exitCode = 1; + return; + } + + if (!opts?.force) { + const confirmed = await confirm( + "This will DROP the neoboard database and recreate it. Continue?", + ); + if (!confirmed) { + info("Aborted."); + return; + } + } + + const config = readProjectConfig(); + const mode = getMode(); + const { user, database } = config.postgres; + + const spinner = createSpinner("Resetting database..."); + spinner.start(); + + if (mode === "docker") { + // Connect to 'postgres' db to drop/create target db + dockerExec( + "neoboard-postgres", + `psql -U ${user} -d postgres -c "DROP DATABASE IF EXISTS ${database}"`, + ); + dockerExec( + "neoboard-postgres", + `psql -U ${user} -d postgres -c "CREATE DATABASE ${database}"`, + ); + } else { + run( + `psql -h localhost -U ${user} -d postgres -c "DROP DATABASE IF EXISTS ${database}"`, + ); + run( + `psql -h localhost -U ${user} -d postgres -c "CREATE DATABASE ${database}"`, + ); + } + + spinner.succeed("Database dropped and recreated"); + + // Replay migrations + await runDbMigrate({}); + + // Re-seed unless --no-seed + if (!opts?.noSeed) { + await runDbSeed(); + } + + success("Database reset complete"); +} diff --git a/cli/src/commands/db/seed.ts b/cli/src/commands/db/seed.ts new file mode 100644 index 00000000..fdb63cff --- /dev/null +++ b/cli/src/commands/db/seed.ts @@ -0,0 +1,60 @@ +import { run } from "../../lib/exec.js"; +import { dockerExec } from "../../lib/docker.js"; +import { paths, readProjectConfig } from "../../lib/config.js"; +import { success, createSpinner } from "../../lib/output.js"; + +function getNeo4jNodeCount(): number { + const config = readProjectConfig(); + const out = dockerExec( + "neoboard-neo4j", + `cypher-shell -u ${config.neo4j.user} -p ${config.neo4j.password} "MATCH (n) RETURN count(n) AS c"`, + ); + const match = out.match(/(\d+)/); + return match ? parseInt(match[1], 10) : 0; +} + +export async function seedNeo4j(): Promise { + const spinner = createSpinner("Seeding Neo4j..."); + spinner.start(); + + const count = getNeo4jNodeCount(); + if (count > 0) { + spinner.succeed(`Neo4j already has ${count} nodes — skipping seed`); + return; + } + + const config = readProjectConfig(); + dockerExec( + "neoboard-neo4j", + `cypher-shell -u ${config.neo4j.user} -p ${config.neo4j.password} -f /var/lib/neo4j/import/init.cypher`, + ); + spinner.succeed("Neo4j seeded with demo data"); +} + +export async function seedPostgres(): Promise { + const config = readProjectConfig(); + const spinner = createSpinner("Seeding PostgreSQL demo data..."); + spinner.start(); + + run(`node ${paths.root}/${config.seed.script}`, { cwd: paths.root }); + spinner.succeed("PostgreSQL seeded with demo data"); +} + +export async function runDbSeed(opts?: { + neo4j?: boolean; + demo?: boolean; +}): Promise { + const seedNeo4jOnly = opts?.neo4j && !opts?.demo; + const seedDemoOnly = opts?.demo && !opts?.neo4j; + const seedBoth = (!opts?.neo4j && !opts?.demo) || (opts?.neo4j && opts?.demo); + + if (seedBoth || seedNeo4jOnly) { + await seedNeo4j(); + } + + if (seedBoth || seedDemoOnly) { + await seedPostgres(); + } + + success("Seeding complete"); +} diff --git a/cli/src/commands/demo.ts b/cli/src/commands/demo.ts new file mode 100644 index 00000000..8d3a5642 --- /dev/null +++ b/cli/src/commands/demo.ts @@ -0,0 +1,19 @@ +import { runSetup } from "./setup.js"; +import { runDbSeed } from "./db/seed.js"; +import { success, banner } from "../lib/output.js"; + +export async function runDemo(opts?: { + mode?: "docker" | "local"; +}): Promise { + await runSetup(opts); + await runDbSeed({ neo4j: true, demo: true }); + + banner([ + "Demo environment ready!", + "", + "Login credentials:", + " Email: admin@neoboard.local", + " Password: admin123", + ]); + success("Open http://localhost:3000 to get started"); +} diff --git a/cli/src/commands/dev.ts b/cli/src/commands/dev.ts new file mode 100644 index 00000000..8b6b8a0c --- /dev/null +++ b/cli/src/commands/dev.ts @@ -0,0 +1,26 @@ +import { spawn } from "../lib/exec.js"; +import { paths, getMode } from "../lib/config.js"; +import { info } from "../lib/output.js"; + +export async function runDev(): Promise { + const mode = getMode(); + + if (mode === "docker") { + info( + "In Docker mode, the app runs inside the container. Use 'neoboard start' and visit http://localhost:3000.", + ); + return; + } + + info("Starting Next.js dev server..."); + const child = spawn("npm", ["run", "dev"], { cwd: paths.appDir }); + + // Forward signals for clean shutdown + const cleanup = () => child.kill(); + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + + await new Promise((resolve) => { + child.on("close", () => resolve()); + }); +} diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts new file mode 100644 index 00000000..1cc3ebdd --- /dev/null +++ b/cli/src/commands/doctor.ts @@ -0,0 +1,115 @@ +import { existsSync } from "node:fs"; +import { runOrNull } from "../lib/exec.js"; +import { isPortAvailable } from "../lib/ports.js"; +import { paths, readProjectConfig } from "../lib/config.js"; +import { success, warn, error as logError } from "../lib/output.js"; + +export interface CheckResult { + name: string; + status: "ok" | "warn" | "fail"; + message: string; +} + +export function checkDockerRunning(): CheckResult { + const ok = runOrNull("docker info") !== null; + return { + name: "Docker daemon", + status: ok ? "ok" : "fail", + message: ok ? "Docker daemon running" : "Docker daemon not running", + }; +} + +export function checkDockerComposeV2(): CheckResult { + const out = runOrNull("docker compose version"); + const ok = out !== null && out.includes("v2"); + return { + name: "Docker Compose v2", + status: ok ? "ok" : "fail", + message: ok ? "Docker Compose v2 available" : "Docker Compose v2 not found", + }; +} + +export function checkNodeVersion(): CheckResult { + const major = parseInt(process.version.slice(1), 10); + const ok = major >= 20; + return { + name: "Node.js", + status: ok ? "ok" : "fail", + message: ok + ? `Node.js ${process.version}` + : `Node.js >= 20 required (found: ${process.version})`, + }; +} + +export async function checkPortAvailable( + port: number, + label: string, +): Promise { + const available = await isPortAvailable(port); + return { + name: `Port ${port} (${label})`, + status: available ? "ok" : "warn", + message: available + ? `Port ${port} available` + : `Port ${port} in use — another process may be running`, + }; +} + +export function checkNodeModulesExist(): CheckResult { + const exists = existsSync(`${paths.appDir}/node_modules`); + return { + name: "Dependencies", + status: exists ? "ok" : "warn", + message: exists + ? "app/node_modules exists" + : "app/node_modules missing — run 'neoboard init'", + }; +} + +export function checkEnvFileExists(): CheckResult { + const exists = existsSync(paths.envFile); + return { + name: ".env.local", + status: exists ? "ok" : "warn", + message: exists + ? "app/.env.local exists" + : "app/.env.local missing — run 'neoboard env'", + }; +} + +export async function runDoctor(): Promise { + const config = readProjectConfig(); + const results: CheckResult[] = [ + checkDockerRunning(), + checkDockerComposeV2(), + checkNodeVersion(), + ]; + + const portChecks = await Promise.all([ + checkPortAvailable(config.ports.postgres, "PostgreSQL"), + checkPortAvailable(config.ports.neo4j_http, "Neo4j HTTP"), + checkPortAvailable(config.ports.neo4j_bolt, "Neo4j Bolt"), + checkPortAvailable(config.ports.app, "App"), + ]); + results.push(...portChecks); + + results.push(checkNodeModulesExist()); + results.push(checkEnvFileExists()); + + return results; +} + +export function printResults(results: CheckResult[]): boolean { + let hasFailure = false; + for (const r of results) { + if (r.status === "ok") { + success(r.message); + } else if (r.status === "warn") { + warn(r.message); + } else { + logError(r.message); + hasFailure = true; + } + } + return hasFailure; +} diff --git a/cli/src/commands/env.ts b/cli/src/commands/env.ts new file mode 100644 index 00000000..1e6b3e72 --- /dev/null +++ b/cli/src/commands/env.ts @@ -0,0 +1,100 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { paths, readProjectConfig, getMode } from "../lib/config.js"; +import { + info, + success, + error as logError, + banner, +} from "../lib/output.js"; + +const REQUIRED_VARS = [ + "DATABASE_URL", + "ENCRYPTION_KEY", + "NEXTAUTH_SECRET", + "NEXTAUTH_URL", +]; + +function generateSecret(): string { + return randomBytes(32).toString("hex"); +} + +function parseEnvFile(content: string): Record { + const vars: Record = {}; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eqIdx = trimmed.indexOf("="); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + const value = trimmed.slice(eqIdx + 1).trim(); + vars[key] = value; + } + return vars; +} + +export function validateEnv(): { ok: boolean; missing: string[] } { + if (!existsSync(paths.envFile)) { + return { ok: false, missing: ["(file does not exist)"] }; + } + const content = readFileSync(paths.envFile, "utf-8"); + const vars = parseEnvFile(content); + const missing = REQUIRED_VARS.filter((k) => !vars[k]); + return { ok: missing.length === 0, missing }; +} + +export function generateEnvFile(opts?: { regenerate?: boolean }): void { + if (existsSync(paths.envFile) && !opts?.regenerate) { + info("app/.env.local already exists. Use --regenerate to overwrite."); + return; + } + + const config = readProjectConfig(); + const dbUrl = `postgresql://${config.postgres.user}:${config.postgres.password}@localhost:${config.ports.postgres}/${config.postgres.database}`; + const encryptionKey = generateSecret(); + const nextauthSecret = generateSecret(); + const bootstrapToken = generateSecret(); + + const lines = [ + `DATABASE_URL=${dbUrl}`, + `ENCRYPTION_KEY=${encryptionKey}`, + `NEXTAUTH_SECRET=${nextauthSecret}`, + `NEXTAUTH_URL=http://localhost:${config.ports.app}`, + `ADMIN_BOOTSTRAP_TOKEN=${bootstrapToken}`, + "", + ]; + + writeFileSync(paths.envFile, lines.join("\n")); + success("Generated app/.env.local"); + + banner([ + "Save this token — you'll need it for first-time signup:", + "", + `ADMIN_BOOTSTRAP_TOKEN=${bootstrapToken}`, + ]); +} + +export async function runEnv(opts: { + regenerate?: boolean; + validate?: boolean; +}): Promise { + if (getMode() === "docker") { + info( + "In Docker mode, environment is managed by docker-compose. Not needed.", + ); + return; + } + + if (opts.validate) { + const result = validateEnv(); + if (result.ok) { + success("All required environment variables are set."); + } else { + logError(`Missing variables: ${result.missing.join(", ")}`); + process.exitCode = 1; + } + return; + } + + generateEnvFile({ regenerate: opts.regenerate }); +} diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts new file mode 100644 index 00000000..94d4603c --- /dev/null +++ b/cli/src/commands/init.ts @@ -0,0 +1,46 @@ +import { existsSync, writeFileSync } from "node:fs"; +import { run } from "../lib/exec.js"; +import { + paths, + readProjectConfig, + writeLocalConfig, + type ProjectConfig, +} from "../lib/config.js"; +import { info, success, createSpinner } from "../lib/output.js"; +import { generateEnvFile } from "./env.js"; + +function writeProjectConfig(config: ProjectConfig): void { + writeFileSync(paths.projectConfig, JSON.stringify(config, null, 2) + "\n"); +} + +export async function runInit(opts?: { + mode?: "docker" | "local"; +}): Promise { + const mode = opts?.mode ?? "docker"; + + if (existsSync(paths.projectConfig)) { + info("neoboard.config.json already exists — skipping config generation."); + } else { + const config = readProjectConfig(); // returns defaults + writeProjectConfig(config); + success("Created neoboard.config.json"); + } + + writeLocalConfig({ mode }); + success(`Mode set to '${mode}' in .neoboard.local`); + + if (mode === "local") { + const spinner = createSpinner("Installing dependencies..."); + spinner.start(); + const dirs = [paths.root, paths.appDir]; + for (const dir of dirs) { + run("npm install", { cwd: dir }); + } + spinner.succeed("Dependencies installed"); + + generateEnvFile(); + } + + info(""); + info("Next step: run 'neoboard start' to launch services."); +} diff --git a/cli/src/commands/setup.ts b/cli/src/commands/setup.ts new file mode 100644 index 00000000..620b1693 --- /dev/null +++ b/cli/src/commands/setup.ts @@ -0,0 +1,11 @@ +import { runInit } from "./init.js"; +import { runStart } from "./start.js"; +import { success } from "../lib/output.js"; + +export async function runSetup(opts?: { + mode?: "docker" | "local"; +}): Promise { + await runInit(opts); + await runStart(); + success("Setup complete!"); +} diff --git a/cli/src/commands/start.ts b/cli/src/commands/start.ts new file mode 100644 index 00000000..0f7cd20b --- /dev/null +++ b/cli/src/commands/start.ts @@ -0,0 +1,42 @@ +import { composeUp } from "../lib/docker.js"; +import { waitForHealth } from "../lib/health.js"; +import { isPgReady, isNeo4jReady } from "../lib/docker.js"; +import { readProjectConfig, getMode } from "../lib/config.js"; +import { info, success, banner } from "../lib/output.js"; +import { runDoctor, printResults } from "./doctor.js"; +import { runDbMigrate } from "./db/migrate.js"; + +export async function runStart(): Promise { + // 1. Prerequisite checks + const results = await runDoctor(); + const hasFailure = printResults(results); + if (hasFailure) { + process.exitCode = 1; + return; + } + + // 2. Start containers + const mode = getMode(); + const full = mode === "docker"; + info(full ? "Starting full stack..." : "Starting database containers..."); + composeUp({ full }); + + // 3. Wait for health + const config = readProjectConfig(); + await waitForHealth({ check: isPgReady, label: "PostgreSQL" }); + await waitForHealth({ check: isNeo4jReady, label: "Neo4j" }); + + // 4. Run migrations + await runDbMigrate({}); + + // 5. Done + const url = `http://localhost:${config.ports.app}`; + banner([ + "NeoBoard is running!", + "", + `App: ${url}`, + `Neo4j: http://localhost:${config.ports.neo4j_http}`, + `PostgreSQL: localhost:${config.ports.postgres}`, + ]); + success(`Open ${url} in your browser`); +} diff --git a/cli/src/commands/status.ts b/cli/src/commands/status.ts new file mode 100644 index 00000000..737bf777 --- /dev/null +++ b/cli/src/commands/status.ts @@ -0,0 +1,66 @@ +import { existsSync, readFileSync } from "node:fs"; +import { composePs, isPgReady, isNeo4jReady } from "../lib/docker.js"; +import { paths, getMode, readProjectConfig } from "../lib/config.js"; +import { info } from "../lib/output.js"; +import { runOrNull } from "../lib/exec.js"; + +function getAppHealth(port: number): string { + const out = runOrNull( + `curl -s -o /dev/null -w "%{http_code}" http://localhost:${port}`, + ); + if (out === "200") return "healthy"; + if (out) return `unhealthy (HTTP ${out})`; + return "not running"; +} + +function getMigrationStatus(): string { + if (!existsSync(paths.journalPath)) return "no journal found"; + try { + const journal = JSON.parse(readFileSync(paths.journalPath, "utf-8")); + const count = journal.entries?.length ?? 0; + const latest = journal.entries?.[count - 1]?.tag ?? "none"; + return `${count} applied (latest: ${latest})`; + } catch { + return "error reading journal"; + } +} + +function getVersion(): string { + try { + const pkg = JSON.parse( + readFileSync(`${paths.root}/cli/package.json`, "utf-8"), + ); + return pkg.version ?? "unknown"; + } catch { + return "unknown"; + } +} + +export async function runStatus(): Promise { + const mode = getMode(); + const config = readProjectConfig(); + const containers = composePs(); + + info(`Mode: ${mode}`); + info(`Version: ${getVersion()}`); + info( + `Docker: ${containers.length > 0 ? `running (${containers.length} containers)` : "no containers"}`, + ); + info(""); + + const pgHealthy = isPgReady(); + const neo4jHealthy = isNeo4jReady(); + const appHealth = getAppHealth(config.ports.app); + + info("Service Status"); + info("\u2500".repeat(30)); + info( + `PostgreSQL ${pgHealthy ? "healthy" : "stopped"} (localhost:${config.ports.postgres})`, + ); + info( + `Neo4j ${neo4jHealthy ? "healthy" : "stopped"} (localhost:${config.ports.neo4j_bolt})`, + ); + info(`App ${appHealth} (http://localhost:${config.ports.app})`); + info(""); + info(`Migrations: ${getMigrationStatus()}`); +} diff --git a/cli/src/commands/stop.ts b/cli/src/commands/stop.ts new file mode 100644 index 00000000..76c1f502 --- /dev/null +++ b/cli/src/commands/stop.ts @@ -0,0 +1,7 @@ +import { composeDown } from "../lib/docker.js"; +import { success } from "../lib/output.js"; + +export async function runStop(opts?: { volumes?: boolean }): Promise { + composeDown({ volumes: opts?.volumes }); + success("NeoBoard services stopped"); +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 00000000..d7e35a3c --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +import { Command } from "commander"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const pkg = JSON.parse( + readFileSync(join(__dirname, "..", "package.json"), "utf-8"), +); + +export const program = new Command(); + +program + .name("neoboard") + .description("NeoBoard CLI — local development and database management") + .version(pkg.version); + +// Top-level commands + +program + .command("init") + .description("Initialize a new NeoBoard project") + .option("--mode ", "Set mode: docker or local", "docker") + .action(async (opts) => { + const { runInit } = await import("./commands/init.js"); + await runInit({ mode: opts.mode }); + }); + +program + .command("start") + .description("Start NeoBoard services") + .action(async () => { + const { runStart } = await import("./commands/start.js"); + await runStart(); + }); + +program + .command("stop") + .description("Stop NeoBoard services") + .option("--volumes", "Also remove volumes") + .action(async (opts) => { + const { runStop } = await import("./commands/stop.js"); + await runStop({ volumes: opts.volumes }); + }); + +program + .command("dev") + .description("Start NeoBoard in development mode") + .action(async () => { + const { runDev } = await import("./commands/dev.js"); + await runDev(); + }); + +program + .command("setup") + .description("Set up local development environment (init + start)") + .option("--mode ", "Set mode: docker or local", "docker") + .action(async (opts) => { + const { runSetup } = await import("./commands/setup.js"); + await runSetup({ mode: opts.mode }); + }); + +program + .command("status") + .description("Show status of NeoBoard services") + .action(async () => { + const { runStatus } = await import("./commands/status.js"); + await runStatus(); + }); + +program + .command("doctor") + .description("Check system prerequisites and configuration") + .action(async () => { + const { runDoctor, printResults } = await import("./commands/doctor.js"); + const results = await runDoctor(); + const hasFailure = printResults(results); + if (hasFailure) process.exitCode = 1; + }); + +program + .command("demo") + .description("Load demo data and dashboards") + .option("--mode ", "Set mode: docker or local", "docker") + .action(async (opts) => { + const { runDemo } = await import("./commands/demo.js"); + await runDemo({ mode: opts.mode }); + }); + +program + .command("env") + .description("Manage environment variables") + .option("--regenerate", "Force regenerate all secrets") + .option("--validate", "Check all required vars are set") + .action(async (opts) => { + const { runEnv } = await import("./commands/env.js"); + await runEnv({ regenerate: opts.regenerate, validate: opts.validate }); + }); + +// db subcommand group + +const db = program.command("db").description("Database management commands"); + +db.command("migrate") + .description("Run database migrations") + .option("--status", "Show migration status") + .option("--to ", "Target version") + .option("--dry-run", "Preview without applying") + .action(async (opts) => { + const { runDbMigrate } = await import("./commands/db/migrate.js"); + await runDbMigrate({ + status: opts.status, + to: opts.to, + dryRun: opts.dryRun, + }); + }); + +db.command("reset") + .description("Reset database to clean state") + .option("--no-seed", "Skip seeding after reset") + .option("--force", "Skip confirmation prompt") + .action(async (opts) => { + const { runDbReset } = await import("./commands/db/reset.js"); + await runDbReset({ noSeed: !opts.seed, force: opts.force }); + }); + +db.command("seed") + .description("Seed database with sample data") + .option("--neo4j", "Seed Neo4j graph data only") + .option("--demo", "Seed demo user/dashboards only") + .action(async (opts) => { + const { runDbSeed } = await import("./commands/db/seed.js"); + await runDbSeed({ neo4j: opts.neo4j, demo: opts.demo }); + }); + +db.command("dump") + .description("Dump database contents") + .option("--output ", "Output file path") + .option("--data-only", "Dump data only, no schema") + .action(async (opts) => { + const { runDbDump } = await import("./commands/db/dump.js"); + await runDbDump({ output: opts.output, dataOnly: opts.dataOnly }); + }); + +// Only parse when run directly (not when imported in tests) +const isDirectRun = + process.argv[1]?.endsWith("index.js") || + process.argv[1]?.endsWith("neoboard"); + +if (isDirectRun) { + program.parse(); +} diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts new file mode 100644 index 00000000..70a8f44d --- /dev/null +++ b/cli/src/lib/config.ts @@ -0,0 +1,129 @@ +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; + +// Types +export interface ProjectConfig { + ports: { + app: number; + postgres: number; + neo4j_http: number; + neo4j_bolt: number; + }; + postgres: { user: string; password: string; database: string }; + neo4j: { user: string; password: string }; + seed: { script: string; neo4j_cypher: string }; +} + +export interface LocalConfig { + mode: "docker" | "local"; +} + +// Project root detection +export function findProjectRoot(startDir?: string): string { + let dir = startDir ?? dirname(fileURLToPath(import.meta.url)); + while (dir !== "/") { + const pkgPath = join(dir, "package.json"); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.name === "neoboard") return dir; + } catch { + /* skip */ + } + } + dir = dirname(dir); + } + throw new Error( + "Could not find NeoBoard project root (package.json with name 'neoboard')", + ); +} + +// Path constants (lazy-initialized) +let _root: string | null = null; +function root(): string { + if (!_root) _root = findProjectRoot(); + return _root; +} + +/** @internal — test-only helper to override cached root */ +export function _setRootForTesting(dir: string | null): void { + _root = dir; +} + +export const paths = { + get root() { + return root(); + }, + get appDir() { + return join(root(), "app"); + }, + get dockerDir() { + return join(root(), "docker"); + }, + get migrationsDir() { + return join(root(), "app", "drizzle", "migrations"); + }, + get journalPath() { + return join( + root(), + "app", + "drizzle", + "migrations", + "meta", + "_journal.json", + ); + }, + get envFile() { + return join(root(), "app", ".env.local"); + }, + get envExample() { + return join(root(), ".env.example"); + }, + get projectConfig() { + return join(root(), "neoboard.config.json"); + }, + get localConfig() { + return join(root(), ".neoboard.local"); + }, +}; + +// Config defaults +const DEFAULT_PROJECT_CONFIG: ProjectConfig = { + ports: { app: 3000, postgres: 5432, neo4j_http: 7474, neo4j_bolt: 7687 }, + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + neo4j: { user: "neo4j", password: "neoboard123" }, + seed: { + script: "scripts/seed-demo.mjs", + neo4j_cypher: "docker/neo4j/init.cypher", + }, +}; + +const DEFAULT_LOCAL_CONFIG: LocalConfig = { mode: "docker" }; + +// Read/write functions +export function readProjectConfig(): ProjectConfig { + if (!existsSync(paths.projectConfig)) return DEFAULT_PROJECT_CONFIG; + try { + return JSON.parse(readFileSync(paths.projectConfig, "utf-8")); + } catch { + return DEFAULT_PROJECT_CONFIG; + } +} + +export function readLocalConfig(): LocalConfig { + if (!existsSync(paths.localConfig)) return DEFAULT_LOCAL_CONFIG; + try { + return JSON.parse(readFileSync(paths.localConfig, "utf-8")); + } catch { + return DEFAULT_LOCAL_CONFIG; + } +} + +export function writeLocalConfig(config: LocalConfig): void { + writeFileSync(paths.localConfig, JSON.stringify(config, null, 2) + "\n"); +} + +export function getMode(): "docker" | "local" { + return readLocalConfig().mode; +} diff --git a/cli/src/lib/docker.ts b/cli/src/lib/docker.ts new file mode 100644 index 00000000..860a9661 --- /dev/null +++ b/cli/src/lib/docker.ts @@ -0,0 +1,80 @@ +import { run, runOrNull } from "./exec.js"; +import { paths, readProjectConfig } from "./config.js"; +import { join } from "node:path"; + +export function isDockerRunning(): boolean { + return runOrNull("docker info") !== null; +} + +export function isComposeV2(): boolean { + const out = runOrNull("docker compose version"); + return out !== null && out.includes("v2"); +} + +export function composeFile(full = false): string { + const name = full ? "docker-compose.full.yml" : "docker-compose.yml"; + return join(paths.dockerDir, name); +} + +export function composeUp(opts?: { full?: boolean }): void { + const file = composeFile(opts?.full); + run(`docker compose -f ${file} up -d --build`, { cwd: paths.root }); +} + +export function composeDown(opts?: { volumes?: boolean }): void { + const file = composeFile(); + const flags = opts?.volumes ? " -v" : ""; + run(`docker compose -f ${file} down${flags}`, { cwd: paths.root }); +} + +export interface ContainerInfo { + name: string; + state: string; + status: string; +} + +export function composePs(): ContainerInfo[] { + const file = composeFile(); + const out = runOrNull(`docker compose -f ${file} ps --format json`, { + cwd: paths.root, + }); + if (!out) return []; + try { + // docker compose ps --format json outputs one JSON object per line + return out + .split("\n") + .filter(Boolean) + .map((line) => { + const obj = JSON.parse(line); + return { + name: obj.Name ?? obj.name ?? "", + state: obj.State ?? obj.state ?? "", + status: obj.Status ?? obj.status ?? "", + }; + }); + } catch { + return []; + } +} + +export function dockerExec(container: string, cmd: string): string { + return run(`docker exec ${container} ${cmd}`); +} + +export function isPgReady(): boolean { + const config = readProjectConfig(); + return ( + runOrNull( + `docker exec neoboard-postgres pg_isready -U ${config.postgres.user}`, + ) !== null + ); +} + +export function isNeo4jReady(): boolean { + const config = readProjectConfig(); + return ( + runOrNull( + `docker exec neoboard-neo4j cypher-shell -u ${config.neo4j.user} -p ${config.neo4j.password} "RETURN 1"`, + ) !== null + ); +} diff --git a/cli/src/lib/exec.ts b/cli/src/lib/exec.ts new file mode 100644 index 00000000..92c3a7e5 --- /dev/null +++ b/cli/src/lib/exec.ts @@ -0,0 +1,51 @@ +import { execSync, spawn as nodeSpawn } from "node:child_process"; +import type { SpawnOptions, ChildProcess } from "node:child_process"; + +export class ExecError extends Error { + constructor( + public readonly cmd: string, + public readonly exitCode: number, + public readonly stderr: string, + ) { + super(`Command failed (exit ${exitCode}): ${cmd}\n${stderr}`); + this.name = "ExecError"; + } +} + +export interface RunOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + timeout?: number; +} + +export function run(cmd: string, opts?: RunOptions): string { + try { + const result = execSync(cmd, { + cwd: opts?.cwd, + env: opts?.env ?? process.env, + timeout: opts?.timeout, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + return result.trim(); + } catch (err: unknown) { + const e = err as { status?: number; stderr?: string | Buffer }; + throw new ExecError(cmd, e.status ?? 1, String(e.stderr ?? "").trim()); + } +} + +export function runOrNull(cmd: string, opts?: RunOptions): string | null { + try { + return run(cmd, opts); + } catch { + return null; + } +} + +export function spawn( + cmd: string, + args: string[], + opts?: SpawnOptions, +): ChildProcess { + return nodeSpawn(cmd, args, { stdio: "inherit", ...opts }); +} diff --git a/cli/src/lib/health.ts b/cli/src/lib/health.ts new file mode 100644 index 00000000..c393865d --- /dev/null +++ b/cli/src/lib/health.ts @@ -0,0 +1,26 @@ +import { createSpinner } from "./output.js"; + +export interface HealthCheckOptions { + check: () => boolean; + label: string; + interval?: number; + timeout?: number; +} + +export async function waitForHealth(opts: HealthCheckOptions): Promise { + const { check, label, interval = 1000, timeout = 60_000 } = opts; + const spinner = createSpinner(`Waiting for ${label}...`); + spinner.start(); + + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (check()) { + spinner.succeed(`${label} is ready`); + return; + } + await new Promise((r) => setTimeout(r, interval)); + } + + spinner.fail(`${label} did not become ready within ${timeout / 1000}s`); + throw new Error(`Timeout waiting for ${label}`); +} diff --git a/cli/src/lib/output.ts b/cli/src/lib/output.ts new file mode 100644 index 00000000..e0718dfe --- /dev/null +++ b/cli/src/lib/output.ts @@ -0,0 +1,34 @@ +import ora from "ora"; +import chalk from "chalk"; + +export function createSpinner(text: string) { + return ora(text); +} + +export function info(msg: string): void { + console.log(chalk.blue(msg)); +} + +export function warn(msg: string): void { + console.log(chalk.yellow(`WARN: ${msg}`)); +} + +export function error(msg: string): void { + console.log(chalk.red(`ERROR: ${msg}`)); +} + +export function success(msg: string): void { + console.log(chalk.green(`\u2714 ${msg}`)); +} + +export function banner(lines: string[]): void { + const maxLen = Math.max(...lines.map((l) => l.length)); + const top = "\u2554" + "\u2550".repeat(maxLen + 2) + "\u2557"; + const bottom = "\u255A" + "\u2550".repeat(maxLen + 2) + "\u255D"; + + console.log(top); + for (const line of lines) { + console.log("\u2551 " + line.padEnd(maxLen) + " \u2551"); + } + console.log(bottom); +} diff --git a/cli/src/lib/ports.ts b/cli/src/lib/ports.ts new file mode 100644 index 00000000..332e1534 --- /dev/null +++ b/cli/src/lib/ports.ts @@ -0,0 +1,12 @@ +import { createServer } from "node:net"; + +export function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); + }); +} diff --git a/cli/src/lib/prompt.ts b/cli/src/lib/prompt.ts new file mode 100644 index 00000000..cda81f35 --- /dev/null +++ b/cli/src/lib/prompt.ts @@ -0,0 +1,14 @@ +import { createInterface } from "node:readline"; + +export function confirm(message: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question(`${message} [y/N] `, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === "y"); + }); + }); +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 00000000..514307e1 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "declaration": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/__tests__/**"] +} diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts new file mode 100644 index 00000000..2a52742d --- /dev/null +++ b/cli/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/__tests__/**/*.test.ts"], + coverage: { + provider: "v8", + reportsDirectory: "./coverage", + reporter: ["text", "lcov"], + include: ["src/**/*.ts"], + exclude: ["src/__tests__/**", "src/**/*.d.ts"], + }, + }, +}); diff --git a/neoboard.config.json b/neoboard.config.json new file mode 100644 index 00000000..a06057a2 --- /dev/null +++ b/neoboard.config.json @@ -0,0 +1,21 @@ +{ + "ports": { + "app": 3000, + "postgres": 5432, + "neo4j_http": 7474, + "neo4j_bolt": 7687 + }, + "postgres": { + "user": "neoboard", + "password": "neoboard", + "database": "neoboard" + }, + "neo4j": { + "user": "neo4j", + "password": "neoboard123" + }, + "seed": { + "script": "scripts/seed-demo.mjs", + "neo4j_cypher": "docker/neo4j/init.cypher" + } +} diff --git a/package.json b/package.json index c803bac4..3d925aae 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,10 @@ "scripts": { "dev": "npm run dev --prefix app", "build": "npm run build --prefix app", - "test": "npm run test --prefix app && npm run test --prefix component", + "test": "npm run test --prefix app && npm run test --prefix component && npm run test --prefix cli", "test:app": "npm run test --prefix app", "test:components": "npm run test --prefix component", + "test:cli": "npm run test --prefix cli", "test:e2e": "npm run test:e2e --prefix app", "storybook": "npm run storybook --prefix component", "lint": "eslint .", @@ -16,6 +17,8 @@ "db:generate": "npm run db:generate --prefix app", "docs:dev": "npm run dev --prefix docs", "docs:build": "npm run build --prefix docs", + "neoboard": "node cli/dist/index.js", + "cli:build": "npm run build --prefix cli", "prepare": "husky" }, "lint-staged": { diff --git a/sonar-project.properties b/sonar-project.properties index 0419b504..5978b0eb 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,10 +3,10 @@ sonar.organization=alfredo1996 sonar.projectName=NeoBoard # Sources -sonar.sources=app/src,component/src,connection/src +sonar.sources=app/src,component/src,connection/src,cli/src # Tests -sonar.tests=app/src,component/src,connection/__tests__ +sonar.tests=app/src,component/src,connection/__tests__,cli/src # Only scan these three packages — everything else (scripts/, stress/, docker/, etc.) is excluded sonar.exclusions=\ @@ -64,7 +64,8 @@ sonar.javascript.lcov.reportPaths=\ app/coverage/lcov.info,\ app/coverage-e2e/lcov.info,\ component/coverage/lcov.info,\ - connection/coverage/lcov.info + connection/coverage/lcov.info,\ + cli/coverage/lcov.info # TypeScript configs sonar.typescript.tsconfigPath=app/tsconfig.json