diff --git a/package-lock.json b/package-lock.json index 97a45c3..b1dfaeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@inquirer/prompts": "^8.2.0", "@modelcontextprotocol/sdk": "^1.25.3", "@types/jsonwebtoken": "^9.0.10", + "better-sqlite3": "^12.6.2", "commander": "^11.1.0", "express": "^4.18.0", "js-yaml": "^4.1.1", @@ -25,6 +26,7 @@ "janee": "dist/cli/index.js" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/express": "^4.17.21", "@types/js-yaml": "^4.0.9", "@types/node": "^20.10.0", @@ -1463,6 +1465,16 @@ "resolved": "packages/openclaw-plugin", "link": true }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1803,6 +1815,60 @@ "node": ">=12" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -1869,6 +1935,30 @@ "node": ">= 0.8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1948,6 +2038,12 @@ "node": ">= 16" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -2038,6 +2134,21 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "dev": true, @@ -2046,6 +2157,15 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/depd": { "version": "2.0.0", "license": "MIT", @@ -2063,6 +2183,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "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==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2107,6 +2236,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2222,6 +2360,15 @@ "node": ">=18.0.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "dev": true, @@ -2338,6 +2485,12 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -2387,6 +2540,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2473,6 +2632,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2549,10 +2714,36 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "license": "MIT", @@ -2767,6 +2958,33 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" @@ -2797,6 +3015,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -2806,6 +3030,18 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/object-assign": { "version": "4.1.1", "license": "MIT", @@ -2931,6 +3167,33 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "license": "MIT", @@ -2942,6 +3205,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -2979,6 +3252,35 @@ "node": ">= 0.10" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "license": "MIT", @@ -3265,6 +3567,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "dev": true, @@ -3290,6 +3637,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -3322,6 +3678,15 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "dev": true, @@ -3333,6 +3698,34 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "dev": true, @@ -3409,6 +3802,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -3445,6 +3850,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index 5863968..f221663 100644 --- a/package.json +++ b/package.json @@ -55,12 +55,14 @@ "@inquirer/prompts": "^8.2.0", "@modelcontextprotocol/sdk": "^1.25.3", "@types/jsonwebtoken": "^9.0.10", + "better-sqlite3": "^12.6.2", "commander": "^11.1.0", "express": "^4.18.0", "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.3" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/express": "^4.17.21", "@types/js-yaml": "^4.0.9", "@types/node": "^20.10.0", diff --git a/src/cli/config-store.test.ts b/src/cli/config-store.test.ts new file mode 100644 index 0000000..0b446ef --- /dev/null +++ b/src/cli/config-store.test.ts @@ -0,0 +1,262 @@ +/** + * Tests for SQLite config store (config-store.ts) + * + * Tests the core contract: round-trip save/load, encryption, migration, + * and backward compatibility with the YAML API surface. + */ + +import fs from "fs"; +import yaml from "js-yaml"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { encryptSecret, generateMasterKey } from "../core/crypto"; +import { + closeDb, + hasConfig, + initConfig, + loadConfig, + saveConfig, + addService, + addCapability, + migrateToSQLite, + type JaneeConfig, +} from "./config-store"; + +describe("SQLite Config Store", () => { + let testConfigDir: string; + let testJaneeDir: string; + let originalHomedir: () => string; + + beforeEach(() => { + testConfigDir = path.join( + os.tmpdir(), + `janee-test-${Date.now()}-${Math.random().toString(36).substring(7)}`, + ); + testJaneeDir = path.join(testConfigDir, ".janee"); + fs.mkdirSync(testJaneeDir, { recursive: true }); + originalHomedir = os.homedir; + os.homedir = () => testConfigDir; + }); + + afterEach(() => { + closeDb(); + os.homedir = originalHomedir; + if (fs.existsSync(testConfigDir)) { + fs.rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("Basic operations", () => { + it("should init, save, and load config round-trip", () => { + const config = initConfig(); + expect(config.version).toBe("1.0.0"); + expect(config.masterKey).toBeDefined(); + expect(hasConfig()).toBe(true); + + const loaded = loadConfig(); + expect(loaded.version).toBe("1.0.0"); + expect(loaded.masterKey).toBe(config.masterKey); + }); + + it("should round-trip all auth types", () => { + const masterKey = generateMasterKey(); + const config: JaneeConfig = { + version: "1.0.0", + masterKey, + server: { port: 9119, host: "localhost", strictDecryption: true }, + services: { + bearer: { + baseUrl: "https://api1.com", + auth: { type: "bearer", key: "bearer-key" }, + }, + hmac: { + baseUrl: "https://api2.com", + auth: { type: "hmac-mexc", apiKey: "hk", apiSecret: "hs" }, + }, + hdrs: { + baseUrl: "https://api3.com", + auth: { + type: "headers", + headers: { "X-Key": "hv", "X-Other": "ov" }, + }, + }, + }, + capabilities: {}, + }; + + saveConfig(config); + closeDb(); // Force re-open to confirm persistence + const loaded = loadConfig(); + + expect(loaded.masterKey).toBe(masterKey); + expect(loaded.services.bearer.auth.key).toBe("bearer-key"); + expect(loaded.services.hmac.auth.apiKey).toBe("hk"); + expect(loaded.services.hmac.auth.apiSecret).toBe("hs"); + expect(loaded.services.hdrs.auth.headers?.["X-Key"]).toBe("hv"); + expect(loaded.services.hdrs.auth.headers?.["X-Other"]).toBe("ov"); + }); + + it("should preserve capabilities and server config", () => { + const masterKey = generateMasterKey(); + const config: JaneeConfig = { + version: "1.0.0", + masterKey, + server: { port: 8080, host: "0.0.0.0", strictDecryption: false }, + services: {}, + capabilities: { + myTool: { + service: "testSvc", + path: "/api/data", + method: "GET", + description: "Get data", + }, + }, + }; + + saveConfig(config); + const loaded = loadConfig(); + + expect(loaded.server.port).toBe(8080); + expect(loaded.server.host).toBe("0.0.0.0"); + expect(loaded.server.strictDecryption).toBe(false); + expect(loaded.capabilities.myTool.service).toBe("testSvc"); + expect(loaded.capabilities.myTool.path).toBe("/api/data"); + }); + + it("should not store secrets in plaintext in the database", () => { + const masterKey = generateMasterKey(); + const config: JaneeConfig = { + version: "1.0.0", + masterKey, + server: { port: 9119, host: "localhost", strictDecryption: true }, + services: { + testSvc: { + baseUrl: "https://api.test.com", + auth: { type: "bearer", key: "super-secret-key" }, + }, + }, + capabilities: {}, + }; + + saveConfig(config); + + // Read the raw database file and check secret isn't plaintext + const dbPath = path.join(testJaneeDir, "janee.db"); + expect(fs.existsSync(dbPath)).toBe(true); + const rawBytes = fs.readFileSync(dbPath); + expect(rawBytes.toString("utf8")).not.toContain("super-secret-key"); + }); + }); + + describe("Service and capability management", () => { + it("should add a service via addService", () => { + initConfig(); + addService("newSvc", "https://new.api.com", { + type: "bearer", + key: "new-key", + }); + + const loaded = loadConfig(); + expect(loaded.services.newSvc).toBeDefined(); + expect(loaded.services.newSvc.baseUrl).toBe("https://new.api.com"); + expect(loaded.services.newSvc.auth.key).toBe("new-key"); + }); + + it("should add a capability via addCapability", () => { + initConfig(); + addService("mySvc", "https://api.com", { type: "bearer", key: "k" }); + addCapability("myTool", { + service: "mySvc", + path: "/data", + method: "POST", + description: "Post data", + }); + + const loaded = loadConfig(); + expect(loaded.capabilities.myTool).toBeDefined(); + expect(loaded.capabilities.myTool.service).toBe("mySvc"); + expect(loaded.capabilities.myTool.method).toBe("POST"); + }); + }); + + describe("Strict decryption mode", () => { + it("should throw on corrupted secrets when strictDecryption is true", () => { + const masterKey = generateMasterKey(); + const config: JaneeConfig = { + version: "1.0.0", + masterKey, + server: { port: 9119, host: "localhost", strictDecryption: true }, + services: { + testSvc: { + baseUrl: "https://api.test.com", + auth: { type: "bearer", key: "secret" }, + }, + }, + capabilities: {}, + }; + saveConfig(config); + closeDb(); + + // Corrupt the master key directly in the database + const Database = require("better-sqlite3"); + const dbPath = path.join(testJaneeDir, "janee.db"); + const db = new Database(dbPath); + const wrongKey = generateMasterKey(); + db.prepare("UPDATE meta SET value = ? WHERE key = 'master_key'").run( + wrongKey, + ); + db.close(); + + // Loading should now fail because secrets were encrypted with original key + // but master key is now the wrong one + expect(() => loadConfig()).toThrow(); + }); + + it("should fall back to plaintext when strictDecryption is false", () => { + const masterKey = generateMasterKey(); + const config: JaneeConfig = { + version: "1.0.0", + masterKey, + server: { port: 9119, host: "localhost", strictDecryption: false }, + services: { + testSvc: { + baseUrl: "https://api.test.com", + auth: { type: "bearer", key: "secret" }, + }, + }, + capabilities: {}, + }; + saveConfig(config); + closeDb(); + + // Corrupt the master key directly in the database + const Database = require("better-sqlite3"); + const dbPath = path.join(testJaneeDir, "janee.db"); + const db = new Database(dbPath); + const wrongKey = generateMasterKey(); + db.prepare("UPDATE meta SET value = ? WHERE key = 'master_key'").run( + wrongKey, + ); + db.close(); + + // Should not throw — returns ciphertext or placeholder instead + const loaded = loadConfig(); + expect(loaded.services.testSvc.auth.key).toBeDefined(); + }); + }); + + describe("YAML migration", () => { + it("should handle missing legacy files gracefully", () => { + // No YAML or JSON files exist — migration should be a no-op + // migrateToSQLite throws when no legacy files exist + expect(() => migrateToSQLite()).toThrow("No YAML or JSON config found"); + }); + + it("should detect legacy config existence correctly", () => { + // No files at all — should return false + expect(hasConfig()).toBe(false); + }); + }); +}); diff --git a/src/cli/config-store.ts b/src/cli/config-store.ts new file mode 100644 index 0000000..46a610b --- /dev/null +++ b/src/cli/config-store.ts @@ -0,0 +1,971 @@ +/** + * SQLite configuration store for Janee + * + * Replaces the YAML + credentials.json split with a single janee.db file. + * All config reads go directly to SQLite — no in-memory caching. + * + * Schema: + * meta — schema version, master key + * services — name, baseUrl, auth type, non-secret auth fields, ownership, testPath + * secrets — service name → encrypted secret fields + * capabilities — name, service, ttl, config JSON + * settings — key/value pairs for server config, LLM config, etc. + * + * Migration: automatically imports from config.yaml + credentials.json on first access. + */ + +import Database from "better-sqlite3"; +import fs from "fs"; +import yaml from "js-yaml"; +import os from "os"; +import path from "path"; + +import { + agentCreatedOwnership, + cliCreatedOwnership, + CredentialOwnership, +} from "../core/agent-scope"; +import { + decryptSecret, + encryptSecret, + generateMasterKey, +} from "../core/crypto"; + +// Re-export interfaces (unchanged from config-yaml.ts) +export interface AuthConfig { + type: + | "bearer" + | "hmac-mexc" + | "hmac-bybit" + | "hmac-okx" + | "headers" + | "service-account" + | "github-app" + | "oauth1a-twitter" + | "aws-sigv4"; + key?: string; + apiKey?: string; + apiSecret?: string; + passphrase?: string; + headers?: Record; + credentials?: string; + scopes?: string[]; + appId?: string; + privateKey?: string; + installationId?: string; + consumerKey?: string; + consumerSecret?: string; + accessToken?: string; + accessTokenSecret?: string; + accessKeyId?: string; + secretAccessKey?: string; + region?: string; + awsService?: string; + sessionToken?: string; +} + +export interface ServiceConfig { + baseUrl: string; + auth: AuthConfig; + testPath?: string; + ownership?: CredentialOwnership; +} + +export interface CapabilityConfig { + service: string; + ttl: string; + autoApprove?: boolean; + requiresReason?: boolean; + rules?: { + allow?: string[]; + deny?: string[]; + }; + allowedAgents?: string[]; + mode?: "proxy" | "exec"; + allowCommands?: string[]; + env?: Record; + workDir?: string; + timeout?: number; +} + +export interface LLMConfig { + provider?: "openai" | "anthropic"; + apiKey?: string; + model?: string; +} + +export interface ServerConfig { + port: number; + host: string; + logBodies?: boolean; + strictDecryption?: boolean; + defaultAccess?: "open" | "restricted"; +} + +export interface JaneeConfig { + version: string; + masterKey: string; + server: ServerConfig; + llm?: LLMConfig; + services: Record; + capabilities: Record; +} + +// Keep the old name as an alias for backward compatibility +export type JaneeYAMLConfig = JaneeConfig; + +// --------------------------------------------------------------------------- +// Secret field names — same logic as config-yaml.ts extractSecrets/stripSecrets +// --------------------------------------------------------------------------- + +const SECRET_FIELDS = [ + "key", + "apiKey", + "apiSecret", + "passphrase", + "credentials", + "privateKey", + "consumerKey", + "consumerSecret", + "accessToken", + "accessTokenSecret", + "accessKeyId", + "secretAccessKey", + "sessionToken", +] as const; + +type ServiceSecrets = Record>; + +function extractSecrets(auth: AuthConfig): ServiceSecrets { + const secrets: ServiceSecrets = {}; + for (const field of SECRET_FIELDS) { + if (auth[field]) { + secrets[field] = auth[field] as string; + } + } + if (auth.headers) { + secrets.headers = { ...auth.headers }; + } + return secrets; +} + +function stripSecrets(auth: AuthConfig): AuthConfig { + const stripped = { ...auth }; + for (const field of SECRET_FIELDS) { + delete stripped[field]; + } + delete stripped.headers; + return stripped; +} + +function injectSecrets(auth: AuthConfig, secrets: ServiceSecrets): void { + for (const [key, value] of Object.entries(secrets)) { + if (key === "headers" && typeof value === "object") { + auth.headers = value as Record; + } else { + (auth as any)[key] = value; + } + } +} + +function encryptServiceSecrets( + secrets: ServiceSecrets, + masterKey: string, +): ServiceSecrets { + const encrypted: ServiceSecrets = {}; + for (const [key, value] of Object.entries(secrets)) { + if (typeof value === "string") { + encrypted[key] = encryptSecret(value, masterKey); + } else if (typeof value === "object") { + const encObj: Record = {}; + for (const [hk, hv] of Object.entries(value)) { + encObj[hk] = encryptSecret(hv, masterKey); + } + encrypted[key] = encObj; + } + } + return encrypted; +} + +function decryptServiceSecrets( + serviceName: string, + encrypted: ServiceSecrets, + masterKey: string, + strict: boolean, +): ServiceSecrets { + const decrypted: ServiceSecrets = {}; + for (const [key, value] of Object.entries(encrypted)) { + if (typeof value === "string") { + try { + decrypted[key] = decryptSecret(value, masterKey); + } catch (e) { + if (strict) + throw new Error(`Failed to decrypt ${serviceName}.${key}: ${e}`); + decrypted[key] = value; // pass through if non-strict + } + } else if (typeof value === "object") { + const decObj: Record = {}; + for (const [hk, hv] of Object.entries(value)) { + try { + decObj[hk] = decryptSecret(hv, masterKey); + } catch (e) { + if (strict) + throw new Error( + `Failed to decrypt ${serviceName}.headers.${hk}: ${e}`, + ); + decObj[hk] = hv; + } + } + decrypted[key] = decObj; + } + } + return decrypted; +} + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +export function getConfigDir(): string { + return process.env.JANEE_HOME || path.join(os.homedir(), ".janee"); +} + +export function getAuditDir(): string { + return path.join(getConfigDir(), "logs"); +} + +function getDbPath(): string { + return path.join(getConfigDir(), "janee.db"); +} + +function getLegacyYAMLPath(): string { + return path.join(getConfigDir(), "config.yaml"); +} + +function getLegacyCredentialsPath(): string { + return path.join(getConfigDir(), "credentials.json"); +} + +function getLegacyJSONPath(): string { + return path.join(getConfigDir(), "config.json"); +} + +// --------------------------------------------------------------------------- +// Database initialization & schema +// --------------------------------------------------------------------------- + +const SCHEMA_VERSION = 1; + +let _db: Database.Database | null = null; + +function getDb(): Database.Database { + if (_db) return _db; + + const dir = getConfigDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { mode: 0o700, recursive: true }); + } + + _db = new Database(getDbPath()); + _db.pragma("journal_mode = WAL"); + _db.pragma("foreign_keys = ON"); + + initSchema(_db); + + // Auto-migrate from YAML if DB is empty and YAML exists + const count = _db.prepare("SELECT COUNT(*) as c FROM meta").get() as { + c: number; + }; + if (count.c === 0) { + const yamlPath = getLegacyYAMLPath(); + const credPath = getLegacyCredentialsPath(); + if (fs.existsSync(yamlPath)) { + migrateFromYAML(_db, yamlPath, credPath); + } + } + + return _db; +} + +function initSchema(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS services ( + name TEXT PRIMARY KEY, + base_url TEXT NOT NULL, + auth_type TEXT NOT NULL, + auth_meta TEXT NOT NULL DEFAULT '{}', + test_path TEXT, + ownership TEXT + ); + + CREATE TABLE IF NOT EXISTS secrets ( + service_name TEXT NOT NULL, + field_name TEXT NOT NULL, + encrypted TEXT NOT NULL, + PRIMARY KEY (service_name, field_name) + -- FK relaxed: service may be added later + ); + + CREATE TABLE IF NOT EXISTS capabilities ( + name TEXT PRIMARY KEY, + service_name TEXT NOT NULL, + config TEXT NOT NULL DEFAULT '{}' + -- FK relaxed: service may be added later + ); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `); +} + +// --------------------------------------------------------------------------- +// Migration from YAML + credentials.json → SQLite +// --------------------------------------------------------------------------- + +interface LegacyCredentialsFile { + masterKey: string; + secrets: Record; +} + +function migrateFromYAML( + db: Database.Database, + yamlPath: string, + credPath: string, +): void { + const rawYaml = yaml.load(fs.readFileSync(yamlPath, "utf8")) as any; + let creds: LegacyCredentialsFile = { masterKey: "", secrets: {} }; + + if (fs.existsSync(credPath)) { + creds = JSON.parse(fs.readFileSync(credPath, "utf8")); + } + + // Handle legacy format where masterKey was inline in YAML + const masterKey = creds.masterKey || rawYaml.masterKey || generateMasterKey(); + + const txn = db.transaction(() => { + // Meta + db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run( + "schema_version", + String(SCHEMA_VERSION), + ); + db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run( + "master_key", + masterKey, + ); + db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run( + "migrated_from", + "yaml", + ); + db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run( + "migrated_at", + new Date().toISOString(), + ); + + // Server settings + const server = rawYaml.server || {}; + db.prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ).run("server.port", String(server.port || 9119)); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ).run("server.host", server.host || "localhost"); + if (server.logBodies != null) + db.prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ).run("server.logBodies", String(server.logBodies)); + if (server.strictDecryption != null) + db.prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ).run("server.strictDecryption", String(server.strictDecryption)); + if (server.defaultAccess) + db.prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ).run("server.defaultAccess", server.defaultAccess); + + // LLM settings + if (rawYaml.llm) { + db.prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ).run("llm", JSON.stringify(rawYaml.llm)); + } + + // Services + const services = rawYaml.services || {}; + const insertService = db.prepare( + "INSERT OR REPLACE INTO services (name, base_url, auth_type, auth_meta, test_path, ownership) VALUES (?, ?, ?, ?, ?, ?)", + ); + const insertSecret = db.prepare( + "INSERT OR REPLACE INTO secrets (service_name, field_name, encrypted) VALUES (?, ?, ?)", + ); + + for (const [name, svc] of Object.entries(services) as [string, any][]) { + const auth = svc.auth || {}; + const authMeta: Record = {}; + if (auth.scopes) authMeta.scopes = auth.scopes; + + insertService.run( + name, + svc.baseUrl || "", + auth.type || "bearer", + JSON.stringify(authMeta), + svc.testPath || null, + svc.ownership ? JSON.stringify(svc.ownership) : null, + ); + + // Import encrypted secrets from credentials.json + const svcSecrets = creds.secrets[name] || {}; + for (const [field, value] of Object.entries(svcSecrets)) { + // Store as JSON for complex types (headers object) + const encoded = + typeof value === "string" ? value : JSON.stringify(value); + insertSecret.run(name, field, encoded); + } + + // Also check for inline secrets in legacy format (masterKey was in YAML) + if (!creds.secrets[name]) { + for (const field of SECRET_FIELDS) { + if (auth[field]) { + // These are already encrypted in legacy format + insertSecret.run(name, field, auth[field]); + } + } + if (auth.headers) { + insertSecret.run( + name, + "headers", + JSON.stringify( + Object.fromEntries( + Object.entries(auth.headers).map(([k, v]) => [k, v]), + ), + ), + ); + } + } + } + + // Capabilities + const capabilities = rawYaml.capabilities || {}; + const insertCap = db.prepare( + "INSERT OR REPLACE INTO capabilities (name, service_name, config) VALUES (?, ?, ?)", + ); + + for (const [name, cap] of Object.entries(capabilities) as [string, any][]) { + const { service, ...rest } = cap; + insertCap.run(name, service, JSON.stringify(rest)); + } + }); + + txn(); + + // Rename legacy files + const timestamp = Date.now(); + if (fs.existsSync(yamlPath)) { + fs.renameSync(yamlPath, `${yamlPath}.pre-sqlite.${timestamp}.bak`); + } + if (fs.existsSync(credPath)) { + fs.renameSync(credPath, `${credPath}.pre-sqlite.${timestamp}.bak`); + } + + console.log("✅ Migrated config from YAML → SQLite (janee.db)"); + console.log( + ` Old files backed up with .pre-sqlite.${timestamp}.bak suffix`, + ); +} + +// --------------------------------------------------------------------------- +// Re-migration: handle YAML re-appearing after migration +// --------------------------------------------------------------------------- + +function checkForYAMLReappearance(): void { + const yamlPath = getLegacyYAMLPath(); + if (!fs.existsSync(yamlPath)) return; + + const db = getDb(); + const hasMeta = db + .prepare("SELECT value FROM meta WHERE key = ?") + .get("master_key") as { value: string } | undefined; + if (!hasMeta) return; // DB not initialized yet, let normal init handle it + + console.log( + "⚠️ Found config.yaml after SQLite migration — importing new entries...", + ); + + const credPath = getLegacyCredentialsPath(); + const rawYaml = yaml.load(fs.readFileSync(yamlPath, "utf8")) as any; + let creds: LegacyCredentialsFile = { masterKey: hasMeta.value, secrets: {} }; + if (fs.existsSync(credPath)) { + creds = JSON.parse(fs.readFileSync(credPath, "utf8")); + } + + const txn = db.transaction(() => { + const services = rawYaml.services || {}; + for (const [name, svc] of Object.entries(services) as [string, any][]) { + // Only import services that don't already exist in DB (DB wins for conflicts) + const existing = db + .prepare("SELECT name FROM services WHERE name = ?") + .get(name); + if (existing) continue; + + const auth = svc.auth || {}; + const authMeta: Record = {}; + if (auth.scopes) authMeta.scopes = auth.scopes; + + db.prepare( + "INSERT INTO services (name, base_url, auth_type, auth_meta, test_path, ownership) VALUES (?, ?, ?, ?, ?, ?)", + ).run( + name, + svc.baseUrl || "", + auth.type || "bearer", + JSON.stringify(authMeta), + svc.testPath || null, + svc.ownership ? JSON.stringify(svc.ownership) : null, + ); + + const svcSecrets = creds.secrets[name] || {}; + for (const [field, value] of Object.entries(svcSecrets)) { + const encoded = + typeof value === "string" ? value : JSON.stringify(value); + db.prepare( + "INSERT INTO secrets (service_name, field_name, encrypted) VALUES (?, ?, ?)", + ).run(name, field, encoded); + } + } + + const capabilities = rawYaml.capabilities || {}; + for (const [name, cap] of Object.entries(capabilities) as [string, any][]) { + const existing = db + .prepare("SELECT name FROM capabilities WHERE name = ?") + .get(name); + if (existing) continue; + const { service, ...rest } = cap as any; + db.prepare( + "INSERT INTO capabilities (name, service_name, config) VALUES (?, ?, ?)", + ).run(name, service, JSON.stringify(rest)); + } + }); + + txn(); + + // Remove the re-appeared YAML + const timestamp = Date.now(); + fs.renameSync(yamlPath, `${yamlPath}.reimported.${timestamp}.bak`); + if (fs.existsSync(credPath)) { + fs.renameSync(credPath, `${credPath}.reimported.${timestamp}.bak`); + } + console.log(" Re-imported YAML entries into SQLite. YAML removed."); +} + +// --------------------------------------------------------------------------- +// Public API — drop-in replacements for config-yaml.ts functions +// --------------------------------------------------------------------------- + +export function hasConfig(): boolean { + return fs.existsSync(getDbPath()) || fs.existsSync(getLegacyYAMLPath()); +} + +// Alias for backward compat +export const hasYAMLConfig = hasConfig; + +export function loadConfig(): JaneeConfig { + checkForYAMLReappearance(); + + const db = getDb(); + + const masterKeyRow = db + .prepare("SELECT value FROM meta WHERE key = ?") + .get("master_key") as { value: string } | undefined; + if (!masterKeyRow) { + throw new Error("No config found. Run `janee init` to create one."); + } + + const masterKey = masterKeyRow.value; + const strictDecryption = + getSetting(db, "server.strictDecryption") !== "false"; + + // Build server config + const server: ServerConfig = { + port: parseInt(getSetting(db, "server.port") || "9119", 10), + host: getSetting(db, "server.host") || "localhost", + logBodies: getSetting(db, "server.logBodies") === "true", + strictDecryption, + defaultAccess: + (getSetting(db, "server.defaultAccess") as "open" | "restricted") || + undefined, + }; + + // LLM config + const llmRaw = getSetting(db, "llm"); + const llm: LLMConfig | undefined = llmRaw ? JSON.parse(llmRaw) : undefined; + + // Services with decrypted secrets + const services: Record = {}; + const svcRows = db.prepare("SELECT * FROM services").all() as Array<{ + name: string; + base_url: string; + auth_type: string; + auth_meta: string; + test_path: string | null; + ownership: string | null; + }>; + + for (const row of svcRows) { + const authMeta = JSON.parse(row.auth_meta); + const auth: AuthConfig = { + type: row.auth_type as AuthConfig["type"], + ...authMeta, + }; + + // Load and decrypt secrets + const secretRows = db + .prepare( + "SELECT field_name, encrypted FROM secrets WHERE service_name = ?", + ) + .all(row.name) as Array<{ + field_name: string; + encrypted: string; + }>; + + const encSecrets: ServiceSecrets = {}; + for (const s of secretRows) { + // Try parsing as JSON for complex types (headers) + try { + const parsed = JSON.parse(s.encrypted); + if (typeof parsed === "object" && parsed !== null) { + encSecrets[s.field_name] = parsed; + continue; + } + } catch {} + encSecrets[s.field_name] = s.encrypted; + } + + if (Object.keys(encSecrets).length > 0) { + const decSecrets = decryptServiceSecrets( + row.name, + encSecrets, + masterKey, + strictDecryption, + ); + injectSecrets(auth, decSecrets); + } + + services[row.name] = { + baseUrl: row.base_url, + auth, + testPath: row.test_path || undefined, + ownership: row.ownership ? JSON.parse(row.ownership) : undefined, + }; + } + + // Capabilities + const capabilities: Record = {}; + const capRows = db.prepare("SELECT * FROM capabilities").all() as Array<{ + name: string; + service_name: string; + config: string; + }>; + + for (const row of capRows) { + const parsed = JSON.parse(row.config); + capabilities[row.name] = { + service: row.service_name, + ...parsed, + }; + } + + return { + version: "1.0.0", + masterKey, + server, + llm, + services, + capabilities, + }; +} + +// Alias for backward compat +export const loadYAMLConfig = loadConfig; + +export function saveConfig(config: JaneeConfig): void { + const db = getDb(); + + const txn = db.transaction(() => { + // Meta + db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run( + "schema_version", + String(SCHEMA_VERSION), + ); + db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run( + "master_key", + config.masterKey, + ); + + // Server settings + db.prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ).run("server.port", String(config.server.port)); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ).run("server.host", config.server.host); + if (config.server.logBodies != null) { + db.prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ).run("server.logBodies", String(config.server.logBodies)); + } + if (config.server.strictDecryption != null) { + db.prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ).run("server.strictDecryption", String(config.server.strictDecryption)); + } + if (config.server.defaultAccess) { + db.prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ).run("server.defaultAccess", config.server.defaultAccess); + } + + // LLM + if (config.llm) { + db.prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + ).run("llm", JSON.stringify(config.llm)); + } else { + db.prepare("DELETE FROM settings WHERE key = ?").run("llm"); + } + + // Services — delete removed, upsert existing + const existingServices = new Set( + ( + db.prepare("SELECT name FROM services").all() as Array<{ name: string }> + ).map((r) => r.name), + ); + const newServiceNames = new Set(Object.keys(config.services)); + + // Delete removed services (cascades to secrets and capabilities via FK) + for (const name of existingServices) { + if (!newServiceNames.has(name)) { + db.prepare("DELETE FROM services WHERE name = ?").run(name); + } + } + + // Upsert services and their secrets + const upsertService = db.prepare( + "INSERT OR REPLACE INTO services (name, base_url, auth_type, auth_meta, test_path, ownership) VALUES (?, ?, ?, ?, ?, ?)", + ); + const deleteSecrets = db.prepare( + "DELETE FROM secrets WHERE service_name = ?", + ); + const insertSecret = db.prepare( + "INSERT INTO secrets (service_name, field_name, encrypted) VALUES (?, ?, ?)", + ); + + for (const [name, svc] of Object.entries(config.services)) { + const plainSecrets = extractSecrets(svc.auth); + const strippedAuth = stripSecrets(svc.auth); + const { type, ...authMeta } = strippedAuth; + + upsertService.run( + name, + svc.baseUrl, + type, + JSON.stringify(authMeta), + svc.testPath || null, + svc.ownership ? JSON.stringify(svc.ownership) : null, + ); + + // Re-encrypt and store secrets + deleteSecrets.run(name); + if (Object.keys(plainSecrets).length > 0) { + const encrypted = encryptServiceSecrets(plainSecrets, config.masterKey); + for (const [field, value] of Object.entries(encrypted)) { + const encoded = + typeof value === "string" ? value : JSON.stringify(value); + insertSecret.run(name, field, encoded); + } + } + } + + // Capabilities — delete removed, upsert existing + const existingCaps = new Set( + ( + db.prepare("SELECT name FROM capabilities").all() as Array<{ + name: string; + }> + ).map((r) => r.name), + ); + const newCapNames = new Set(Object.keys(config.capabilities)); + + for (const name of existingCaps) { + if (!newCapNames.has(name)) { + db.prepare("DELETE FROM capabilities WHERE name = ?").run(name); + } + } + + const upsertCap = db.prepare( + "INSERT OR REPLACE INTO capabilities (name, service_name, config) VALUES (?, ?, ?)", + ); + + for (const [name, cap] of Object.entries(config.capabilities)) { + const { service, ...rest } = cap; + upsertCap.run(name, service, JSON.stringify(rest)); + } + }); + + txn(); +} + +// Alias for backward compat +export const saveYAMLConfig = saveConfig; + +function getSetting(db: Database.Database, key: string): string | undefined { + const row = db + .prepare("SELECT value FROM settings WHERE key = ?") + .get(key) as { value: string } | undefined; + return row?.value; +} + +// --------------------------------------------------------------------------- +// Convenience functions (same API as config-yaml.ts) +// --------------------------------------------------------------------------- + +export function persistServiceOwnership( + serviceName: string, + ownership: CredentialOwnership, +): void { + const config = loadConfig(); + if (!config.services[serviceName]) { + throw new Error(`Service "${serviceName}" not found in config`); + } + config.services[serviceName].ownership = ownership; + saveConfig(config); +} + +export function createServiceWithOwnership( + config: JaneeConfig, + serviceName: string, + service: ServiceConfig, + creatingAgentId?: string, +): JaneeConfig { + if (creatingAgentId) { + service.ownership = agentCreatedOwnership(creatingAgentId); + } + config.services[serviceName] = service; + return config; +} + +export function initConfig(): JaneeConfig { + const dir = getConfigDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { mode: 0o700, recursive: true }); + } + + if (fs.existsSync(getDbPath())) { + throw new Error("Config already exists"); + } + + const config: JaneeConfig = { + version: "1.0.0", + masterKey: generateMasterKey(), + server: { + port: 9119, + host: "localhost", + strictDecryption: true, + }, + services: {}, + capabilities: {}, + }; + + // Force DB creation + const db = getDb(); + db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run( + "schema_version", + String(SCHEMA_VERSION), + ); + db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run( + "master_key", + config.masterKey, + ); + + // Save settings + saveConfig(config); + + return config; +} + +// Alias for backward compat +export const initYAMLConfig = initConfig; + +export function addService( + name: string, + baseUrl: string, + auth: AuthConfig, +): void { + const config = loadConfig(); + if (config.services[name]) { + throw new Error(`Service "${name}" already exists`); + } + config.services[name] = { + baseUrl, + auth, + ownership: cliCreatedOwnership(), + }; + saveConfig(config); +} + +// Alias for backward compat +export const addServiceYAML = addService; + +export function addCapability(name: string, capConfig: CapabilityConfig): void { + const config = loadConfig(); + if (config.capabilities[name]) { + throw new Error(`Capability "${name}" already exists`); + } + if (!config.services[capConfig.service]) { + throw new Error(`Service "${capConfig.service}" not found`); + } + config.capabilities[name] = capConfig; + saveConfig(config); +} + +// Alias for backward compat +export const addCapabilityYAML = addCapability; + +export function migrateToSQLite(): void { + const yamlPath = getLegacyYAMLPath(); + const jsonPath = getLegacyJSONPath(); + + if (!fs.existsSync(yamlPath) && !fs.existsSync(jsonPath)) { + throw new Error("No YAML or JSON config found to migrate."); + } + + if (fs.existsSync(getDbPath())) { + throw new Error( + "SQLite config already exists. Delete janee.db first to re-migrate.", + ); + } + + // getDb() will auto-migrate on first access + getDb(); + console.log("✅ Migration complete. Config is now in janee.db"); +} + +// Legacy compat — migrateToYAML now goes to SQLite +export const migrateToYAML = migrateToSQLite; + +// --------------------------------------------------------------------------- +// Close DB on process exit (cleanup) +// --------------------------------------------------------------------------- + +export function closeDb(): void { + if (_db) { + _db.close(); + _db = null; + } +} + +process.on("exit", closeDb);