diff --git a/package-lock.json b/package-lock.json index 95da29a..01d793f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@inquirer/prompts": "^8.5.0", + "better-sqlite3": "^11.10.0", "chalk": "^5.6.2", "commander": "^12.1.0", "glob": "^11.0.0", @@ -387,6 +388,57 @@ "node": "18 || 20 || >=22" } }, + "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": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "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/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -399,6 +451,30 @@ "node": "18 || 20 || >=22" } }, + "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/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -432,6 +508,12 @@ "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", "license": "MIT" }, + "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", @@ -483,6 +565,30 @@ "node": ">= 8" } }, + "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-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/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -523,6 +629,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/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/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/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -547,6 +680,12 @@ "fast-string-width": "^3.0.2" } }, + "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/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -563,6 +702,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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/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/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -616,6 +767,38 @@ "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", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "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/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -694,6 +877,18 @@ "node": "20 || >=22" } }, + "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/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -709,6 +904,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -718,6 +922,39 @@ "node": ">=16 || 14 >=14.17" } }, + "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/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/node-abi": { + "version": "3.95.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.95.0.tgz", + "integrity": "sha512-T9iGctuocf0qIWFFOTxPzjT5q0SILqaBYXt272tlBHvTKC5+3JnkMirLxNJNkXHtFyBjU2Jx+NL4Zipr0B/c6Q==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -767,6 +1004,72 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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/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/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/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -779,12 +1082,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -818,6 +1153,97 @@ "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/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/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/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "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/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -834,6 +1260,24 @@ "tinycolor2": "^1.0.0" } }, + "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/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/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -849,6 +1293,12 @@ "node": ">= 8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", diff --git a/package.json b/package.json index f0772d4..817be72 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@inquirer/prompts": "^8.5.0", + "better-sqlite3": "^11.10.0", "chalk": "^5.6.2", "commander": "^12.1.0", "glob": "^11.0.0", diff --git a/src/parser.js b/src/parser.js index 2357e2f..460cfe5 100644 --- a/src/parser.js +++ b/src/parser.js @@ -2,7 +2,7 @@ import { glob } from 'glob'; import { homedir } from 'os'; import { parseClaudeFiles } from './parsers/claude.js'; import { parseCodexFiles } from './parsers/codex.js'; -import { parseOpencodeFiles } from './parsers/opencode.js'; +import { parseOpencodeDatabase, parseOpencodeFiles } from './parsers/opencode.js'; import { resolveClaudeDirs, resolveOpencodeDirs } from './lib/paths.js'; import { getCustomClaudePaths } from './auth.js'; @@ -64,19 +64,30 @@ export async function parseAll(fromDate = null) { ...(await globSorted(`${codexHome}/archived_sessions/**/*.jsonl`)), ]; - // opencode는 message(모델·시각)와 part(step-finish 토큰)를 모두 읽어야 조인할 수 있다. - // parseOpencodeFiles가 내용으로 분류하므로 두 목록을 합쳐 넘긴다(순서 무관). + // 최신 opencode는 data home 바로 아래 opencode.db에 저장한다. + // DB가 있으면 그 파일만 직접 읽고(구버전에서 마이그레이션된 JSON과의 이중 집계 방지), + // DB가 없는 구버전만 storage/message·part JSON을 읽는다. + let opencodeDatabase = null; const opencodeFiles = []; for (const dir of opencodeDirCandidates()) { const base = dir.replaceAll('\\', '/'); - opencodeFiles.push(...await globSorted(`${base}/storage/message/**/*.json`)); - opencodeFiles.push(...await globSorted(`${base}/storage/part/**/*.json`)); + if (!opencodeDatabase) { + [opencodeDatabase = null] = await globSorted(`${base}/opencode.db`); + } + if (!opencodeDatabase) { + opencodeFiles.push(...await globSorted(`${base}/storage/message/**/*.json`)); + opencodeFiles.push(...await globSorted(`${base}/storage/part/**/*.json`)); + } } + const opencodePromise = opencodeDatabase + ? parseOpencodeDatabase(opencodeDatabase) + : parseOpencodeFiles(opencodeFiles); + const [claudeEntries, codexEntries, opencodeEntries] = await Promise.all([ parseClaudeFiles(claudeFiles), parseCodexFiles(codexFiles), - parseOpencodeFiles(opencodeFiles), + opencodePromise, ]); return aggregateRecords([...claudeEntries, ...codexEntries, ...opencodeEntries], fromDate); } diff --git a/src/parsers/opencode.js b/src/parsers/opencode.js index a9fbe25..de8f2db 100644 --- a/src/parsers/opencode.js +++ b/src/parsers/opencode.js @@ -1,9 +1,17 @@ import { readFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import Database from 'better-sqlite3'; import { localDateOf } from '../lib/dates.js'; // opencode(sst) 세션 저장 파서. +// 최신 opencode는 사용량을 data home 아래 opencode.db(SQLite)에 저장하고, +// 구버전은 storage/message·part JSON 파일에 저장한다. 두 형태를 모두 지원한다. +// +// DB는 외부 App(`opencode` CLI)을 거치지 않고 read-only로 직접 조회한다. +// OpenCode의 설치 여부·버전·오작동이 tokenphage sync에 영향을 주지 않아야 하기 때문이다. +// // opencode는 턴당 assistant 메시지 1개에 스텝마다 tokens를 덮어써(=) 마지막 스텝만 남기므로, -// 스텝별 토큰이 보존된 step-finish part(storage/part//*.json)를 전부 합산해야 정확하다. +// 스텝별 토큰이 보존된 step-finish part를 전부 합산해야 정확하다. // 전략: // - message(role==='assistant')에서 id→{model, date}를 모으고, step-finish part를 messageID로 조인한다. // - 입력 파일 목록에 message/part가 섞여 오고 순서 보장이 없으므로, 두 맵을 모은 뒤 마지막에 조인한다. @@ -12,6 +20,25 @@ import { localDateOf } from '../lib/dates.js'; // cache.write는 cacheCreate로 둔다(opencode는 Anthropic cacheCreation을 여기 담는다). // - 손상 파일·부모 없는 part·모델/날짜 없는 메시지는 집계에서 제외한다. +// DB 스키마도 JSON 파일과 같은 구조(message.data / part.data)를 담고 있어 동일 규칙으로 집계한다. +const USAGE_QUERY = ` +SELECT + strftime('%Y-%m-%d', m.time_created / 1000, 'unixepoch', 'localtime') AS date, + json_extract(m.data, '$.modelID') AS model, + SUM(MAX(COALESCE(json_extract(p.data, '$.tokens.input'), 0), 0)) AS input, + SUM(MAX(COALESCE(json_extract(p.data, '$.tokens.output'), 0), 0)) AS output, + SUM(MAX(COALESCE(json_extract(p.data, '$.tokens.reasoning'), 0), 0)) AS reasoning, + SUM(MAX(COALESCE(json_extract(p.data, '$.tokens.cache.read'), 0), 0)) AS cache_read, + SUM(MAX(COALESCE(json_extract(p.data, '$.tokens.cache.write'), 0), 0)) AS cache_write +FROM message m +JOIN part p ON p.message_id = m.id +WHERE json_extract(m.data, '$.role') = 'assistant' + AND json_extract(p.data, '$.type') = 'step-finish' + AND json_type(p.data, '$.tokens') = 'object' + AND json_extract(m.data, '$.modelID') IS NOT NULL +GROUP BY date, model +`; + // step-finish part의 tokens를 tokenphage 레코드 4필드로 매핑한다. 음수 방어로 max(…,0). function entryOf(date, model, tokens) { const cache = tokens.cache ?? {}; @@ -26,6 +53,76 @@ function entryOf(date, model, tokens) { }; } +// USAGE_QUERY가 내보내는 7개 별칭(date·model·input·output·reasoning·cache_read·cache_write)만 읽는다. +// date는 strftime 결과라 'YYYY-MM-DD' 아니면 NULL이다(m.time_created가 NULL인 경우). +// 날짜나 모델을 못 만든 행은 어느 날짜·모델에 귀속시킬지 알 수 없으므로 집계에서 뺀다. +function entryOfRow(row) { + if (!row || typeof row.model !== 'string' || !row.model) return null; + if (typeof row.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(row.date)) return null; + return entryOf(row.date, row.model, { + input: row.input, + output: row.output, + reasoning: row.reasoning, + cache: { read: row.cache_read, write: row.cache_write }, + }); +} + +// OpenCode를 지운 뒤 남은 빈 DB와, 스키마가 바뀐 DB를 구분하기 위한 조회. +function tableCount(db, filter = '') { + return db.prepare(`SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'${filter}`).get().n; +} +function hasUsageTables(db) { + return tableCount(db, " AND name IN ('message', 'part')") === 2; +} +function hasAnyTable(db) { + return tableCount(db, " AND name NOT LIKE 'sqlite_%'") > 0; +} + +// opencode.db를 read-only로 직접 연다. OpenCode CLI/App은 호출하지 않는다. +// OpenCode가 쓰는 중이어도 WAL 기반이라 읽기는 막히지 않는다. +// DB 삭제·손상·권한·스키마 변경 등 어떤 실패도 claude/codex sync를 막지 않도록 +// 예외를 올리지 않고 빈 목록으로 격리한다(경고만 남긴다). +function queryOpencodeRows(dbPath) { + let db; + try { + db = new Database(dbPath, { readonly: true, fileMustExist: true }); + // 기록이 아직 없는 빈 DB는 정상 상태다. 반대로 다른 테이블은 있는데 message/part만 + // 없다면 OpenCode 스키마가 바뀐 것이므로 집계가 0으로 새지 않게 알린다. + if (!hasUsageTables(db)) { + if (hasAnyTable(db)) console.warn(`Skipping OpenCode usage: ${dbPath} has no message/part table (schema changed?)`); + return []; + } + return db.prepare(USAGE_QUERY).all(); + } catch (err) { + // 파일이 사라졌으면 집계할 기록이 없을 뿐이라 알릴 것이 없다. + // 파일이 남아 있는데 실패했다면 손상·권한 문제로 사용량이 누락되는 중이다. + if (existsSync(dbPath)) console.warn(`Skipping OpenCode usage: could not read ${dbPath} (${err.message})`); + return []; + } finally { + try { db?.close(); } catch { /* close 실패는 이미 읽은 결과에 영향이 없다 */ } + } +} + +/** OpenCode SQLite 조회 결과를 tokenphage usage 목록으로 변환한다. */ +export function parseOpencodeRows(rows) { + if (!Array.isArray(rows)) return []; + const entries = []; + for (const row of rows) { + const entry = entryOfRow(row); + if (entry) entries.push(entry); + } + return entries; +} + +/** + * opencode.db(SQLite)를 직접 조회해 tokenphage usage 목록으로 변환한다. + * @param {string} dbPath opencode.db 절대 경로 + * @param {(dbPath: string) => Array} [queryRows] 조회 구현(테스트 주입용) + */ +export async function parseOpencodeDatabase(dbPath, queryRows = queryOpencodeRows) { + return parseOpencodeRows(await queryRows(dbPath)); +} + /** * opencode message/part 파일들을 스텝(step-finish) 단위 usage 목록으로 파싱한다. * @param {string[]} files 메시지·파트 JSON 파일 경로 목록 (섞여 있어도 됨) diff --git a/test/parser-opencode.test.js b/test/parser-opencode.test.js index 4c7b213..1a07a3d 100644 --- a/test/parser-opencode.test.js +++ b/test/parser-opencode.test.js @@ -3,7 +3,8 @@ import assert from 'node:assert/strict'; import { mkdtempSync, writeFileSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { parseOpencodeFiles } from '../src/parsers/opencode.js'; +import Database from 'better-sqlite3'; +import { parseOpencodeDatabase, parseOpencodeFiles, parseOpencodeRows } from '../src/parsers/opencode.js'; import { aggregateRecords } from '../src/parser.js'; import { localDateOf } from '../src/lib/dates.js'; @@ -34,6 +35,7 @@ const TS = '2026-06-10T03:00:00.000Z'; const TS_MS = Date.parse(TS); // 기준일 epoch millis const TS2_MS = Date.parse('2026-06-11T03:00:00.000Z'); // 하루 뒤(어느 타임존이든 다른 날짜) const OK_MODEL = 'ok-model'; +const OK_DATE = localDateOf(TS_MS); // DB 쿼리의 date 별칭과 같은 로컬 날짜 let dir; before(() => { dir = mkdtempSync(join(tmpdir(), 'tp-opencode-')); }); @@ -73,6 +75,37 @@ function assertOnlyOk(entries) { assert.equal(entries[0].outputTok, 7); } +// opencode.db와 같은 스키마(message.data / part.data JSON)의 SQLite 파일을 만든다. +function createUsageDb(name, steps) { + const path = join(dir, name); + const db = new Database(path); + db.exec(` + CREATE TABLE message (id TEXT PRIMARY KEY, time_created INTEGER, data TEXT); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, data TEXT); + `); + const insertMessage = db.prepare('INSERT OR IGNORE INTO message (id, time_created, data) VALUES (?, ?, ?)'); + const insertPart = db.prepare('INSERT INTO part (id, message_id, data) VALUES (?, ?, ?)'); + steps.forEach((step, i) => { + const { messageID, modelID, timeCreated, role = 'assistant', partType = 'step-finish', tokens } = step; + insertMessage.run(messageID, timeCreated, JSON.stringify({ id: messageID, role, modelID })); + insertPart.run(`prt_${i}`, messageID, JSON.stringify({ type: partType, messageID, tokens })); + }); + db.close(); + return path; +} + +// console.warn을 가로채 경고 발생 여부까지 함께 단언한다. +async function captureWarnings(fn) { + const original = console.warn; + const warnings = []; + console.warn = (msg) => warnings.push(String(msg)); + try { + return { entries: await fn(), warnings }; + } finally { + console.warn = original; + } +} + describe('성공 — 포함·매핑', () => { test('parseOpencodeFiles_단일스텝_토큰4필드매핑', async () => { // Given @@ -359,3 +392,159 @@ describe('정규화 — 값 보정 후 포함', () => { assert.equal(entries[0].cacheCreateTok, 0); }); }); + +describe('SQLite 저장소', () => { + test('parseOpencodeRows_DB컬럼을기존토큰계약으로매핑', () => { + const entries = parseOpencodeRows([{ + date: '2026-06-10', + model: 'claude-sonnet-4-6', + input: 400, + output: 300, + reasoning: 900, + cache_read: 500, + cache_write: 20, + }]); + + assert.deepEqual(entries, [{ + date: '2026-06-10', model: 'claude-sonnet-4-6', + inputTok: 400, outputTok: 1200, cacheReadTok: 500, cacheCreateTok: 20, + }]); + }); + + // 행 shape은 USAGE_QUERY가 내보내는 7개 별칭 그대로다. + // date는 strftime 결과이므로 'YYYY-MM-DD' 아니면 NULL(m.time_created가 NULL일 때)이다. + test('parseOpencodeRows_모델또는날짜가없는행은제외', () => { + const entries = parseOpencodeRows([ + { date: OK_DATE, model: null, output: 999 }, + { date: null, model: 'bad-time', output: 999 }, + { date: OK_DATE, model: OK_MODEL, output: 7 }, + ]); + + assert.equal(entries.length, 1); + assert.equal(entries[0].date, OK_DATE); + assert.equal(entries[0].model, OK_MODEL); + assert.equal(entries[0].outputTok, 7); + }); + + test('parseOpencodeDatabase_time_created가NULL인메시지_집계에서제외', async () => { + // Given: time_created가 NULL이면 strftime이 NULL을 돌려줘 date를 만들 수 없다 + 정상 대조군 + const dbPath = createUsageDb('null-time.db', [ + { messageID: 'no_time', modelID: 'skip', timeCreated: null, tokens: { output: 999 } }, + { messageID: 'ok', modelID: OK_MODEL, timeCreated: TS_MS, tokens: { output: 7 } }, + ]); + + // When / Then: 귀속할 날짜가 없는 행만 빠진다 + assertOnlyOk(await parseOpencodeDatabase(dbPath)); + }); + + test('parseOpencodeDatabase_실제DB를외부CLI없이직접조회', async () => { + // Given: opencode.db와 같은 스키마의 실제 SQLite 파일(OpenCode CLI는 설치되어 있지 않아도 된다) + const dbPath = createUsageDb('direct.db', [ + { messageID: 'm1', modelID: 'db-model', timeCreated: TS_MS, tokens: { input: 400, output: 300, reasoning: 900, cache: { read: 500, write: 20 } } }, + ]); + + // When + const entries = await parseOpencodeDatabase(dbPath); + + // Then: 4필드 매핑(reasoning은 output에 합산) + assert.deepEqual(entries, [{ + date: localDateOf(TS_MS), model: 'db-model', + inputTok: 400, outputTok: 1200, cacheReadTok: 500, cacheCreateTok: 20, + }]); + }); + + test('parseOpencodeDatabase_같은날짜모델의여러스텝_DB에서합산', async () => { + // Given: 한 메시지에 step-finish part 3개(스텝마다 토큰이 다름) + const dbPath = createUsageDb('steps.db', [ + { messageID: 'm1', modelID: 'db-model', timeCreated: TS_MS, tokens: { input: 100, output: 10 } }, + { messageID: 'm1', modelID: 'db-model', timeCreated: TS_MS, tokens: { input: 200, output: 20 } }, + { messageID: 'm1', modelID: 'db-model', timeCreated: TS_MS, tokens: { input: 300, output: 30 } }, + ]); + + // When + const entries = await parseOpencodeDatabase(dbPath); + + // Then: date×model 1건으로 전 스텝 합산(마지막 스텝만이 아님) + assert.equal(entries.length, 1); + assert.equal(entries[0].inputTok, 600); + assert.equal(entries[0].outputTok, 60); + }); + + test('parseOpencodeDatabase_assistant아니거나step_finish아닌행_제외', async () => { + // Given: user 메시지·step-finish 아닌 part·tokens 없는 part + 정상 대조군 + const dbPath = createUsageDb('filtered.db', [ + { messageID: 'u1', modelID: 'skip', timeCreated: TS_MS, role: 'user', tokens: { output: 999 } }, + { messageID: 't1', modelID: 'skip', timeCreated: TS_MS, partType: 'text', tokens: { output: 999 } }, + { messageID: 'n1', modelID: 'skip', timeCreated: TS_MS, tokens: null }, + { messageID: 'x1', modelID: null, timeCreated: TS_MS, tokens: { output: 999 } }, + { messageID: 'ok', modelID: OK_MODEL, timeCreated: TS_MS, tokens: { output: 7 } }, + ]); + + // When / Then + assertOnlyOk(await parseOpencodeDatabase(dbPath)); + }); + + // OpenCode 상태(삭제·손상·스키마 변경)가 claude/codex sync를 막으면 안 된다. + // parseAll이 Promise.all로 묶기 때문에 여기서 throw하면 다른 source 결과까지 버려진다. + // 또한 집계할 기록이 없을 뿐인 상황은 경고 없이 조용히 넘어가야 한다. + test('parseOpencodeDatabase_DB없음_경고없이빈목록', async () => { + // Given: 존재하지 않는 경로(= 집계할 기록이 없음) + const missing = join(dir, 'nope.db'); + // When + const { entries, warnings } = await captureWarnings(() => parseOpencodeDatabase(missing)); + // Then: OpenCode 미사용자를 시끄럽게 하지 않는다 + assert.deepEqual(entries, []); + assert.deepEqual(warnings, []); + }); + + test('parseOpencodeDatabase_빈DB_경고없이빈목록', async () => { + // Given: 테이블이 하나도 없는 DB(OpenCode 삭제 후 잔존 파일) + const path = join(dir, 'empty.db'); + new Database(path).close(); + // When + const { entries, warnings } = await captureWarnings(() => parseOpencodeDatabase(path)); + // Then + assert.deepEqual(entries, []); + assert.deepEqual(warnings, []); + }); + + test('parseOpencodeDatabase_손상된DB파일_경고와함께빈목록', async () => { + // Given: 파일은 남아 있는데 SQLite가 아님 → 사용량이 누락되는 중이므로 알려야 한다 + const broken = join(dir, 'broken.db'); + writeFileSync(broken, 'not a sqlite file'); + // When + const { entries, warnings } = await captureWarnings(() => parseOpencodeDatabase(broken)); + // Then + assert.deepEqual(entries, []); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /could not read/); + }); + + test('parseOpencodeDatabase_다른테이블만있음_스키마변경경고', async () => { + // Given: 유효한 DB에 테이블은 있으나 message/part가 없음(OpenCode 스키마 변경) + const path = join(dir, 'schema.db'); + const db = new Database(path); + db.exec('CREATE TABLE unrelated (id TEXT)'); + db.close(); + // When + const { entries, warnings } = await captureWarnings(() => parseOpencodeDatabase(path)); + // Then: 집계가 0으로 새는 것을 조용히 넘기지 않는다 + assert.deepEqual(entries, []); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /schema changed/); + }); + + test('parseAll_OpenCodeDB실패_claude와codex집계는정상', async () => { + // Given: OpenCode 조회는 실패하고, 다른 source는 정상 항목을 낸 상황 + const opencodeEntries = await parseOpencodeDatabase(join(dir, 'gone.db')); + const claudeLike = { date: '2026-06-10', model: 'claude', inputTok: 5, outputTok: 5, cacheReadTok: 0, cacheCreateTok: 0 }; + const codexLike = { date: '2026-06-10', model: 'codex', inputTok: 3, outputTok: 3, cacheReadTok: 0, cacheCreateTok: 0 }; + + // When: parseAll과 동일하게 합산 + const records = aggregateRecords([claudeLike, codexLike, ...opencodeEntries]); + + // Then: OpenCode만 빠지고 나머지는 그대로 집계된다 + assert.equal(records.length, 2); + assert.deepEqual(records.map((r) => r.model).sort(), ['claude', 'codex']); + }); +});