diff --git a/package-lock.json b/package-lock.json index eee3e553..90dc1d8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -311,6 +312,10 @@ "node": ">=6.9.0" } }, + "node_modules/@codenomad/cli": { + "resolved": "packages/cli", + "link": true + }, "node_modules/@codenomad/electron-app": { "resolved": "packages/electron-app", "link": true @@ -331,6 +336,30 @@ "solid-js": "^1.8" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -639,6 +668,89 @@ "node": ">=18" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", + "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "fast-uri": "^2.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@fastify/cors": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz", + "integrity": "sha512-/oZ1QSb02XjP0IK1U0IXktEsw/dUBTxJOW7IpIeO8c/tNalw/KjoNSJv1Sf6eqoBPO+TDGkifq6ynFK3v68HFQ==", + "license": "MIT", + "dependencies": { + "fastify-plugin": "^4.0.0", + "mnemonist": "0.39.6" + } + }, + "node_modules/@fastify/error": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", + "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", + "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^5.7.0" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", + "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -1033,6 +1145,12 @@ "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.68.tgz", "integrity": "sha512-QdpLZw2L/nHdPFGCz8z4du2RvlALgZTFgNeKUM+kJuZTtOWC5t425ELGg5xKIpynD0kj83Euvfn6l6uHs99g3w==" }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1311,6 +1429,34 @@ "node": ">= 10" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1444,6 +1590,7 @@ "integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1532,6 +1679,38 @@ "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", "license": "MIT" }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -1551,6 +1730,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1562,6 +1742,61 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", @@ -1726,7 +1961,6 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -1746,7 +1980,6 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -1769,7 +2002,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -1785,8 +2017,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", @@ -1794,7 +2025,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -1869,6 +2099,15 @@ "node": ">= 4.0.0" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -1907,6 +2146,16 @@ "postcss": "^8.1.0" } }, + "node_modules/avvio": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", + "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "license": "MIT", + "dependencies": { + "@fastify/error": "^3.3.0", + "fastq": "^1.17.1" + } + }, "node_modules/babel-plugin-jsx-dom-expressions": { "version": "0.40.3", "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.40.3.tgz", @@ -2013,7 +2262,6 @@ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -2089,6 +2337,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -2552,7 +2801,6 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -2635,6 +2883,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -2659,7 +2916,6 @@ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -2673,7 +2929,6 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -2682,6 +2937,13 @@ "node": ">= 10" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2858,6 +3120,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dir-compare": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", @@ -2906,6 +3178,7 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -3090,7 +3363,6 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -3104,7 +3376,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -3120,7 +3391,6 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -3134,7 +3404,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -3470,11 +3739,22 @@ "license": "MIT", "optional": true }, + "node_modules/fast-content-type-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -3520,11 +3800,147 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-stringify": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", + "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.1.0", + "ajv": "^8.10.0", + "ajv-formats": "^3.0.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^2.1.0", + "json-schema-ref-resolver": "^1.0.1", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-json-stringify/node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", + "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", + "license": "MIT" + }, + "node_modules/fastify": { + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", + "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^3.5.0", + "@fastify/error": "^3.4.0", + "@fastify/fast-json-stringify-compiler": "^4.3.0", + "abstract-logging": "^2.0.1", + "avvio": "^8.3.0", + "fast-content-type-parse": "^1.1.0", + "fast-json-stringify": "^5.8.0", + "find-my-way": "^8.0.0", + "light-my-request": "^5.11.0", + "pino": "^9.0.0", + "process-warning": "^3.0.0", + "proxy-addr": "^2.0.7", + "rfdc": "^1.3.0", + "secure-json-parse": "^2.7.0", + "semver": "^7.5.4", + "toad-cache": "^3.3.0" + } + }, + "node_modules/fastify-plugin": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", + "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", + "license": "MIT" + }, + "node_modules/fastify/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -3563,6 +3979,20 @@ "node": ">=8" } }, + "node_modules/find-my-way": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", + "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^3.1.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3597,6 +4027,15 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -3616,8 +4055,7 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fs-extra": { "version": "8.1.0", @@ -3774,6 +4212,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/github-markdown-css": { "version": "5.8.1", "resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.8.1.tgz", @@ -4233,6 +4684,15 @@ "dev": true, "license": "ISC" }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4336,8 +4796,7 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/isbinaryfile": { "version": "5.0.6", @@ -4399,6 +4858,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4443,6 +4903,15 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-ref-resolver": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", + "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4504,7 +4973,6 @@ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -4518,7 +4986,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -4534,8 +5001,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", @@ -4543,11 +5009,21 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } }, + "node_modules/light-my-request": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", + "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^0.7.0", + "process-warning": "^3.0.0", + "set-cookie-parser": "^2.4.1" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4580,40 +5056,35 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lowercase-keys": { "version": "2.0.0", @@ -4669,6 +5140,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/marked": { "version": "12.0.2", "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", @@ -4981,6 +5459,15 @@ "node": ">=10" } }, + "node_modules/mnemonist": { + "version": "0.39.6", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz", + "integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5098,6 +5585,21 @@ "node": ">= 0.4" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -5243,6 +5745,59 @@ "node": ">=0.10.0" } }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "license": "MIT" + }, + "node_modules/pino/node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -5308,6 +5863,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5456,8 +6012,13 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "license": "MIT" }, "node_modules/progress": { "version": "2.0.3", @@ -5493,6 +6054,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -5535,6 +6109,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -5606,7 +6186,6 @@ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -5622,7 +6201,6 @@ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -5640,6 +6218,15 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", @@ -5674,6 +6261,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -5702,6 +6298,16 @@ "dev": true, "license": "MIT" }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", @@ -5715,6 +6321,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ret": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", + "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -5729,13 +6344,18 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -5840,8 +6460,25 @@ "url": "https://feross.org/support" } ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", + "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", "license": "MIT", - "peer": true + "dependencies": { + "ret": "~0.4.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/safer-buffer": { "version": "2.1.2", @@ -5867,6 +6504,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -5907,6 +6550,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -5923,6 +6567,12 @@ "seroval": "^1.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6034,6 +6684,7 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz", "integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", @@ -6088,6 +6739,15 @@ "solid-js": "^1.5.4" } }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6129,6 +6789,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -6153,7 +6822,6 @@ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -6411,7 +7079,6 @@ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -6502,6 +7169,15 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -6535,6 +7211,15 @@ "node": ">=8.0" } }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -6562,12 +7247,83 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -6588,6 +7344,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6745,6 +7502,13 @@ "dev": true, "license": "MIT" }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -6795,6 +7559,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -7077,13 +7842,22 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/zip-stream": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -7099,7 +7873,6 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -7116,6 +7889,15 @@ "node": ">= 10" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -7126,6 +7908,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/cli": { + "name": "@codenomad/cli", + "version": "0.1.0", + "dependencies": { + "@fastify/cors": "^8.5.0", + "fastify": "^4.28.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "ts-node": "^10.9.2", + "tsx": "^4.20.6", + "typescript": "^5.6.3" + } + }, "packages/electron-app": { "name": "@codenomad/electron-app", "version": "0.1.2", diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..f75ee1a4 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,22 @@ +{ + "name": "@codenomad/cli", + "version": "0.1.0", + "description": "CodeNomad CLI server for HTTP/SSE control plane", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsx src/index.ts", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@fastify/cors": "^8.5.0", + "fastify": "^4.28.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "ts-node": "^10.9.2", + "tsx": "^4.20.6", + "typescript": "^5.6.3" + } +} diff --git a/packages/cli/src/api-types.ts b/packages/cli/src/api-types.ts new file mode 100644 index 00000000..936e3786 --- /dev/null +++ b/packages/cli/src/api-types.ts @@ -0,0 +1,153 @@ +import type { + AgentModelSelections, + ConfigFile, + ModelPreference, + OpenCodeBinary, + Preferences, + RecentFolder, +} from "./config/schema" + +/** + * Canonical HTTP/SSE contract for the CLI server. + * These types are consumed by both the CLI implementation and any UI clients. + */ + +export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error" + +export interface WorkspaceDescriptor { + id: string + /** Absolute path on the server host. */ + path: string + name?: string + status: WorkspaceStatus + /** PID/port are populated when the workspace is running. */ + pid?: number + port?: number + /** Identifier of the binary resolved from config. */ + binaryId: string + binaryLabel: string + binaryVersion?: string + createdAt: string + updatedAt: string + /** Present when `status` is "error". */ + error?: string +} + +export interface WorkspaceCreateRequest { + path: string + name?: string +} + +export type WorkspaceCreateResponse = WorkspaceDescriptor +export type WorkspaceListResponse = WorkspaceDescriptor[] +export type WorkspaceDetailResponse = WorkspaceDescriptor + +export interface WorkspaceDeleteResponse { + id: string + status: WorkspaceStatus +} + +export type LogLevel = "debug" | "info" | "warn" | "error" + +export interface WorkspaceLogEntry { + workspaceId: string + timestamp: string + level: LogLevel + message: string +} + +export interface FileSystemEntry { + name: string + /** Path relative to the CLI server root ("." represents the root itself). */ + path: string + type: "file" | "directory" + size?: number + /** ISO timestamp of last modification when available. */ + modifiedAt?: string +} + +export type FileSystemListResponse = FileSystemEntry[] + +export interface WorkspaceFileResponse { + workspaceId: string + relativePath: string + /** UTF-8 file contents; binary files should be base64 encoded by the caller. */ + contents: string +} + +export interface InstanceData { + messageHistory: string[] +} + +export interface BinaryRecord { + id: string + path: string + label: string + version?: string + /** Indicates that this binary will be picked when workspaces omit an explicit choice. */ + isDefault: boolean + lastValidatedAt?: string + validationError?: string +} + +export type AppConfig = ConfigFile +export type AppConfigResponse = AppConfig +export type AppConfigUpdateRequest = Partial + +export interface BinaryListResponse { + binaries: BinaryRecord[] +} + +export interface BinaryCreateRequest { + path: string + label?: string + makeDefault?: boolean +} + +export interface BinaryUpdateRequest { + label?: string + makeDefault?: boolean +} + +export interface BinaryValidationResult { + valid: boolean + version?: string + error?: string +} + +export type WorkspaceEventType = + | "workspace.created" + | "workspace.started" + | "workspace.error" + | "workspace.stopped" + | "workspace.log" + | "config.appChanged" + | "config.binariesChanged" + +export type WorkspaceEventPayload = + | { type: "workspace.created"; workspace: WorkspaceDescriptor } + | { type: "workspace.started"; workspace: WorkspaceDescriptor } + | { type: "workspace.error"; workspace: WorkspaceDescriptor } + | { type: "workspace.stopped"; workspaceId: string } + | { type: "workspace.log"; entry: WorkspaceLogEntry } + | { type: "config.appChanged"; config: AppConfig } + | { type: "config.binariesChanged"; binaries: BinaryRecord[] } + +export interface ServerMeta { + /** Base URL clients should target for REST calls (useful for Electron embedding). */ + httpBaseUrl: string + /** SSE endpoint advertised to clients (`/api/events` by default). */ + eventsUrl: string + /** Display label for the host (e.g., hostname or friendly name). */ + hostLabel: string + /** Absolute path of the filesystem root exposed to clients. */ + workspaceRoot: string +} + +export type { + Preferences, + ModelPreference, + AgentModelSelections, + RecentFolder, + OpenCodeBinary, +} diff --git a/packages/cli/src/config/binaries.ts b/packages/cli/src/config/binaries.ts new file mode 100644 index 00000000..19347fb1 --- /dev/null +++ b/packages/cli/src/config/binaries.ts @@ -0,0 +1,144 @@ +import { + BinaryCreateRequest, + BinaryRecord, + BinaryUpdateRequest, + BinaryValidationResult, +} from "../api-types" +import { ConfigStore } from "./store" +import { EventBus } from "../events/bus" +import type { ConfigFileUpdate } from "./schema" + +export class BinaryRegistry { + constructor(private readonly configStore: ConfigStore, private readonly eventBus?: EventBus) {} + + list(): BinaryRecord[] { + return this.mapRecords() + } + + resolveDefault(): BinaryRecord { + const binaries = this.mapRecords() + if (binaries.length === 0) { + return this.buildFallbackRecord("opencode") + } + return binaries.find((binary) => binary.isDefault) ?? binaries[0] + } + + create(request: BinaryCreateRequest): BinaryRecord { + const entry = { + path: request.path, + version: undefined, + lastUsed: Date.now(), + label: request.label, + } + + const config = this.configStore.get() + const deduped = config.opencodeBinaries.filter((binary) => binary.path !== request.path) + + const update: ConfigFileUpdate = { + opencodeBinaries: [entry, ...deduped], + } + + if (request.makeDefault) { + update.preferences = { lastUsedBinary: request.path } + } + + this.configStore.update(update) + const record = this.getById(request.path) + this.emitChange() + return record + } + + update(id: string, updates: BinaryUpdateRequest): BinaryRecord { + const config = this.configStore.get() + const updatedEntries = config.opencodeBinaries.map((binary) => + binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary, + ) + + const update: ConfigFileUpdate = { + opencodeBinaries: updatedEntries, + } + + if (updates.makeDefault) { + update.preferences = { lastUsedBinary: id } + } + + this.configStore.update(update) + const record = this.getById(id) + this.emitChange() + return record + } + + remove(id: string) { + const config = this.configStore.get() + const remaining = config.opencodeBinaries.filter((binary) => binary.path !== id) + const update: ConfigFileUpdate = { opencodeBinaries: remaining } + + if (config.preferences.lastUsedBinary === id) { + update.preferences = { lastUsedBinary: remaining[0]?.path } + } + + this.configStore.update(update) + this.emitChange() + } + + validatePath(path: string): BinaryValidationResult { + return this.validateRecord({ + id: path, + path, + label: this.prettyLabel(path), + isDefault: false, + }) + } + + private mapRecords(): BinaryRecord[] { + const config = this.configStore.get() + const configuredBinaries = config.opencodeBinaries.map((binary) => ({ + id: binary.path, + path: binary.path, + label: binary.label ?? this.prettyLabel(binary.path), + version: binary.version, + isDefault: false, + })) + + const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode" + + const annotated = configuredBinaries.map((binary) => ({ + ...binary, + isDefault: binary.path === defaultPath, + })) + + if (!annotated.some((binary) => binary.path === defaultPath)) { + annotated.unshift(this.buildFallbackRecord(defaultPath)) + } + + return annotated + } + + private getById(id: string): BinaryRecord { + return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id) + } + + private emitChange() { + this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() }) + } + + private validateRecord(record: BinaryRecord): BinaryValidationResult { + // TODO: call actual binary -v check. + return { valid: true, version: record.version } + } + + private buildFallbackRecord(path: string): BinaryRecord { + return { + id: path, + path, + label: this.prettyLabel(path), + isDefault: true, + } + } + + private prettyLabel(path: string) { + const parts = path.split(/[\\/]/) + const last = parts[parts.length - 1] || path + return last || path + } +} diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts new file mode 100644 index 00000000..0bd6aa88 --- /dev/null +++ b/packages/cli/src/config/schema.ts @@ -0,0 +1,80 @@ +import { z } from "zod" + +const ModelPreferenceSchema = z.object({ + providerId: z.string(), + modelId: z.string(), +}) + +const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema) +const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema) + +const PreferencesSchema = z.object({ + showThinkingBlocks: z.boolean().default(false), + lastUsedBinary: z.string().optional(), + environmentVariables: z.record(z.string()).default({}), + modelRecents: z.array(ModelPreferenceSchema).default([]), + agentModelSelections: AgentModelSelectionsSchema.default({}), + diffViewMode: z.enum(["split", "unified"]).default("split"), + toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), + diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), +}) + +const PreferencesUpdateSchema = z.object({ + showThinkingBlocks: z.boolean().optional(), + lastUsedBinary: z.string().optional(), + environmentVariables: z.record(z.string()).optional(), + modelRecents: z.array(ModelPreferenceSchema).optional(), + agentModelSelections: AgentModelSelectionsSchema.optional(), + diffViewMode: z.enum(["split", "unified"]).optional(), + toolOutputExpansion: z.enum(["expanded", "collapsed"]).optional(), + diagnosticsExpansion: z.enum(["expanded", "collapsed"]).optional(), +}) + +const RecentFolderSchema = z.object({ + path: z.string(), + lastAccessed: z.number().nonnegative(), +}) + +const OpenCodeBinarySchema = z.object({ + path: z.string(), + version: z.string().optional(), + lastUsed: z.number().nonnegative(), + label: z.string().optional(), +}) + +const ConfigFileSchema = z.object({ + preferences: PreferencesSchema.default({}), + recentFolders: z.array(RecentFolderSchema).default([]), + opencodeBinaries: z.array(OpenCodeBinarySchema).default([]), + theme: z.enum(["light", "dark", "system"]).optional(), +}) + +const ConfigFileUpdateSchema = z.object({ + preferences: PreferencesUpdateSchema.optional(), + recentFolders: z.array(RecentFolderSchema).optional(), + opencodeBinaries: z.array(OpenCodeBinarySchema).optional(), + theme: z.enum(["light", "dark", "system"]).optional(), +}) + +const DEFAULT_CONFIG = ConfigFileSchema.parse({}) + +export { + ModelPreferenceSchema, + AgentModelSelectionSchema, + AgentModelSelectionsSchema, + PreferencesSchema, + RecentFolderSchema, + OpenCodeBinarySchema, + ConfigFileSchema, + ConfigFileUpdateSchema, + DEFAULT_CONFIG, +} + +export type ModelPreference = z.infer +export type AgentModelSelection = z.infer +export type AgentModelSelections = z.infer +export type Preferences = z.infer +export type RecentFolder = z.infer +export type OpenCodeBinary = z.infer +export type ConfigFile = z.infer +export type ConfigFileUpdate = z.infer diff --git a/packages/cli/src/config/store.ts b/packages/cli/src/config/store.ts new file mode 100644 index 00000000..bc511f83 --- /dev/null +++ b/packages/cli/src/config/store.ts @@ -0,0 +1,111 @@ +import fs from "fs" +import path from "path" +import { EventBus } from "../events/bus" +import { + AgentModelSelections, + ConfigFile, + ConfigFileUpdate, + ConfigFileSchema, + ConfigFileUpdateSchema, + DEFAULT_CONFIG, +} from "./schema" + +export class ConfigStore { + private cache: ConfigFile = DEFAULT_CONFIG + private loaded = false + + constructor(private readonly configPath: string, private readonly eventBus?: EventBus) {} + + load(): ConfigFile { + if (this.loaded) { + return this.cache + } + + try { + const resolved = this.resolvePath(this.configPath) + if (fs.existsSync(resolved)) { + const content = fs.readFileSync(resolved, "utf-8") + const parsed = JSON.parse(content) + this.cache = ConfigFileSchema.parse(parsed) + } else { + this.cache = DEFAULT_CONFIG + } + } catch (error) { + console.warn("Failed to load config", error) + this.cache = DEFAULT_CONFIG + } + + this.loaded = true + return this.cache + } + + get(): ConfigFile { + return this.load() + } + + update(partial: ConfigFile | ConfigFileUpdate) { + const safePartial = + "recentFolders" in partial && "opencodeBinaries" in partial + ? ConfigFileSchema.parse(partial) + : ConfigFileUpdateSchema.parse(partial ?? {}) + const merged = this.mergeConfig(this.load(), safePartial) + this.cache = ConfigFileSchema.parse(merged) + this.persist() + this.eventBus?.publish({ type: "config.appChanged", config: this.cache }) + } + + private mergeConfig(current: ConfigFile, partial: ConfigFile | ConfigFileUpdate): ConfigFile { + const mergedPreferences = { + ...current.preferences, + ...partial.preferences, + environmentVariables: { + ...current.preferences.environmentVariables, + ...(partial.preferences?.environmentVariables ?? {}), + }, + agentModelSelections: this.mergeAgentSelections( + current.preferences.agentModelSelections, + partial.preferences?.agentModelSelections, + ), + } + + return { + ...current, + ...partial, + preferences: mergedPreferences, + recentFolders: partial.recentFolders ?? current.recentFolders, + opencodeBinaries: partial.opencodeBinaries ?? current.opencodeBinaries, + } + } + + private mergeAgentSelections(base: AgentModelSelections, update?: AgentModelSelections) { + if (!update) { + return base + } + + const result: AgentModelSelections = { ...base } + for (const [instanceId, agentMap] of Object.entries(update)) { + result[instanceId] = { + ...(base[instanceId] ?? {}), + ...agentMap, + } + } + return result + } + + private persist() { + try { + const resolved = this.resolvePath(this.configPath) + fs.mkdirSync(path.dirname(resolved), { recursive: true }) + fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8") + } catch (error) { + console.warn("Failed to persist config", error) + } + } + + private resolvePath(filePath: string) { + if (filePath.startsWith("~/")) { + return path.join(process.env.HOME ?? "", filePath.slice(2)) + } + return path.resolve(filePath) + } +} diff --git a/packages/cli/src/events/bus.ts b/packages/cli/src/events/bus.ts new file mode 100644 index 00000000..801cebcb --- /dev/null +++ b/packages/cli/src/events/bus.ts @@ -0,0 +1,28 @@ +import { EventEmitter } from "events" +import { WorkspaceEventPayload } from "../api-types" + +export class EventBus extends EventEmitter { + publish(event: WorkspaceEventPayload): boolean { + return super.emit(event.type, event) + } + + onEvent(listener: (event: WorkspaceEventPayload) => void) { + const handler = (event: WorkspaceEventPayload) => listener(event) + this.on("workspace.created", handler) + this.on("workspace.started", handler) + this.on("workspace.error", handler) + this.on("workspace.stopped", handler) + this.on("workspace.log", handler) + this.on("config.appChanged", handler) + this.on("config.binariesChanged", handler) + return () => { + this.off("workspace.created", handler) + this.off("workspace.started", handler) + this.off("workspace.error", handler) + this.off("workspace.stopped", handler) + this.off("workspace.log", handler) + this.off("config.appChanged", handler) + this.off("config.binariesChanged", handler) + } + } +} diff --git a/packages/cli/src/filesystem/browser.ts b/packages/cli/src/filesystem/browser.ts new file mode 100644 index 00000000..f6803d75 --- /dev/null +++ b/packages/cli/src/filesystem/browser.ts @@ -0,0 +1,54 @@ +import fs from "fs" +import path from "path" +import { FileSystemEntry } from "../api-types" + +interface FileSystemBrowserOptions { + rootDir: string +} + +export class FileSystemBrowser { + private readonly root: string + + constructor(options: FileSystemBrowserOptions) { + this.root = path.resolve(options.rootDir) + } + + list(relativePath: string): FileSystemEntry[] { + const resolved = this.toAbsolute(relativePath) + const entries = fs.readdirSync(resolved, { withFileTypes: true }) + + return entries.flatMap((entry) => { + const entryPath = path.join(relativePath, entry.name) + const absolutePath = this.toAbsolute(entryPath) + const stats = fs.statSync(absolutePath) + + const current: FileSystemEntry = { + name: entry.name, + path: entryPath, + type: entry.isDirectory() ? "directory" : "file", + size: entry.isDirectory() ? undefined : stats.size, + modifiedAt: stats.mtime.toISOString(), + } + + if (entry.isDirectory()) { + const nested = this.list(entryPath) + return [current, ...nested] + } + + return [current] + }) + } + + readFile(relativePath: string): string { + const resolved = this.toAbsolute(relativePath) + return fs.readFileSync(resolved, "utf-8") + } + + private toAbsolute(relativePath: string) { + const target = path.resolve(this.root, relativePath) + if (!target.startsWith(this.root)) { + throw new Error("Access outside of root is not allowed") + } + return target + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 00000000..58a75add --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,89 @@ +/** + * CLI entry point. + * For now this only wires the typed modules together; actual command handling comes later. + */ +import { createHttpServer } from "./server/http-server" +import { WorkspaceManager } from "./workspaces/manager" +import { ConfigStore } from "./config/store" +import { BinaryRegistry } from "./config/binaries" +import { FileSystemBrowser } from "./filesystem/browser" +import { EventBus } from "./events/bus" +import { ServerMeta } from "./api-types" +import { InstanceStore } from "./storage/instance-store" + +interface CliOptions { + port: number + host: string + rootDir: string + configPath: string +} + +function parseCliOptions(argv: string[]): CliOptions { + // TODO: replace with commander/yargs; this is placeholder logic. + const args = new Map() + for (let i = 0; i < argv.length; i += 2) { + const key = argv[i] + const value = argv[i + 1] + if (key && key.startsWith("--") && value) { + args.set(key.slice(2), value) + } + } + + return { + port: Number(args.get("port") ?? process.env.CLI_PORT ?? 5777), + host: args.get("host") ?? process.env.CLI_HOST ?? "127.0.0.1", + rootDir: args.get("root") ?? process.cwd(), + configPath: args.get("config") ?? process.env.CLI_CONFIG ?? "~/.config/codenomad/config.json", + } +} + +async function main() { + const options = parseCliOptions(process.argv.slice(2)) + + const eventBus = new EventBus() + const configStore = new ConfigStore(options.configPath, eventBus) + const binaryRegistry = new BinaryRegistry(configStore, eventBus) + const workspaceManager = new WorkspaceManager({ + rootDir: options.rootDir, + configStore, + binaryRegistry, + eventBus, + }) + const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir }) + const instanceStore = new InstanceStore() + + const serverMeta: ServerMeta = { + httpBaseUrl: `http://${options.host}:${options.port}`, + eventsUrl: `/api/events`, + hostLabel: options.host, + workspaceRoot: options.rootDir, + } + + const server = createHttpServer({ + host: options.host, + port: options.port, + workspaceManager, + configStore, + binaryRegistry, + fileSystemBrowser, + eventBus, + serverMeta, + instanceStore, + }) + + await server.start() + + const shutdown = async () => { + await server.stop() + await workspaceManager.shutdown() + process.exit(0) + } + + process.on("SIGINT", shutdown) + process.on("SIGTERM", shutdown) +} + +main().catch((error) => { + console.error("CLI server crashed", error) + process.exit(1) +}) diff --git a/packages/cli/src/server/http-server.ts b/packages/cli/src/server/http-server.ts new file mode 100644 index 00000000..944b662c --- /dev/null +++ b/packages/cli/src/server/http-server.ts @@ -0,0 +1,49 @@ +import Fastify from "fastify" +import cors from "@fastify/cors" +import { WorkspaceManager } from "../workspaces/manager" +import { ConfigStore } from "../config/store" +import { BinaryRegistry } from "../config/binaries" +import { FileSystemBrowser } from "../filesystem/browser" +import { EventBus } from "../events/bus" +import { registerWorkspaceRoutes } from "./routes/workspaces" +import { registerConfigRoutes } from "./routes/config" +import { registerFilesystemRoutes } from "./routes/filesystem" +import { registerMetaRoutes } from "./routes/meta" +import { registerEventRoutes } from "./routes/events" +import { registerStorageRoutes } from "./routes/storage" +import { ServerMeta } from "../api-types" +import { InstanceStore } from "../storage/instance-store" + +interface HttpServerDeps { + host: string + port: number + workspaceManager: WorkspaceManager + configStore: ConfigStore + binaryRegistry: BinaryRegistry + fileSystemBrowser: FileSystemBrowser + eventBus: EventBus + serverMeta: ServerMeta + instanceStore: InstanceStore +} + +export function createHttpServer(deps: HttpServerDeps) { + const app = Fastify({ logger: false }) + + app.register(cors, { + origin: true, + credentials: true, + }) + + registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) + registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry }) + registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) + registerMetaRoutes(app, { serverMeta: deps.serverMeta }) + registerEventRoutes(app, { eventBus: deps.eventBus }) + registerStorageRoutes(app, { instanceStore: deps.instanceStore }) + + return { + instance: app, + start: () => app.listen({ port: deps.port, host: deps.host }), + stop: () => app.close(), + } +} diff --git a/packages/cli/src/server/routes/config.ts b/packages/cli/src/server/routes/config.ts new file mode 100644 index 00000000..dc81ce27 --- /dev/null +++ b/packages/cli/src/server/routes/config.ts @@ -0,0 +1,68 @@ +import { FastifyInstance } from "fastify" +import { z } from "zod" +import { ConfigStore } from "../../config/store" +import { BinaryRegistry } from "../../config/binaries" +import { ConfigFileSchema, ConfigFileUpdateSchema } from "../../config/schema" + +interface RouteDeps { + configStore: ConfigStore + binaryRegistry: BinaryRegistry +} + +const BinaryCreateSchema = z.object({ + path: z.string(), + label: z.string().optional(), + makeDefault: z.boolean().optional(), +}) + +const BinaryUpdateSchema = z.object({ + label: z.string().optional(), + makeDefault: z.boolean().optional(), +}) + +const BinaryValidateSchema = z.object({ + path: z.string(), +}) + +export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get("/api/config/app", async () => deps.configStore.get()) + + app.put("/api/config/app", async (request) => { + const body = ConfigFileSchema.parse(request.body ?? {}) + deps.configStore.update(body) + return deps.configStore.get() + }) + + app.patch("/api/config/app", async (request) => { + const body = ConfigFileUpdateSchema.parse(request.body ?? {}) + deps.configStore.update(body) + return deps.configStore.get() + }) + + app.get("/api/config/binaries", async () => { + return { binaries: deps.binaryRegistry.list() } + }) + + app.post("/api/config/binaries", async (request, reply) => { + const body = BinaryCreateSchema.parse(request.body ?? {}) + const binary = deps.binaryRegistry.create(body) + reply.code(201) + return { binary } + }) + + app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => { + const body = BinaryUpdateSchema.parse(request.body ?? {}) + const binary = deps.binaryRegistry.update(request.params.id, body) + return { binary } + }) + + app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => { + deps.binaryRegistry.remove(request.params.id) + reply.code(204) + }) + + app.post("/api/config/binaries/validate", async (request) => { + const body = BinaryValidateSchema.parse(request.body ?? {}) + return deps.binaryRegistry.validatePath(body.path) + }) +} diff --git a/packages/cli/src/server/routes/events.ts b/packages/cli/src/server/routes/events.ts new file mode 100644 index 00000000..0c511d11 --- /dev/null +++ b/packages/cli/src/server/routes/events.ts @@ -0,0 +1,37 @@ +import { FastifyInstance } from "fastify" +import { EventBus } from "../../events/bus" +import { WorkspaceEventPayload } from "../../api-types" + +interface RouteDeps { + eventBus: EventBus +} + +export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get("/api/events", (request, reply) => { + const origin = request.headers.origin ?? "*" + reply.raw.setHeader("Access-Control-Allow-Origin", origin) + reply.raw.setHeader("Access-Control-Allow-Credentials", "true") + reply.raw.setHeader("Content-Type", "text/event-stream") + reply.raw.setHeader("Cache-Control", "no-cache") + reply.raw.setHeader("Connection", "keep-alive") + reply.raw.flushHeaders?.() + reply.hijack() + + const send = (event: WorkspaceEventPayload) => { + reply.raw.write(`data: ${JSON.stringify(event)}\n\n`) + } + + const unsubscribe = deps.eventBus.onEvent(send) + const heartbeat = setInterval(() => { + reply.raw.write(`:hb ${Date.now()}\n\n`) + }, 15000) + + const close = () => { + clearInterval(heartbeat) + unsubscribe() + } + + request.raw.on("close", close) + request.raw.on("error", close) + }) +} diff --git a/packages/cli/src/server/routes/filesystem.ts b/packages/cli/src/server/routes/filesystem.ts new file mode 100644 index 00000000..d3a3d705 --- /dev/null +++ b/packages/cli/src/server/routes/filesystem.ts @@ -0,0 +1,25 @@ +import { FastifyInstance } from "fastify" +import { z } from "zod" +import { FileSystemBrowser } from "../../filesystem/browser" + +interface RouteDeps { + fileSystemBrowser: FileSystemBrowser +} + +const FilesystemQuerySchema = z.object({ + path: z.string().optional(), +}) + +export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get("/api/filesystem", async (request, reply) => { + const query = FilesystemQuerySchema.parse(request.query ?? {}) + const targetPath = query.path ?? "." + + try { + return deps.fileSystemBrowser.list(targetPath) + } catch (error) { + reply.code(400) + return { error: (error as Error).message } + } + }) +} diff --git a/packages/cli/src/server/routes/meta.ts b/packages/cli/src/server/routes/meta.ts new file mode 100644 index 00000000..ed8f142f --- /dev/null +++ b/packages/cli/src/server/routes/meta.ts @@ -0,0 +1,10 @@ +import { FastifyInstance } from "fastify" +import { ServerMeta } from "../../api-types" + +interface RouteDeps { + serverMeta: ServerMeta +} + +export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get("/api/meta", async () => deps.serverMeta) +} diff --git a/packages/cli/src/server/routes/storage.ts b/packages/cli/src/server/routes/storage.ts new file mode 100644 index 00000000..285b2aed --- /dev/null +++ b/packages/cli/src/server/routes/storage.ts @@ -0,0 +1,44 @@ +import { FastifyInstance } from "fastify" +import { z } from "zod" +import { InstanceStore } from "../../storage/instance-store" + +interface RouteDeps { + instanceStore: InstanceStore +} + +const InstanceDataSchema = z.object({ + messageHistory: z.array(z.string()).default([]), +}) + +export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => { + try { + const data = await deps.instanceStore.read(request.params.id) + return data + } catch (error) { + reply.code(500) + return { error: error instanceof Error ? error.message : "Failed to read instance data" } + } + }) + + app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => { + try { + const body = InstanceDataSchema.parse(request.body ?? {}) + await deps.instanceStore.write(request.params.id, body) + reply.code(204) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Failed to save instance data" } + } + }) + + app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => { + try { + await deps.instanceStore.delete(request.params.id) + reply.code(204) + } catch (error) { + reply.code(500) + return { error: error instanceof Error ? error.message : "Failed to delete instance data" } + } + }) +} diff --git a/packages/cli/src/server/routes/workspaces.ts b/packages/cli/src/server/routes/workspaces.ts new file mode 100644 index 00000000..a2364e1f --- /dev/null +++ b/packages/cli/src/server/routes/workspaces.ts @@ -0,0 +1,80 @@ +import { FastifyInstance, FastifyReply } from "fastify" +import { z } from "zod" +import { WorkspaceManager } from "../../workspaces/manager" + +interface RouteDeps { + workspaceManager: WorkspaceManager +} + +const WorkspaceCreateSchema = z.object({ + path: z.string(), + name: z.string().optional(), +}) + +const WorkspaceFilesQuerySchema = z.object({ + path: z.string().optional(), +}) + +const WorkspaceFileContentQuerySchema = z.object({ + path: z.string(), +}) + +export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get("/api/workspaces", async () => { + return deps.workspaceManager.list() + }) + + app.post("/api/workspaces", async (request, reply) => { + const body = WorkspaceCreateSchema.parse(request.body ?? {}) + const workspace = await deps.workspaceManager.create(body.path, body.name) + reply.code(201) + return workspace + }) + + app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => { + const workspace = deps.workspaceManager.get(request.params.id) + if (!workspace) { + reply.code(404) + return { error: "Workspace not found" } + } + return workspace + }) + + app.delete<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => { + await deps.workspaceManager.delete(request.params.id) + reply.code(204) + }) + + app.get<{ + Params: { id: string } + Querystring: { path?: string } + }>("/api/workspaces/:id/files", async (request, reply) => { + try { + const query = WorkspaceFilesQuerySchema.parse(request.query ?? {}) + return deps.workspaceManager.listFiles(request.params.id, query.path ?? ".") + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) + + app.get<{ + Params: { id: string } + Querystring: { path?: string } + }>("/api/workspaces/:id/files/content", async (request, reply) => { + try { + const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {}) + return deps.workspaceManager.readFile(request.params.id, query.path) + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) +} + +function handleWorkspaceError(error: unknown, reply: FastifyReply) { + if (error instanceof Error && error.message === "Workspace not found") { + reply.code(404) + return { error: "Workspace not found" } + } + reply.code(400) + return { error: error instanceof Error ? error.message : "Unable to fulfill request" } +} diff --git a/packages/cli/src/storage/instance-store.ts b/packages/cli/src/storage/instance-store.ts new file mode 100644 index 00000000..a63973d3 --- /dev/null +++ b/packages/cli/src/storage/instance-store.ts @@ -0,0 +1,63 @@ +import fs from "fs" +import { promises as fsp } from "fs" +import os from "os" +import path from "path" +import type { InstanceData } from "../api-types" + +const DEFAULT_INSTANCE_DATA: InstanceData = { + messageHistory: [], +} + +export class InstanceStore { + private readonly instancesDir: string + + constructor(baseDir = path.join(os.homedir(), ".config", "codenomad", "instances")) { + this.instancesDir = baseDir + fs.mkdirSync(this.instancesDir, { recursive: true }) + } + + async read(id: string): Promise { + try { + const filePath = this.resolvePath(id) + const content = await fsp.readFile(filePath, "utf-8") + const parsed = JSON.parse(content) + return { ...DEFAULT_INSTANCE_DATA, ...parsed } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return DEFAULT_INSTANCE_DATA + } + throw error + } + } + + async write(id: string, data: InstanceData): Promise { + const filePath = this.resolvePath(id) + await fsp.mkdir(path.dirname(filePath), { recursive: true }) + await fsp.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8") + } + + async delete(id: string): Promise { + try { + const filePath = this.resolvePath(id) + await fsp.unlink(filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error + } + } + } + + private resolvePath(id: string): string { + const filename = this.sanitizeId(id) + return path.join(this.instancesDir, `${filename}.json`) + } + + private sanitizeId(id: string): string { + return id + .replace(/[\\/]/g, "_") + .replace(/[^a-zA-Z0-9_.-]/g, "_") + .replace(/_{2,}/g, "_") + .replace(/^_|_$/g, "") + .toLowerCase() + } +} diff --git a/packages/cli/src/workspaces/manager.ts b/packages/cli/src/workspaces/manager.ts new file mode 100644 index 00000000..e3644544 --- /dev/null +++ b/packages/cli/src/workspaces/manager.ts @@ -0,0 +1,148 @@ +import path from "path" +import { EventBus } from "../events/bus" +import { ConfigStore } from "../config/store" +import { BinaryRegistry } from "../config/binaries" +import { FileSystemBrowser } from "../filesystem/browser" +import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" +import { WorkspaceRuntime } from "./runtime" + +interface WorkspaceManagerOptions { + rootDir: string + configStore: ConfigStore + binaryRegistry: BinaryRegistry + eventBus: EventBus +} + +interface WorkspaceRecord extends WorkspaceDescriptor {} + +export class WorkspaceManager { + private readonly workspaces = new Map() + private readonly runtime: WorkspaceRuntime + + constructor(private readonly options: WorkspaceManagerOptions) { + this.runtime = new WorkspaceRuntime(this.options.eventBus) + } + + list(): WorkspaceDescriptor[] { + return Array.from(this.workspaces.values()) + } + + get(id: string): WorkspaceDescriptor | undefined { + return this.workspaces.get(id) + } + + listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] { + const workspace = this.requireWorkspace(workspaceId) + const browser = new FileSystemBrowser({ rootDir: workspace.path }) + return browser.list(relativePath) + } + + readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse { + const workspace = this.requireWorkspace(workspaceId) + const browser = new FileSystemBrowser({ rootDir: workspace.path }) + const contents = browser.readFile(relativePath) + return { + workspaceId, + relativePath, + contents, + } + } + + async create(folder: string, name?: string): Promise { + const id = `${Date.now().toString(36)}` + const binary = this.options.binaryRegistry.resolveDefault() + const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) + + const descriptor: WorkspaceRecord = { + id, + path: workspacePath, + name, + status: "starting", + binaryId: binary.id, + binaryLabel: binary.label, + binaryVersion: binary.version, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + + this.workspaces.set(id, descriptor) + this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) + + const environment = this.options.configStore.get().preferences.environmentVariables ?? {} + + try { + const { pid, port } = await this.runtime.launch({ + workspaceId: id, + folder: workspacePath, + binaryPath: binary.path, + environment, + onExit: (info) => this.handleProcessExit(info.workspaceId, info), + }) + + descriptor.pid = pid + descriptor.port = port + descriptor.status = "ready" + descriptor.updatedAt = new Date().toISOString() + this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor }) + return descriptor + } catch (error) { + descriptor.status = "error" + descriptor.error = error instanceof Error ? error.message : String(error) + descriptor.updatedAt = new Date().toISOString() + this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor }) + throw error + } + } + + async delete(id: string): Promise { + const workspace = this.workspaces.get(id) + if (!workspace) return undefined + + const wasRunning = Boolean(workspace.pid) + if (wasRunning) { + await this.runtime.stop(id).catch(() => {}) + } + + this.workspaces.delete(id) + if (!wasRunning) { + this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id }) + } + return workspace + } + + async shutdown() { + for (const [id] of this.workspaces) { + if (this.workspaces.get(id)?.pid) { + await this.runtime.stop(id).catch(() => {}) + } + } + this.workspaces.clear() + } + + private requireWorkspace(id: string): WorkspaceRecord { + const workspace = this.workspaces.get(id) + if (!workspace) { + throw new Error("Workspace not found") + } + return workspace + } + + private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) { + const workspace = this.workspaces.get(workspaceId) + if (!workspace) return + + workspace.pid = undefined + workspace.port = undefined + workspace.updatedAt = new Date().toISOString() + + if (info.requested || info.code === 0) { + workspace.status = "stopped" + workspace.error = undefined + this.options.eventBus.publish({ type: "workspace.stopped", workspaceId }) + } else { + workspace.status = "error" + workspace.error = `Process exited with code ${info.code}` + this.options.eventBus.publish({ type: "workspace.error", workspace }) + } + } +} diff --git a/packages/cli/src/workspaces/runtime.ts b/packages/cli/src/workspaces/runtime.ts new file mode 100644 index 00000000..e6aa6be2 --- /dev/null +++ b/packages/cli/src/workspaces/runtime.ts @@ -0,0 +1,180 @@ +import { ChildProcess, spawn } from "child_process" +import { existsSync, statSync } from "fs" +import path from "path" +import { EventBus } from "../events/bus" +import { LogLevel, WorkspaceLogEntry } from "../api-types" + +interface LaunchOptions { + workspaceId: string + folder: string + binaryPath: string + environment?: Record + onExit?: (info: ProcessExitInfo) => void +} + +interface ProcessExitInfo { + workspaceId: string + code: number | null + signal: NodeJS.Signals | null + requested: boolean +} + +interface ManagedProcess { + child: ChildProcess + requestedStop: boolean +} + +export class WorkspaceRuntime { + private processes = new Map() + + constructor(private readonly eventBus: EventBus) {} + + async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> { + this.validateFolder(options.folder) + + const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"] + const env = { ...process.env, ...(options.environment ?? {}) } + + return new Promise((resolve, reject) => { + const child = spawn(options.binaryPath, args, { + cwd: options.folder, + env, + stdio: ["ignore", "pipe", "pipe"], + }) + + const managed: ManagedProcess = { child, requestedStop: false } + this.processes.set(options.workspaceId, managed) + + let stdoutBuffer = "" + let stderrBuffer = "" + let portFound = false + + const timeout = setTimeout(() => { + child.kill("SIGKILL") + reject(new Error("Server startup timeout (10s exceeded)")) + }, 10000) + + const cleanup = () => { + clearTimeout(timeout) + child.stdout?.removeAllListeners() + child.stderr?.removeAllListeners() + child.removeListener("error", handleError) + } + + const handleExit = (code: number | null, signal: NodeJS.Signals | null) => { + this.processes.delete(options.workspaceId) + if (!portFound) { + cleanup() + const reason = stderrBuffer || `Process exited with code ${code}` + reject(new Error(reason)) + } else { + options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop }) + } + } + + const handleError = (error: Error) => { + cleanup() + this.processes.delete(options.workspaceId) + child.removeListener("exit", handleExit) + reject(error) + } + + child.on("error", handleError) + child.on("exit", handleExit) + + child.stdout?.on("data", (data: Buffer) => { + const text = data.toString() + stdoutBuffer += text + const lines = stdoutBuffer.split("\n") + stdoutBuffer = lines.pop() ?? "" + + for (const line of lines) { + if (!line.trim()) continue + this.emitLog(options.workspaceId, "info", line) + + if (!portFound) { + const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i) + if (portMatch) { + portFound = true + cleanup() + resolve({ pid: child.pid!, port: parseInt(portMatch[1], 10) }) + } + } + } + }) + + child.stderr?.on("data", (data: Buffer) => { + const text = data.toString() + stderrBuffer += text + const lines = stderrBuffer.split("\n") + stderrBuffer = lines.pop() ?? "" + + for (const line of lines) { + if (!line.trim()) continue + this.emitLog(options.workspaceId, "error", line) + } + }) + + child.on("exit", (code, signal) => { + this.processes.delete(options.workspaceId) + if (!portFound) { + cleanup() + const reason = stderrBuffer || `Process exited with code ${code}` + reject(new Error(reason)) + } + options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop }) + }) + }) + } + + async stop(workspaceId: string): Promise { + const managed = this.processes.get(workspaceId) + if (!managed) return + + managed.requestedStop = true + const child = managed.child + + await new Promise((resolve, reject) => { + const onExit = () => { + child.removeListener("error", onError) + resolve() + } + const onError = (error: Error) => { + child.removeListener("exit", onExit) + reject(error) + } + + child.once("exit", onExit) + child.once("error", onError) + + child.kill("SIGTERM") + setTimeout(() => { + if (!child.killed) { + child.kill("SIGKILL") + } + }, 2000) + }) + } + + private emitLog(workspaceId: string, level: LogLevel, message: string) { + const entry: WorkspaceLogEntry = { + workspaceId, + timestamp: new Date().toISOString(), + level, + message: message.trim(), + } + + this.eventBus.publish({ type: "workspace.log", entry }) + } + + private validateFolder(folder: string) { + const resolved = path.resolve(folder) + if (!existsSync(resolved)) { + throw new Error(`Folder does not exist: ${resolved}`) + } + const stats = statSync(resolved) + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${resolved}`) + } + } +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000..5f9cd234 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 2692caab..48780cc4 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -85,22 +85,16 @@ const App: Component = () => { const clearLaunchError = () => setLaunchErrorBinary(null) - async function handleSelectFolder(folderPath?: string, binaryPath?: string) { + async function handleSelectFolder(folderPath: string, binaryPath?: string) { + if (!folderPath) { + return + } setIsSelectingFolder(true) const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode" try { - let folder: string | null | undefined = folderPath - - if (!folder) { - folder = await window.electronAPI.selectFolder() - if (!folder) { - return - } - } - - addRecentFolder(folder) + addRecentFolder(folderPath) clearLaunchError() - const instanceId = await createInstance(folder, selectedBinary) + const instanceId = await createInstance(folderPath, selectedBinary) setHasInstances(true) setShowFolderSelection(false) setIsAdvancedSettingsOpen(false) @@ -129,8 +123,6 @@ const App: Component = () => { function handleNewInstanceRequest() { if (hasInstances()) { setShowFolderSelection(true) - } else { - void handleSelectFolder() } } diff --git a/packages/ui/src/components/file-picker.tsx b/packages/ui/src/components/file-picker.tsx index eacbb2bc..b004dc6c 100644 --- a/packages/ui/src/components/file-picker.tsx +++ b/packages/ui/src/components/file-picker.tsx @@ -1,6 +1,7 @@ import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js" import type { OpencodeClient } from "@opencode-ai/sdk/client" +import { cliApi } from "../lib/api-client" interface FileItem { path: string @@ -17,7 +18,7 @@ interface FilePickerProps { instanceClient: OpencodeClient searchQuery: string textareaRef?: HTMLTextAreaElement - workspaceFolder: string + workspaceId: string } const FilePicker: Component = (props) => { @@ -36,10 +37,10 @@ const FilePicker: Component = (props) => { try { if (allFiles().length === 0) { - console.log(`[FilePicker] Scanning workspace: ${props.workspaceFolder}`) - const scannedPaths = await window.electronAPI.scanDirectory(props.workspaceFolder) - const scannedFiles: FileItem[] = scannedPaths.map((path) => ({ - path, + console.log(`[FilePicker] Scanning workspace: ${props.workspaceId}`) + const entries = await cliApi.listWorkspaceFiles(props.workspaceId) + const scannedFiles: FileItem[] = entries.map((entry) => ({ + path: entry.path, isGitFile: false, })) setAllFiles(scannedFiles) diff --git a/packages/ui/src/components/filesystem-browser-dialog.tsx b/packages/ui/src/components/filesystem-browser-dialog.tsx new file mode 100644 index 00000000..db0d8a2c --- /dev/null +++ b/packages/ui/src/components/filesystem-browser-dialog.tsx @@ -0,0 +1,297 @@ +import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js" +import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X } from "lucide-solid" +import type { FileSystemEntry } from "../../../cli/src/api-types" +import { cliApi } from "../lib/api-client" +import { getServerMeta } from "../lib/server-meta" + +const MAX_RESULTS = 200 + +let cachedEntries: FileSystemEntry[] | null = null +let entriesPromise: Promise | null = null + +async function loadFileSystemEntries(): Promise { + if (cachedEntries) { + return cachedEntries + } + if (entriesPromise) { + return entriesPromise + } + entriesPromise = cliApi + .listFileSystem(".") + .then((entries) => { + cachedEntries = entries.slice().sort((a, b) => a.path.localeCompare(b.path)) + entriesPromise = null + return cachedEntries + }) + .catch((error) => { + entriesPromise = null + throw error + }) + return entriesPromise +} + +function resolveAbsolutePath(root: string, relativePath: string): string { + if (!root) { + return relativePath + } + if (!relativePath || relativePath === "." || relativePath === "./") { + return root + } + const separator = root.includes("\\") ? "\\" : "/" + const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}` + const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "") + return `${trimmedRoot}${normalized}` +} + +function formatRootLabel(root: string): string { + if (!root) return "Workspace Root" + const parts = root.split(/[/\\]/).filter(Boolean) + return parts[parts.length - 1] || root || "Workspace Root" +} + +interface FileSystemBrowserDialogProps { + open: boolean + mode: "directories" | "files" + title: string + description?: string + onSelect: (absolutePath: string) => void + onClose: () => void +} + +const FileSystemBrowserDialog: Component = (props) => { + const [entries, setEntries] = createSignal([]) + const [rootPath, setRootPath] = createSignal("") + const [loading, setLoading] = createSignal(false) + const [error, setError] = createSignal(null) + const [searchQuery, setSearchQuery] = createSignal("") + const [selectedIndex, setSelectedIndex] = createSignal(0) + + let searchInputRef: HTMLInputElement | undefined + + async function refreshEntries() { + setLoading(true) + setError(null) + try { + const [items, meta] = await Promise.all([loadFileSystemEntries(), getServerMeta()]) + setEntries(items) + setRootPath(meta.workspaceRoot) + } catch (err) { + const message = err instanceof Error ? err.message : "Unable to load filesystem" + setError(message) + } finally { + setLoading(false) + } + } + + const filteredEntries = createMemo(() => { + const query = searchQuery().trim().toLowerCase() + const mode = props.mode + const root = rootPath() + const matchesType = entries().filter((entry) => (mode === "directories" ? entry.type === "directory" : entry.type === "file")) + + const baseEntries = mode === "directories" && root + ? [ + { + name: formatRootLabel(root), + path: ".", + type: "directory" as const, + }, + ...matchesType, + ] + : matchesType + + if (!query) { + return baseEntries + } + + return baseEntries.filter((entry) => { + const absolute = resolveAbsolutePath(root, entry.path) + return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query) + }) + }) + + const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS)) + + createEffect(() => { + const list = visibleEntries() + if (list.length === 0) { + setSelectedIndex(0) + return + } + if (selectedIndex() >= list.length) { + setSelectedIndex(list.length - 1) + } + }) + + createEffect(() => { + if (!props.open) { + return + } + setSearchQuery("") + setSelectedIndex(0) + void refreshEntries() + setTimeout(() => searchInputRef?.focus(), 50) + + const handleKeyDown = (event: KeyboardEvent) => { + if (!props.open) return + const results = visibleEntries() + if (event.key === "Escape") { + event.preventDefault() + props.onClose() + return + } + if (results.length === 0) { + return + } + if (event.key === "ArrowDown") { + event.preventDefault() + setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1)) + } else if (event.key === "ArrowUp") { + event.preventDefault() + setSelectedIndex((prev) => Math.max(prev - 1, 0)) + } else if (event.key === "Enter") { + event.preventDefault() + const entry = results[selectedIndex()] + if (entry) { + handleEntrySelect(entry) + } + } + } + + window.addEventListener("keydown", handleKeyDown) + onCleanup(() => { + window.removeEventListener("keydown", handleKeyDown) + }) + }) + + function handleEntrySelect(entry: FileSystemEntry) { + const absolute = resolveAbsolutePath(rootPath(), entry.path) + props.onSelect(absolute) + } + + function handleOverlayClick(event: MouseEvent) { + if (event.target === event.currentTarget) { + props.onClose() + } + } + + return ( + +
+ +
+ +
+ ) +} + +export default FileSystemBrowserDialog diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 685f4b1e..46021220 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -2,12 +2,13 @@ import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid" import { useConfig } from "../stores/preferences" import AdvancedSettingsModal from "./advanced-settings-modal" +import FileSystemBrowserDialog from "./filesystem-browser-dialog" import Kbd from "./kbd" const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href interface FolderSelectionViewProps { - onSelectFolder: (folder?: string, binaryPath?: string) => void + onSelectFolder: (folder: string, binaryPath?: string) => void isLoading?: boolean advancedSettingsOpen?: boolean onAdvancedSettingsOpen?: () => void @@ -19,6 +20,7 @@ const FolderSelectionView: Component = (props) => { const [selectedIndex, setSelectedIndex] = createSignal(0) const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode") + const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false) let recentListRef: HTMLDivElement | undefined const folders = () => recentFolders() @@ -173,12 +175,17 @@ const FolderSelectionView: Component = (props) => { function handleBrowse() { if (isLoading()) return - updateLastUsedBinary(selectedBinary()) - props.onSelectFolder(undefined, selectedBinary()) + setFocusMode("new") + setIsFolderBrowserOpen(true) } - - + + function handleBrowserSelect(path: string) { + setIsFolderBrowserOpen(false) + handleFolderSelect(path) + } + function handleBinaryChange(binary: string) { + setSelectedBinary(binary) } @@ -378,6 +385,15 @@ const FolderSelectionView: Component = (props) => { onBinaryChange={handleBinaryChange} isLoading={props.isLoading} /> + + setIsFolderBrowserOpen(false)} + onSelect={handleBrowserSelect} + /> ) } diff --git a/packages/ui/src/components/opencode-binary-selector.tsx b/packages/ui/src/components/opencode-binary-selector.tsx index a3668b4c..3faa40aa 100644 --- a/packages/ui/src/components/opencode-binary-selector.tsx +++ b/packages/ui/src/components/opencode-binary-selector.tsx @@ -1,6 +1,8 @@ import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid" import { useConfig } from "../stores/preferences" +import { cliApi } from "../lib/api-client" +import FileSystemBrowserDialog from "./filesystem-browser-dialog" interface BinaryOption { path: string @@ -29,6 +31,7 @@ const OpenCodeBinarySelector: Component = (props) = const [validationError, setValidationError] = createSignal(null) const [versionInfo, setVersionInfo] = createSignal>(new Map()) const [validatingPaths, setValidatingPaths] = createSignal>(new Set()) + const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false) const binaries = () => opencodeBinaries() const lastUsedBinary = () => preferences().lastUsedBinary @@ -102,7 +105,7 @@ const OpenCodeBinarySelector: Component = (props) = setValidating(true) setValidationError(null) - const result = await window.electronAPI.validateOpenCodeBinary(path) + const result = await cliApi.validateBinary(path) if (result.valid && result.version) { const updatedVersionInfo = new Map(versionInfo()) @@ -125,18 +128,12 @@ const OpenCodeBinarySelector: Component = (props) = } } - async function handleBrowseBinary() { - try { - const path = await window.electronAPI.selectOpenCodeBinary() - if (!path) return - - setCustomPath(path) - await handleValidateAndAdd(path) - } catch (error) { - setValidationError(error instanceof Error ? error.message : "Failed to select binary") - } + function handleBrowseBinary() { + if (props.disabled) return + setValidationError(null) + setIsBinaryBrowserOpen(true) } - + async function handleValidateAndAdd(path: string) { const validation = await validateBinary(path) @@ -150,8 +147,15 @@ const OpenCodeBinarySelector: Component = (props) = setValidationError(validation.error || "Invalid OpenCode binary") } } - + + function handleBinaryBrowserSelect(path: string) { + setIsBinaryBrowserOpen(false) + setCustomPath(path) + void handleValidateAndAdd(path) + } + async function handleCustomPathSubmit() { + const path = customPath().trim() if (!path) return await handleValidateAndAdd(path) @@ -197,128 +201,140 @@ const OpenCodeBinarySelector: Component = (props) = const isPathValidating = (path: string) => validatingPaths().has(path) return ( -
-
-
-

