diff --git a/package.json b/package.json index 87709f3c..b5a0131d 100644 --- a/package.json +++ b/package.json @@ -42,9 +42,12 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@fastify/cors": "^11.2.0", + "@fastify/static": "^9.0.0", "@tanstack/react-virtual": "^3.10.8", "date-fns": "^3.6.0", "electron-updater": "^6.7.3", + "fastify": "^5.7.4", "idb-keyval": "^6.2.2", "lucide-react": "^0.562.0", "mdast-util-to-hast": "^13.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d077f17..5c84b078 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,12 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@18.3.1) + '@fastify/cors': + specifier: ^11.2.0 + version: 11.2.0 + '@fastify/static': + specifier: ^9.0.0 + version: 9.0.0 '@tanstack/react-virtual': specifier: ^3.10.8 version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -29,6 +35,9 @@ importers: electron-updater: specifier: ^6.7.3 version: 6.7.3 + fastify: + specifier: ^5.7.4 + version: 5.7.4 idb-keyval: specifier: ^6.2.2 version: 6.2.2 @@ -74,7 +83,7 @@ importers: version: 9.39.2 '@tailwindcss/typography': specifier: ^0.5.19 - version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)) + version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) '@types/hast': specifier: ^3.0.4 version: 3.0.4 @@ -149,7 +158,7 @@ importers: version: 3.0.6(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-tailwindcss: specifier: ^3.18.2 - version: 3.18.2(tailwindcss@3.4.19(tsx@4.21.0)) + version: 3.18.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) globals: specifier: ^17.2.0 version: 17.2.0 @@ -170,7 +179,7 @@ importers: version: 0.7.2(prettier@3.8.1) tailwindcss: specifier: ^3.4.1 - version: 3.4.19(tsx@4.21.0) + version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -692,6 +701,36 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@9.0.0': + resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -716,6 +755,10 @@ packages: resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} engines: {node: 20 || >=22} + '@isaacs/brace-expansion@5.0.1': + resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} + engines: {node: 20 || >=22} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -743,6 +786,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@malept/cross-spawn-promise@1.1.1': resolution: {integrity: sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==} engines: {node: '>= 10'} @@ -869,6 +916,9 @@ packages: cpu: [x64] os: [win32] + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1333,6 +1383,9 @@ packages: resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1347,6 +1400,14 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -1355,6 +1416,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1481,6 +1545,10 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + autoprefixer@10.4.23: resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==} engines: {node: ^10 || ^12 || >=14} @@ -1492,6 +1560,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + avvio@9.1.0: + resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + axe-core@4.11.1: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} @@ -1711,9 +1782,17 @@ packages: config-file-ts@0.2.6: resolution: {integrity: sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -1813,6 +1892,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1965,6 +2048,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2148,6 +2234,9 @@ packages: resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} engines: {'0': node >=0.6.0} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2158,9 +2247,24 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-json-stringify@6.3.0: + resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} + fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.7.4: + resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2190,6 +2294,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way@9.4.0: + resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} + engines: {node: '>=20'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2304,6 +2412,10 @@ packages: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} hasBin: true + glob@13.0.2: + resolution: {integrity: sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==} + engines: {node: 20 || >=22} + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -2396,6 +2508,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} @@ -2453,6 +2569,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -2655,9 +2775,15 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2716,6 +2842,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2772,6 +2901,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2955,6 +3088,11 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -2967,6 +3105,10 @@ packages: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} + minimatch@10.1.2: + resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3077,6 +3219,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -3132,6 +3278,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3157,6 +3307,16 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -3287,6 +3447,12 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -3311,6 +3477,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -3358,6 +3527,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + refa@0.12.1: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -3394,6 +3567,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -3416,6 +3593,10 @@ packages: responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -3424,6 +3605,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + roarr@2.15.4: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} @@ -3454,9 +3638,16 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-regex2@5.0.0: + resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + safe-regex@2.1.1: resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -3474,6 +3665,9 @@ packages: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -3490,6 +3684,9 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3502,6 +3699,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3549,6 +3749,9 @@ packages: resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} engines: {node: '>= 18'} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3563,6 +3766,10 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} @@ -3584,6 +3791,10 @@ packages: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -3710,6 +3921,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + tiny-typed-emitter@2.1.0: resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} @@ -3746,6 +3961,14 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4022,6 +4245,11 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4508,6 +4736,53 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.3.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@9.0.0': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.0.1 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.2 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -4525,6 +4800,10 @@ snapshots: dependencies: '@isaacs/balanced-match': 4.0.1 + '@isaacs/brace-expansion@5.0.1': + dependencies: + '@isaacs/balanced-match': 4.0.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -4561,6 +4840,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lukeed/ms@2.0.2': {} + '@malept/cross-spawn-promise@1.1.1': dependencies: cross-spawn: 7.0.6 @@ -4662,6 +4943,8 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.16.4': optional: true + '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -4750,10 +5033,10 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0))': + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.19(tsx@4.21.0) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) '@tanstack/react-virtual@3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -5115,6 +5398,8 @@ snapshots: '@xmldom/xmldom@0.8.11': {} + abstract-logging@2.0.1: {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -5127,6 +5412,10 @@ snapshots: transitivePeerDependencies: - supports-color + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv-keywords@3.5.2(ajv@6.12.6): dependencies: ajv: 6.12.6 @@ -5138,6 +5427,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -5330,6 +5626,8 @@ snapshots: at-least-node@1.0.0: {} + atomic-sleep@1.0.0: {} + autoprefixer@10.4.23(postcss@8.5.6): dependencies: browserslist: 4.28.1 @@ -5343,6 +5641,11 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + avvio@9.1.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + axe-core@4.11.1: {} axobject-query@4.1.0: {} @@ -5581,8 +5884,12 @@ snapshots: glob: 10.5.0 typescript: 5.9.3 + content-disposition@1.0.1: {} + convert-source-map@2.0.0: {} + cookie@1.1.1: {} + core-util-is@1.0.2: optional: true @@ -5674,6 +5981,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-node@2.1.0: @@ -5987,6 +6296,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -6157,11 +6468,11 @@ snapshots: semver: 7.7.3 typescript: 5.9.3 - eslint-plugin-tailwindcss@3.18.2(tailwindcss@3.4.19(tsx@4.21.0)): + eslint-plugin-tailwindcss@3.18.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): dependencies: fast-glob: 3.3.3 postcss: 8.5.6 - tailwindcss: 3.4.19(tsx@4.21.0) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) eslint-scope@8.4.0: dependencies: @@ -6254,6 +6565,8 @@ snapshots: extsprintf@1.4.1: optional: true + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -6266,8 +6579,43 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-json-stringify@6.3.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.0: {} + + fastify-plugin@5.1.0: {} + + fastify@5.7.4: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.1.0 + fast-json-stringify: 6.3.0 + find-my-way: 9.4.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.3 + toad-cache: 3.7.0 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -6296,6 +6644,12 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-my-way@9.4.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -6431,6 +6785,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@13.0.2: + dependencies: + minimatch: 10.1.2 + minipass: 7.1.2 + path-scurry: 2.0.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -6553,6 +6913,14 @@ snapshots: http-cache-semantics@4.2.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@5.0.0: dependencies: '@tootallnate/once': 2.0.0 @@ -6613,6 +6981,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + ipaddr.js@2.3.0: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -6816,8 +7186,14 @@ snapshots: json-buffer@3.0.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: @@ -6886,6 +7262,12 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -6924,6 +7306,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7318,6 +7702,8 @@ snapshots: mime@2.6.0: {} + mime@3.0.0: {} + mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -7326,6 +7712,10 @@ snapshots: dependencies: '@isaacs/brace-expansion': 5.0.0 + minimatch@10.1.2: + dependencies: + '@isaacs/brace-expansion': 5.0.1 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -7427,6 +7817,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + on-exit-leak-free@2.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -7508,6 +7900,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-scurry@2.0.1: + dependencies: + lru-cache: 11.2.6 + minipass: 7.1.2 + pathe@2.0.3: {} pathval@2.0.1: {} @@ -7522,6 +7919,26 @@ snapshots: pify@2.3.0: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 + pirates@4.0.7: {} plist@3.1.0: @@ -7544,13 +7961,14 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 tsx: 4.21.0 + yaml: 2.8.2 postcss-nested@6.2.0(postcss@8.5.6): dependencies: @@ -7585,6 +8003,10 @@ snapshots: process-nextick-args@2.0.1: {} + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + progress@2.0.3: {} promise-retry@2.0.1: @@ -7609,6 +8031,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + quick-lru@5.1.1: {} react-dom@18.3.1(react@18.3.1): @@ -7680,6 +8104,8 @@ snapshots: dependencies: picomatch: 2.3.1 + real-require@0.2.0: {} + refa@0.12.1: dependencies: '@eslint-community/regexpp': 4.12.2 @@ -7747,6 +8173,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} @@ -7769,10 +8197,14 @@ snapshots: dependencies: lowercase-keys: 2.0.0 + ret@0.5.0: {} + retry@0.12.0: {} reusify@1.1.0: {} + rfdc@1.4.1: {} + roarr@2.15.4: dependencies: boolean: 3.2.0 @@ -7841,10 +8273,16 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-regex2@5.0.0: + dependencies: + ret: 0.5.0 + safe-regex@2.1.1: dependencies: regexp-tree: 0.1.27 + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} sanitize-filename@1.6.3: @@ -7863,6 +8301,8 @@ snapshots: refa: 0.12.1 regexp-ast-analysis: 0.7.1 + secure-json-parse@4.1.0: {} + semver-compare@1.0.0: optional: true @@ -7875,6 +8315,8 @@ snapshots: type-fest: 0.13.1 optional: true + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -7897,6 +8339,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -7951,6 +8395,10 @@ snapshots: smol-toml@1.6.0: {} + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -7962,6 +8410,8 @@ snapshots: space-separated-tokens@2.0.2: {} + split2@4.2.0: {} + sprintf-js@1.1.3: optional: true @@ -7981,6 +8431,8 @@ snapshots: stat-mode@1.0.0: {} + statuses@2.0.2: {} + std-env@3.10.0: {} stop-iteration-iterator@1.1.0: @@ -8111,7 +8563,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tailwindcss@3.4.19(tsx@4.21.0): + tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -8130,7 +8582,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -8183,6 +8635,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + tiny-typed-emitter@2.1.0: {} tinybench@2.9.0: {} @@ -8210,6 +8666,10 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.0: {} + + toidentifier@1.0.1: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -8555,6 +9015,9 @@ snapshots: yallist@4.0.0: {} + yaml@2.8.2: + optional: true + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/src/main/http/config.ts b/src/main/http/config.ts new file mode 100644 index 00000000..9894d5a3 --- /dev/null +++ b/src/main/http/config.ts @@ -0,0 +1,394 @@ +/** + * HTTP route handlers for App Configuration. + * + * Routes: + * - GET /api/config - Get full config + * - POST /api/config/update - Update config section + * - POST /api/config/ignore-regex - Add ignore pattern + * - DELETE /api/config/ignore-regex - Remove ignore pattern + * - POST /api/config/ignore-repository - Add ignored repository + * - DELETE /api/config/ignore-repository - Remove ignored repository + * - POST /api/config/snooze - Set snooze + * - POST /api/config/clear-snooze - Clear snooze + * - POST /api/config/triggers - Add trigger + * - PUT /api/config/triggers/:triggerId - Update trigger + * - DELETE /api/config/triggers/:triggerId - Remove trigger + * - GET /api/config/triggers - Get all triggers + * - POST /api/config/triggers/:triggerId/test - Test trigger + * - POST /api/config/pin-session - Pin session + * - POST /api/config/unpin-session - Unpin session + * - POST /api/config/select-folders - No-op in browser + * - POST /api/config/open-in-editor - No-op in browser + */ + +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; + +import { validateConfigUpdatePayload } from '../ipc/configValidation'; +import { validateTriggerId } from '../ipc/guards'; +import { + ConfigManager, + type NotificationTrigger, + type TriggerContentType, + type TriggerMatchField, + type TriggerMode, + type TriggerTokenType, +} from '../services'; + +import type { TriggerColor } from '@shared/constants/triggerColors'; +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('HTTP:config'); + +interface ConfigResult { + success: boolean; + data?: T; + error?: string; +} + +export function registerConfigRoutes(app: FastifyInstance): void { + const configManager = ConfigManager.getInstance(); + + // Get full config + app.get('/api/config', async () => { + try { + const config = configManager.getConfig(); + return { success: true, data: config }; + } catch (error) { + logger.error('Error in GET /api/config:', error); + return { success: false, error: getErrorMessage(error) }; + } + }); + + // Update config section + app.post<{ Body: { section: unknown; data: unknown } }>('/api/config/update', async (request) => { + try { + const { section, data } = request.body; + const validation = validateConfigUpdatePayload(section, data); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + configManager.updateConfig(validation.section, validation.data); + const updatedConfig = configManager.getConfig(); + return { success: true, data: updatedConfig }; + } catch (error) { + logger.error('Error in POST /api/config/update:', error); + return { success: false, error: getErrorMessage(error) }; + } + }); + + // Add ignore regex + app.post<{ Body: { pattern: string } }>('/api/config/ignore-regex', async (request) => { + try { + const { pattern } = request.body; + if (!pattern || typeof pattern !== 'string') { + return { success: false, error: 'Pattern is required and must be a string' }; + } + + try { + new RegExp(pattern); + } catch { + return { success: false, error: 'Invalid regex pattern' }; + } + + configManager.addIgnoreRegex(pattern); + return { success: true }; + } catch (error) { + logger.error('Error in POST /api/config/ignore-regex:', error); + return { success: false, error: getErrorMessage(error) }; + } + }); + + // Remove ignore regex + app.delete<{ Body: { pattern: string } }>('/api/config/ignore-regex', async (request) => { + try { + const { pattern } = request.body; + if (!pattern || typeof pattern !== 'string') { + return { success: false, error: 'Pattern is required and must be a string' }; + } + + configManager.removeIgnoreRegex(pattern); + return { success: true }; + } catch (error) { + logger.error('Error in DELETE /api/config/ignore-regex:', error); + return { success: false, error: getErrorMessage(error) }; + } + }); + + // Add ignore repository + app.post<{ Body: { repositoryId: string } }>('/api/config/ignore-repository', async (request) => { + try { + const { repositoryId } = request.body; + if (!repositoryId || typeof repositoryId !== 'string') { + return { success: false, error: 'Repository ID is required and must be a string' }; + } + + configManager.addIgnoreRepository(repositoryId); + return { success: true }; + } catch (error) { + logger.error('Error in POST /api/config/ignore-repository:', error); + return { success: false, error: getErrorMessage(error) }; + } + }); + + // Remove ignore repository + app.delete<{ Body: { repositoryId: string } }>( + '/api/config/ignore-repository', + async (request) => { + try { + const { repositoryId } = request.body; + if (!repositoryId || typeof repositoryId !== 'string') { + return { success: false, error: 'Repository ID is required and must be a string' }; + } + + configManager.removeIgnoreRepository(repositoryId); + return { success: true }; + } catch (error) { + logger.error('Error in DELETE /api/config/ignore-repository:', error); + return { success: false, error: getErrorMessage(error) }; + } + } + ); + + // Set snooze + app.post<{ Body: { minutes: number } }>('/api/config/snooze', async (request) => { + try { + const { minutes } = request.body; + if (typeof minutes !== 'number' || minutes <= 0 || minutes > 24 * 60) { + return { success: false, error: 'Minutes must be a positive number' }; + } + + configManager.setSnooze(minutes); + return { success: true }; + } catch (error) { + logger.error('Error in POST /api/config/snooze:', error); + return { success: false, error: getErrorMessage(error) }; + } + }); + + // Clear snooze + app.post('/api/config/clear-snooze', async () => { + try { + configManager.clearSnooze(); + return { success: true }; + } catch (error) { + logger.error('Error in POST /api/config/clear-snooze:', error); + return { success: false, error: getErrorMessage(error) }; + } + }); + + // Add trigger + app.post<{ + Body: { + id: string; + name: string; + enabled: boolean; + contentType: string; + mode?: TriggerMode; + requireError?: boolean; + toolName?: string; + matchField?: string; + matchPattern?: string; + ignorePatterns?: string[]; + tokenThreshold?: number; + tokenType?: TriggerTokenType; + repositoryIds?: string[]; + color?: string; + }; + }>('/api/config/triggers', async (request) => { + try { + const trigger = request.body; + if (!trigger.id || !trigger.name || !trigger.contentType) { + return { success: false, error: 'Trigger must have id, name, and contentType' }; + } + + configManager.addTrigger({ + id: trigger.id, + name: trigger.name, + enabled: trigger.enabled, + contentType: trigger.contentType as TriggerContentType, + mode: trigger.mode ?? (trigger.requireError ? 'error_status' : 'content_match'), + requireError: trigger.requireError, + toolName: trigger.toolName, + matchField: trigger.matchField as TriggerMatchField | undefined, + matchPattern: trigger.matchPattern, + ignorePatterns: trigger.ignorePatterns, + tokenThreshold: trigger.tokenThreshold, + tokenType: trigger.tokenType, + repositoryIds: trigger.repositoryIds, + color: trigger.color as TriggerColor | undefined, + isBuiltin: false, + }); + + return { success: true }; + } catch (error) { + logger.error('Error in POST /api/config/triggers:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to add trigger', + }; + } + }); + + // Update trigger + app.put<{ + Params: { triggerId: string }; + Body: Partial<{ + name: string; + enabled: boolean; + contentType: string; + requireError: boolean; + toolName: string; + matchField: string; + matchPattern: string; + ignorePatterns: string[]; + mode: TriggerMode; + tokenThreshold: number; + tokenType: TriggerTokenType; + repositoryIds: string[]; + color: string; + }>; + }>('/api/config/triggers/:triggerId', async (request) => { + try { + const validated = validateTriggerId(request.params.triggerId); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Trigger ID is required' }; + } + + configManager.updateTrigger(validated.value!, request.body as Partial); + return { success: true }; + } catch (error) { + logger.error('Error in PUT /api/config/triggers:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update trigger', + }; + } + }); + + // Remove trigger + app.delete<{ Params: { triggerId: string } }>( + '/api/config/triggers/:triggerId', + async (request) => { + try { + const validated = validateTriggerId(request.params.triggerId); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Trigger ID is required' }; + } + + configManager.removeTrigger(validated.value!); + return { success: true }; + } catch (error) { + logger.error('Error in DELETE /api/config/triggers:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to remove trigger', + }; + } + } + ); + + // Get triggers + app.get('/api/config/triggers', async () => { + try { + const triggers = configManager.getTriggers(); + return { success: true, data: triggers }; + } catch (error) { + logger.error('Error in GET /api/config/triggers:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get triggers', + }; + } + }); + + // Test trigger + app.post<{ Params: { triggerId: string }; Body: NotificationTrigger }>( + '/api/config/triggers/:triggerId/test', + async (request) => { + try { + const { errorDetector } = await import('../services'); + const result = await errorDetector.testTrigger(request.body, 50); + + const errors = result.errors.map((error) => ({ + id: error.id, + sessionId: error.sessionId, + projectId: error.projectId, + message: error.message, + timestamp: error.timestamp, + source: error.source, + toolUseId: error.toolUseId, + subagentId: error.subagentId, + lineNumber: error.lineNumber, + context: { projectName: error.context.projectName }, + })); + + return { + success: true, + data: { totalCount: result.totalCount, errors, truncated: result.truncated }, + }; + } catch (error) { + logger.error('Error in POST /api/config/triggers/test:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to test trigger', + }; + } + } + ); + + // Pin session + app.post<{ Body: { projectId: string; sessionId: string } }>( + '/api/config/pin-session', + async (request) => { + try { + const { projectId, sessionId } = request.body; + if (!projectId || typeof projectId !== 'string') { + return { success: false, error: 'Project ID is required and must be a string' }; + } + if (!sessionId || typeof sessionId !== 'string') { + return { success: false, error: 'Session ID is required and must be a string' }; + } + + configManager.pinSession(projectId, sessionId); + return { success: true }; + } catch (error) { + logger.error('Error in POST /api/config/pin-session:', error); + return { success: false, error: getErrorMessage(error) }; + } + } + ); + + // Unpin session + app.post<{ Body: { projectId: string; sessionId: string } }>( + '/api/config/unpin-session', + async (request) => { + try { + const { projectId, sessionId } = request.body; + if (!projectId || typeof projectId !== 'string') { + return { success: false, error: 'Project ID is required and must be a string' }; + } + if (!sessionId || typeof sessionId !== 'string') { + return { success: false, error: 'Session ID is required and must be a string' }; + } + + configManager.unpinSession(projectId, sessionId); + return { success: true }; + } catch (error) { + logger.error('Error in POST /api/config/unpin-session:', error); + return { success: false, error: getErrorMessage(error) }; + } + } + ); + + // Select folders - no-op in browser mode + app.post('/api/config/select-folders', async (): Promise> => { + return { success: true, data: [] }; + }); + + // Open in editor - no-op in browser mode + app.post('/api/config/open-in-editor', async (): Promise => { + return { success: true }; + }); +} diff --git a/src/main/http/events.ts b/src/main/http/events.ts new file mode 100644 index 00000000..46f1968b --- /dev/null +++ b/src/main/http/events.ts @@ -0,0 +1,63 @@ +/** + * SSE (Server-Sent Events) route for real-time event streaming. + * + * Routes: + * - GET /api/events: SSE stream with keep-alive pings + */ + +import { createLogger } from '@shared/utils/logger'; + +import type { FastifyInstance, FastifyReply } from 'fastify'; + +const logger = createLogger('HTTP:events'); + +const KEEPALIVE_INTERVAL_MS = 30_000; + +/** All connected SSE clients */ +const clients = new Set(); + +/** + * Registers the SSE events endpoint. + */ +export function registerEventRoutes(app: FastifyInstance): void { + app.get('/api/events', async (request, reply) => { + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + clients.add(reply); + logger.info(`SSE client connected (total: ${clients.size})`); + + // Keep-alive ping + const timer = setInterval(() => { + reply.raw.write(':ping\n\n'); + }, KEEPALIVE_INTERVAL_MS); + + // Cleanup on disconnect + request.raw.on('close', () => { + clearInterval(timer); + clients.delete(reply); + logger.info(`SSE client disconnected (total: ${clients.size})`); + }); + + // Prevent Fastify from ending the response + await reply; + }); +} + +/** + * Broadcasts an event to all connected SSE clients. + */ +export function broadcastEvent(channel: string, data: unknown): void { + const payload = `event: ${channel}\ndata: ${JSON.stringify(data)}\n\n`; + + for (const client of clients) { + try { + client.raw.write(payload); + } catch { + clients.delete(client); + } + } +} diff --git a/src/main/http/index.ts b/src/main/http/index.ts new file mode 100644 index 00000000..54fe996c --- /dev/null +++ b/src/main/http/index.ts @@ -0,0 +1,65 @@ +/** + * HTTP Route Registration Orchestrator. + * + * Registers all domain-specific route handlers on a Fastify instance. + * Each route file mirrors the corresponding IPC handler. + */ + +import { createLogger } from '@shared/utils/logger'; + +import { registerConfigRoutes } from './config'; +import { broadcastEvent, registerEventRoutes } from './events'; +import { registerNotificationRoutes } from './notifications'; +import { registerProjectRoutes } from './projects'; +import { registerSearchRoutes } from './search'; +import { registerSessionRoutes } from './sessions'; +import { registerSshRoutes } from './ssh'; +import { registerSubagentRoutes } from './subagents'; +import { registerUpdaterRoutes } from './updater'; +import { registerUtilityRoutes } from './utility'; +import { registerValidationRoutes } from './validation'; + +import type { + ChunkBuilder, + DataCache, + ProjectScanner, + SessionParser, + SubagentResolver, + UpdaterService, +} from '../services'; +import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager'; +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('HTTP:routes'); + +export interface HttpServices { + projectScanner: ProjectScanner; + sessionParser: SessionParser; + subagentResolver: SubagentResolver; + chunkBuilder: ChunkBuilder; + dataCache: DataCache; + updaterService: UpdaterService; + sshConnectionManager: SshConnectionManager; +} + +export function registerHttpRoutes( + app: FastifyInstance, + services: HttpServices, + sshModeSwitchCallback: (mode: 'local' | 'ssh') => Promise +): void { + registerProjectRoutes(app, services); + registerSessionRoutes(app, services); + registerSearchRoutes(app, services); + registerSubagentRoutes(app, services); + registerNotificationRoutes(app); + registerConfigRoutes(app); + registerValidationRoutes(app); + registerUtilityRoutes(app); + registerSshRoutes(app, services.sshConnectionManager, sshModeSwitchCallback); + registerUpdaterRoutes(app, services); + registerEventRoutes(app); + + logger.info('All HTTP routes registered'); +} + +export { broadcastEvent }; diff --git a/src/main/http/notifications.ts b/src/main/http/notifications.ts new file mode 100644 index 00000000..535ca925 --- /dev/null +++ b/src/main/http/notifications.ts @@ -0,0 +1,121 @@ +/** + * HTTP route handlers for Notification Operations. + * + * Routes: + * - GET /api/notifications - Get notifications (paginated) + * - POST /api/notifications/:id/read - Mark as read + * - POST /api/notifications/read-all - Mark all as read + * - DELETE /api/notifications/:id - Delete notification + * - DELETE /api/notifications - Clear all notifications + * - GET /api/notifications/unread-count - Get unread count + */ + +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; + +import { coercePageLimit, validateNotificationId } from '../ipc/guards'; +import { NotificationManager } from '../services'; + +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('HTTP:notifications'); + +export function registerNotificationRoutes(app: FastifyInstance): void { + // Get notifications + app.get<{ Querystring: { limit?: string; offset?: string } }>( + '/api/notifications', + async (request) => { + try { + const limit = coercePageLimit( + request.query.limit ? Number(request.query.limit) : undefined, + 20 + ); + const rawOffset = request.query.offset ? Number(request.query.offset) : 0; + const offset = + typeof rawOffset === 'number' && Number.isFinite(rawOffset) && rawOffset >= 0 + ? Math.floor(rawOffset) + : 0; + + const manager = NotificationManager.getInstance(); + const result = await manager.getNotifications({ limit, offset }); + return result; + } catch (error) { + logger.error('Error in GET /api/notifications:', getErrorMessage(error)); + return { + notifications: [], + total: 0, + totalCount: 0, + unreadCount: 0, + hasMore: false, + }; + } + } + ); + + // Mark read + app.post<{ Params: { id: string } }>('/api/notifications/:id/read', async (request) => { + try { + const validated = validateNotificationId(request.params.id); + if (!validated.valid) { + logger.error(`POST notifications/:id/read rejected: ${validated.error ?? 'unknown'}`); + return false; + } + + const manager = NotificationManager.getInstance(); + return await manager.markRead(validated.value!); + } catch (error) { + logger.error(`Error in POST notifications/${request.params.id}/read:`, error); + return false; + } + }); + + // Mark all read + app.post('/api/notifications/read-all', async () => { + try { + const manager = NotificationManager.getInstance(); + return await manager.markAllRead(); + } catch (error) { + logger.error('Error in POST /api/notifications/read-all:', error); + return false; + } + }); + + // Delete notification + app.delete<{ Params: { id: string } }>('/api/notifications/:id', async (request) => { + try { + const validated = validateNotificationId(request.params.id); + if (!validated.valid) { + logger.error(`DELETE notifications/:id rejected: ${validated.error ?? 'unknown'}`); + return false; + } + + const manager = NotificationManager.getInstance(); + return manager.deleteNotification(validated.value!); + } catch (error) { + logger.error(`Error in DELETE notifications/${request.params.id}:`, error); + return false; + } + }); + + // Clear all + app.delete('/api/notifications', async () => { + try { + const manager = NotificationManager.getInstance(); + return await manager.clearAll(); + } catch (error) { + logger.error('Error in DELETE /api/notifications:', error); + return false; + } + }); + + // Unread count + app.get('/api/notifications/unread-count', async () => { + try { + const manager = NotificationManager.getInstance(); + return await manager.getUnreadCount(); + } catch (error) { + logger.error('Error in GET /api/notifications/unread-count:', error); + return 0; + } + }); +} diff --git a/src/main/http/projects.ts b/src/main/http/projects.ts new file mode 100644 index 00000000..2feae8c0 --- /dev/null +++ b/src/main/http/projects.ts @@ -0,0 +1,55 @@ +/** + * HTTP route handlers for Project Operations. + * + * Routes: + * - GET /api/projects - List all projects + * - GET /api/repository-groups - List projects grouped by git repository + * - GET /api/worktrees/:id/sessions - List sessions for a worktree + */ + +import { createLogger } from '@shared/utils/logger'; + +import { validateProjectId } from '../ipc/guards'; + +import type { HttpServices } from './index'; +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('HTTP:projects'); + +export function registerProjectRoutes(app: FastifyInstance, services: HttpServices): void { + app.get('/api/projects', async () => { + try { + const projects = await services.projectScanner.scan(); + return projects; + } catch (error) { + logger.error('Error in GET /api/projects:', error); + return []; + } + }); + + app.get('/api/repository-groups', async () => { + try { + const groups = await services.projectScanner.scanWithWorktreeGrouping(); + return groups; + } catch (error) { + logger.error('Error in GET /api/repository-groups:', error); + return []; + } + }); + + app.get<{ Params: { id: string } }>('/api/worktrees/:id/sessions', async (request) => { + try { + const validated = validateProjectId(request.params.id); + if (!validated.valid) { + logger.error(`GET /api/worktrees/:id/sessions rejected: ${validated.error ?? 'unknown'}`); + return []; + } + + const sessions = await services.projectScanner.listWorktreeSessions(validated.value!); + return sessions; + } catch (error) { + logger.error(`Error in GET /api/worktrees/${request.params.id}/sessions:`, error); + return []; + } + }); +} diff --git a/src/main/http/search.ts b/src/main/http/search.ts new file mode 100644 index 00000000..920a6898 --- /dev/null +++ b/src/main/http/search.ts @@ -0,0 +1,50 @@ +/** + * HTTP route handlers for Search Operations. + * + * Routes: + * - GET /api/projects/:projectId/search - Search sessions in a project + */ + +import { createLogger } from '@shared/utils/logger'; + +import { coerceSearchMaxResults, validateProjectId, validateSearchQuery } from '../ipc/guards'; + +import type { HttpServices } from './index'; +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('HTTP:search'); + +export function registerSearchRoutes(app: FastifyInstance, services: HttpServices): void { + app.get<{ + Params: { projectId: string }; + Querystring: { q?: string; maxResults?: string }; + }>('/api/projects/:projectId/search', async (request) => { + const query = request.query.q ?? ''; + + try { + const validatedProject = validateProjectId(request.params.projectId); + const validatedQuery = validateSearchQuery(query); + if (!validatedProject.valid || !validatedQuery.valid) { + logger.error( + `GET search rejected: ${validatedProject.error ?? validatedQuery.error ?? 'Invalid inputs'}` + ); + return { results: [], totalMatches: 0, sessionsSearched: 0, query }; + } + + const maxResults = coerceSearchMaxResults( + request.query.maxResults ? Number(request.query.maxResults) : undefined, + 50 + ); + + const result = await services.projectScanner.searchSessions( + validatedProject.value!, + validatedQuery.value!, + maxResults + ); + return result; + } catch (error) { + logger.error(`Error in GET search for ${request.params.projectId}:`, error); + return { results: [], totalMatches: 0, sessionsSearched: 0, query }; + } + }); +} diff --git a/src/main/http/sessions.ts b/src/main/http/sessions.ts new file mode 100644 index 00000000..4e86ac75 --- /dev/null +++ b/src/main/http/sessions.ts @@ -0,0 +1,279 @@ +/** + * HTTP route handlers for Session Operations. + * + * Routes: + * - GET /api/projects/:projectId/sessions - List sessions + * - GET /api/projects/:projectId/sessions-paginated - Paginated sessions + * - GET /api/projects/:projectId/sessions/:sessionId - Full session detail + * - GET /api/projects/:projectId/sessions/:sessionId/groups - Conversation groups + * - GET /api/projects/:projectId/sessions/:sessionId/metrics - Session metrics + * - GET /api/projects/:projectId/sessions/:sessionId/waterfall - Waterfall data + */ + +import { createLogger } from '@shared/utils/logger'; + +import { coercePageLimit, validateProjectId, validateSessionId } from '../ipc/guards'; +import { DataCache } from '../services'; + +import type { SessionsPaginationOptions } from '../types'; +import type { HttpServices } from './index'; +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('HTTP:sessions'); + +export function registerSessionRoutes(app: FastifyInstance, services: HttpServices): void { + // List sessions + app.get<{ Params: { projectId: string } }>( + '/api/projects/:projectId/sessions', + async (request) => { + try { + const validated = validateProjectId(request.params.projectId); + if (!validated.valid) { + logger.error(`GET sessions rejected: ${validated.error ?? 'unknown'}`); + return []; + } + + const sessions = await services.projectScanner.listSessions(validated.value!); + return sessions; + } catch (error) { + logger.error(`Error in GET sessions for ${request.params.projectId}:`, error); + return []; + } + } + ); + + // Paginated sessions + app.get<{ + Params: { projectId: string }; + Querystring: { + cursor?: string; + limit?: string; + includeTotalCount?: string; + prefilterAll?: string; + }; + }>('/api/projects/:projectId/sessions-paginated', async (request) => { + try { + const validated = validateProjectId(request.params.projectId); + if (!validated.valid) { + logger.error(`GET sessions-paginated rejected: ${validated.error ?? 'unknown'}`); + return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 }; + } + + const cursor = request.query.cursor || null; + const limit = coercePageLimit( + request.query.limit ? Number(request.query.limit) : undefined, + 20 + ); + const options: SessionsPaginationOptions = { + includeTotalCount: request.query.includeTotalCount !== 'false', + prefilterAll: request.query.prefilterAll !== 'false', + }; + + const result = await services.projectScanner.listSessionsPaginated( + validated.value!, + cursor, + limit, + options + ); + return result; + } catch (error) { + logger.error(`Error in GET sessions-paginated for ${request.params.projectId}:`, error); + return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 }; + } + }); + + // Session detail + app.get<{ Params: { projectId: string; sessionId: string } }>( + '/api/projects/:projectId/sessions/:sessionId', + async (request) => { + try { + const validatedProject = validateProjectId(request.params.projectId); + const validatedSession = validateSessionId(request.params.sessionId); + if (!validatedProject.valid || !validatedSession.valid) { + logger.error( + `GET session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}` + ); + return null; + } + + const safeProjectId = validatedProject.value!; + const safeSessionId = validatedSession.value!; + const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId); + + // Check cache first + let sessionDetail = services.dataCache.get(cacheKey); + if (sessionDetail) { + return sessionDetail; + } + + // Get session metadata + const session = await services.projectScanner.getSession(safeProjectId, safeSessionId); + if (!session) { + logger.error(`Session not found: ${safeSessionId}`); + return null; + } + + // Parse session messages + const parsedSession = await services.sessionParser.parseSession( + safeProjectId, + safeSessionId + ); + + // Resolve subagents + const subagents = await services.subagentResolver.resolveSubagents( + safeProjectId, + safeSessionId, + parsedSession.taskCalls, + parsedSession.messages + ); + + // Build session detail with chunks + sessionDetail = services.chunkBuilder.buildSessionDetail( + session, + parsedSession.messages, + subagents + ); + + // Cache the result + services.dataCache.set(cacheKey, sessionDetail); + + return sessionDetail; + } catch (error) { + logger.error( + `Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`, + error + ); + return null; + } + } + ); + + // Conversation groups + app.get<{ Params: { projectId: string; sessionId: string } }>( + '/api/projects/:projectId/sessions/:sessionId/groups', + async (request) => { + try { + const validatedProject = validateProjectId(request.params.projectId); + const validatedSession = validateSessionId(request.params.sessionId); + if (!validatedProject.valid || !validatedSession.valid) { + logger.error( + `GET session-groups rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}` + ); + return []; + } + + const safeProjectId = validatedProject.value!; + const safeSessionId = validatedSession.value!; + + const parsedSession = await services.sessionParser.parseSession( + safeProjectId, + safeSessionId + ); + + const subagents = await services.subagentResolver.resolveSubagents( + safeProjectId, + safeSessionId, + parsedSession.taskCalls, + parsedSession.messages + ); + + const groups = services.chunkBuilder.buildGroups(parsedSession.messages, subagents); + return groups; + } catch (error) { + logger.error( + `Error in GET session-groups for ${request.params.projectId}/${request.params.sessionId}:`, + error + ); + return []; + } + } + ); + + // Session metrics + app.get<{ Params: { projectId: string; sessionId: string } }>( + '/api/projects/:projectId/sessions/:sessionId/metrics', + async (request) => { + try { + const validatedProject = validateProjectId(request.params.projectId); + const validatedSession = validateSessionId(request.params.sessionId); + if (!validatedProject.valid || !validatedSession.valid) { + return null; + } + + const safeProjectId = validatedProject.value!; + const safeSessionId = validatedSession.value!; + + // Try cache first + const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId); + const cached = services.dataCache.get(cacheKey); + if (cached) { + return cached.metrics; + } + + const parsedSession = await services.sessionParser.parseSession( + safeProjectId, + safeSessionId + ); + return parsedSession.metrics; + } catch (error) { + logger.error( + `Error in GET session-metrics for ${request.params.projectId}/${request.params.sessionId}:`, + error + ); + return null; + } + } + ); + + // Waterfall data + app.get<{ Params: { projectId: string; sessionId: string } }>( + '/api/projects/:projectId/sessions/:sessionId/waterfall', + async (request) => { + try { + const validatedProject = validateProjectId(request.params.projectId); + const validatedSession = validateSessionId(request.params.sessionId); + if (!validatedProject.valid || !validatedSession.valid) { + return null; + } + + const safeProjectId = validatedProject.value!; + const safeSessionId = validatedSession.value!; + const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId); + + // Try cache first for session detail + let detail = services.dataCache.get(cacheKey); + + if (!detail) { + const session = await services.projectScanner.getSession(safeProjectId, safeSessionId); + if (!session) return null; + + const parsedSession = await services.sessionParser.parseSession( + safeProjectId, + safeSessionId + ); + const subagents = await services.subagentResolver.resolveSubagents( + safeProjectId, + safeSessionId, + parsedSession.taskCalls, + parsedSession.messages + ); + + detail = services.chunkBuilder.buildSessionDetail( + session, + parsedSession.messages, + subagents + ); + services.dataCache.set(cacheKey, detail); + } + + return services.chunkBuilder.buildWaterfallData(detail.chunks, detail.processes); + } catch (error) { + logger.error( + `Error in GET waterfall for ${request.params.projectId}/${request.params.sessionId}:`, + error + ); + return null; + } + } + ); +} diff --git a/src/main/http/ssh.ts b/src/main/http/ssh.ts new file mode 100644 index 00000000..8e3a304c --- /dev/null +++ b/src/main/http/ssh.ts @@ -0,0 +1,133 @@ +/** + * HTTP route handlers for SSH Connection Management. + * + * Routes: + * - POST /api/ssh/connect - Connect to SSH host + * - POST /api/ssh/disconnect - Disconnect SSH + * - GET /api/ssh/state - Get connection state + * - POST /api/ssh/test - Test connection + * - GET /api/ssh/config-hosts - Get SSH config hosts + * - POST /api/ssh/resolve-host - Resolve host config + * - POST /api/ssh/save-last-connection - Save last connection + * - GET /api/ssh/last-connection - Get last connection + */ + +import { createLogger } from '@shared/utils/logger'; + +import { ConfigManager } from '../services'; + +import type { + SshConnectionConfig, + SshConnectionManager, +} from '../services/infrastructure/SshConnectionManager'; +import type { SshLastConnection } from '@shared/types'; +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('HTTP:ssh'); + +export function registerSshRoutes( + app: FastifyInstance, + connectionManager: SshConnectionManager, + modeSwitchCallback: (mode: 'local' | 'ssh') => Promise +): void { + const configManager = ConfigManager.getInstance(); + + // Connect + app.post<{ Body: SshConnectionConfig }>('/api/ssh/connect', async (request) => { + try { + await connectionManager.connect(request.body); + await modeSwitchCallback('ssh'); + return { success: true, data: connectionManager.getStatus() }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error('SSH connect failed:', message); + return { success: false, error: message }; + } + }); + + // Disconnect + app.post('/api/ssh/disconnect', async () => { + try { + connectionManager.disconnect(); + await modeSwitchCallback('local'); + return { success: true, data: connectionManager.getStatus() }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error('SSH disconnect failed:', message); + return { success: false, error: message }; + } + }); + + // Get state + app.get('/api/ssh/state', async () => { + return connectionManager.getStatus(); + }); + + // Test connection + app.post<{ Body: SshConnectionConfig }>('/api/ssh/test', async (request) => { + try { + const result = await connectionManager.testConnection(request.body); + return { success: true, data: result }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: message }; + } + }); + + // Get config hosts + app.get('/api/ssh/config-hosts', async () => { + try { + const hosts = await connectionManager.getConfigHosts(); + return { success: true, data: hosts }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error('Failed to get SSH config hosts:', message); + return { success: true, data: [] }; + } + }); + + // Resolve host + app.post<{ Body: { alias: string } }>('/api/ssh/resolve-host', async (request) => { + try { + const entry = await connectionManager.resolveHostConfig(request.body.alias); + return { success: true, data: entry }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`Failed to resolve SSH host "${request.body.alias}":`, message); + return { success: true, data: null }; + } + }); + + // Save last connection + app.post<{ Body: SshLastConnection }>('/api/ssh/save-last-connection', async (request) => { + try { + const config = request.body; + configManager.updateConfig('ssh', { + lastConnection: { + host: config.host, + port: config.port, + username: config.username, + authMethod: config.authMethod, + privateKeyPath: config.privateKeyPath, + }, + }); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error('Failed to save SSH connection:', message); + return { success: false, error: message }; + } + }); + + // Get last connection + app.get('/api/ssh/last-connection', async () => { + try { + const config = configManager.getConfig(); + return { success: true, data: config.ssh.lastConnection }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error('Failed to get last SSH connection:', message); + return { success: true, data: null }; + } + }); +} diff --git a/src/main/http/subagents.ts b/src/main/http/subagents.ts new file mode 100644 index 00000000..8b66c3d4 --- /dev/null +++ b/src/main/http/subagents.ts @@ -0,0 +1,77 @@ +/** + * HTTP route handlers for Subagent Operations. + * + * Routes: + * - GET /api/projects/:projectId/sessions/:sessionId/subagents/:subagentId - Subagent detail + */ + +import { createLogger } from '@shared/utils/logger'; + +import { validateProjectId, validateSessionId, validateSubagentId } from '../ipc/guards'; + +import type { HttpServices } from './index'; +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('HTTP:subagents'); + +export function registerSubagentRoutes(app: FastifyInstance, services: HttpServices): void { + app.get<{ Params: { projectId: string; sessionId: string; subagentId: string } }>( + '/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId', + async (request) => { + try { + const validatedProject = validateProjectId(request.params.projectId); + const validatedSession = validateSessionId(request.params.sessionId); + const validatedSubagent = validateSubagentId(request.params.subagentId); + if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) { + logger.error( + `GET subagent-detail rejected: ${ + validatedProject.error ?? + validatedSession.error ?? + validatedSubagent.error ?? + 'Invalid parameters' + }` + ); + return null; + } + + const safeProjectId = validatedProject.value!; + const safeSessionId = validatedSession.value!; + const safeSubagentId = validatedSubagent.value!; + + const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`; + + // Check cache first + let subagentDetail = services.dataCache.getSubagent(cacheKey); + if (subagentDetail) { + return subagentDetail; + } + + const fsProvider = services.projectScanner.getFileSystemProvider(); + const projectsDir = services.projectScanner.getProjectsDir(); + + const builtDetail = await services.chunkBuilder.buildSubagentDetail( + safeProjectId, + safeSessionId, + safeSubagentId, + services.sessionParser, + services.subagentResolver, + fsProvider, + projectsDir + ); + + if (!builtDetail) { + logger.error(`Subagent not found: ${safeSubagentId}`); + return null; + } + + subagentDetail = builtDetail; + services.dataCache.setSubagent(cacheKey, subagentDetail); + + return subagentDetail; + } catch (error) { + logger.error(`Error in GET subagent-detail for ${request.params.subagentId}:`, error); + return null; + } + } + ); +} diff --git a/src/main/http/updater.ts b/src/main/http/updater.ts new file mode 100644 index 00000000..9b1a308f --- /dev/null +++ b/src/main/http/updater.ts @@ -0,0 +1,48 @@ +/** + * HTTP route handlers for Update Operations. + * + * Routes: + * - POST /api/updater/check - Check for updates + * - POST /api/updater/download - Download update + * - POST /api/updater/install - Install update + */ + +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; + +import type { HttpServices } from './index'; +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('HTTP:updater'); + +export function registerUpdaterRoutes(app: FastifyInstance, services: HttpServices): void { + app.post('/api/updater/check', async () => { + try { + await services.updaterService.checkForUpdates(); + return { success: true }; + } catch (error) { + logger.error('Error in POST /api/updater/check:', getErrorMessage(error)); + return { success: false, error: getErrorMessage(error) }; + } + }); + + app.post('/api/updater/download', async () => { + try { + await services.updaterService.downloadUpdate(); + return { success: true }; + } catch (error) { + logger.error('Error in POST /api/updater/download:', getErrorMessage(error)); + return { success: false, error: getErrorMessage(error) }; + } + }); + + app.post('/api/updater/install', async () => { + try { + services.updaterService.quitAndInstall(); + return { success: true }; + } catch (error) { + logger.error('Error in POST /api/updater/install:', getErrorMessage(error)); + return { success: false, error: getErrorMessage(error) }; + } + }); +} diff --git a/src/main/http/utility.ts b/src/main/http/utility.ts new file mode 100644 index 00000000..2c7f4504 --- /dev/null +++ b/src/main/http/utility.ts @@ -0,0 +1,126 @@ +/** + * HTTP route handlers for Utility Operations. + * + * Routes: + * - GET /api/version - App version + * - POST /api/read-claude-md - Read CLAUDE.md files + * - POST /api/read-directory-claude-md - Read directory CLAUDE.md + * - POST /api/read-mentioned-file - Read mentioned file + * - POST /api/open-path - No-op in browser + * - POST /api/open-external - No-op in browser + */ + +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { type ClaudeMdFileInfo, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services'; +import { validateFilePath } from '../utils/pathValidation'; +import { countTokens } from '../utils/tokenizer'; + +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('HTTP:utility'); + +export function registerUtilityRoutes(app: FastifyInstance): void { + // App version + app.get('/api/version', async () => { + try { + // Read version from package.json (works in both Electron and Node) + const pkgPath = path.resolve(__dirname, '../../../package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version: string }; + return pkg.version; + } catch { + return '0.0.0'; + } + }); + + // Read CLAUDE.md files + app.post<{ Body: { projectRoot: string } }>('/api/read-claude-md', async (request) => { + try { + const { projectRoot } = request.body; + const result = await readAllClaudeMdFiles(projectRoot); + const files: Record = {}; + result.files.forEach((info, key) => { + files[key] = info; + }); + return files; + } catch (error) { + logger.error('Error in POST /api/read-claude-md:', error); + return {}; + } + }); + + // Read directory CLAUDE.md + app.post<{ Body: { dirPath: string } }>('/api/read-directory-claude-md', async (request) => { + try { + const { dirPath } = request.body; + const info = await readDirectoryClaudeMd(dirPath); + return info; + } catch (error) { + logger.error('Error in POST /api/read-directory-claude-md:', error); + return { + path: request.body.dirPath, + exists: false, + charCount: 0, + estimatedTokens: 0, + }; + } + }); + + // Read mentioned file + app.post<{ Body: { absolutePath: string; projectRoot: string; maxTokens?: number } }>( + '/api/read-mentioned-file', + async (request) => { + try { + const { absolutePath, projectRoot, maxTokens = 25000 } = request.body; + + const validation = validateFilePath(absolutePath, projectRoot || null); + if (!validation.valid) { + return null; + } + + const safePath = validation.normalizedPath!; + + if (!fs.existsSync(safePath)) { + return null; + } + + const stats = fs.statSync(safePath); + if (!stats.isFile()) { + return null; + } + + const content = fs.readFileSync(safePath, 'utf8'); + const estimatedTokens = countTokens(content); + + if (estimatedTokens > maxTokens) { + return null; + } + + return { + path: safePath, + exists: true, + charCount: content.length, + estimatedTokens, + }; + } catch (error) { + logger.error( + `Error in POST /api/read-mentioned-file for ${request.body.absolutePath}:`, + error + ); + return null; + } + } + ); + + // Open path - no-op in browser mode + app.post('/api/open-path', async () => { + return { success: false, error: 'Not available in browser mode' }; + }); + + // Open external - no-op in browser mode + app.post<{ Body: { url: string } }>('/api/open-external', async () => { + return { success: false, error: 'Not available in browser mode' }; + }); +} diff --git a/src/main/http/validation.ts b/src/main/http/validation.ts new file mode 100644 index 00000000..12863622 --- /dev/null +++ b/src/main/http/validation.ts @@ -0,0 +1,98 @@ +/** + * HTTP route handlers for Validation Operations. + * + * Routes: + * - POST /api/validate/path - Validate file/directory path + * - POST /api/validate/mentions - Batch validate path mentions + * - POST /api/session/scroll-to-line - Deep link scroll handler + */ + +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('HTTP:validation'); + +/** + * Checks if a path is contained within a base directory. + * Prevents path traversal attacks. + */ +function isPathContained(fullPath: string, basePath: string): boolean { + const normalizedFull = path.normalize(fullPath); + const normalizedBase = path.normalize(basePath); + return normalizedFull === normalizedBase || normalizedFull.startsWith(normalizedBase + path.sep); +} + +export function registerValidationRoutes(app: FastifyInstance): void { + // Validate path + app.post<{ Body: { relativePath: string; projectPath: string } }>( + '/api/validate/path', + async (request) => { + try { + const { relativePath, projectPath } = request.body; + const fullPath = path.join(projectPath, relativePath); + + if (!isPathContained(fullPath, projectPath)) { + logger.warn('validate-path blocked path traversal attempt:', relativePath); + return { exists: false }; + } + + if (!fs.existsSync(fullPath)) { + return { exists: false }; + } + + const stats = fs.statSync(fullPath); + return { exists: true, isDirectory: stats.isDirectory() }; + } catch { + return { exists: false }; + } + } + ); + + // Validate mentions + app.post<{ Body: { mentions: { type: 'path'; value: string }[]; projectPath: string } }>( + '/api/validate/mentions', + async (request) => { + const { mentions, projectPath } = request.body; + const results = new Map(); + + for (const mention of mentions) { + const fullPath = path.join(projectPath, mention.value); + if (!isPathContained(fullPath, projectPath)) { + results.set(`@${mention.value}`, false); + continue; + } + results.set(`@${mention.value}`, fs.existsSync(fullPath)); + } + + return Object.fromEntries(results); + } + ); + + // Scroll to line + app.post<{ Body: { sessionId: string; lineNumber: number } }>( + '/api/session/scroll-to-line', + async (request) => { + try { + const { sessionId, lineNumber } = request.body; + + if (!sessionId) { + logger.error('scroll-to-line called with empty sessionId'); + return { success: false, sessionId: '', lineNumber: 0 }; + } + + if (typeof lineNumber !== 'number' || lineNumber < 0) { + logger.error('scroll-to-line called with invalid lineNumber'); + return { success: false, sessionId, lineNumber: 0 }; + } + + return { success: true, sessionId, lineNumber }; + } catch (error) { + logger.error('Error in POST /api/session/scroll-to-line:', error); + return { success: false, sessionId: '', lineNumber: 0 }; + } + } + ); +} diff --git a/src/main/index.ts b/src/main/index.ts index 2539c961..57a87b5d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,7 +17,7 @@ import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, } from '@shared/constants'; import { createLogger } from '@shared/utils/logger'; -import { app, BrowserWindow } from 'electron'; +import { app, BrowserWindow, ipcMain } from 'electron'; import { join } from 'path'; import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; @@ -32,8 +32,13 @@ const getIconPath = (): string => { }; const logger = createLogger('App'); -import { SSH_STATUS } from '@preload/constants/ipcChannels'; +// IPC channel constants (duplicated from @preload to avoid boundary violation) +const SSH_STATUS = 'ssh:status'; +const HTTP_SERVER_START = 'httpServer:start'; +const HTTP_SERVER_STOP = 'httpServer:stop'; +const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus'; +import { HttpServer } from './services/infrastructure/HttpServer'; import { configManager, LocalFileSystemProvider, @@ -55,13 +60,14 @@ let contextRegistry: ServiceContextRegistry; let notificationManager: NotificationManager; let updaterService: UpdaterService; let sshConnectionManager: SshConnectionManager; +let httpServer: HttpServer; // File watcher event cleanup functions let fileChangeCleanup: (() => void) | null = null; let todoChangeCleanup: (() => void) | null = null; /** - * Wires file watcher events from a ServiceContext to the renderer. + * Wires file watcher events from a ServiceContext to the renderer and HTTP SSE clients. * Cleans up previous listeners before adding new ones. */ function wireFileWatcherEvents(context: ServiceContext): void { @@ -77,20 +83,22 @@ function wireFileWatcherEvents(context: ServiceContext): void { todoChangeCleanup = null; } - // Wire file-change events + // Wire file-change events to renderer and HTTP SSE const fileChangeHandler = (event: unknown) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('file-change', event); } + httpServer?.broadcast('file-change', event); }; context.fileWatcher.on('file-change', fileChangeHandler); fileChangeCleanup = () => context.fileWatcher.off('file-change', fileChangeHandler); - // Wire todo-change events + // Wire todo-change events to renderer and HTTP SSE const todoChangeHandler = (event: unknown) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('todo-change', event); } + httpServer?.broadcast('todo-change', event); }; context.fileWatcher.on('todo-change', todoChangeHandler); todoChangeCleanup = () => context.fileWatcher.off('todo-change', todoChangeHandler); @@ -98,6 +106,17 @@ function wireFileWatcherEvents(context: ServiceContext): void { logger.info(`FileWatcher events wired for context: ${context.id}`); } +/** + * Handles mode switch requests from the HTTP server. + * Switches the active context back to local when requested. + */ +async function handleModeSwitch(mode: 'local' | 'ssh'): Promise { + if (mode === 'local' && contextRegistry.getActiveContextId() !== 'local') { + const { current } = contextRegistry.switch('local'); + onContextSwitched(current); + } +} + /** * Callback invoked when context switches (called by SSH IPC handler). * Re-wires file watcher events and notifies renderer. @@ -152,26 +171,116 @@ function initializeServices(): void { // Initialize updater service updaterService = new UpdaterService(); + httpServer = new HttpServer(); // Initialize IPC handlers with registry initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, onContextSwitched); - // Forward SSH state changes to renderer + // HTTP Server control IPC handlers + ipcMain.handle(HTTP_SERVER_START, async () => { + try { + if (httpServer.isRunning()) { + return { success: true, data: { running: true, port: httpServer.getPort() } }; + } + await startHttpServer(handleModeSwitch); + // Persist the enabled state + configManager.updateConfig('httpServer', { enabled: true, port: httpServer.getPort() }); + return { success: true, data: { running: true, port: httpServer.getPort() } }; + } catch (error) { + logger.error('Failed to start HTTP server via IPC:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to start server', + }; + } + }); + + ipcMain.handle(HTTP_SERVER_STOP, async () => { + try { + await httpServer.stop(); + // Persist the disabled state + configManager.updateConfig('httpServer', { enabled: false }); + return { success: true, data: { running: false, port: httpServer.getPort() } }; + } catch (error) { + logger.error('Failed to stop HTTP server via IPC:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to stop server', + }; + } + }); + + ipcMain.handle(HTTP_SERVER_GET_STATUS, () => { + return { running: httpServer.isRunning(), port: httpServer.getPort() }; + }); + + // Forward SSH state changes to renderer and HTTP SSE clients sshConnectionManager.on('state-change', (status: unknown) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(SSH_STATUS, status); } + httpServer.broadcast('ssh:status', status); }); + // Forward notification events to HTTP SSE clients + notificationManager.on('notification-new', (notification: unknown) => { + httpServer.broadcast('notification:new', notification); + }); + notificationManager.on('notification-updated', (data: unknown) => { + httpServer.broadcast('notification:updated', data); + }); + notificationManager.on('notification-clicked', (data: unknown) => { + httpServer.broadcast('notification:clicked', data); + }); + + // Start HTTP server if enabled in config + const appConfig = configManager.getConfig(); + if (appConfig.httpServer?.enabled) { + void startHttpServer(handleModeSwitch); + } + logger.info('Services initialized successfully'); } +/** + * Starts the HTTP sidecar server with services from the active context. + */ +async function startHttpServer( + modeSwitchHandler: (mode: 'local' | 'ssh') => Promise +): Promise { + try { + const config = configManager.getConfig(); + const activeContext = contextRegistry.getActive(); + const port = await httpServer.start( + { + projectScanner: activeContext.projectScanner, + sessionParser: activeContext.sessionParser, + subagentResolver: activeContext.subagentResolver, + chunkBuilder: activeContext.chunkBuilder, + dataCache: activeContext.dataCache, + updaterService, + sshConnectionManager, + }, + modeSwitchHandler, + config.httpServer?.port ?? 3456 + ); + logger.info(`HTTP sidecar server running on port ${port}`); + } catch (error) { + logger.error('Failed to start HTTP server:', error); + } +} + /** * Shuts down all services. */ function shutdownServices(): void { logger.info('Shutting down services...'); + // Stop HTTP server + if (httpServer?.isRunning()) { + void httpServer.stop(); + } + // Clean up file watcher event listeners if (fileChangeCleanup) { fileChangeCleanup(); diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 205ae670..cdd889ee 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -7,6 +7,7 @@ import type { AppConfig, DisplayConfig, GeneralConfig, + HttpServerConfig, NotificationConfig, NotificationTrigger, } from '../services'; @@ -28,9 +29,15 @@ export type ConfigUpdateValidationResult = | ValidationSuccess<'notifications'> | ValidationSuccess<'general'> | ValidationSuccess<'display'> + | ValidationSuccess<'httpServer'> | ValidationFailure; -const VALID_SECTIONS = new Set(['notifications', 'general', 'display']); +const VALID_SECTIONS = new Set([ + 'notifications', + 'general', + 'display', + 'httpServer', +]); const MAX_SNOOZE_MINUTES = 24 * 60; function isPlainObject(value: unknown): value is Record { @@ -271,12 +278,58 @@ function validateDisplaySection(data: unknown): ValidationSuccess<'display'> | V }; } +function validateHttpServerSection( + data: unknown +): ValidationSuccess<'httpServer'> | ValidationFailure { + if (!isPlainObject(data)) { + return { valid: false, error: 'httpServer update must be an object' }; + } + + const allowedKeys: (keyof HttpServerConfig)[] = ['enabled', 'port']; + const result: Partial = {}; + + for (const [key, value] of Object.entries(data)) { + if (!allowedKeys.includes(key as keyof HttpServerConfig)) { + return { valid: false, error: `httpServer.${key} is not a valid setting` }; + } + + switch (key as keyof HttpServerConfig) { + case 'enabled': + if (typeof value !== 'boolean') { + return { valid: false, error: 'httpServer.enabled must be a boolean' }; + } + result.enabled = value; + break; + case 'port': + if (!isFiniteNumber(value) || !Number.isInteger(value) || value < 1024 || value > 65535) { + return { + valid: false, + error: 'httpServer.port must be an integer between 1024 and 65535', + }; + } + result.port = value; + break; + default: + return { valid: false, error: `Unsupported httpServer key: ${key}` }; + } + } + + return { + valid: true, + section: 'httpServer', + data: result, + }; +} + export function validateConfigUpdatePayload( section: unknown, data: unknown ): ConfigUpdateValidationResult { if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) { - return { valid: false, error: 'Section must be one of: notifications, general, display' }; + return { + valid: false, + error: 'Section must be one of: notifications, general, display, httpServer', + }; } switch (section as ConfigSection) { @@ -286,6 +339,8 @@ export function validateConfigUpdatePayload( return validateGeneralSection(data); case 'display': return validateDisplaySection(data); + case 'httpServer': + return validateHttpServerSection(data); default: return { valid: false, error: 'Invalid section' }; } diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 422b1c9b..b2f57e46 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -204,12 +204,18 @@ export interface SshPersistConfig { lastActiveContextId: string; } +export interface HttpServerConfig { + enabled: boolean; + port: number; +} + export interface AppConfig { notifications: NotificationConfig; general: GeneralConfig; display: DisplayConfig; sessions: SessionsConfig; ssh: SshPersistConfig; + httpServer: HttpServerConfig; } // Config section keys for type-safe updates @@ -253,6 +259,10 @@ const DEFAULT_CONFIG: AppConfig = { profiles: [], lastActiveContextId: 'local', }, + httpServer: { + enabled: false, + port: 3456, + }, }; // =========================================================================== @@ -377,6 +387,10 @@ export class ConfigManager { ...DEFAULT_CONFIG.ssh, ...(loaded.ssh ?? {}), }, + httpServer: { + ...DEFAULT_CONFIG.httpServer, + ...(loaded.httpServer ?? {}), + }, }; } diff --git a/src/main/services/infrastructure/HttpServer.ts b/src/main/services/infrastructure/HttpServer.ts new file mode 100644 index 00000000..86ad5374 --- /dev/null +++ b/src/main/services/infrastructure/HttpServer.ts @@ -0,0 +1,134 @@ +/** + * HttpServer - Fastify-based HTTP server for serving the renderer UI and API routes. + * + * Binds to 127.0.0.1 only for localhost security. + * Dynamically allocates a port starting from 3456. + * In production, serves static files from the renderer output directory. + * In development, Vite dev server handles static files. + */ + +import cors from '@fastify/cors'; +import fastifyStatic from '@fastify/static'; +import { type HttpServices, registerHttpRoutes } from '@main/http'; +import { broadcastEvent } from '@main/http/events'; +import { createLogger } from '@shared/utils/logger'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { join } from 'path'; + +const logger = createLogger('Service:HttpServer'); + +export class HttpServer { + private app: FastifyInstance | null = null; + private port: number = 3456; + private running: boolean = false; + + /** + * Start the HTTP server. + * @param services - Service instances to pass to route handlers + * @param sshModeSwitchCallback - Callback for SSH mode switching + * @param preferredPort - Port to try first (default 3456) + */ + async start( + services: HttpServices, + sshModeSwitchCallback: (mode: 'local' | 'ssh') => Promise, + preferredPort: number = 3456 + ): Promise { + this.app = Fastify({ logger: false }); + + // Register CORS - allow all localhost origins + const localhostPattern = /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/; + await this.app.register(cors, { + origin: (origin, cb) => { + // Allow requests with no origin (same-origin, curl, etc.) + if (!origin) { + cb(null, true); + return; + } + // Allow any localhost origin + if (localhostPattern.test(origin)) { + cb(null, true); + return; + } + cb(new Error('Not allowed by CORS'), false); + }, + credentials: true, + }); + + // Register static file serving (production only) + const isDev = process.env.NODE_ENV === 'development'; + if (!isDev) { + const rendererPath = join(__dirname, '../../renderer'); + await this.app.register(fastifyStatic, { + root: rendererPath, + prefix: '/', + // Don't serve index.html for API routes + wildcard: false, + }); + + // Serve index.html for all non-API routes (SPA fallback) + this.app.setNotFoundHandler(async (request, reply) => { + if (request.url.startsWith('/api/')) { + return reply.status(404).send({ error: 'Not found' }); + } + return reply.sendFile('index.html'); + }); + } + + // Register all API routes + registerHttpRoutes(this.app, services, sshModeSwitchCallback); + + // Try ports starting from preferredPort + for (let attempt = 0; attempt <= 10; attempt++) { + const tryPort = preferredPort + attempt; + try { + await this.app.listen({ host: '127.0.0.1', port: tryPort }); + this.port = tryPort; + this.running = true; + logger.info(`HTTP server started on http://127.0.0.1:${tryPort}`); + return tryPort; + } catch (err: unknown) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'EADDRINUSE') { + logger.info(`Port ${tryPort} in use, trying next...`); + continue; + } + throw err; + } + } + + throw new Error(`Could not find available port (tried ${preferredPort}-${preferredPort + 10})`); + } + + /** + * Stop the HTTP server gracefully. + */ + async stop(): Promise { + if (this.app && this.running) { + await this.app.close(); + this.running = false; + this.app = null; + logger.info('HTTP server stopped'); + } + } + + /** + * Broadcast an event to all connected SSE clients. + */ + broadcast(channel: string, data: unknown): void { + broadcastEvent(channel, data); + } + + /** + * Get the current port the server is running on. + */ + getPort(): number { + return this.port; + } + + /** + * Check if the server is currently running. + */ + isRunning(): boolean { + return this.running; + } +} diff --git a/src/main/services/infrastructure/index.ts b/src/main/services/infrastructure/index.ts index bdc1d24a..52a8840b 100644 --- a/src/main/services/infrastructure/index.ts +++ b/src/main/services/infrastructure/index.ts @@ -13,12 +13,14 @@ * - SshConnectionManager: SSH connection lifecycle * - ServiceContext: Service bundle for a single workspace context * - ServiceContextRegistry: Registry coordinator for all contexts + * - HttpServer: Fastify-based HTTP server for API and static file serving */ export * from './ConfigManager'; export * from './DataCache'; export type * from './FileSystemProvider'; export * from './FileWatcher'; +export * from './HttpServer'; export * from './LocalFileSystemProvider'; export * from './NotificationManager'; export * from './ServiceContext'; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 9b190f31..550c10ae 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -121,3 +121,16 @@ export const CONTEXT_SWITCH = 'context:switch'; /** Context changed event channel (main -> renderer) */ export const CONTEXT_CHANGED = 'context:changed'; + +// ============================================================================= +// HTTP Server API Channels +// ============================================================================= + +/** Start HTTP sidecar server */ +export const HTTP_SERVER_START = 'httpServer:start'; + +/** Stop HTTP sidecar server */ +export const HTTP_SERVER_STOP = 'httpServer:stop'; + +/** Get HTTP server status */ +export const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 729bdb8d..75aacbf4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,9 @@ import { CONTEXT_GET_ACTIVE, CONTEXT_LIST, CONTEXT_SWITCH, + HTTP_SERVER_GET_STATUS, + HTTP_SERVER_START, + HTTP_SERVER_STOP, SSH_CONNECT, SSH_DISCONNECT, SSH_GET_CONFIG_HOSTS, @@ -44,6 +47,7 @@ import type { AppConfig, ContextInfo, ElectronAPI, + HttpServerStatus, NotificationTrigger, SessionsPaginationOptions, SshConfigHostEntry, @@ -393,6 +397,19 @@ const electronAPI: ElectronAPI = { }; }, }, + + // HTTP Server API + httpServer: { + start: async (): Promise => { + return invokeIpcWithResult(HTTP_SERVER_START); + }, + stop: async (): Promise => { + return invokeIpcWithResult(HTTP_SERVER_STOP); + }, + getStatus: async (): Promise => { + return ipcRenderer.invoke(HTTP_SERVER_GET_STATUS); + }, + }, }; // Use contextBridge to securely expose the API to the renderer process diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts new file mode 100644 index 00000000..07544248 --- /dev/null +++ b/src/renderer/api/httpClient.ts @@ -0,0 +1,535 @@ +/** + * HTTP-based implementation of ElectronAPI for browser mode. + * + * Replaces Electron IPC with fetch() for request/response and + * EventSource (SSE) for real-time events. Allows the renderer + * to run in a regular browser connected to an HTTP server. + */ + +import type { + AppConfig, + ClaudeMdFileInfo, + ConfigAPI, + ContextInfo, + ConversationGroup, + ElectronAPI, + FileChangeEvent, + HttpServerAPI, + HttpServerStatus, + NotificationsAPI, + NotificationTrigger, + PaginatedSessionsResult, + Project, + RepositoryGroup, + SearchSessionsResult, + Session, + SessionAPI, + SessionDetail, + SessionMetrics, + SessionsPaginationOptions, + SshAPI, + SshConfigHostEntry, + SshConnectionConfig, + SshConnectionStatus, + SshLastConnection, + SubagentDetail, + TriggerTestResult, + UpdaterAPI, + WaterfallData, +} from '@shared/types'; + +export class HttpAPIClient implements ElectronAPI { + private baseUrl: string; + private eventSource: EventSource | null = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures + private eventListeners = new Map void>>(); + + constructor(port: number) { + this.baseUrl = `http://127.0.0.1:${port}`; + this.initEventSource(); + } + + // --------------------------------------------------------------------------- + // SSE event infrastructure + // --------------------------------------------------------------------------- + + private initEventSource(): void { + this.eventSource = new EventSource(`${this.baseUrl}/api/events`); + this.eventSource.onopen = () => console.log('[HttpAPIClient] SSE connected'); + this.eventSource.onerror = () => { + // Auto-reconnect is built into EventSource + console.warn('[HttpAPIClient] SSE connection error, will reconnect...'); + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures + private addEventListener(channel: string, callback: (...args: any[]) => void): () => void { + if (!this.eventListeners.has(channel)) { + this.eventListeners.set(channel, new Set()); + // Register SSE listener for this channel once + this.eventSource?.addEventListener(channel, ((event: MessageEvent) => { + const data: unknown = JSON.parse(event.data as string); + const listeners = this.eventListeners.get(channel); + listeners?.forEach((cb) => cb(data)); + }) as EventListener); + } + this.eventListeners.get(channel)!.add(callback); + + return () => { + this.eventListeners.get(channel)?.delete(callback); + }; + } + + // --------------------------------------------------------------------------- + // HTTP helpers + // --------------------------------------------------------------------------- + + /** + * JSON reviver that converts ISO 8601 date strings back to Date objects. + * Electron IPC preserves Date instances via structured clone, but HTTP JSON + * serialization turns them into strings. This restores them so that + * `.getTime()` and other Date methods work in the renderer. + */ + private static readonly ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/; + + private static reviveDates(_key: string, value: unknown): unknown { + if (typeof value === 'string' && HttpAPIClient.ISO_DATE_RE.test(value)) { + const d = new Date(value); + if (!isNaN(d.getTime())) return d; + } + return value; + } + + private async parseJson(res: Response): Promise { + const text = await res.text(); + return JSON.parse(text, HttpAPIClient.reviveDates) as T; + } + + private async get(path: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const res = await fetch(`${this.baseUrl}${path}`, { signal: controller.signal }); + return this.parseJson(res); + } finally { + clearTimeout(timeout); + } + } + + private async post(path: string, body?: unknown): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const res = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + return this.parseJson(res); + } finally { + clearTimeout(timeout); + } + } + + private async del(path: string, body?: unknown): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const res = await fetch(`${this.baseUrl}${path}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + return this.parseJson(res); + } finally { + clearTimeout(timeout); + } + } + + private async put(path: string, body?: unknown): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const res = await fetch(`${this.baseUrl}${path}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + return this.parseJson(res); + } finally { + clearTimeout(timeout); + } + } + + // --------------------------------------------------------------------------- + // Core session/project APIs + // --------------------------------------------------------------------------- + + getAppVersion = (): Promise => this.get('/api/version'); + + getProjects = (): Promise => this.get('/api/projects'); + + getSessions = (projectId: string): Promise => + this.get(`/api/projects/${encodeURIComponent(projectId)}/sessions`); + + getSessionsPaginated = ( + projectId: string, + cursor: string | null, + limit?: number, + options?: SessionsPaginationOptions + ): Promise => { + const params = new URLSearchParams(); + if (cursor) params.set('cursor', cursor); + if (limit) params.set('limit', String(limit)); + if (options?.includeTotalCount === false) params.set('includeTotalCount', 'false'); + if (options?.prefilterAll === false) params.set('prefilterAll', 'false'); + const qs = params.toString(); + const encodedId = encodeURIComponent(projectId); + const path = `/api/projects/${encodedId}/sessions-paginated`; + return this.get(qs ? `${path}?${qs}` : path); + }; + + searchSessions = ( + projectId: string, + query: string, + maxResults?: number + ): Promise => { + const params = new URLSearchParams({ q: query }); + if (maxResults) params.set('maxResults', String(maxResults)); + return this.get( + `/api/projects/${encodeURIComponent(projectId)}/search?${params}` + ); + }; + + getSessionDetail = (projectId: string, sessionId: string): Promise => + this.get( + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}` + ); + + getSessionMetrics = (projectId: string, sessionId: string): Promise => + this.get( + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/metrics` + ); + + getWaterfallData = (projectId: string, sessionId: string): Promise => + this.get( + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/waterfall` + ); + + getSubagentDetail = ( + projectId: string, + sessionId: string, + subagentId: string + ): Promise => + this.get( + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}` + ); + + getSessionGroups = (projectId: string, sessionId: string): Promise => + this.get( + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups` + ); + + // --------------------------------------------------------------------------- + // Repository grouping + // --------------------------------------------------------------------------- + + getRepositoryGroups = (): Promise => + this.get('/api/repository-groups'); + + getWorktreeSessions = (worktreeId: string): Promise => + this.get(`/api/worktrees/${encodeURIComponent(worktreeId)}/sessions`); + + // --------------------------------------------------------------------------- + // Validation + // --------------------------------------------------------------------------- + + validatePath = ( + relativePath: string, + projectPath: string + ): Promise<{ exists: boolean; isDirectory?: boolean }> => + this.post<{ exists: boolean; isDirectory?: boolean }>('/api/validate/path', { + relativePath, + projectPath, + }); + + validateMentions = ( + mentions: { type: 'path'; value: string }[], + projectPath: string + ): Promise> => + this.post>('/api/validate/mentions', { mentions, projectPath }); + + // --------------------------------------------------------------------------- + // CLAUDE.md reading + // --------------------------------------------------------------------------- + + readClaudeMdFiles = (projectRoot: string): Promise> => + this.post>('/api/read-claude-md', { projectRoot }); + + readDirectoryClaudeMd = (dirPath: string): Promise => + this.post('/api/read-directory-claude-md', { dirPath }); + + readMentionedFile = ( + absolutePath: string, + projectRoot: string, + maxTokens?: number + ): Promise => + this.post('/api/read-mentioned-file', { + absolutePath, + projectRoot, + maxTokens, + }); + + // --------------------------------------------------------------------------- + // Notifications (nested API) + // --------------------------------------------------------------------------- + + notifications: NotificationsAPI = { + get: (options) => + this.get( + `/api/notifications?${new URLSearchParams( + options + ? { + limit: String(options.limit ?? 20), + offset: String(options.offset ?? 0), + } + : {} + )}` + ), + markRead: (id) => this.post(`/api/notifications/${encodeURIComponent(id)}/read`), + markAllRead: () => this.post('/api/notifications/read-all'), + delete: (id) => this.del(`/api/notifications/${encodeURIComponent(id)}`), + clear: () => this.del('/api/notifications'), + getUnreadCount: () => this.get('/api/notifications/unread-count'), + // IPC signature: (event: unknown, error: unknown) => void + onNew: (callback) => + this.addEventListener('notification:new', (data: unknown) => callback(null, data)), + // IPC signature: (event: unknown, payload: { total; unreadCount }) => void + onUpdated: (callback) => + this.addEventListener('notification:updated', (data: unknown) => + callback(null, data as { total: number; unreadCount: number }) + ), + // IPC signature: (event: unknown, data: unknown) => void + onClicked: (callback) => + this.addEventListener('notification:clicked', (data: unknown) => callback(null, data)), + }; + + // --------------------------------------------------------------------------- + // Config (nested API) + // --------------------------------------------------------------------------- + + config: ConfigAPI = { + get: async (): Promise => { + const result = await this.get<{ success: boolean; data?: AppConfig; error?: string }>( + '/api/config' + ); + if (!result.success) throw new Error(result.error ?? 'Failed to get config'); + return result.data!; + }, + update: async (section: string, data: object): Promise => { + const result = await this.post<{ success: boolean; data?: AppConfig; error?: string }>( + '/api/config/update', + { section, data } + ); + if (!result.success) throw new Error(result.error ?? 'Failed to update config'); + return result.data!; + }, + addIgnoreRegex: async (pattern: string): Promise => { + await this.post('/api/config/ignore-regex', { pattern }); + return this.config.get(); + }, + removeIgnoreRegex: async (pattern: string): Promise => { + await this.del('/api/config/ignore-regex', { pattern }); + return this.config.get(); + }, + addIgnoreRepository: async (repositoryId: string): Promise => { + await this.post('/api/config/ignore-repository', { repositoryId }); + return this.config.get(); + }, + removeIgnoreRepository: async (repositoryId: string): Promise => { + await this.del('/api/config/ignore-repository', { repositoryId }); + return this.config.get(); + }, + snooze: async (minutes: number): Promise => { + await this.post('/api/config/snooze', { minutes }); + return this.config.get(); + }, + clearSnooze: async (): Promise => { + await this.post('/api/config/clear-snooze'); + return this.config.get(); + }, + addTrigger: async (trigger): Promise => { + await this.post('/api/config/triggers', trigger); + return this.config.get(); + }, + updateTrigger: async (triggerId: string, updates): Promise => { + await this.put(`/api/config/triggers/${encodeURIComponent(triggerId)}`, updates); + return this.config.get(); + }, + removeTrigger: async (triggerId: string): Promise => { + await this.del(`/api/config/triggers/${encodeURIComponent(triggerId)}`); + return this.config.get(); + }, + getTriggers: async (): Promise => { + const result = await this.get<{ success: boolean; data?: NotificationTrigger[] }>( + '/api/config/triggers' + ); + return result.data ?? []; + }, + testTrigger: async (trigger: NotificationTrigger): Promise => { + const result = await this.post<{ + success: boolean; + data?: TriggerTestResult; + error?: string; + }>(`/api/config/triggers/${encodeURIComponent(trigger.id)}/test`, trigger); + if (!result.success) throw new Error(result.error ?? 'Failed to test trigger'); + return result.data!; + }, + selectFolders: async (): Promise => { + console.warn('[HttpAPIClient] selectFolders is not available in browser mode'); + return []; + }, + openInEditor: async (): Promise => { + console.warn('[HttpAPIClient] openInEditor is not available in browser mode'); + }, + pinSession: (projectId: string, sessionId: string): Promise => + this.post('/api/config/pin-session', { projectId, sessionId }), + unpinSession: (projectId: string, sessionId: string): Promise => + this.post('/api/config/unpin-session', { projectId, sessionId }), + }; + + // --------------------------------------------------------------------------- + // Session navigation + // --------------------------------------------------------------------------- + + session: SessionAPI = { + scrollToLine: (sessionId: string, lineNumber: number): Promise => + this.post('/api/session/scroll-to-line', { sessionId, lineNumber }), + }; + + // --------------------------------------------------------------------------- + // Zoom (browser fallbacks) + // --------------------------------------------------------------------------- + + getZoomFactor = async (): Promise => 1.0; + + onZoomFactorChanged = (_callback: (zoomFactor: number) => void): (() => void) => { + // No-op in browser mode — zoom is managed by the browser itself + return () => {}; + }; + + // --------------------------------------------------------------------------- + // File change events (via SSE) + // --------------------------------------------------------------------------- + + onFileChange = (callback: (event: FileChangeEvent) => void): (() => void) => + this.addEventListener('file-change', callback); + + onTodoChange = (callback: (event: FileChangeEvent) => void): (() => void) => + this.addEventListener('todo-change', callback); + + // --------------------------------------------------------------------------- + // Shell operations (browser fallbacks) + // --------------------------------------------------------------------------- + + openPath = async ( + _targetPath: string, + _projectRoot?: string + ): Promise<{ success: boolean; error?: string }> => { + console.warn('[HttpAPIClient] openPath is not available in browser mode'); + return { success: false, error: 'Not available in browser mode' }; + }; + + openExternal = async (url: string): Promise<{ success: boolean; error?: string }> => { + window.open(url, '_blank'); + return { success: true }; + }; + + // --------------------------------------------------------------------------- + // Updater (browser no-ops) + // --------------------------------------------------------------------------- + + updater: UpdaterAPI = { + check: async (): Promise => { + console.warn('[HttpAPIClient] updater not available in browser mode'); + }, + download: async (): Promise => { + console.warn('[HttpAPIClient] updater not available in browser mode'); + }, + install: async (): Promise => { + console.warn('[HttpAPIClient] updater not available in browser mode'); + }, + onStatus: (_callback): (() => void) => { + return () => {}; + }, + }; + + // --------------------------------------------------------------------------- + // SSH + // --------------------------------------------------------------------------- + + ssh: SshAPI = { + connect: (config: SshConnectionConfig): Promise => + this.post('/api/ssh/connect', config), + disconnect: (): Promise => this.post('/api/ssh/disconnect'), + getState: (): Promise => this.get('/api/ssh/state'), + test: (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> => + this.post('/api/ssh/test', config), + getConfigHosts: async (): Promise => { + const result = await this.get<{ success: boolean; data?: SshConfigHostEntry[] }>( + '/api/ssh/config-hosts' + ); + return result.data ?? []; + }, + resolveHost: async (alias: string): Promise => { + const result = await this.post<{ + success: boolean; + data?: SshConfigHostEntry | null; + }>('/api/ssh/resolve-host', { alias }); + return result.data ?? null; + }, + saveLastConnection: (config: SshLastConnection): Promise => + this.post('/api/ssh/save-last-connection', config), + getLastConnection: async (): Promise => { + const result = await this.get<{ success: boolean; data?: SshLastConnection | null }>( + '/api/ssh/last-connection' + ); + return result.data ?? null; + }, + // IPC signature: (event: unknown, status: SshConnectionStatus) => void + onStatus: (callback): (() => void) => + this.addEventListener('ssh:status', (data: unknown) => + callback(null, data as SshConnectionStatus) + ), + }; + + // --------------------------------------------------------------------------- + // Context API + // --------------------------------------------------------------------------- + + context = { + list: (): Promise => this.get('/api/contexts'), + getActive: (): Promise => this.get('/api/contexts/active'), + switch: (contextId: string): Promise<{ contextId: string }> => + this.post<{ contextId: string }>('/api/contexts/switch', { contextId }), + onChanged: (callback: (event: unknown, data: ContextInfo) => void): (() => void) => + this.addEventListener('context:changed', (data: unknown) => + callback(null, data as ContextInfo) + ), + }; + + // HTTP Server API — in browser mode, server is already running (we're using it) + httpServer: HttpServerAPI = { + start: (): Promise => + Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }), + stop: (): Promise => { + console.warn('[HttpAPIClient] Cannot stop HTTP server from browser mode'); + return Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }); + }, + getStatus: (): Promise => + Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }), + }; +} diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts new file mode 100644 index 00000000..fb2ba881 --- /dev/null +++ b/src/renderer/api/index.ts @@ -0,0 +1,51 @@ +/** + * Unified API adapter. + * + * When running inside Electron, the preload script exposes `window.electronAPI`. + * When running in a browser (e.g. via the HTTP server), we fall back to an + * HTTP+SSE client that implements the same interface. + * + * All renderer code should import `api` from this module instead of + * accessing `window.electronAPI` directly. + * + * The instance is resolved lazily on first property access so that test code + * can install mocks on `window.electronAPI` before the adapter resolves. + */ + +import { HttpAPIClient } from './httpClient'; + +import type { ElectronAPI } from '@shared/types/api'; + +function getHttpPort(): number { + const params = new URLSearchParams(window.location.search); + return parseInt(params.get('port') ?? '3456', 10); +} + +let httpClient: HttpAPIClient | null = null; + +function getImpl(): ElectronAPI { + if (window.electronAPI) return window.electronAPI; + // Lazily create the HTTP client only when actually needed (browser mode). + // Caching avoids creating multiple EventSource connections. + if (!httpClient) { + httpClient = new HttpAPIClient(getHttpPort()); + } + return httpClient; +} + +/** + * Proxy that lazily resolves the underlying ElectronAPI on first property access. + * In Electron: delegates to `window.electronAPI` (set by preload). + * In browser: delegates to `HttpAPIClient` (created on first use). + * In tests: delegates to whatever mock is installed on `window.electronAPI`. + */ +export const api: ElectronAPI = new Proxy({} as ElectronAPI, { + get(_target, prop, receiver) { + const impl = getImpl(); + const value = Reflect.get(impl, prop, receiver) as unknown; + if (typeof value === 'function') { + return (value as (...args: unknown[]) => unknown).bind(impl); + } + return value; + }, +}); diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 7b1a296e..719cc7cb 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import ReactMarkdown, { type Components } from 'react-markdown'; +import { api } from '@renderer/api'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useStore } from '@renderer/store'; import { createLogger } from '@shared/utils/logger'; @@ -360,7 +361,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. const validatePaths = async (): Promise => { try { const toValidate = pathMentions.map((m) => ({ type: 'path' as const, value: m.value })); - const results = await window.electronAPI.validateMentions(toValidate, projectPath); + const results = await api.validateMentions(toValidate, projectPath); if (isCurrent) { setValidatedPaths(results); } diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 80b5e52e..670b4050 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactMarkdown, { type Components } from 'react-markdown'; +import { api } from '@renderer/api'; import { CopyButton } from '@renderer/components/common/CopyButton'; import { CODE_BG, @@ -107,7 +108,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon onClick={(e) => { e.preventDefault(); if (href) { - void window.electronAPI.openExternal(href); + void api.openExternal(href); } }} > diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 2ad29f24..593be370 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -9,6 +9,7 @@ import React, { useEffect, useMemo, useState } from 'react'; +import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { createLogger } from '@shared/utils/logger'; import { useShallow } from 'zustand/react/shallow'; @@ -203,7 +204,7 @@ const NewProjectCard = (): React.JSX.Element => { const handleClick = async (): Promise => { try { - const selectedPaths = await window.electronAPI.config.selectFolders(); + const selectedPaths = await api.config.selectFolders(); if (!selectedPaths || selectedPaths.length === 0) { return; // User cancelled } @@ -221,7 +222,7 @@ const NewProjectCard = (): React.JSX.Element => { } // No match found - open the folder in file manager as fallback - const result = await window.electronAPI.openPath(selectedPath); + const result = await api.openPath(selectedPath); if (!result.success) { logger.error('Failed to open folder:', result.error); } diff --git a/src/renderer/components/search/CommandPalette.tsx b/src/renderer/components/search/CommandPalette.tsx index 056d15c4..eff78dde 100644 --- a/src/renderer/components/search/CommandPalette.tsx +++ b/src/renderer/components/search/CommandPalette.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { createLogger } from '@shared/utils/logger'; import { useShallow } from 'zustand/react/shallow'; @@ -220,11 +221,7 @@ export const CommandPalette = (): React.JSX.Element | null => { latestSearchRequestRef.current = requestId; setLoading(true); try { - const searchResult = await window.electronAPI.searchSessions( - selectedProjectId, - query.trim(), - 50 - ); + const searchResult = await api.searchSessions(selectedProjectId, query.trim(), 50); if (latestSearchRequestRef.current !== requestId) { return; } diff --git a/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts index 99c03d8f..ec3f2d0e 100644 --- a/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts +++ b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts @@ -4,6 +4,7 @@ import { useCallback, useState } from 'react'; +import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { createLogger } from '@shared/utils/logger'; @@ -91,7 +92,7 @@ export function useTriggerForm(_options: UseTriggerFormOptions = {}): UseTrigger setPreviewResult({ loading: true, totalCount: 0, errors: [] }); try { - const result = await window.electronAPI.config.testTrigger(trigger); + const result = await api.config.testTrigger(trigger); setPreviewResult({ loading: false, totalCount: result.totalCount, diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index 064ca78a..af3ead86 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { useShallow } from 'zustand/react/shallow'; @@ -87,7 +88,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn { try { setLoading(true); setError(null); - const loadedConfig = await window.electronAPI.config.get(); + const loadedConfig = await api.config.get(); setConfig(loadedConfig); setOptimisticConfig(loadedConfig); } catch (err) { @@ -124,7 +125,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn { try { setSaving(true); - const updatedConfig = await window.electronAPI.config.update(section, data); + const updatedConfig = await api.config.update(section, data as object); setConfig(updatedConfig); setOptimisticConfig(updatedConfig); // Update global store so other components (like useTheme) see the change diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 23de0485..4ae8d22a 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -5,6 +5,7 @@ import { useCallback, useRef } from 'react'; +import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import type { RepositoryDropdownItem } from './useSettingsConfig'; @@ -99,7 +100,7 @@ export function useSettingsHandlers({ async (minutes: number) => { try { setSaving(true); - const updatedConfig = await window.electronAPI.config.snooze(minutes); + const updatedConfig = await api.config.snooze(minutes); setConfig(updatedConfig); setOptimisticConfig(updatedConfig); setStoreState({ appConfig: updatedConfig }); @@ -115,7 +116,7 @@ export function useSettingsHandlers({ const handleClearSnooze = useCallback(async () => { try { setSaving(true); - const updatedConfig = await window.electronAPI.config.clearSnooze(); + const updatedConfig = await api.config.clearSnooze(); setConfig(updatedConfig); setOptimisticConfig(updatedConfig); setStoreState({ appConfig: updatedConfig }); @@ -130,7 +131,7 @@ export function useSettingsHandlers({ async (item: RepositoryDropdownItem) => { try { setSaving(true); - const updatedConfig = await window.electronAPI.config.addIgnoreRepository(item.id); + const updatedConfig = await api.config.addIgnoreRepository(item.id); setConfig(updatedConfig); setOptimisticConfig(updatedConfig); setStoreState({ appConfig: updatedConfig }); @@ -147,7 +148,7 @@ export function useSettingsHandlers({ async (repositoryId: string) => { try { setSaving(true); - const updatedConfig = await window.electronAPI.config.removeIgnoreRepository(repositoryId); + const updatedConfig = await api.config.removeIgnoreRepository(repositoryId); setConfig(updatedConfig); setOptimisticConfig(updatedConfig); setStoreState({ appConfig: updatedConfig }); @@ -165,7 +166,7 @@ export function useSettingsHandlers({ async (trigger: Omit) => { try { setSaving(true); - const updatedConfig = await window.electronAPI.config.addTrigger(trigger); + const updatedConfig = await api.config.addTrigger(trigger); setConfig(updatedConfig); setOptimisticConfig(updatedConfig); setStoreState({ appConfig: updatedConfig }); @@ -198,7 +199,7 @@ export function useSettingsHandlers({ try { setSaving(true); - const updatedConfig = await window.electronAPI.config.updateTrigger(triggerId, updates); + const updatedConfig = await api.config.updateTrigger(triggerId, updates); setConfig(updatedConfig); setOptimisticConfig(updatedConfig); setStoreState({ appConfig: updatedConfig }); @@ -217,7 +218,7 @@ export function useSettingsHandlers({ async (triggerId: string) => { try { setSaving(true); - const updatedConfig = await window.electronAPI.config.removeTrigger(triggerId); + const updatedConfig = await api.config.removeTrigger(triggerId); setConfig(updatedConfig); setOptimisticConfig(updatedConfig); setStoreState({ appConfig: updatedConfig }); @@ -296,12 +297,9 @@ export function useSettingsHandlers({ }, }; - await window.electronAPI.config.update('notifications', defaultConfig.notifications); - await window.electronAPI.config.update('general', defaultConfig.general); - const updatedConfig = await window.electronAPI.config.update( - 'display', - defaultConfig.display - ); + await api.config.update('notifications', defaultConfig.notifications); + await api.config.update('general', defaultConfig.general); + const updatedConfig = await api.config.update('display', defaultConfig.display); setConfig(updatedConfig); setOptimisticConfig(updatedConfig); setStoreState({ appConfig: updatedConfig }); @@ -328,7 +326,7 @@ export function useSettingsHandlers({ const handleOpenInEditor = useCallback(async () => { try { - await window.electronAPI.config.openInEditor(); + await api.config.openInEditor(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to open config in editor'); } @@ -348,16 +346,16 @@ export function useSettingsHandlers({ const importedConfig = JSON.parse(text) as AppConfig; if (importedConfig.notifications) { - await window.electronAPI.config.update('notifications', importedConfig.notifications); + await api.config.update('notifications', importedConfig.notifications); } if (importedConfig.general) { - await window.electronAPI.config.update('general', importedConfig.general); + await api.config.update('general', importedConfig.general); } if (importedConfig.display) { - await window.electronAPI.config.update('display', importedConfig.display); + await api.config.update('display', importedConfig.display); } - const updatedConfig = await window.electronAPI.config.get(); + const updatedConfig = await api.config.get(); setConfig(updatedConfig); setOptimisticConfig(updatedConfig); setStoreState({ appConfig: updatedConfig }); diff --git a/src/renderer/components/settings/sections/AdvancedSection.tsx b/src/renderer/components/settings/sections/AdvancedSection.tsx index ec2e1606..3511067b 100644 --- a/src/renderer/components/settings/sections/AdvancedSection.tsx +++ b/src/renderer/components/settings/sections/AdvancedSection.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { api } from '@renderer/api'; import appIcon from '@renderer/favicon.png'; import { useStore } from '@renderer/store'; import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide-react'; @@ -44,7 +45,7 @@ export const AdvancedSection = ({ }, [updateStatus]); useEffect(() => { - window.electronAPI.getAppVersion().then(setVersion).catch(console.error); + api.getAppVersion().then(setVersion).catch(console.error); }, []); const handleCheckForUpdates = useCallback(() => { diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index 87194319..0544c872 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -1,10 +1,16 @@ /** - * GeneralSection - General settings including startup and appearance. + * GeneralSection - General settings including startup, appearance, and browser access. */ +import { useCallback, useEffect, useState } from 'react'; + +import { api } from '@renderer/api'; +import { Check, Copy, Loader2 } from 'lucide-react'; + import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components'; import type { SafeConfig } from '../hooks/useSettingsConfig'; +import type { HttpServerStatus } from '@shared/types/api'; // Theme options const THEME_OPTIONS = [ @@ -26,6 +32,37 @@ export const GeneralSection = ({ onGeneralToggle, onThemeChange, }: GeneralSectionProps): React.JSX.Element => { + const [serverStatus, setServerStatus] = useState({ running: false, port: 3456 }); + const [serverLoading, setServerLoading] = useState(false); + const [copied, setCopied] = useState(false); + + // Fetch server status on mount + useEffect(() => { + void api.httpServer.getStatus().then(setServerStatus); + }, []); + + const handleServerToggle = useCallback(async (enabled: boolean) => { + setServerLoading(true); + try { + const status = enabled + ? await api.httpServer.start() + : await api.httpServer.stop(); + setServerStatus(status); + } catch { + // Status didn't change + } finally { + setServerLoading(false); + } + }, []); + + const serverUrl = `http://localhost:${serverStatus.port}`; + + const handleCopyUrl = useCallback(() => { + void navigator.clipboard.writeText(serverUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [serverUrl]); + return (
@@ -55,6 +92,58 @@ export const GeneralSection = ({ disabled={saving} /> + + + + {serverLoading ? ( + + ) : ( + + )} + + + {serverStatus.running && ( +
+
+ + Running on + + + {serverUrl} + + +
+ )}
); }; diff --git a/src/renderer/hooks/useZoomFactor.ts b/src/renderer/hooks/useZoomFactor.ts index 0602ae9c..c990ae22 100644 --- a/src/renderer/hooks/useZoomFactor.ts +++ b/src/renderer/hooks/useZoomFactor.ts @@ -1,5 +1,7 @@ import { useEffect, useState } from 'react'; +import { api } from '@renderer/api'; + /** * Reads current zoom factor and stays subscribed to zoom updates from main. */ @@ -9,7 +11,7 @@ export function useZoomFactor(): number { useEffect(() => { let isMounted = true; - void window.electronAPI + void api .getZoomFactor() .then((value) => { if (isMounted) { @@ -20,7 +22,7 @@ export function useZoomFactor(): number { // Keep default 1 if zoom factor cannot be read. }); - const unsubscribe = window.electronAPI.onZoomFactorChanged((value) => { + const unsubscribe = api.onZoomFactorChanged((value) => { setZoomFactor(value); }); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index bfd6238e..fcfdb79c 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -2,6 +2,7 @@ * Store index - combines all slices and exports the unified store. */ +import { api } from '@renderer/api'; import { create } from 'zustand'; import { createConfigSlice } from './slices/configSlice'; @@ -93,8 +94,8 @@ export function initializeNotificationListeners(): () => void { }; // Listen for new notifications from main process - if (window.electronAPI.notifications?.onNew) { - const cleanup = window.electronAPI.notifications.onNew((_event: unknown, error: unknown) => { + if (api.notifications?.onNew) { + const cleanup = api.notifications.onNew((_event: unknown, error: unknown) => { // Cast the error to DetectedError type const notification = error as DetectedError; if (notification?.id) { @@ -113,8 +114,8 @@ export function initializeNotificationListeners(): () => void { } // Listen for notification updates from main process - if (window.electronAPI.notifications?.onUpdated) { - const cleanup = window.electronAPI.notifications.onUpdated( + if (api.notifications?.onUpdated) { + const cleanup = api.notifications.onUpdated( (_event: unknown, payload: { total: number; unreadCount: number }) => { const unreadCount = typeof payload.unreadCount === 'number' && Number.isFinite(payload.unreadCount) @@ -129,8 +130,8 @@ export function initializeNotificationListeners(): () => void { } // Navigate to error when user clicks a native OS notification - if (window.electronAPI.notifications?.onClicked) { - const cleanup = window.electronAPI.notifications.onClicked((_event: unknown, data: unknown) => { + if (api.notifications?.onClicked) { + const cleanup = api.notifications.onClicked((_event: unknown, data: unknown) => { const error = data as DetectedError; if (error?.id && error?.sessionId && error?.projectId) { useStore.getState().navigateToError(error); @@ -161,8 +162,8 @@ export function initializeNotificationListeners(): () => void { }; // Listen for task-list file changes to refresh currently viewed session metadata - if (window.electronAPI.onTodoChange) { - const cleanup = window.electronAPI.onTodoChange((event) => { + if (api.onTodoChange) { + const cleanup = api.onTodoChange((event) => { if (!event.sessionId || event.type === 'unlink') { return; } @@ -198,8 +199,8 @@ export function initializeNotificationListeners(): () => void { } // Listen for file changes to auto-refresh current session and detect new sessions - if (window.electronAPI.onFileChange) { - const cleanup = window.electronAPI.onFileChange((event) => { + if (api.onFileChange) { + const cleanup = api.onFileChange((event) => { // Skip unlink events if (event.type === 'unlink') { return; @@ -234,8 +235,8 @@ export function initializeNotificationListeners(): () => void { } // Listen for updater status events from main process - if (window.electronAPI.updater?.onStatus) { - const cleanup = window.electronAPI.updater.onStatus((_event: unknown, status: unknown) => { + if (api.updater?.onStatus) { + const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => { const s = status as UpdaterStatus; switch (s.type) { case 'checking': @@ -279,8 +280,8 @@ export function initializeNotificationListeners(): () => void { } // Listen for SSH connection status changes from main process - if (window.electronAPI.ssh?.onStatus) { - const cleanup = window.electronAPI.ssh.onStatus((_event: unknown, status: unknown) => { + if (api.ssh?.onStatus) { + const cleanup = api.ssh.onStatus((_event: unknown, status: unknown) => { const s = status as { state: string; host: string | null; error: string | null }; useStore .getState() diff --git a/src/renderer/store/slices/configSlice.ts b/src/renderer/store/slices/configSlice.ts index 17e1a91d..da32ba77 100644 --- a/src/renderer/store/slices/configSlice.ts +++ b/src/renderer/store/slices/configSlice.ts @@ -2,6 +2,7 @@ * Config slice - manages app configuration state and actions. */ +import { api } from '@renderer/api'; import { createLogger } from '@shared/utils/logger'; import type { AppState } from '../types'; @@ -40,7 +41,7 @@ export const createConfigSlice: StateCreator = (s fetchConfig: async () => { set({ configLoading: true, configError: null }); try { - const config = await window.electronAPI.config.get(); + const config = await api.config.get(); set({ appConfig: config, configLoading: false, @@ -56,9 +57,9 @@ export const createConfigSlice: StateCreator = (s // Update a section of the app configuration updateConfig: async (section: string, data: Record) => { try { - await window.electronAPI.config.update(section, data); + await api.config.update(section, data); // Refresh config after update - const config = await window.electronAPI.config.get(); + const config = await api.config.get(); set({ appConfig: config }); } catch (error) { logger.error('Failed to update config:', error); diff --git a/src/renderer/store/slices/connectionSlice.ts b/src/renderer/store/slices/connectionSlice.ts index a14c317a..5847ceb8 100644 --- a/src/renderer/store/slices/connectionSlice.ts +++ b/src/renderer/store/slices/connectionSlice.ts @@ -5,6 +5,8 @@ * and provides actions for connecting/disconnecting. */ +import { api } from '@renderer/api'; + import { getFullResetState } from '../utils/stateResetHelpers'; import type { AppState } from '../types'; @@ -68,7 +70,7 @@ export const createConnectionSlice: StateCreator => { try { - const status = await window.electronAPI.ssh.disconnect(); + const status = await api.ssh.disconnect(); set({ connectionMode: 'local', connectionState: status.state, @@ -130,7 +132,7 @@ export const createConnectionSlice: StateCreator => { try { - return await window.electronAPI.ssh.test(config); + return await api.ssh.test(config); } catch (err) { const message = err instanceof Error ? err.message : String(err); return { success: false, error: message }; @@ -152,7 +154,7 @@ export const createConnectionSlice: StateCreator => { try { - const hosts = await window.electronAPI.ssh.getConfigHosts(); + const hosts = await api.ssh.getConfigHosts(); set({ sshConfigHosts: hosts }); } catch { // Gracefully ignore - SSH config may not exist @@ -162,7 +164,7 @@ export const createConnectionSlice: StateCreator => { try { - return await window.electronAPI.ssh.resolveHost(alias); + return await api.ssh.resolveHost(alias); } catch { return null; } @@ -170,7 +172,7 @@ export const createConnectionSlice: StateCreator => { try { - const saved = await window.electronAPI.ssh.getLastConnection(); + const saved = await api.ssh.getLastConnection(); set({ lastSshConfig: saved }); } catch { // Gracefully ignore - no saved connection diff --git a/src/renderer/store/slices/notificationSlice.ts b/src/renderer/store/slices/notificationSlice.ts index 58d095b8..e4e00541 100644 --- a/src/renderer/store/slices/notificationSlice.ts +++ b/src/renderer/store/slices/notificationSlice.ts @@ -2,6 +2,7 @@ * Notification slice - manages notifications state and actions. */ +import { api } from '@renderer/api'; import { createErrorNavigationRequest, findTabBySessionAndProject } from '@renderer/types/tabs'; import { createLogger } from '@shared/utils/logger'; @@ -54,7 +55,7 @@ export const createNotificationSlice: StateCreator { try { - const success = await window.electronAPI.notifications.markRead(id); + const success = await api.notifications.markRead(id); if (!success) { await get().fetchNotifications(); return; @@ -101,7 +102,7 @@ export const createNotificationSlice: StateCreator { try { - const success = await window.electronAPI.notifications.markAllRead(); + const success = await api.notifications.markAllRead(); if (!success) { await get().fetchNotifications(); return; @@ -119,7 +120,7 @@ export const createNotificationSlice: StateCreator { try { - const success = await window.electronAPI.notifications.delete(id); + const success = await api.notifications.delete(id); if (!success) { await get().fetchNotifications(); return; @@ -138,7 +139,7 @@ export const createNotificationSlice: StateCreator { try { - const success = await window.electronAPI.notifications.clear(); + const success = await api.notifications.clear(); if (!success) { await get().fetchNotifications(); return; diff --git a/src/renderer/store/slices/projectSlice.ts b/src/renderer/store/slices/projectSlice.ts index 989679f2..7f1293af 100644 --- a/src/renderer/store/slices/projectSlice.ts +++ b/src/renderer/store/slices/projectSlice.ts @@ -2,6 +2,8 @@ * Project slice - manages project list state and selection. */ +import { api } from '@renderer/api'; + import { getSessionResetState } from '../utils/stateResetHelpers'; import type { AppState } from '../types'; @@ -39,7 +41,7 @@ export const createProjectSlice: StateCreator = fetchProjects: async () => { set({ projectsLoading: true, projectsError: null }); try { - const projects = await window.electronAPI.getProjects(); + const projects = await api.getProjects(); // Sort by most recent session (descending) const sorted = [...projects].sort( (a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0) diff --git a/src/renderer/store/slices/repositorySlice.ts b/src/renderer/store/slices/repositorySlice.ts index 14476c84..8313a8ea 100644 --- a/src/renderer/store/slices/repositorySlice.ts +++ b/src/renderer/store/slices/repositorySlice.ts @@ -2,6 +2,7 @@ * Repository slice - manages repository grouping state (worktree support). */ +import { api } from '@renderer/api'; import { createLogger } from '@shared/utils/logger'; import { getSessionResetState } from '../utils/stateResetHelpers'; @@ -52,7 +53,7 @@ export const createRepositorySlice: StateCreator { set({ repositoryGroupsLoading: true, repositoryGroupsError: null }); try { - const groups = await window.electronAPI.getRepositoryGroups(); + const groups = await api.getRepositoryGroups(); // Already sorted by most recent session in the scanner set({ repositoryGroups: groups, repositoryGroupsLoading: false }); } catch (error) { diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index f4542a82..415013b4 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -2,6 +2,7 @@ * Session detail slice - manages session detail, conversation, and stats. */ +import { api } from '@renderer/api'; import { asEnhancedChunkArray } from '@renderer/types/data'; import { findTabBySession, truncateLabel } from '@renderer/types/tabs'; import { processSessionClaudeMd } from '@renderer/utils/claudeMdTracker'; @@ -163,7 +164,7 @@ export const createSessionDetailSlice: StateCreator = {}; try { - claudeMdTokenData = await window.electronAPI.readClaudeMdFiles(projectRoot); + claudeMdTokenData = await api.readClaudeMdFiles(projectRoot); if (requestGeneration !== sessionDetailFetchGeneration) { return; } @@ -227,7 +228,7 @@ export const createSessionDetailSlice: StateCreator { try { const dirPath = fullPath.replace(/[\\/]CLAUDE\.md$/, ''); - const fileInfo = await window.electronAPI.readDirectoryClaudeMd(dirPath); + const fileInfo = await api.readDirectoryClaudeMd(dirPath); return { fullPath, fileInfo, error: false }; } catch (err) { logger.error('Failed to read directory CLAUDE.md:', fullPath, err); @@ -324,7 +325,7 @@ export const createSessionDetailSlice: StateCreator { try { - const fileInfo = await window.electronAPI.readMentionedFile(filePath, projectRoot); + const fileInfo = await api.readMentionedFile(filePath, projectRoot); return { filePath, fileInfo }; } catch (err) { logger.error('Failed to read mentioned file:', filePath, err); @@ -471,7 +472,7 @@ export const createSessionDetailSlice: StateCreator = fetchSessions: async (projectId: string) => { set({ sessionsLoading: true, sessionsError: null }); try { - const sessions = await window.electronAPI.getSessions(projectId); + const sessions = await api.getSessions(projectId); // Sort by createdAt (descending) const sorted = [...sessions].sort((a, b) => b.createdAt - a.createdAt); set({ sessions: sorted, sessionsLoading: false }); @@ -94,7 +95,7 @@ export const createSessionSlice: StateCreator = sessionsTotalCount: 0, }); try { - const result = await window.electronAPI.getSessionsPaginated(projectId, null, 20, { + const result = await api.getSessionsPaginated(projectId, null, 20, { includeTotalCount: false, prefilterAll: false, }); @@ -128,15 +129,10 @@ export const createSessionSlice: StateCreator = set({ sessionsLoadingMore: true }); try { - const result = await window.electronAPI.getSessionsPaginated( - selectedProjectId, - sessionsCursor, - 20, - { - includeTotalCount: false, - prefilterAll: false, - } - ); + const result = await api.getSessionsPaginated(selectedProjectId, sessionsCursor, 20, { + includeTotalCount: false, + prefilterAll: false, + }); set((prevState) => ({ sessions: [...prevState.sessions, ...result.sessions], sessionsCursor: result.nextCursor, @@ -208,7 +204,7 @@ export const createSessionSlice: StateCreator = projectRefreshGeneration.set(projectId, generation); try { - const result = await window.electronAPI.getSessionsPaginated(projectId, null, 20, { + const result = await api.getSessionsPaginated(projectId, null, 20, { includeTotalCount: false, prefilterAll: false, }); @@ -242,10 +238,10 @@ export const createSessionSlice: StateCreator = try { if (isPinned) { - await window.electronAPI.config.unpinSession(projectId, sessionId); + await api.config.unpinSession(projectId, sessionId); set({ pinnedSessionIds: state.pinnedSessionIds.filter((id) => id !== sessionId) }); } else { - await window.electronAPI.config.pinSession(projectId, sessionId); + await api.config.pinSession(projectId, sessionId); set({ pinnedSessionIds: [sessionId, ...state.pinnedSessionIds] }); } } catch (error) { @@ -263,7 +259,7 @@ export const createSessionSlice: StateCreator = } try { - const config = await window.electronAPI.config.get(); + const config = await api.config.get(); const pins = config.sessions?.pinnedSessions?.[projectId] ?? []; set({ pinnedSessionIds: pins.map((p) => p.sessionId) }); } catch (error) { diff --git a/src/renderer/store/slices/subagentSlice.ts b/src/renderer/store/slices/subagentSlice.ts index 06c5dbc6..959e2230 100644 --- a/src/renderer/store/slices/subagentSlice.ts +++ b/src/renderer/store/slices/subagentSlice.ts @@ -2,6 +2,8 @@ * Subagent slice - manages subagent drill-down state. */ +import { api } from '@renderer/api'; + import type { AppState, BreadcrumbItem } from '../types'; import type { SubagentDetail } from '@renderer/types/data'; import type { StateCreator } from 'zustand'; @@ -48,7 +50,7 @@ export const createSubagentSlice: StateCreator ) => { set({ subagentDetailLoading: true, subagentDetailError: null }); try { - const detail = await window.electronAPI.getSubagentDetail(projectId, sessionId, subagentId); + const detail = await api.getSubagentDetail(projectId, sessionId, subagentId); if (!detail) { set({ @@ -108,7 +110,7 @@ export const createSubagentSlice: StateCreator set({ subagentDetailLoading: true, subagentDetailError: null }); - window.electronAPI + api .getSubagentDetail(projectId, sessionId, targetItem.id) .then((detail) => { if (detail) { diff --git a/src/renderer/store/slices/updateSlice.ts b/src/renderer/store/slices/updateSlice.ts index dd9061ae..aa0852c8 100644 --- a/src/renderer/store/slices/updateSlice.ts +++ b/src/renderer/store/slices/updateSlice.ts @@ -2,6 +2,7 @@ * Update slice - manages OTA auto-update state and actions. */ +import { api } from '@renderer/api'; import { createLogger } from '@shared/utils/logger'; import type { AppState } from '../types'; @@ -54,20 +55,20 @@ export const createUpdateSlice: StateCreator = (s checkForUpdates: () => { set({ updateStatus: 'checking', updateError: null }); - window.electronAPI.updater.check().catch((error) => { + api.updater.check().catch((error) => { logger.error('Failed to check for updates:', error); }); }, downloadUpdate: () => { set({ showUpdateDialog: false, showUpdateBanner: true, downloadProgress: 0 }); - window.electronAPI.updater.download().catch((error) => { + api.updater.download().catch((error) => { logger.error('Failed to download update:', error); }); }, installUpdate: () => { - window.electronAPI.updater.install().catch((error) => { + api.updater.install().catch((error) => { logger.error('Failed to install update:', error); }); }, diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 78b5e9a4..a1e0336d 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -242,6 +242,27 @@ export interface SshAPI { onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void) => () => void; } +// ============================================================================= +// HTTP Server API +// ============================================================================= + +/** + * HTTP server status returned from main process. + */ +export interface HttpServerStatus { + running: boolean; + port: number; +} + +/** + * HTTP Server API for controlling the sidecar server. + */ +export interface HttpServerAPI { + start: () => Promise; + stop: () => Promise; + getStatus: () => Promise; +} + // ============================================================================= // Main Electron API // ============================================================================= @@ -334,6 +355,9 @@ export interface ElectronAPI { switch: (contextId: string) => Promise<{ contextId: string }>; onChanged: (callback: (event: unknown, data: ContextInfo) => void) => () => void; }; + + // HTTP Server API + httpServer: HttpServerAPI; } // ============================================================================= diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 31a70e94..970948c3 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -275,4 +275,11 @@ export interface AppConfig { /** Pinned sessions per project. Key is projectId, value is array of pinned sessions */ pinnedSessions: Record; }; + /** HTTP sidecar server settings for iframe embedding */ + httpServer?: { + /** Whether the HTTP server is enabled */ + enabled: boolean; + /** Port for the HTTP server (default 3456) */ + port: number; + }; }