mirror of
https://github.com/Routstr/routstrd.git
synced 2026-07-22 12:22:20 +00:00
Initial commit
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -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/
|
||||
188
README.md
Normal file
188
README.md
Normal file
@@ -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
|
||||
173
bun.lock
Normal file
173
bun.lock
Normal file
@@ -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=="],
|
||||
}
|
||||
}
|
||||
31
package.json
Normal file
31
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
123
src/cli-shared.ts
Normal file
123
src/cli-shared.ts
Normal file
@@ -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<RoutstrdConfig> {
|
||||
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<CommandResponse> {
|
||||
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<CommandResponse>;
|
||||
}
|
||||
|
||||
export async function isDaemonRunning(): Promise<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (await isDaemonRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Starting daemon...");
|
||||
await startDaemonProcess();
|
||||
}
|
||||
|
||||
export async function handleDaemonCommand(
|
||||
path: string,
|
||||
options: { method?: "GET" | "POST"; body?: object } = {},
|
||||
): Promise<CommandResponse> {
|
||||
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 };
|
||||
159
src/cli.ts
Normal file
159
src/cli.ts
Normal file
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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>", "Port to listen on", "8008")
|
||||
.option("-p, --provider <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);
|
||||
}
|
||||
480
src/daemon.ts
Normal file
480
src/daemon.ts
Normal file
@@ -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<T>(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<T>(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<void> {
|
||||
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<RoutstrdConfig> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string, number> {
|
||||
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<string, number> = {};
|
||||
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<void> {
|
||||
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<string, "sat" | "msat"> = {};
|
||||
|
||||
const walletAdapter = {
|
||||
async getBalances(): Promise<Record<string, number>> {
|
||||
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<string, "sat" | "msat"> {
|
||||
return mintUnits;
|
||||
},
|
||||
getActiveMintUrl(): string | null {
|
||||
return activeMintUrl;
|
||||
},
|
||||
async sendToken(mintUrl: string, amount: number): Promise<string> {
|
||||
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<string, unknown>;
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
4
src/index.ts
Executable file
4
src/index.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bun
|
||||
import { cli } from "./cli";
|
||||
|
||||
cli(process.argv);
|
||||
19
src/utils/config.ts
Normal file
19
src/utils/config.ts
Normal file
@@ -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,
|
||||
};
|
||||
42
src/utils/logger.ts
Normal file
42
src/utils/logger.ts
Normal file
@@ -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);
|
||||
},
|
||||
};
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user