From dfd9b2e9d25255e2ddf0ca6f7268440b8e726802 Mon Sep 17 00:00:00 2001 From: red Date: Tue, 24 Feb 2026 05:55:21 +0000 Subject: [PATCH] Initial commit --- .gitignore | 26 +++ README.md | 188 +++++++++++++++++ bun.lock | 173 ++++++++++++++++ package.json | 31 +++ src/cli-shared.ts | 123 ++++++++++++ src/cli.ts | 159 +++++++++++++++ src/daemon.ts | 480 ++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 4 + src/utils/config.ts | 19 ++ src/utils/logger.ts | 42 ++++ tsconfig.json | 20 ++ 11 files changed, 1265 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/cli-shared.ts create mode 100644 src/cli.ts create mode 100644 src/daemon.ts create mode 100755 src/index.ts create mode 100644 src/utils/config.ts create mode 100644 src/utils/logger.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2cc499e --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +node_modules/ + +dist/ +build/ + +.env +.env.local +.env.*.local + +*.log +npm-debug.log* +bun-debug.log* + +.DS_Store +Thumbs.db + +.idea/ +.vscode/ +*.swp +*.swo + +coverage/ +.nyc_output/ + +tmp/ +temp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4730715 --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# routstrd + +Routstr daemon - A CLI tool for managing routstr processes, similar to `cocod` (a Cashu wallet daemon). + +## Overview + +routstrd is a Bun-based CLI tool that provides a background daemon for the Routstr protocol. It integrates with `cocod` for wallet management and uses the Routstr SDK to handle provider routing and model discovery. + +## Features + +- **Daemon Mode**: Run routstrd as a background HTTP server +- **Wallet Integration**: Works with cocod for Cashu token management +- **Provider Routing**: Automatically discovers and routes requests to available providers +- **Config Management**: Stores configuration in `~/.routstrd/` + +## Requirements + +- [Bun](https://bun.sh) runtime +- [cocod](https://github.com/cocodash/cocod) (Cashu wallet daemon) + +## Installation + +### From Source + +```sh +cd routstrd +bun install +``` + +### Global Installation + +Link the package for global CLI access: + +```sh +cd routstrd +bun link +``` + +Or install globally from the local path: + +```sh +bun install -g /path/to/routstrd +``` + +## Usage + +### Initialize + +Initialize routstrd (creates config directory and sets up cocod): + +```sh +routstrd init +``` + +This will: +- Create `~/.routstrd/` directory +- Create config file at `~/.routstrd/config.json` +- Run `cocod init` to set up the wallet + +### Start Daemon + +Start the background daemon: + +```sh +routstrd daemon +``` + +With custom port: +```sh +routstrd daemon --port 9000 +``` + +With specific provider: +```sh +routstrd daemon --provider https://your-provider.com +``` + +### CLI Commands + +Check daemon status: +```sh +routstrd status +``` + +Get wallet balance: +```sh +routstrd balance +``` + +Test connection: +```sh +routstrd ping +``` + +Stop the daemon: +```sh +routstrd stop +``` + +### Daemon API + +The daemon exposes an HTTP server (default port 8008) with the following endpoints: + +#### Health Check +``` +GET /health +``` + +#### Route Request +``` +POST / +``` + +Request body: +```json +{ + "model": "model-id", + "messages": [...], + "stream": false +} +``` + +Response: +```json +{ + "choices": [...], + "usage": {...} +} +``` + +## Configuration + +Configuration is stored in `~/.routstrd/config.json`: + +```json +{ + "port": 8008, + "provider": null, + "cocodPath": null +} +``` + +### Environment Variables + +- `ROUTSTRD_DIR` - Config directory (default: `~/.routstrd`) +- `ROUTSTRD_SOCKET` - Socket path (default: `~/.routstrd/routstrd.sock`) +- `ROUTSTRD_PID` - PID file path (default: `~/.routstrd/routstrd.pid`) + +## Development + +Install dependencies: +```sh +bun install +``` + +Run CLI: +```sh +bun run start +``` + +Run daemon: +```sh +bun run daemon +``` + +Typecheck: +```sh +bun run lint +``` + +## Project Structure + +``` +routstrd/ +├── src/ +│ ├── index.ts # Entry point with shebang +│ ├── cli.ts # Commander CLI commands +│ ├── cli-shared.ts # IPC utilities +│ ├── daemon.ts # HTTP server daemon +│ └── utils/ +│ └── config.ts # Path configuration +├── package.json +└── tsconfig.json +``` + +## License + +MIT \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..35512b7 --- /dev/null +++ b/bun.lock @@ -0,0 +1,173 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "routstrd", + "dependencies": { + "@cashu/cashu-ts": "^3.1.1", + "applesauce-core": "^5.1.0", + "applesauce-relay": "^5.1.0", + "better-sqlite3": "^11.7.2", + "commander": "^14.0.2", + "rxjs": "^7.8.1", + "zustand": "^5.0.5", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@cashu/cashu-ts": ["@cashu/cashu-ts@3.5.0", "", { "dependencies": { "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@scure/base": "^2.0.0", "@scure/bip32": "^2.0.1" } }, "sha512-LVOfCY1ZjQ+/f/CwekCeH+QRjN9SaHp8rTUjf8f4feHxT9Bqn/Bw+qYfNhaBKLWrqS19DMK/q/ern3AQ2MCq9w=="], + + "@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="], + + "@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], + + "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + + "@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], + + "@scure/bip32": ["@scure/bip32@2.0.1", "", { "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA=="], + + "@scure/bip39": ["@scure/bip39@1.2.1", "", { "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" } }, "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg=="], + + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "applesauce-core": ["applesauce-core@5.1.0", "", { "dependencies": { "debug": "^4.4.0", "fast-deep-equal": "^3.1.3", "hash-sum": "^2.0.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-kk4nHndK4zjS8Sa6mC8LGtQ0LDSP4hlCGPJ9lpyIln7MkZaNFWD9eFd+fsEhfE9kyrne9IyYuVfJNp+EqY1b9w=="], + + "applesauce-relay": ["applesauce-relay@5.1.0", "", { "dependencies": { "@noble/hashes": "^1.7.1", "applesauce-core": "^5.1.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-d0LTJmQmr5gsYFm9A6efPEo2Bx/ewoL7LNsIdieMx34QohZBpPb137RvU9KQ1lFIXTm0tudd8VYfAPncqti2OQ=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "better-sqlite3": ["better-sqlite3@11.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + + "hash-sum": ["hash-sum@2.0.0", "", {}, "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], + + "nostr-tools": ["nostr-tools@2.19.4", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg=="], + + "nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], + + "@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], + + "@scure/bip39/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="], + + "applesauce-relay/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "nostr-tools/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], + + "nostr-tools/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], + + "nostr-tools/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="], + + "nostr-tools/@scure/bip32": ["@scure/bip32@1.3.1", "", { "dependencies": { "@noble/curves": "~1.1.0", "@noble/hashes": "~1.3.1", "@scure/base": "~1.1.0" } }, "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A=="], + + "nostr-tools/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + + "nostr-tools/@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], + + "nostr-tools/@scure/bip32/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + + "nostr-tools/@scure/bip32/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9b44e40 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "routstrd", + "version": "0.1.0", + "module": "src/index.ts", + "type": "module", + "private": false, + "bin": { + "routstrd": "./src/index.ts" + }, + "scripts": { + "start": "bun src/index.ts", + "daemon": "bun src/index.ts daemon", + "lint": "tsc --noEmit", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@cashu/cashu-ts": "^3.1.1", + "applesauce-core": "^5.1.0", + "applesauce-relay": "^5.1.0", + "commander": "^14.0.2", + "rxjs": "^7.8.1", + "zustand": "^5.0.5", + "better-sqlite3": "^11.7.2" + } +} diff --git a/src/cli-shared.ts b/src/cli-shared.ts new file mode 100644 index 0000000..2a8ea33 --- /dev/null +++ b/src/cli-shared.ts @@ -0,0 +1,123 @@ +import { program } from "commander"; +import { existsSync } from "fs"; +import { + CONFIG_FILE, + DEFAULT_CONFIG, + type RoutstrdConfig, +} from "./utils/config"; + +export interface CommandResponse { + output?: unknown; + error?: string; +} + +async function loadConfig(): Promise { + try { + if (existsSync(CONFIG_FILE)) { + const content = await Bun.file(CONFIG_FILE).text(); + return { ...DEFAULT_CONFIG, ...JSON.parse(content) }; + } + } catch (error) { + console.error("Failed to load config:", error); + } + return DEFAULT_CONFIG; +} + +async function callDaemon( + path: string, + options: { method?: "GET" | "POST"; body?: object } = {}, +): Promise { + const { method = "GET", body } = options; + const config = await loadConfig(); + + const response = await fetch(`http://localhost:${config.port}${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const errorData = (await response.json()) as { error?: string }; + throw new Error(errorData.error || `HTTP ${response.status}`); + } + + return response.json() as Promise; +} + +export async function isDaemonRunning(): Promise { + try { + const config = await loadConfig(); + const response = await fetch(`http://localhost:${config.port}/health`); + return response.ok; + } catch { + return false; + } +} + +export async function startDaemonProcess(): Promise { + const proc = Bun.spawn({ + cmd: ["bun", "run", `${import.meta.dir}/index.ts`, "daemon"], + stdout: "ignore", + stderr: "ignore", + stdin: "ignore", + }); + proc.unref(); + + for (let i = 0; i < 50; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (await isDaemonRunning()) { + return; + } + } + + throw new Error("Daemon failed to start within 5 seconds"); +} + +export async function ensureDaemonRunning(): Promise { + if (await isDaemonRunning()) { + return; + } + + console.log("Starting daemon..."); + await startDaemonProcess(); +} + +export async function handleDaemonCommand( + path: string, + options: { method?: "GET" | "POST"; body?: object } = {}, +): Promise { + try { + await ensureDaemonRunning(); + const result = await callDaemon(path, options); + + if (result.error) { + console.log(result.error); + process.exit(1); + } + + if (result.output !== undefined) { + if (typeof result.output === "string") { + console.log(result.output); + } else { + try { + const formatted = JSON.stringify(result.output, null, 2); + console.log(formatted ?? String(result.output)); + } catch { + console.log(String(result.output)); + } + } + } + + return result; + } catch (error) { + const message = (error as Error).message; + if (message?.includes("fetch failed") || message?.includes("Connection refused")) { + console.error("Daemon is not running and failed to auto-start"); + process.exit(1); + } + console.error(message); + process.exit(1); + } +} + +export { program, callDaemon }; diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..c764604 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,159 @@ +import { startDaemon } from "./daemon"; +import { program, handleDaemonCommand, callDaemon, ensureDaemonRunning } from "./cli-shared"; +import { existsSync, mkdirSync } from "fs"; +import { join } from "path"; +import { + CONFIG_DIR, + DB_PATH, + CONFIG_FILE, + DEFAULT_CONFIG, + type RoutstrdConfig, +} from "./utils/config"; + +const cliVersion = "0.1.0"; + +async function initDaemon(): Promise { + console.log("Initializing routstrd..."); + + // Create config directory + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + console.log(`Created config directory: ${CONFIG_DIR}`); + } + + // Create initial config + if (!existsSync(CONFIG_FILE)) { + const config: RoutstrdConfig = { + ...DEFAULT_CONFIG, + cocodPath: null, + }; + Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2)); + console.log(`Created config file: ${CONFIG_FILE}`); + } + + console.log(`Database will be stored at: ${DB_PATH}`); + console.log("\nInitializing cocod..."); + + // Initialize cocod + const initProc = Bun.spawn({ + cmd: ["cocod", "init"], + stdout: "inherit", + stderr: "inherit", + }); + const initCode = await initProc.exited; + + if (initCode !== 0) { + console.error("Failed to initialize cocod. Please run 'cocod init' manually."); + } else { + console.log("cocod initialized successfully."); + } + + console.log("\nInitialization complete!"); + console.log(`Run 'routstrd daemon' to start the daemon.`); +} + +async function checkCocodInstalled(): Promise { + try { + const proc = Bun.spawn({ + cmd: ["which", "cocod"], + stdout: "pipe", + }); + const code = await proc.exited; + return code === 0; + } catch { + return false; + } +} + +program + .name("routstrd") + .description("Routstr daemon - Manage routstr processes") + .version(cliVersion, "--version", "output the version number"); + +// Init - initialize the daemon +program + .command("init") + .description("Initialize routstrd (creates config directory and initializes cocod)") + .action(() => { + initDaemon(); + }); + +// Daemon - start the background daemon +program + .command("daemon") + .description("Start the background daemon") + .option("--port ", "Port to listen on", "8008") + .option("-p, --provider ", "Default provider to use") + .action(async (options: { port?: string; provider?: string }) => { + if (!checkCocodInstalled()) { + console.error("cocod is not installed. Run 'routstrd init' first to install cocod."); + process.exit(1); + } + await startDaemon(options); + }); + +// Status - check daemon status +program + .command("status") + .description("Check daemon and wallet status") + .action(async () => { + await handleDaemonCommand("/status"); + }); + +// Balance - get wallet and API key balances +program + .command("balance") + .description("Get wallet and API key balances") + .action(async () => { + await ensureDaemonRunning(); + + const [walletResult, keysResult] = await Promise.all([ + callDaemon("/balance"), + callDaemon("/keys/balance"), + ]); + + console.log("=== Wallet Balance ==="); + if (walletResult.output) { + if (typeof walletResult.output === "string") { + console.log(walletResult.output); + } else { + console.log(JSON.stringify(walletResult.output, null, 2)); + } + } else if (walletResult.error) { + console.error("Wallet error:", walletResult.error); + } + + console.log("\n=== API Key Balances ==="); + if (keysResult.output && typeof keysResult.output === "object" && "keys" in keysResult.output) { + const keys = (keysResult.output as { keys: Array<{ id: string; name: string; balance: number }> }).keys; + if (keys.length === 0) { + console.log("No API keys found"); + } else { + for (const key of keys) { + console.log(`${key.name}: ${key.balance} sats`); + } + } + } else if (keysResult.error) { + console.error("Keys error:", keysResult.error); + } + }); + +// Ping +program + .command("ping") + .description("Test connection to the daemon") + .action(async () => { + await handleDaemonCommand("/ping"); + }); + +// Stop +program + .command("stop") + .description("Stop the background daemon") + .action(async () => { + await handleDaemonCommand("/stop", { method: "POST" }); + }); + +export function cli(args: string[]) { + program.parse(args); +} diff --git a/src/daemon.ts b/src/daemon.ts new file mode 100644 index 0000000..fb7475c --- /dev/null +++ b/src/daemon.ts @@ -0,0 +1,480 @@ +import { createServer, IncomingMessage, ServerResponse } from "http"; +import { Readable } from "stream"; +import { ReadableStream as WebReadableStream } from "stream/web"; +import { spawn } from "child_process"; +import { getDecodedToken } from "@cashu/cashu-ts"; +import { mkdir } from "fs/promises"; +import { join } from "path"; +import { existsSync } from "fs"; +import SQLite from "bun:sqlite"; +import { + CONFIG_DIR, + DB_PATH, + SOCKET_PATH, + PID_FILE, + CONFIG_FILE, + DEFAULT_CONFIG, + type RoutstrdConfig, +} from "./utils/config"; +import { logger } from "./utils/logger"; + +const SDK_PATH = "/home/debian/knightclaw/projects/routstr-chat/sdk/dist/index.mjs"; + +let sdk: any = null; + +async function loadSdk() { + if (!sdk) { + sdk = await import(SDK_PATH); + } + return sdk; +} + +function createBunSqliteDriver(dbPath: string) { + const db = new SQLite(dbPath); + + db.exec(` + CREATE TABLE IF NOT EXISTS sdk_storage ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `); + + return { + getItem(key: string, defaultValue: T): T { + try { + const row = db.query("SELECT value FROM sdk_storage WHERE key = ?").get(key) as { value: string } | undefined; + if (!row || typeof row.value !== "string") return defaultValue; + try { + return JSON.parse(row.value) as T; + } catch (parseError) { + if (typeof defaultValue === "string") { + return row.value as T; + } + throw parseError; + } + } catch (error) { + logger.error(`SQLite getItem failed for key "${key}":`, error); + return defaultValue; + } + }, + setItem(key: string, value: T): void { + try { + db.query( + "INSERT INTO sdk_storage (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value" + ).run(key, JSON.stringify(value)); + } catch (error) { + logger.error(`SQLite setItem failed for key "${key}":`, error); + } + }, + removeItem(key: string): void { + try { + db.query("DELETE FROM sdk_storage WHERE key = ?").run(key); + } catch (error) { + logger.error(`SQLite removeItem failed for key "${key}":`, error); + } + }, + }; +} + +const REQUESTS_DIR = join(CONFIG_DIR, "requests"); + +async function ensureDirs(): Promise { + try { + await mkdir(CONFIG_DIR, { recursive: true }); + await mkdir(REQUESTS_DIR, { recursive: true }); + } catch (error) { + // Directory may already exist + } +} + +function parseArgs(argv: string[]): { + port: number; + provider: string | null; +} { + const portFlagIndex = argv.findIndex((arg) => arg === "--port"); + const providerFlagIndex = argv.findIndex( + (arg) => arg === "--provider" || arg === "-p" + ); + + const port = + portFlagIndex !== -1 + ? Number.parseInt(argv[portFlagIndex + 1] || "8008", 10) + : 8008; + const provider = + providerFlagIndex !== -1 ? argv[providerFlagIndex + 1]?.trim() : null; + + return { port, provider }; +} + +async function loadConfig(): Promise { + try { + if (existsSync(CONFIG_FILE)) { + const content = await Bun.file(CONFIG_FILE).text(); + return { ...DEFAULT_CONFIG, ...JSON.parse(content) }; + } + } catch (error) { + logger.error("Failed to load config:", error); + } + return DEFAULT_CONFIG; +} + +function saveConfig(config: RoutstrdConfig): void { + Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2)); +} + +async function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let data = ""; + req.on("data", (chunk) => { + data += chunk.toString(); + }); + req.on("end", () => resolve(data)); + req.on("error", reject); + }); +} + +async function runWalletCommand(args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn("cocod", args, { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => reject(error)); + child.on("close", (code) => { + if (code && code !== 0) { + reject( + new Error(stderr.trim() || stdout.trim() || "Wallet CLI failed") + ); + return; + } + resolve(stdout.trim()); + }); + }); +} + +function parseBalances(output: string): Record { + const trimmed = output.trim(); + if (!trimmed) return {}; + + try { + const parsed = JSON.parse(trimmed) as Record< + string, + { sats?: number } | number + >; + if (parsed && typeof parsed === "object") { + return Object.fromEntries( + Object.entries(parsed).map(([mintUrl, value]) => { + if (typeof value === "number") { + return [mintUrl, value]; + } + if (value && typeof value === "object" && "sats" in value) { + return [mintUrl, Number(value.sats ?? 0)]; + } + return [mintUrl, 0]; + }) + ); + } + } catch { + // Fall back to line parsing. + } + + const balances: Record = {}; + trimmed + .split("\n") + .map((line) => line.trim()) + .forEach((line) => { + const match = line.match(/^(\S+):\s+(\d+)\s+s$/); + if (match) { + balances[match[1]] = Number.parseInt(match[2], 10); + } + }); + return balances; +} + +function parseMints(output: string): Array<{ url: string; trusted: boolean }> { + return output + .split("\n") + .map((line) => line.trim()) + .map((line) => { + const urlMatch = line.match(/https?:\/\/\S+/i); + if (!urlMatch) return null; + const trustedMatch = line.match(/trusted:\s*(true|false)/i); + return { + url: urlMatch[0], + trusted: trustedMatch + ? trustedMatch[1].toLowerCase() === "true" + : false, + }; + }) + .filter((entry): entry is { url: string; trusted: boolean } => + Boolean(entry) + ); +} + +function pickTokenLine(output: string): string { + const lines = output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + return lines[lines.length - 1] || ""; +} + +async function main(): Promise { + const args = parseArgs(process.argv); + const config = await loadConfig(); + + const port = args.port; + const provider = args.provider || config.provider; + + await ensureDirs(); + + // Save updated config + const updatedConfig = { ...config, port, provider }; + saveConfig(updatedConfig); + + const sdkModule = await loadSdk(); + const { ModelManager, getDefaultDiscoveryAdapter, getDefaultProviderRegistry, getDefaultStorageAdapter, createMemoryDriver, createSdkStore } = sdkModule; + + // For now, use memory driver (can be upgraded to sqlite later) + const memoryDriver = createMemoryDriver(); + const store = createSdkStore({ driver: memoryDriver }); + + // Get adapters (these use the default store, but we'll work with what we have) + const discoveryAdapter = getDefaultDiscoveryAdapter(); + const providerRegistry = getDefaultProviderRegistry(); + const storageAdapter = getDefaultStorageAdapter(); + + logger.log("Bootstrapping providers..."); + const modelManager = new ModelManager(discoveryAdapter); + const providers = await modelManager.bootstrapProviders(false); + logger.log(`Bootstrapped ${providers.length} providers`); + await modelManager.fetchModels(providers); + logger.log("Provider bootstrap complete."); + + let activeMintUrl: string | null = null; + let mintUnits: Record = {}; + + const walletAdapter = { + async getBalances(): Promise> { + const output = await runWalletCommand(["balance"]); + const balances = parseBalances(output); + mintUnits = Object.fromEntries( + Object.keys(balances).map((mintUrl) => [mintUrl, "sat"]) + ); + if (!activeMintUrl) { + activeMintUrl = Object.keys(balances)[0] || null; + } + return balances; + }, + getMintUnits(): Record { + return mintUnits; + }, + getActiveMintUrl(): string | null { + return activeMintUrl; + }, + async sendToken(mintUrl: string, amount: number): Promise { + const output = await runWalletCommand([ + "send", + "cashu", + String(amount), + "--mint-url", + mintUrl, + ]); + const token = pickTokenLine(output); + if (!token) { + throw new Error("Wallet CLI did not return a token."); + } + return token; + }, + async receiveToken( + token: string + ): Promise<{ success: boolean; amount: number; unit: "sat" | "msat" }> { + await runWalletCommand(["receive", "cashu", token]); + const decoded = getDecodedToken(token); + const amount = decoded?.proofs?.reduce( + (sum, proof) => sum + proof.amount, + 0 + ); + const unit = decoded?.unit === "msat" ? "msat" : "sat"; + return { success: true, amount: amount ?? 0, unit }; + }, + isUsingNip60(): boolean { + return false; + }, + }; + + try { + const mintsOutput = await runWalletCommand(["mints", "list"]); + const mints = parseMints(mintsOutput); + activeMintUrl = + mints.find((mint) => mint.trusted)?.url || mints[0]?.url || null; + } catch (error) { + logger.error("Failed to read mints from wallet:", error); + } + + const server = createServer( + async (req: IncomingMessage, res: ServerResponse) => { + const host = req.headers.host || "localhost"; + const url = new URL(req.url || "/", `http://${host}`); + + if (req.method === "GET" && url.pathname === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (req.method === "GET" && url.pathname === "/keys/balance") { + try { + const keys: Array<{ id: string; name: string; balance: number }> = []; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ output: { keys } })); + } catch (error) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + + if (req.method !== "POST") { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Only POST is supported." })); + return; + } + + let requestBody: unknown = {}; + try { + const bodyText = await readBody(req); + requestBody = bodyText ? JSON.parse(bodyText) : {}; + } catch (error) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: "Invalid JSON body.", + details: error instanceof Error ? error.message : String(error), + }) + ); + return; + } + + const bodyObj = requestBody as Record; + const modelId = typeof bodyObj.model === "string" ? bodyObj.model : ""; + + if (!modelId) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing required 'model' field." })); + return; + } + + const forcedProvider = + url.searchParams.get("provider") || + (req.headers["x-routstr-provider"] as string | undefined) || + provider || + undefined; + + try { + const { routeRequests, InsufficientBalanceError } = sdkModule; + const response = await routeRequests({ + modelId, + requestBody, + forcedProvider, + walletAdapter, + storageAdapter, + providerRegistry, + discoveryAdapter, + modelManager, + }); + + const isStream = bodyObj.stream === true; + + if (isStream) { + const body = response.body; + if (body) { + const nodeReadable = Readable.fromWeb( + body as unknown as WebReadableStream + ); + nodeReadable.pipe(res); + } else { + res.end(); + } + return; + } + + const responseBody = await response.json(); + res.writeHead(response.status, { + "Content-Type": "application/json", + }); + res.end(JSON.stringify(responseBody)); + } catch (error) { + const sdkModuleError = await loadSdk(); + const { InsufficientBalanceError } = sdkModuleError; + const message = error instanceof Error ? error.message : String(error); + logger.error(`[daemon] Error: ${message}`); + + if (error instanceof InsufficientBalanceError) { + res.writeHead(402, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: message, + error_type: "insufficient_balance", + required: error.required, + available: error.available, + maxMintBalance: error.maxMintBalance, + maxMintUrl: error.maxMintUrl, + }) + ); + return; + } + + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: message })); + } + } + ); + + // Write PID file + Bun.write(PID_FILE, String(process.pid)); + + // Remove old socket if exists + try { + if (existsSync(SOCKET_PATH)) { + Bun.spawn(["rm", SOCKET_PATH]); + } + } catch { + // Ignore + } + + server.listen(port, async () => { + logger.log(`Routstr daemon listening on http://localhost:${port}`); + }); +} + +main().catch((error) => { + logger.error("Failed to start Routstr daemon:", error); + process.exit(1); +}); + +export async function startDaemon(options: { port?: string; provider?: string } = {}): Promise { + const port = options.port ? parseInt(options.port, 10) : 8008; + const args = [...process.argv.slice(0, 2), "daemon"]; + if (options.port) { + args.push("--port", options.port); + } + if (options.provider) { + args.push("--provider", options.provider); + } + + const proc = Bun.spawn({ + cmd: ["bun", "run", `${import.meta.dir}/daemon.ts`, ...args.slice(2)], + stdout: "inherit", + stderr: "inherit", + }); + await proc.exited; +} diff --git a/src/index.ts b/src/index.ts new file mode 100755 index 0000000..011b571 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env bun +import { cli } from "./cli"; + +cli(process.argv); diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..205256b --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,19 @@ +const HOME = process.env.HOME || process.env.USERPROFILE || ""; + +export const CONFIG_DIR = process.env.ROUTSTRD_DIR || `${HOME}/.routstrd`; +export const SOCKET_PATH = process.env.ROUTSTRD_SOCKET || `${CONFIG_DIR}/routstrd.sock`; +export const PID_FILE = process.env.ROUTSTRD_PID || `${CONFIG_DIR}/routstrd.pid`; +export const DB_PATH = `${CONFIG_DIR}/routstr.db`; +export const CONFIG_FILE = `${CONFIG_DIR}/config.json`; + +export interface RoutstrdConfig { + port: number; + provider: string | null; + cocodPath: string | null; +} + +export const DEFAULT_CONFIG: RoutstrdConfig = { + port: 8008, + provider: null, + cocodPath: null, +}; diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..2acf4a6 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,42 @@ +import { appendFile, mkdir } from "fs/promises"; +import { existsSync } from "fs"; +import { join } from "path"; + +const HOME = process.env.HOME || process.env.USERPROFILE || ""; +const LOG_DIR = process.env.ROUTSTRD_DIR || `${HOME}/.routstrd`; +const LOG_FILE = join(LOG_DIR, "routstrd.log"); + +async function ensureLogDir() { + if (!existsSync(LOG_DIR)) { + await mkdir(LOG_DIR, { recursive: true }); + } +} + +async function writeLog(level: string, ...args: unknown[]) { + await ensureLogDir(); + const timestamp = new Date().toISOString(); + const message = args + .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))) + .join(" "); + const line = `[${timestamp}] [${level}] ${message}\n`; + try { + await appendFile(LOG_FILE, line); + } catch (error) { + console.error("Failed to write log:", error); + } +} + +export const logger = { + log: (...args: unknown[]) => { + console.log(...args); + writeLog("INFO", ...args); + }, + error: (...args: unknown[]) => { + console.error(...args); + writeLog("ERROR", ...args); + }, + info: (...args: unknown[]) => { + console.log(...args); + writeLog("INFO", ...args); + }, +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..02b7b87 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "types": ["bun-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +}