diff --git a/package-lock.json b/package-lock.json index a036a8f..52a467b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "0.3.3", "license": "MIT", "dependencies": { + "@fastify/cors": "^11.2.0", "commander": "^14.0.0", "cron-parser": "^5.5.0", "execa": "^9.5.2", + "fastify": "^5.7.4", "nanoid": "^5.1.5", "proper-lockfile": "^4.1.2", "write-file-atomic": "^7.0.0", @@ -474,6 +476,137 @@ "node": ">=18" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -513,6 +646,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.58.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", @@ -1048,6 +1187,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1061,6 +1206,39 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1078,6 +1256,35 @@ "node": ">=12" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1173,6 +1380,19 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cron-parser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", @@ -1227,6 +1447,15 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1322,6 +1551,125 @@ "node": ">=12.0.0" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1355,6 +1703,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -1435,6 +1797,15 @@ "node": ">=0.8.19" } }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -1494,6 +1865,68 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1638,6 +2071,15 @@ "node": ">=0.10.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/parse-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", @@ -1696,6 +2138,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -1824,6 +2303,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -1841,6 +2336,12 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1855,6 +2356,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -1875,6 +2394,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -1884,6 +2412,22 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.58.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", @@ -1929,6 +2473,68 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1969,6 +2575,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1989,6 +2604,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2084,6 +2708,18 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2145,6 +2781,15 @@ "node": ">=14.0.0" } }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", diff --git a/package.json b/package.json index b4cd8bf..fba723c 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,9 @@ "nanoid": "^5.1.5", "proper-lockfile": "^4.1.2", "write-file-atomic": "^7.0.0", - "yaml": "^2.7.1" + "yaml": "^2.7.1", + "fastify": "^5.7.4", + "@fastify/cors": "^11.2.0" }, "devDependencies": { "@types/node": "^22.13.4", diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 5dca227..4d97845 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -1,13 +1,11 @@ -import { execa } from 'execa'; import { requireManifest, updateManifest, resolveWorktree } from '../core/manifest.js'; import { refreshAllAgentStatuses } from '../core/agent.js'; -import { getRepoRoot, getCurrentBranch } from '../core/worktree.js'; -import { cleanupWorktree } from '../core/cleanup.js'; +import { getRepoRoot } from '../core/worktree.js'; +import { mergeWorktree } from '../core/merge.js'; import { getCurrentPaneId } from '../core/self.js'; -import { listSessionPanes, type PaneInfo } from '../core/tmux.js'; -import { PpgError, WorktreeNotFoundError, MergeFailedError } from '../lib/errors.js'; +import { listSessionPanes } from '../core/tmux.js'; +import { WorktreeNotFoundError } from '../lib/errors.js'; import { output, success, info, warn } from '../lib/output.js'; -import { execaEnv } from '../lib/env.js'; export interface MergeOptions { strategy?: 'squash' | 'no-ff'; @@ -29,18 +27,6 @@ export async function mergeCommand(worktreeId: string, options: MergeOptions): P if (!wt) throw new WorktreeNotFoundError(worktreeId); - // Check all agents finished - const agents = Object.values(wt.agents); - const incomplete = agents.filter((a) => a.status === 'running'); - - if (incomplete.length > 0 && !options.force) { - const ids = incomplete.map((a) => a.id).join(', '); - throw new PpgError( - `${incomplete.length} agent(s) still running: ${ids}. Use --force to merge anyway.`, - 'AGENTS_RUNNING', - ); - } - if (options.dryRun) { info('Dry run — no changes will be made'); info(`Would merge branch ${wt.branch} into ${wt.baseBranch} using ${options.strategy ?? 'squash'} strategy`); @@ -50,89 +36,42 @@ export async function mergeCommand(worktreeId: string, options: MergeOptions): P return; } - // Set worktree status to merging - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'merging'; - } - return m; - }); - - const strategy = options.strategy ?? 'squash'; - - try { - const currentBranch = await getCurrentBranch(projectRoot); - if (currentBranch !== wt.baseBranch) { - info(`Switching to base branch ${wt.baseBranch}`); - await execa('git', ['checkout', wt.baseBranch], { ...execaEnv, cwd: projectRoot }); - } - - info(`Merging ${wt.branch} into ${wt.baseBranch} (${strategy})`); + const cleanupEnabled = options.cleanup !== false; - if (strategy === 'squash') { - await execa('git', ['merge', '--squash', wt.branch], { ...execaEnv, cwd: projectRoot }); - await execa('git', ['commit', '-m', `ppg: merge ${wt.name} (${wt.branch})`], { - ...execaEnv, - cwd: projectRoot, - }); - } else { - await execa('git', ['merge', '--no-ff', wt.branch, '-m', `ppg: merge ${wt.name} (${wt.branch})`], { - ...execaEnv, - cwd: projectRoot, - }); - } - - success(`Merged ${wt.branch} into ${wt.baseBranch}`); - } catch (err) { - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'failed'; - } - return m; - }); - throw new MergeFailedError( - `Merge failed: ${err instanceof Error ? err.message : err}`, - ); + // Build self-protection context for cleanup + const selfPaneId = cleanupEnabled ? getCurrentPaneId() : null; + let paneMap; + if (cleanupEnabled && selfPaneId) { + paneMap = await listSessionPanes(manifest.sessionName); } - // Mark as merged - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'merged'; - m.worktrees[wt.id].mergedAt = new Date().toISOString(); - } - return m; - }); - - // Cleanup with self-protection - let selfProtected = false; - if (options.cleanup !== false) { - info('Cleaning up...'); + info(`Merging ${wt.branch} into ${wt.baseBranch} (${options.strategy ?? 'squash'})`); - const selfPaneId = getCurrentPaneId(); - let paneMap: Map | undefined; - if (selfPaneId) { - paneMap = await listSessionPanes(manifest.sessionName); - } + const result = await mergeWorktree(projectRoot, wt, { + strategy: options.strategy, + cleanup: cleanupEnabled, + force: options.force, + cleanupOptions: cleanupEnabled ? { selfPaneId, paneMap } : undefined, + }); - const cleanupResult = await cleanupWorktree(projectRoot, wt, { selfPaneId, paneMap }); - selfProtected = cleanupResult.selfProtected; + success(`Merged ${wt.branch} into ${wt.baseBranch}`); - if (selfProtected) { - warn(`Some tmux targets skipped during cleanup — contains current ppg process`); - } + if (result.selfProtected) { + warn(`Some tmux targets skipped during cleanup — contains current ppg process`); + } + if (result.cleaned) { success(`Cleaned up worktree ${wt.id}`); } if (options.json) { output({ success: true, - worktreeId: wt.id, - branch: wt.branch, - baseBranch: wt.baseBranch, - strategy, - cleaned: options.cleanup !== false, - selfProtected: selfProtected || undefined, + worktreeId: result.worktreeId, + branch: result.branch, + baseBranch: result.baseBranch, + strategy: result.strategy, + cleaned: result.cleaned, + selfProtected: result.selfProtected || undefined, }, true); } } diff --git a/src/commands/pr.ts b/src/commands/pr.ts index aeb559d..534b471 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -1,13 +1,12 @@ -import { execa } from 'execa'; import { updateManifest, resolveWorktree } from '../core/manifest.js'; import { refreshAllAgentStatuses } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; -import { PpgError, NotInitializedError, WorktreeNotFoundError, GhNotFoundError } from '../lib/errors.js'; +import { createWorktreePr } from '../core/pr.js'; +import { NotInitializedError, WorktreeNotFoundError } from '../lib/errors.js'; import { output, success, info } from '../lib/output.js'; -import { execaEnv } from '../lib/env.js'; -// GitHub PR body limit is 65536 chars; leave room for truncation notice -const MAX_BODY_LENGTH = 60_000; +// Re-export for backwards compatibility with existing tests/consumers +export { buildBodyFromResults, truncateBody } from '../core/pr.js'; export interface PrOptions { title?: string; @@ -31,82 +30,16 @@ export async function prCommand(worktreeRef: string, options: PrOptions): Promis const wt = resolveWorktree(manifest, worktreeRef); if (!wt) throw new WorktreeNotFoundError(worktreeRef); - // Verify gh is available - try { - await execa('gh', ['--version'], execaEnv); - } catch { - throw new GhNotFoundError(); - } - - // Push the worktree branch - info(`Pushing branch ${wt.branch} to origin`); - try { - await execa('git', ['push', '-u', 'origin', wt.branch], { ...execaEnv, cwd: projectRoot }); - } catch (err) { - throw new PpgError( - `Failed to push branch ${wt.branch}: ${err instanceof Error ? err.message : err}`, - 'INVALID_ARGS', - ); - } - - // Build PR title and body - const title = options.title ?? wt.name; - const body = options.body ?? await buildBodyFromResults(Object.values(wt.agents)); - - // Build gh pr create args - const ghArgs = [ - 'pr', 'create', - '--head', wt.branch, - '--base', wt.baseBranch, - '--title', title, - '--body', body, - ]; - if (options.draft) { - ghArgs.push('--draft'); - } - - info(`Creating PR: ${title}`); - let prUrl: string; - try { - const result = await execa('gh', ghArgs, { ...execaEnv, cwd: projectRoot }); - prUrl = result.stdout.trim(); - } catch (err) { - throw new PpgError( - `Failed to create PR: ${err instanceof Error ? err.message : err}`, - 'INVALID_ARGS', - ); - } - - // Store PR URL in manifest - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].prUrl = prUrl; - } - return m; + info(`Creating PR for ${wt.branch}`); + const result = await createWorktreePr(projectRoot, wt, { + title: options.title, + body: options.body, + draft: options.draft, }); if (options.json) { - output({ - success: true, - worktreeId: wt.id, - branch: wt.branch, - baseBranch: wt.baseBranch, - prUrl, - }, true); + output({ success: true, ...result }, true); } else { - success(`PR created: ${prUrl}`); + success(`PR created: ${result.prUrl}`); } } - -/** Build PR body from agent prompts, with truncation. */ -export async function buildBodyFromResults(agents: { id: string; prompt: string }[]): Promise { - if (agents.length === 0) return ''; - const sections = agents.map((a) => `## Agent: ${a.id}\n\n${a.prompt}`); - return truncateBody(sections.join('\n\n---\n\n')); -} - -/** Truncate body to stay within GitHub's PR body size limit. */ -export function truncateBody(body: string): string { - if (body.length <= MAX_BODY_LENGTH) return body; - return body.slice(0, MAX_BODY_LENGTH) + '\n\n---\n\n*[Truncated — full results available in `.ppg/results/`]*'; -} diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..e29d746 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -6,6 +6,7 @@ import { readManifest, resolveWorktree, updateManifest } from '../core/manifest. import { spawnAgent } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; import { agentId, sessionId } from '../lib/id.js'; +import type { Manifest } from '../types/manifest.js'; import * as tmux from '../core/tmux.js'; vi.mock('node:fs/promises', async () => { @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession); const mockedCreateWindow = vi.mocked(tmux.createWindow); const mockedSplitPane = vi.mocked(tmux.splitPane); -function createManifest(tmuxWindow = '') { +function createManifest(tmuxWindow = ''): Manifest { return { version: 1 as const, projectRoot: '/tmp/repo', diff --git a/src/core/kill.test.ts b/src/core/kill.test.ts new file mode 100644 index 0000000..a6db7d1 --- /dev/null +++ b/src/core/kill.test.ts @@ -0,0 +1,74 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { makeWorktree, makeAgent } from '../test-fixtures.js'; +import type { Manifest } from '../types/manifest.js'; + +// ---- Mocks ---- + +let manifestState: Manifest; + +vi.mock('./manifest.js', () => ({ + updateManifest: vi.fn(async (_root: string, updater: (m: Manifest) => Manifest | Promise) => { + manifestState = await updater(structuredClone(manifestState)); + return manifestState; + }), +})); + +vi.mock('./agent.js', () => ({ + killAgents: vi.fn(), +})); + +// ---- Imports (after mocks) ---- + +import { killWorktreeAgents } from './kill.js'; +import { killAgents } from './agent.js'; + +describe('killWorktreeAgents', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given worktree with running agents, should kill running agents and set status to gone', async () => { + const agent1 = makeAgent({ id: 'ag-run00001', status: 'running' }); + const agent2 = makeAgent({ id: 'ag-idle0001', status: 'idle' }); + const wt = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-run00001': agent1, 'ag-idle0001': agent2 }, + }); + manifestState = { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: { 'wt-abc123': structuredClone(wt) }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + + const result = await killWorktreeAgents('/tmp/project', wt); + + expect(result.killed).toEqual(['ag-run00001']); + expect(vi.mocked(killAgents)).toHaveBeenCalledWith([agent1]); + expect(manifestState.worktrees['wt-abc123'].agents['ag-run00001'].status).toBe('gone'); + expect(manifestState.worktrees['wt-abc123'].agents['ag-idle0001'].status).toBe('idle'); + }); + + test('given worktree with no running agents, should return empty killed list', async () => { + const agent = makeAgent({ id: 'ag-done0001', status: 'exited' }); + const wt = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-done0001': agent }, + }); + manifestState = { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: { 'wt-abc123': structuredClone(wt) }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + + const result = await killWorktreeAgents('/tmp/project', wt); + + expect(result.killed).toEqual([]); + expect(vi.mocked(killAgents)).toHaveBeenCalledWith([]); + }); +}); diff --git a/src/core/kill.ts b/src/core/kill.ts new file mode 100644 index 0000000..ef26e67 --- /dev/null +++ b/src/core/kill.ts @@ -0,0 +1,36 @@ +import { updateManifest } from './manifest.js'; +import { killAgents } from './agent.js'; +import type { WorktreeEntry } from '../types/manifest.js'; + +export interface KillWorktreeResult { + worktreeId: string; + killed: string[]; +} + +/** Kill all running agents in a worktree and set their status to 'gone'. */ +export async function killWorktreeAgents( + projectRoot: string, + wt: WorktreeEntry, +): Promise { + const toKill = Object.values(wt.agents).filter((a) => a.status === 'running'); + const killedIds = toKill.map((a) => a.id); + + await killAgents(toKill); + + await updateManifest(projectRoot, (m) => { + const mWt = m.worktrees[wt.id]; + if (mWt) { + for (const agent of Object.values(mWt.agents)) { + if (killedIds.includes(agent.id)) { + agent.status = 'gone'; + } + } + } + return m; + }); + + return { + worktreeId: wt.id, + killed: killedIds, + }; +} diff --git a/src/core/merge.test.ts b/src/core/merge.test.ts new file mode 100644 index 0000000..99eb75a --- /dev/null +++ b/src/core/merge.test.ts @@ -0,0 +1,119 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { makeWorktree, makeAgent } from '../test-fixtures.js'; +import type { Manifest } from '../types/manifest.js'; + +// ---- Mocks ---- + +let manifestState: Manifest; + +vi.mock('./manifest.js', () => ({ + updateManifest: vi.fn(async (_root: string, updater: (m: Manifest) => Manifest | Promise) => { + manifestState = await updater(structuredClone(manifestState)); + return manifestState; + }), +})); + +vi.mock('./worktree.js', () => ({ + getCurrentBranch: vi.fn(() => 'main'), +})); + +vi.mock('./cleanup.js', () => ({ + cleanupWorktree: vi.fn(async () => ({ selfProtected: false, selfProtectedTargets: [] })), +})); + +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +vi.mock('../lib/env.js', () => ({ + execaEnv: {}, +})); + +// ---- Imports (after mocks) ---- + +import { mergeWorktree } from './merge.js'; +import { getCurrentBranch } from './worktree.js'; +import { cleanupWorktree } from './cleanup.js'; +import { execa } from 'execa'; + +describe('mergeWorktree', () => { + beforeEach(() => { + vi.clearAllMocks(); + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + manifestState = { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: { 'wt-abc123': wt }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + }); + + test('given valid worktree, should merge with squash and update manifest to merged', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + + const result = await mergeWorktree('/tmp/project', wt); + + expect(result.strategy).toBe('squash'); + expect(result.cleaned).toBe(true); + expect(manifestState.worktrees['wt-abc123'].status).toBe('merged'); + expect(manifestState.worktrees['wt-abc123'].mergedAt).toBeDefined(); + }); + + test('given no-ff strategy, should call git merge --no-ff', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + + await mergeWorktree('/tmp/project', wt, { strategy: 'no-ff' }); + + const calls = vi.mocked(execa).mock.calls; + const mergeCall = calls.find((c) => c[0] === 'git' && (c[1] as string[])?.[0] === 'merge'); + expect(mergeCall).toBeDefined(); + expect((mergeCall![1] as string[])).toContain('--no-ff'); + }); + + test('given different current branch, should checkout base branch first', async () => { + vi.mocked(getCurrentBranch).mockResolvedValueOnce('feature-x'); + const wt = makeWorktree({ id: 'wt-abc123', baseBranch: 'main', agents: {} }); + + await mergeWorktree('/tmp/project', wt); + + const calls = vi.mocked(execa).mock.calls; + const checkoutCall = calls.find((c) => c[0] === 'git' && (c[1] as string[])?.[0] === 'checkout'); + expect(checkoutCall).toBeDefined(); + expect((checkoutCall![1] as string[])).toContain('main'); + }); + + test('given running agents without force, should throw AGENTS_RUNNING', async () => { + const agent = makeAgent({ id: 'ag-running1', status: 'running' }); + const wt = makeWorktree({ id: 'wt-abc123', agents: { 'ag-running1': agent } }); + + await expect(mergeWorktree('/tmp/project', wt)).rejects.toThrow('agent(s) still running'); + }); + + test('given running agents with force, should merge anyway', async () => { + const agent = makeAgent({ id: 'ag-running1', status: 'running' }); + const wt = makeWorktree({ id: 'wt-abc123', agents: { 'ag-running1': agent } }); + + const result = await mergeWorktree('/tmp/project', wt, { force: true }); + + expect(result.worktreeId).toBe('wt-abc123'); + }); + + test('given cleanup false, should skip cleanup', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + + const result = await mergeWorktree('/tmp/project', wt, { cleanup: false }); + + expect(result.cleaned).toBe(false); + expect(vi.mocked(cleanupWorktree)).not.toHaveBeenCalled(); + }); + + test('given git merge failure, should set status to failed and throw', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + vi.mocked(execa).mockRejectedValueOnce(new Error('conflict')); + + await expect(mergeWorktree('/tmp/project', wt)).rejects.toThrow('Merge failed'); + expect(manifestState.worktrees['wt-abc123'].status).toBe('failed'); + }); +}); diff --git a/src/core/merge.ts b/src/core/merge.ts new file mode 100644 index 0000000..ad98701 --- /dev/null +++ b/src/core/merge.ts @@ -0,0 +1,105 @@ +import { execa } from 'execa'; +import { updateManifest } from './manifest.js'; +import { getCurrentBranch } from './worktree.js'; +import { cleanupWorktree, type CleanupOptions } from './cleanup.js'; +import { PpgError, MergeFailedError } from '../lib/errors.js'; +import { execaEnv } from '../lib/env.js'; +import type { WorktreeEntry } from '../types/manifest.js'; + +export interface MergeWorktreeOptions { + strategy?: 'squash' | 'no-ff'; + cleanup?: boolean; + force?: boolean; + cleanupOptions?: CleanupOptions; +} + +export interface MergeWorktreeResult { + worktreeId: string; + branch: string; + baseBranch: string; + strategy: 'squash' | 'no-ff'; + cleaned: boolean; + selfProtected: boolean; +} + +/** Merge a worktree branch into its base branch. Updates manifest status throughout. */ +export async function mergeWorktree( + projectRoot: string, + wt: WorktreeEntry, + options: MergeWorktreeOptions = {}, +): Promise { + const { strategy = 'squash', cleanup = true, force = false } = options; + + // Check all agents finished + const incomplete = Object.values(wt.agents).filter((a) => a.status === 'running'); + if (incomplete.length > 0 && !force) { + const ids = incomplete.map((a) => a.id).join(', '); + throw new PpgError( + `${incomplete.length} agent(s) still running: ${ids}. Use --force to merge anyway.`, + 'AGENTS_RUNNING', + ); + } + + // Set worktree status to merging + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'merging'; + } + return m; + }); + + try { + const currentBranch = await getCurrentBranch(projectRoot); + if (currentBranch !== wt.baseBranch) { + await execa('git', ['checkout', wt.baseBranch], { ...execaEnv, cwd: projectRoot }); + } + + if (strategy === 'squash') { + await execa('git', ['merge', '--squash', wt.branch], { ...execaEnv, cwd: projectRoot }); + await execa('git', ['commit', '-m', `ppg: merge ${wt.name} (${wt.branch})`], { + ...execaEnv, + cwd: projectRoot, + }); + } else { + await execa('git', ['merge', '--no-ff', wt.branch, '-m', `ppg: merge ${wt.name} (${wt.branch})`], { + ...execaEnv, + cwd: projectRoot, + }); + } + } catch (err) { + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'failed'; + } + return m; + }); + throw new MergeFailedError( + `Merge failed: ${err instanceof Error ? err.message : err}`, + ); + } + + // Mark as merged + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'merged'; + m.worktrees[wt.id].mergedAt = new Date().toISOString(); + } + return m; + }); + + // Cleanup + let selfProtected = false; + if (cleanup) { + const cleanupResult = await cleanupWorktree(projectRoot, wt, options.cleanupOptions); + selfProtected = cleanupResult.selfProtected; + } + + return { + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + strategy, + cleaned: cleanup, + selfProtected, + }; +} diff --git a/src/core/pr.ts b/src/core/pr.ts index 2849401..1411c43 100644 --- a/src/core/pr.ts +++ b/src/core/pr.ts @@ -1,8 +1,106 @@ import { execa } from 'execa'; import { execaEnv } from '../lib/env.js'; +import { PpgError, GhNotFoundError } from '../lib/errors.js'; +import { updateManifest } from './manifest.js'; +import type { WorktreeEntry } from '../types/manifest.js'; export type PrState = 'MERGED' | 'OPEN' | 'CLOSED' | 'UNKNOWN'; +// GitHub PR body limit is 65536 chars; leave room for truncation notice +const MAX_BODY_LENGTH = 60_000; + +/** Build PR body from agent prompts, with truncation. */ +export async function buildBodyFromResults(agents: { id: string; prompt: string }[]): Promise { + if (agents.length === 0) return ''; + const sections = agents.map((a) => `## Agent: ${a.id}\n\n${a.prompt}`); + return truncateBody(sections.join('\n\n---\n\n')); +} + +/** Truncate body to stay within GitHub's PR body size limit. */ +export function truncateBody(body: string): string { + if (body.length <= MAX_BODY_LENGTH) return body; + return body.slice(0, MAX_BODY_LENGTH) + '\n\n---\n\n*[Truncated — full results available in `.ppg/results/`]*'; +} + +export interface CreatePrOptions { + title?: string; + body?: string; + draft?: boolean; +} + +export interface CreatePrResult { + worktreeId: string; + branch: string; + baseBranch: string; + prUrl: string; +} + +/** Push branch and create a GitHub PR for a worktree. Stores prUrl in manifest. */ +export async function createWorktreePr( + projectRoot: string, + wt: WorktreeEntry, + options: CreatePrOptions = {}, +): Promise { + // Verify gh is available + try { + await execa('gh', ['--version'], execaEnv); + } catch { + throw new GhNotFoundError(); + } + + // Push the worktree branch + try { + await execa('git', ['push', '-u', 'origin', wt.branch], { ...execaEnv, cwd: projectRoot }); + } catch (err) { + throw new PpgError( + `Failed to push branch ${wt.branch}: ${err instanceof Error ? err.message : err}`, + 'INVALID_ARGS', + ); + } + + // Build PR title and body + const prTitle = options.title ?? wt.name; + const prBody = options.body ?? await buildBodyFromResults(Object.values(wt.agents)); + + // Build gh pr create args + const ghArgs = [ + 'pr', 'create', + '--head', wt.branch, + '--base', wt.baseBranch, + '--title', prTitle, + '--body', prBody, + ]; + if (options.draft) { + ghArgs.push('--draft'); + } + + let prUrl: string; + try { + const result = await execa('gh', ghArgs, { ...execaEnv, cwd: projectRoot }); + prUrl = result.stdout.trim(); + } catch (err) { + throw new PpgError( + `Failed to create PR: ${err instanceof Error ? err.message : err}`, + 'INVALID_ARGS', + ); + } + + // Store PR URL in manifest + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].prUrl = prUrl; + } + return m; + }); + + return { + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + prUrl, + }; +} + /** * Check the GitHub PR state for a given branch. * Uses `gh pr view` to query the PR associated with the branch. diff --git a/src/lib/paths.ts b/src/lib/paths.ts index d456f5f..1e902e4 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -86,3 +86,11 @@ export function worktreeBaseDir(projectRoot: string): string { export function worktreePath(projectRoot: string, id: string): string { return path.join(worktreeBaseDir(projectRoot), id); } + +export function serveStatePath(projectRoot: string): string { + return path.join(ppgDir(projectRoot), 'serve.json'); +} + +export function servePidPath(projectRoot: string): string { + return path.join(ppgDir(projectRoot), 'serve.pid'); +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..27eec1e --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,135 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import { createRequire } from 'node:module'; +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import { serveStatePath, servePidPath } from '../lib/paths.js'; +import { info, success } from '../lib/output.js'; + +const require = createRequire(import.meta.url); +const pkg = require('../../package.json') as { version: string }; + +export interface ServeOptions { + projectRoot: string; + port: number; + host: string; + token?: string; + json?: boolean; +} + +export interface ServeState { + pid: number; + port: number; + host: string; + lanAddress?: string; + startedAt: string; + version: string; +} + +export function detectLanAddress(): string | undefined { + const interfaces = os.networkInterfaces(); + for (const addrs of Object.values(interfaces)) { + if (!addrs) continue; + for (const addr of addrs) { + if (addr.family === 'IPv4' && !addr.internal) { + return addr.address; + } + } + } + return undefined; +} + +async function writeStateFile(projectRoot: string, state: ServeState): Promise { + const statePath = serveStatePath(projectRoot); + await fs.writeFile(statePath, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 }); +} + +async function writePidFile(projectRoot: string, pid: number): Promise { + const pidPath = servePidPath(projectRoot); + await fs.writeFile(pidPath, String(pid) + '\n', { mode: 0o600 }); +} + +async function removeStateFiles(projectRoot: string): Promise { + for (const filePath of [serveStatePath(projectRoot), servePidPath(projectRoot)]) { + try { + await fs.unlink(filePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + } +} + +export async function startServer(options: ServeOptions): Promise { + const { projectRoot, port, host, token, json } = options; + + const app = Fastify({ logger: false }); + + await app.register(cors, { origin: true }); + + if (token) { + const expectedHeader = `Bearer ${token}`; + app.addHook('onRequest', async (request, reply) => { + if (request.routeOptions.url === '/health') return; + const authHeader = request.headers.authorization ?? ''; + const headerBuf = Buffer.from(authHeader); + const expectedBuf = Buffer.from(expectedHeader); + if (headerBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(headerBuf, expectedBuf)) { + return reply.code(401).send({ error: 'Unauthorized' }); + } + }); + } + + // Decorate with projectRoot so routes can access it + app.decorate('projectRoot', projectRoot); + + app.get('/health', async () => { + return { + status: 'ok', + uptime: process.uptime(), + version: pkg.version, + }; + }); + + // Register route plugins + const { worktreeRoutes } = await import('./routes/worktrees.js'); + await app.register(worktreeRoutes, { prefix: '/api' }); + + const lanAddress = detectLanAddress(); + + const shutdown = async (signal: string) => { + if (!json) info(`Received ${signal}, shutting down...`); + await removeStateFiles(projectRoot); + await app.close(); + process.exit(0); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + await app.listen({ port, host }); + + const state: ServeState = { + pid: process.pid, + port, + host, + lanAddress, + startedAt: new Date().toISOString(), + version: pkg.version, + }; + + await writeStateFile(projectRoot, state); + await writePidFile(projectRoot, process.pid); + + if (json) { + console.log(JSON.stringify(state)); + } else { + success(`Server listening on http://${host}:${port}`); + if (lanAddress) { + info(`LAN address: http://${lanAddress}:${port}`); + } + if (token) { + info('Bearer token authentication enabled'); + } + } +} diff --git a/src/server/routes/worktrees.test.ts b/src/server/routes/worktrees.test.ts new file mode 100644 index 0000000..dad0351 --- /dev/null +++ b/src/server/routes/worktrees.test.ts @@ -0,0 +1,383 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import { makeWorktree, makeAgent } from '../../test-fixtures.js'; +import type { Manifest } from '../../types/manifest.js'; +import type { WorktreeEntry } from '../../types/manifest.js'; + +// ---- Mocks ---- + +const mockManifest: Manifest = { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: {}, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +}; + +vi.mock('../../core/manifest.js', () => ({ + updateManifest: vi.fn(async (_root: string, updater: (m: Manifest) => Manifest | Promise) => { + return updater(structuredClone(mockManifest)); + }), + resolveWorktree: vi.fn(), +})); + +vi.mock('../../core/agent.js', () => ({ + refreshAllAgentStatuses: vi.fn((m: Manifest) => m), +})); + +vi.mock('../../core/merge.js', () => ({ + mergeWorktree: vi.fn(async (_root: string, wt: WorktreeEntry, opts: Record = {}) => ({ + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + strategy: (opts.strategy as string) ?? 'squash', + cleaned: opts.cleanup !== false, + selfProtected: false, + })), +})); + +vi.mock('../../core/kill.js', () => ({ + killWorktreeAgents: vi.fn(async (_root: string, wt: WorktreeEntry) => { + const killed = Object.values(wt.agents) + .filter((a) => a.status === 'running') + .map((a) => a.id); + return { worktreeId: wt.id, killed }; + }), +})); + +vi.mock('../../core/pr.js', () => ({ + createWorktreePr: vi.fn(async (_root: string, wt: WorktreeEntry) => ({ + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + prUrl: 'https://github.com/owner/repo/pull/1', + })), +})); + +// ---- Imports (after mocks) ---- + +import { resolveWorktree, updateManifest } from '../../core/manifest.js'; +import { mergeWorktree } from '../../core/merge.js'; +import { killWorktreeAgents } from '../../core/kill.js'; +import { createWorktreePr } from '../../core/pr.js'; +import { worktreeRoutes } from './worktrees.js'; + +const PROJECT_ROOT = '/tmp/project'; + +async function buildApp(): Promise { + const app = Fastify(); + app.decorate('projectRoot', PROJECT_ROOT); + await app.register(worktreeRoutes, { prefix: '/api' }); + await app.ready(); + return app; +} + +describe('worktreeRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockManifest.worktrees = {}; + }); + + // ================================================================== + // POST /api/worktrees/:id/merge + // ================================================================== + describe('POST /api/worktrees/:id/merge', () => { + test('given valid worktree, should merge with squash strategy by default', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.worktreeId).toBe('wt-abc123'); + expect(body.strategy).toBe('squash'); + expect(body.cleaned).toBe(true); + expect(vi.mocked(mergeWorktree)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, { strategy: undefined, cleanup: undefined, force: undefined }, + ); + }); + + test('given strategy no-ff, should pass strategy to mergeWorktree', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: { strategy: 'no-ff' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().strategy).toBe('no-ff'); + expect(vi.mocked(mergeWorktree)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, expect.objectContaining({ strategy: 'no-ff' }), + ); + }); + + test('given cleanup false, should pass cleanup false', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: { cleanup: false }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().cleaned).toBe(false); + expect(vi.mocked(mergeWorktree)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, expect.objectContaining({ cleanup: false }), + ); + }); + + test('given worktree not found, should return 404', async () => { + vi.mocked(resolveWorktree).mockReturnValue(undefined as unknown as ReturnType); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-nonexist/merge', + payload: {}, + }); + + expect(res.statusCode).toBe(404); + expect(res.json().code).toBe('WORKTREE_NOT_FOUND'); + }); + + test('given AGENTS_RUNNING error from core, should return 409', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const { PpgError } = await import('../../lib/errors.js'); + vi.mocked(mergeWorktree).mockRejectedValueOnce( + new PpgError('1 agent(s) still running', 'AGENTS_RUNNING'), + ); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: {}, + }); + + expect(res.statusCode).toBe(409); + expect(res.json().code).toBe('AGENTS_RUNNING'); + }); + + test('given force flag, should pass force to mergeWorktree', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: { force: true }, + }); + + expect(res.statusCode).toBe(200); + expect(vi.mocked(mergeWorktree)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, expect.objectContaining({ force: true }), + ); + }); + + test('given MERGE_FAILED error from core, should return 500', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const { MergeFailedError } = await import('../../lib/errors.js'); + vi.mocked(mergeWorktree).mockRejectedValueOnce( + new MergeFailedError('Merge failed: conflict'), + ); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: {}, + }); + + expect(res.statusCode).toBe(500); + expect(res.json().code).toBe('MERGE_FAILED'); + }); + }); + + // ================================================================== + // POST /api/worktrees/:id/kill + // ================================================================== + describe('POST /api/worktrees/:id/kill', () => { + test('given worktree with running agents, should kill via core and return killed list', async () => { + const agent1 = makeAgent({ id: 'ag-run00001', status: 'running' }); + const agent2 = makeAgent({ id: 'ag-idle0001', status: 'idle' }); + const wt = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-run00001': agent1, 'ag-idle0001': agent2 }, + }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/kill', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.killed).toEqual(['ag-run00001']); + expect(vi.mocked(killWorktreeAgents)).toHaveBeenCalledWith(PROJECT_ROOT, wt); + }); + + test('given worktree with no running agents, should return empty killed list', async () => { + const agent = makeAgent({ id: 'ag-done0001', status: 'exited' }); + const wt = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-done0001': agent }, + }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/kill', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().killed).toEqual([]); + }); + + test('given worktree not found, should return 404', async () => { + vi.mocked(resolveWorktree).mockReturnValue(undefined as unknown as ReturnType); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-nonexist/kill', + payload: {}, + }); + + expect(res.statusCode).toBe(404); + expect(res.json().code).toBe('WORKTREE_NOT_FOUND'); + }); + }); + + // ================================================================== + // POST /api/worktrees/:id/pr + // ================================================================== + describe('POST /api/worktrees/:id/pr', () => { + test('given valid worktree, should create PR and return URL', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/pr', + payload: { title: 'My PR', body: 'Description' }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.prUrl).toBe('https://github.com/owner/repo/pull/1'); + expect(body.worktreeId).toBe('wt-abc123'); + expect(vi.mocked(createWorktreePr)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, { title: 'My PR', body: 'Description', draft: undefined }, + ); + }); + + test('given draft flag, should pass draft to createWorktreePr', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/pr', + payload: { draft: true }, + }); + + expect(vi.mocked(createWorktreePr)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, expect.objectContaining({ draft: true }), + ); + }); + + test('given worktree not found, should return 404', async () => { + vi.mocked(resolveWorktree).mockReturnValue(undefined as unknown as ReturnType); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-nonexist/pr', + payload: {}, + }); + + expect(res.statusCode).toBe(404); + expect(res.json().code).toBe('WORKTREE_NOT_FOUND'); + }); + + test('given GH_NOT_FOUND error from core, should return 502', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const { GhNotFoundError } = await import('../../lib/errors.js'); + vi.mocked(createWorktreePr).mockRejectedValueOnce(new GhNotFoundError()); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/pr', + payload: {}, + }); + + expect(res.statusCode).toBe(502); + expect(res.json().code).toBe('GH_NOT_FOUND'); + }); + + test('given INVALID_ARGS error from core, should return 400', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const { PpgError } = await import('../../lib/errors.js'); + vi.mocked(createWorktreePr).mockRejectedValueOnce( + new PpgError('Failed to push', 'INVALID_ARGS'), + ); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/pr', + payload: {}, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().code).toBe('INVALID_ARGS'); + }); + }); +}); diff --git a/src/server/routes/worktrees.ts b/src/server/routes/worktrees.ts new file mode 100644 index 0000000..517da5a --- /dev/null +++ b/src/server/routes/worktrees.ts @@ -0,0 +1,124 @@ +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { updateManifest, resolveWorktree } from '../../core/manifest.js'; +import { refreshAllAgentStatuses } from '../../core/agent.js'; +import { mergeWorktree } from '../../core/merge.js'; +import { killWorktreeAgents } from '../../core/kill.js'; +import { createWorktreePr } from '../../core/pr.js'; +import { PpgError, WorktreeNotFoundError } from '../../lib/errors.js'; + +// ------------------------------------------------------------------ +// Fastify plugin — worktree action routes +// ------------------------------------------------------------------ + +declare module 'fastify' { + interface FastifyInstance { + projectRoot: string; + } +} + +interface WorktreeParams { + id: string; +} + +interface MergeBody { + strategy?: 'squash' | 'no-ff'; + cleanup?: boolean; + force?: boolean; +} + +interface PrBody { + title?: string; + body?: string; + draft?: boolean; +} + +function errorReply(reply: FastifyReply, err: unknown): FastifyReply { + if (err instanceof PpgError) { + const statusMap: Record = { + WORKTREE_NOT_FOUND: 404, + AGENT_NOT_FOUND: 404, + NOT_INITIALIZED: 400, + AGENTS_RUNNING: 409, + MERGE_FAILED: 500, + GH_NOT_FOUND: 502, + INVALID_ARGS: 400, + }; + const status = statusMap[err.code] ?? 500; + return reply.code(status).send({ error: err.message, code: err.code }); + } + const message = err instanceof Error ? err.message : String(err); + return reply.code(500).send({ error: message }); +} + +async function resolveWorktreeFromRequest( + projectRoot: string, + id: string, +) { + const manifest = await updateManifest(projectRoot, async (m) => { + return refreshAllAgentStatuses(m, projectRoot); + }); + + const wt = resolveWorktree(manifest, id); + if (!wt) throw new WorktreeNotFoundError(id); + return wt; +} + +export async function worktreeRoutes(app: FastifyInstance): Promise { + const { projectRoot } = app; + + // ---------------------------------------------------------------- + // POST /api/worktrees/:id/merge + // ---------------------------------------------------------------- + app.post<{ Params: WorktreeParams; Body: MergeBody }>( + '/worktrees/:id/merge', + async (request, reply) => { + try { + const wt = await resolveWorktreeFromRequest(projectRoot, request.params.id); + const { strategy, cleanup, force } = request.body ?? {}; + + const result = await mergeWorktree(projectRoot, wt, { strategy, cleanup, force }); + + return { success: true, ...result }; + } catch (err) { + return errorReply(reply, err); + } + }, + ); + + // ---------------------------------------------------------------- + // POST /api/worktrees/:id/kill + // ---------------------------------------------------------------- + app.post<{ Params: WorktreeParams }>( + '/worktrees/:id/kill', + async (request, reply) => { + try { + const wt = await resolveWorktreeFromRequest(projectRoot, request.params.id); + + const result = await killWorktreeAgents(projectRoot, wt); + + return { success: true, ...result }; + } catch (err) { + return errorReply(reply, err); + } + }, + ); + + // ---------------------------------------------------------------- + // POST /api/worktrees/:id/pr + // ---------------------------------------------------------------- + app.post<{ Params: WorktreeParams; Body: PrBody }>( + '/worktrees/:id/pr', + async (request, reply) => { + try { + const wt = await resolveWorktreeFromRequest(projectRoot, request.params.id); + const { title, body, draft } = request.body ?? {}; + + const result = await createWorktreePr(projectRoot, wt, { title, body, draft }); + + return { success: true, ...result }; + } catch (err) { + return errorReply(reply, err); + } + }, + ); +}