OpenCode Binary

-

Choose which executable OpenCode should run

-
- -
- - Checking versions… + <> +
+
+
+

OpenCode Binary

+

Choose which executable OpenCode should run

+
+ +
+ + Checking versions… +
+
+
+ +
+
+ setCustomPath(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleCustomPathSubmit() + } + }} + disabled={props.disabled} + placeholder="Enter path to opencode binary…" + class="selector-input" + /> +
- -
-
-
- setCustomPath(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - handleCustomPathSubmit() - } - }} - disabled={props.disabled} - placeholder="Enter path to opencode binary…" - class="selector-input" - /> + + +
+
+ + {validationError()} +
+
+
- +
+ + {(binary) => { + const isDefault = binary.isDefault + const versionLabel = () => versionInfo().get(binary.path) ?? binary.version - -
-
- - {validationError()} -
-
-
-
- -
- - {(binary) => { - const isDefault = binary.isDefault - const versionLabel = () => versionInfo().get(binary.path) ?? binary.version - - return ( -
- - - -
- ) - }} -
+ + + +
+ ) + }} + +
-
+ + setIsBinaryBrowserOpen(false)} + onSelect={handleBinaryBrowserSelect} + /> + ) } - + export default OpenCodeBinarySelector + diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 6cd2ee34..6331c2d2 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -26,7 +26,7 @@ export default function PromptInput(props: PromptInputProps) { const [history, setHistory] = createSignal([]) const [historyIndex, setHistoryIndex] = createSignal(-1) const [historyDraft, setHistoryDraft] = createSignal(null) - const [isFocused, setIsFocused] = createSignal(false) + const [, setIsFocused] = createSignal(false) const [showPicker, setShowPicker] = createSignal(false) const [searchQuery, setSearchQuery] = createSignal("") const [atPosition, setAtPosition] = createSignal(null) @@ -744,7 +744,7 @@ export default function PromptInput(props: PromptInputProps) { instanceClient={instance()!.client} searchQuery={searchQuery()} textareaRef={textareaRef} - workspaceFolder={props.instanceFolder} + workspaceId={props.instanceId} />
diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index 757f1dde..7b79f1d7 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -1,6 +1,7 @@ import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js" import type { Agent } from "../types/session" import type { OpencodeClient } from "@opencode-ai/sdk/client" +import { cliApi } from "../lib/api-client" interface FileItem { path: string @@ -19,7 +20,7 @@ interface UnifiedPickerProps { instanceClient: OpencodeClient | null searchQuery: string textareaRef?: HTMLTextAreaElement - workspaceFolder: string + workspaceId: string } const UnifiedPicker: Component = (props) => { @@ -38,9 +39,9 @@ const UnifiedPicker: Component = (props) => { try { if (allFiles().length === 0) { - const scannedPaths = await window.electronAPI.scanDirectory(props.workspaceFolder) - const scannedFiles: FileItem[] = scannedPaths.map((path) => ({ - path, + const entries = await cliApi.listWorkspaceFiles(props.workspaceId) + const scannedFiles: FileItem[] = entries.map((entry) => ({ + path: entry.path, isGitFile: false, })) setAllFiles(scannedFiles) diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts new file mode 100644 index 00000000..0ed7584f --- /dev/null +++ b/packages/ui/src/lib/api-client.ts @@ -0,0 +1,143 @@ +import type { + AppConfig, + AppConfigUpdateRequest, + BinaryCreateRequest, + BinaryListResponse, + BinaryUpdateRequest, + BinaryValidationResult, + FileSystemEntry, + InstanceData, + ServerMeta, + + WorkspaceCreateRequest, + WorkspaceDescriptor, + WorkspaceFileResponse, + WorkspaceLogEntry, + WorkspaceEventPayload, + WorkspaceEventType, +} from "../../../cli/src/api-types" + +const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? "" : "" +const DEFAULT_EVENTS_URL = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events" +const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE +const EVENTS_URL = API_BASE ? `${API_BASE}${DEFAULT_EVENTS_URL}` : DEFAULT_EVENTS_URL + +async function request(path: string, init?: RequestInit): Promise { + const url = API_BASE ? new URL(path, API_BASE).toString() : path + const headers: HeadersInit = { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + } + + const response = await fetch(url, { ...init, headers }) + if (!response.ok) { + const message = await response.text() + throw new Error(message || `Request failed with ${response.status}`) + } + if (response.status === 204) { + return undefined as T + } + return (await response.json()) as T +} + +export const cliApi = { + fetchWorkspaces(): Promise { + return request("/api/workspaces") + }, + createWorkspace(payload: WorkspaceCreateRequest): Promise { + return request("/api/workspaces", { + method: "POST", + body: JSON.stringify(payload), + }) + }, + fetchServerMeta(): Promise { + return request("/api/meta") + }, + deleteWorkspace(id: string): Promise { + return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" }) + }, + listWorkspaceFiles(id: string, relativePath = "."): Promise { + const params = new URLSearchParams({ path: relativePath }) + return request(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`) + }, + readWorkspaceFile(id: string, relativePath: string): Promise { + const params = new URLSearchParams({ path: relativePath }) + return request( + `/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`, + ) + }, + fetchConfig(): Promise { + return request("/api/config/app") + }, + updateConfig(payload: AppConfig): Promise { + return request("/api/config/app", { + method: "PUT", + body: JSON.stringify(payload), + }) + }, + patchConfig(payload: AppConfigUpdateRequest): Promise { + return request("/api/config/app", { + method: "PATCH", + body: JSON.stringify(payload), + }) + }, + listBinaries(): Promise { + return request("/api/config/binaries") + }, + createBinary(payload: BinaryCreateRequest) { + return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", { + method: "POST", + body: JSON.stringify(payload), + }) + }, + + updateBinary(id: string, updates: BinaryUpdateRequest) { + return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, { + method: "PATCH", + body: JSON.stringify(updates), + }) + }, + + deleteBinary(id: string): Promise { + return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" }) + }, + validateBinary(path: string): Promise { + return request("/api/config/binaries/validate", { + method: "POST", + body: JSON.stringify({ path }), + }) + }, + listFileSystem(relativePath = "."): Promise { + const params = new URLSearchParams({ path: relativePath }) + return request(`/api/filesystem?${params.toString()}`) + }, + readInstanceData(id: string): Promise { + return request(`/api/storage/instances/${encodeURIComponent(id)}`) + }, + writeInstanceData(id: string, data: InstanceData): Promise { + return request(`/api/storage/instances/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify(data), + }) + }, + deleteInstanceData(id: string): Promise { + return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" }) + }, + connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) { + const source = new EventSource(EVENTS_URL) + source.onmessage = (event) => { + try { + const payload = JSON.parse(event.data) as WorkspaceEventPayload + onEvent(payload) + } catch (error) { + console.error("Failed to parse SSE event", error) + } + } + source.onerror = () => { + onError?.() + } + return source + }, +} + +export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType } diff --git a/packages/ui/src/lib/cli-events.ts b/packages/ui/src/lib/cli-events.ts new file mode 100644 index 00000000..4b0e80d2 --- /dev/null +++ b/packages/ui/src/lib/cli-events.ts @@ -0,0 +1,52 @@ +import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../cli/src/api-types" +import { cliApi } from "./api-client" + +const RETRY_BASE_DELAY = 1000 +const RETRY_MAX_DELAY = 10000 + +class CliEvents { + private handlers = new Map void>>() + private source: EventSource | null = null + private retryDelay = RETRY_BASE_DELAY + + constructor() { + this.connect() + } + + private connect() { + if (this.source) { + this.source.close() + } + this.source = cliApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect()) + this.source.onopen = () => { + this.retryDelay = RETRY_BASE_DELAY + } + } + + private scheduleReconnect() { + if (this.source) { + this.source.close() + this.source = null + } + setTimeout(() => { + this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_DELAY) + this.connect() + }, this.retryDelay) + } + + private dispatch(event: WorkspaceEventPayload) { + this.handlers.get("*")?.forEach((handler) => handler(event)) + this.handlers.get(event.type)?.forEach((handler) => handler(event)) + } + + on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void { + if (!this.handlers.has(type)) { + this.handlers.set(type, new Set()) + } + const bucket = this.handlers.get(type)! + bucket.add(handler) + return () => bucket.delete(handler) + } +} + +export const cliEvents = new CliEvents() diff --git a/packages/ui/src/lib/hooks/use-app-lifecycle.ts b/packages/ui/src/lib/hooks/use-app-lifecycle.ts index 1fe58f4f..16734244 100644 --- a/packages/ui/src/lib/hooks/use-app-lifecycle.ts +++ b/packages/ui/src/lib/hooks/use-app-lifecycle.ts @@ -7,7 +7,6 @@ import { registerEscapeShortcut, setEscapeStateChangeHandler } from "../shortcut import { keyboardRegistry } from "../keyboard-registry" import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions" import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette" -import { addLog, updateInstance } from "../../stores/instances" import type { Instance } from "../../types/instance" interface UseAppLifecycleOptions { @@ -148,29 +147,6 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) { window.addEventListener("keydown", handleKeyDown) - window.electronAPI.onNewInstance(() => { - options.handleNewInstanceRequest() - }) - - window.electronAPI.onInstanceStarted(({ id, port, pid, binaryPath }) => { - console.log("Instance started:", { id, port, pid, binaryPath }) - updateInstance(id, { port, pid, status: "ready", binaryPath }) - }) - - window.electronAPI.onInstanceError(({ id, error }) => { - console.error("Instance error:", { id, error }) - updateInstance(id, { status: "error", error }) - }) - - window.electronAPI.onInstanceStopped(({ id }) => { - console.log("Instance stopped:", id) - updateInstance(id, { status: "stopped" }) - }) - - window.electronAPI.onInstanceLog(({ id, entry }) => { - addLog(id, entry) - }) - onCleanup(() => { window.removeEventListener("keydown", handleKeyDown) }) diff --git a/packages/ui/src/lib/server-meta.ts b/packages/ui/src/lib/server-meta.ts new file mode 100644 index 00000000..66261cee --- /dev/null +++ b/packages/ui/src/lib/server-meta.ts @@ -0,0 +1,20 @@ +import type { ServerMeta } from "../../../cli/src/api-types" +import { cliApi } from "./api-client" + +let cachedMeta: ServerMeta | null = null +let pendingMeta: Promise | null = null + +export async function getServerMeta(): Promise { + if (cachedMeta) { + return cachedMeta + } + if (pendingMeta) { + return pendingMeta + } + pendingMeta = cliApi.fetchServerMeta().then((meta) => { + cachedMeta = meta + pendingMeta = null + return meta + }) + return pendingMeta +} diff --git a/packages/ui/src/lib/storage.ts b/packages/ui/src/lib/storage.ts index 856223fb..b0568674 100644 --- a/packages/ui/src/lib/storage.ts +++ b/packages/ui/src/lib/storage.ts @@ -1,162 +1,48 @@ -import type { Preferences, RecentFolder, OpenCodeBinary } from "../stores/preferences" +import type { AppConfig, InstanceData } from "../../../cli/src/api-types" +import { cliApi } from "./api-client" +import { cliEvents } from "./cli-events" -export interface ConfigData { - preferences: Preferences - recentFolders: RecentFolder[] - opencodeBinaries: OpenCodeBinary[] - theme?: "light" | "dark" | "system" -} +export type ConfigData = AppConfig -export interface InstanceData { - messageHistory: string[] -} -export class FileStorage { - private configPath: string | undefined - private instancesDir: string | undefined +export class ServerStorage { private configChangeListeners: Set<() => void> = new Set() - private initialized = false constructor() { - this.initialize() + cliEvents.on("config.appChanged", () => this.notifyConfigChanged()) } - private async initialize() { - if (this.initialized) return - - this.configPath = await window.electronAPI.getConfigPath() - this.instancesDir = await window.electronAPI.getInstancesDir() - - // Listen for config changes from other instances - window.electronAPI.onConfigChanged(() => { - this.configChangeListeners.forEach((listener) => listener()) - }) - - this.initialized = true - } - - private async ensureInitialized() { - if (!this.initialized) { - await this.initialize() - } - } - - private parseConfig(content: string): ConfigData { - const trimmed = content.trim() - - try { - return JSON.parse(trimmed) - } catch (error) { - const firstBrace = trimmed.indexOf("{") - const lastBrace = trimmed.lastIndexOf("}") - - if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { - const sanitized = trimmed.slice(firstBrace, lastBrace + 1) - - if (sanitized.length !== trimmed.length) { - console.warn("Config file contained trailing data; attempting recovery") - } - - try { - return JSON.parse(sanitized) - } catch { - // Fall through to rethrow original error below - } - } - - throw error - } - } - - // Config operations async loadConfig(): Promise { - await this.ensureInitialized() - try { - const content = await window.electronAPI.readConfigFile() - return this.parseConfig(content) - } catch (error) { - console.warn("Failed to load config, using defaults:", error) - return { - preferences: { - showThinkingBlocks: false, - environmentVariables: {}, - modelRecents: [], - agentModelSelections: {}, - diffViewMode: "split", - toolOutputExpansion: "expanded", - diagnosticsExpansion: "expanded", - }, - recentFolders: [], - opencodeBinaries: [], - } - } + const config = await cliApi.fetchConfig() + return config } async saveConfig(config: ConfigData): Promise { - await this.ensureInitialized() - try { - await window.electronAPI.writeConfigFile(JSON.stringify(config, null, 2)) - } catch (error) { - console.error("Failed to save config:", error) - throw error - } + await cliApi.updateConfig(config) } - // Instance operations async loadInstanceData(instanceId: string): Promise { - await this.ensureInitialized() - try { - const filename = this.instanceIdToFilename(instanceId) - const content = await window.electronAPI.readInstanceFile(filename) - return JSON.parse(content) - } catch (error) { - console.warn(`Failed to load instance data for ${instanceId}, using defaults:`, error) - return { - messageHistory: [], - } - } + return cliApi.readInstanceData(instanceId) } async saveInstanceData(instanceId: string, data: InstanceData): Promise { - await this.ensureInitialized() - try { - const filename = this.instanceIdToFilename(instanceId) - await window.electronAPI.writeInstanceFile(filename, JSON.stringify(data, null, 2)) - } catch (error) { - console.error(`Failed to save instance data for ${instanceId}:`, error) - throw error - } + await cliApi.writeInstanceData(instanceId, data) } async deleteInstanceData(instanceId: string): Promise { - await this.ensureInitialized() - try { - const filename = this.instanceIdToFilename(instanceId) - await window.electronAPI.deleteInstanceFile(filename) - } catch (error) { - console.error(`Failed to delete instance data for ${instanceId}:`, error) - throw error - } + await cliApi.deleteInstanceData(instanceId) } - // Convert folder path to safe filename - private instanceIdToFilename(instanceId: string): string { - // Convert folder path to safe filename - // Replace path separators and other invalid characters - return instanceId - .replace(/[\\/]/g, "_") // Replace path separators - .replace(/[^a-zA-Z0-9_.-]/g, "_") // Replace other invalid chars - .replace(/_{2,}/g, "_") // Replace multiple underscores with single - .replace(/^_|_$/g, "") // Remove leading/trailing underscores - .toLowerCase() - } - - // Config change listeners onConfigChanged(listener: () => void): () => void { this.configChangeListeners.add(listener) return () => this.configChangeListeners.delete(listener) } + + private notifyConfigChanged() { + for (const listener of this.configChangeListeners) { + listener() + } + } } -// Singleton instance -export const storage = new FileStorage() +export const storage = new ServerStorage() diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 075dbab7..5f6f1640 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -4,6 +4,9 @@ import type { LspStatus, Permission } from "@opencode-ai/sdk" import type { ClientPart, Message } from "../types/message" import { sdkManager } from "../lib/sdk-manager" import { sseManager } from "../lib/sse-manager" +import { cliApi } from "../lib/api-client" +import { cliEvents } from "../lib/cli-events" +import type { WorkspaceDescriptor, WorkspaceEventPayload, WorkspaceLogEntry } from "../../../cli/src/api-types" import { fetchSessions, fetchAgents, @@ -35,6 +38,133 @@ const [disconnectedInstance, setDisconnectedInstance] = createSignal { + console.error("Failed to hydrate instance data", error) + }) +} + +function releaseInstanceResources(instanceId: string) { + const instance = instances().get(instanceId) + if (!instance) return + + if (instance.port) { + sdkManager.destroyClient(instance.port) + } + sseManager.disconnect(instanceId) +} + +async function hydrateInstanceData(instanceId: string) { + try { + await fetchSessions(instanceId) + await fetchAgents(instanceId) + await fetchProviders(instanceId) + const instance = instances().get(instanceId) + if (!instance?.client) return + await fetchCommands(instanceId, instance.client) + } catch (error) { + console.error("Failed to fetch initial data:", error) + } +} + +void (async function initializeWorkspaces() { + try { + const workspaces = await cliApi.fetchWorkspaces() + workspaces.forEach((workspace) => upsertWorkspace(workspace)) + if (workspaces.length === 0) { + setHasInstances(false) + } + } catch (error) { + console.error("Failed to load workspaces", error) + } +})() + +cliEvents.on("*", (event) => handleWorkspaceEvent(event)) + +function handleWorkspaceEvent(event: WorkspaceEventPayload) { + switch (event.type) { + case "workspace.created": + upsertWorkspace(event.workspace) + break + case "workspace.started": + upsertWorkspace(event.workspace) + break + case "workspace.error": + upsertWorkspace(event.workspace) + break + case "workspace.stopped": + releaseInstanceResources(event.workspaceId) + removeInstance(event.workspaceId) + if (instances().size === 0) { + setHasInstances(false) + } + break + case "workspace.log": + handleWorkspaceLog(event.entry) + break + default: + break + } +} + +function handleWorkspaceLog(entry: WorkspaceLogEntry) { + const logEntry: LogEntry = { + timestamp: new Date(entry.timestamp).getTime(), + level: (entry.level as LogEntry["level"]) ?? "info", + message: entry.message, + } + addLog(entry.workspaceId, logEntry) +} + function ensureLogContainer(id: string) { setInstanceLogs((prev) => { if (prev.has(id)) { @@ -157,61 +287,17 @@ function removeInstance(id: string) { } async function createInstance(folder: string, binaryPath?: string): Promise { - const id = `instance-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` - - const instance: Instance = { - id, - folder, - port: 0, - pid: 0, - status: "starting", - client: null, - environmentVariables: preferences().environmentVariables ?? {}, - } - - addInstance(instance) - - // Update last used binary if (binaryPath) { updateLastUsedBinary(binaryPath) } try { - const { - id: returnedId, - port, - pid, - binaryPath: actualBinaryPath, - } = await window.electronAPI.createInstance(id, folder, binaryPath, preferences().environmentVariables) - - const client = sdkManager.createClient(port) - - updateInstance(id, { - port, - pid, - client, - status: "ready", - binaryPath: actualBinaryPath, - }) - - setActiveInstanceId(id) - sseManager.connect(id, port) - - try { - await fetchSessions(id) - await fetchAgents(id) - await fetchProviders(id) - await fetchCommands(id, client) - } catch (error) { - console.error("Failed to fetch initial data:", error) - } - - return id + const workspace = await cliApi.createWorkspace({ path: folder }) + upsertWorkspace(workspace) + setActiveInstanceId(workspace.id) + return workspace.id } catch (error) { - updateInstance(id, { - status: "error", - error: error instanceof Error ? error.message : String(error), - }) + console.error("Failed to create workspace", error) throw error } } @@ -220,17 +306,18 @@ async function stopInstance(id: string) { const instance = instances().get(id) if (!instance) return - sseManager.disconnect(id) + releaseInstanceResources(id) - if (instance.port) { - sdkManager.destroyClient(instance.port) - } - - if (instance.pid) { - await window.electronAPI.stopInstance(instance.pid) + try { + await cliApi.deleteWorkspace(id) + } catch (error) { + console.error("Failed to stop workspace", error) } removeInstance(id) + if (instances().size === 0) { + setHasInstances(false) + } } async function fetchLspStatus(instanceId: string): Promise { diff --git a/packages/ui/src/stores/message-history.ts b/packages/ui/src/stores/message-history.ts index 147a7880..83423ddb 100644 --- a/packages/ui/src/stores/message-history.ts +++ b/packages/ui/src/stores/message-history.ts @@ -1,4 +1,4 @@ -import { storage, type InstanceData } from "../lib/storage" +import { storage } from "../lib/storage" const MAX_HISTORY = 100 @@ -48,7 +48,8 @@ async function ensureHistoryLoaded(instanceId: string): Promise { try { const data = await storage.loadInstanceData(instanceId) - instanceHistories.set(instanceId, data.messageHistory) + const history = Array.isArray(data.messageHistory) ? data.messageHistory : [] + instanceHistories.set(instanceId, history) historyLoaded.add(instanceId) } catch (error) { console.warn("Failed to load history:", error) diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 89266ae9..e17bd1fb 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -17,12 +17,12 @@ export type ExpansionPreference = "expanded" | "collapsed" export interface Preferences { showThinkingBlocks: boolean lastUsedBinary?: string - environmentVariables?: Record - modelRecents?: ModelPreference[] - agentModelSelections?: AgentModelSelections - diffViewMode?: DiffViewMode - toolOutputExpansion?: ExpansionPreference - diagnosticsExpansion?: ExpansionPreference + environmentVariables: Record + modelRecents: ModelPreference[] + agentModelSelections: AgentModelSelections + diffViewMode: DiffViewMode + toolOutputExpansion: ExpansionPreference + diagnosticsExpansion: ExpansionPreference } export interface OpenCodeBinary { @@ -41,6 +41,7 @@ const MAX_RECENT_MODELS = 5 const defaultPreferences: Preferences = { showThinkingBlocks: false, + environmentVariables: {}, modelRecents: [], agentModelSelections: {}, diffViewMode: "split", @@ -48,12 +49,41 @@ const defaultPreferences: Preferences = { diagnosticsExpansion: "expanded", } -const [preferences, setPreferences] = createSignal(defaultPreferences) +function normalizePreferences(pref?: Partial): Preferences { + const environmentVariables = { + ...defaultPreferences.environmentVariables, + ...(pref?.environmentVariables ?? {}), + } + + const sourceModelRecents = pref?.modelRecents ?? defaultPreferences.modelRecents + const modelRecents = sourceModelRecents.map((item) => ({ ...item })) + + const sourceAgentSelections = pref?.agentModelSelections ?? defaultPreferences.agentModelSelections + const agentModelSelections: AgentModelSelections = {} + for (const [instanceId, selections] of Object.entries(sourceAgentSelections)) { + agentModelSelections[instanceId] = Object.fromEntries( + Object.entries(selections).map(([agentId, selection]) => [agentId, { ...selection }]), + ) + } + + return { + showThinkingBlocks: pref?.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks, + lastUsedBinary: pref?.lastUsedBinary ?? defaultPreferences.lastUsedBinary, + environmentVariables, + modelRecents, + agentModelSelections, + diffViewMode: pref?.diffViewMode ?? defaultPreferences.diffViewMode, + toolOutputExpansion: pref?.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion, + diagnosticsExpansion: pref?.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion, + } +} + +const [preferences, setPreferences] = createSignal(normalizePreferences()) const [recentFolders, setRecentFolders] = createSignal([]) const [opencodeBinaries, setOpenCodeBinaries] = createSignal([]) const [isConfigLoaded, setIsConfigLoaded] = createSignal(false) let cachedConfig: ConfigData = { - preferences: defaultPreferences, + preferences: normalizePreferences(), recentFolders: [], opencodeBinaries: [], } @@ -64,15 +94,15 @@ async function loadConfig(): Promise { const config = await storage.loadConfig() cachedConfig = { ...config, - preferences: { ...defaultPreferences, ...config.preferences }, - recentFolders: config.recentFolders || [], - opencodeBinaries: config.opencodeBinaries || [], + preferences: normalizePreferences(config.preferences), + recentFolders: config.recentFolders ?? [], + opencodeBinaries: config.opencodeBinaries ?? [], } } catch (error) { console.error("Failed to load config:", error) cachedConfig = { ...cachedConfig, - preferences: { ...defaultPreferences }, + preferences: normalizePreferences(), recentFolders: [], opencodeBinaries: [], } @@ -112,7 +142,7 @@ async function ensureConfigLoaded(): Promise { function updatePreferences(updates: Partial): void { - const updated = { ...preferences(), ...updates } + const updated = normalizePreferences({ ...preferences(), ...updates }) setPreferences(updated) saveConfig().catch(console.error) } diff --git a/packages/ui/src/types/electron-api.ts b/packages/ui/src/types/electron-api.ts deleted file mode 100644 index 67bd578f..00000000 --- a/packages/ui/src/types/electron-api.ts +++ /dev/null @@ -1,31 +0,0 @@ -export interface ElectronAPI { - selectFolder: () => Promise - createInstance: ( - id: string, - folder: string, - binaryPath?: string, - environmentVariables?: Record, - ) => Promise<{ id: string; port: number; pid: number; binaryPath: string }> - stopInstance: (pid: number) => Promise - onInstanceStarted: (callback: (data: { id: string; port: number; pid: number; binaryPath: string }) => void) => void - onInstanceError: (callback: (data: { id: string; error: string }) => void) => void - onInstanceStopped: (callback: (data: { id: string }) => void) => void - onInstanceLog: ( - callback: (data: { - id: string - entry: { timestamp: number; level: "info" | "error" | "warn" | "debug"; message: string } - }) => void, - ) => void - onNewInstance: (callback: () => void) => void - scanDirectory: (workspaceFolder: string) => Promise - selectOpenCodeBinary: () => Promise - validateOpenCodeBinary: (path: string) => Promise<{ valid: boolean; version?: string; error?: string }> - getConfigPath: () => Promise - getInstancesDir: () => Promise - readConfigFile: () => Promise - writeConfigFile: (content: string) => Promise - readInstanceFile: (instanceId: string) => Promise - writeInstanceFile: (instanceId: string, content: string) => Promise - deleteInstanceFile: (instanceId: string) => Promise - onConfigChanged: (callback: () => void) => () => void -} diff --git a/packages/ui/src/types/electron.d.ts b/packages/ui/src/types/electron.d.ts deleted file mode 100644 index d2f97f0e..00000000 --- a/packages/ui/src/types/electron.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ElectronAPI } from "./electron-api" - -declare global { - interface Window { - electronAPI: ElectronAPI - } -} - -export {} diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts new file mode 100644 index 00000000..ca147f2a --- /dev/null +++ b/packages/ui/src/types/global.d.ts @@ -0,0 +1,8 @@ +export {} + +declare global { + interface Window { + __CODENOMAD_API_BASE__?: string + __CODENOMAD_EVENTS_URL__?: string + } +} diff --git a/packages/ui/src/vite-env.d.ts b/packages/ui/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 31560708..7653f414 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -13,6 +13,12 @@ export default defineConfig({ "@": resolve(__dirname, "./src"), }, }, + optimizeDeps: { + exclude: ["lucide-solid"], + }, + ssr: { + noExternal: ["lucide-solid"], + }, server: { port: 3000, },