feat(cli): expand command coverage and config support
Swadhin Biswas committed 7 months ago
7e372c1
1 parent 44a3c9f
commit 7e372c1
24 files changed
+1668 -932
Showing 24 changed files+1668-930
cli/.npmrccli/README.mdcli/assets/opencodehub-cli-logo.svgcli/bin/och.tscli/package-lock.jsoncli/package.jsoncli/src/commands/api/index.tscli/src/commands/auth.test.tscli/src/commands/auth.tscli/src/commands/ci/index.tscli/src/commands/config-command.test.tscli/src/commands/config/index.tscli/src/commands/insights/index.tscli/src/commands/notify/index.tscli/src/commands/queue/index.tscli/src/commands/review/index.tscli/src/commands/search/index.tscli/src/commands/ssh-key/index.tscli/src/lib/api-error.tscli/src/lib/api.test.tscli/src/lib/api.tscli/src/lib/config.test.tscli/src/lib/config.tscli/src/lib/git.test.ts
โผcli/.npmrc
+1-0
| @@ -0,0 +1 @@ | ||
1 | +//registry.npmjs.org/:_authToken=${NPM_TOKEN} | |
0 | 2 | |
โผcli/README.md
+155-215
| @@ -1,298 +1,236 @@ | ||
1 | 1 | <p align="center"> |
2 | - <picture> | |
3 | - <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/swadhinbiswas/OpenCodeHub/main/public/logo-dark.png"> | |
4 | - <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/swadhinbiswas/OpenCodeHub/main/public/logo-light.png"> | |
5 | - <img src="https://raw.githubusercontent.com/swadhinbiswas/OpenCodeHub/main/public/logo-light.png" alt="OpenCodeHub CLI" width="400" /> | |
6 | - </picture> | |
2 | + <img src="https://raw.githubusercontent.com/swadhinbiswas/OpencodeHub/main/cli/assets/opencodehub-cli-logo.svg" alt="OpenCodeHub CLI" width="860" /> | |
7 | 3 | </p> |
8 | 4 | |
9 | -<h1 align="center">OpenCodeHub CLI</h1> | |
5 | +<h1 align="center">OpenCodeHub CLI (OCH)</h1> | |
10 | 6 | |
11 | 7 | <p align="center"> |
12 | - <strong>๐จ Production-grade Git workflows with beautiful UI</strong> | |
8 | + Production-ready Git workflows and stack-first pull request management from your terminal. | |
13 | 9 | </p> |
14 | 10 | |
15 | 11 | <p align="center"> |
16 | 12 | <a href="https://www.npmjs.com/package/opencodehub-cli"><img src="https://img.shields.io/npm/v/opencodehub-cli.svg?style=flat-square" alt="npm version" /></a> |
17 | 13 | <a href="https://www.npmjs.com/package/opencodehub-cli"><img src="https://img.shields.io/npm/dm/opencodehub-cli.svg?style=flat-square" alt="npm downloads" /></a> |
18 | 14 | <a href="https://github.com/swadhinbiswas/OpencodeHub/blob/main/cli/LICENSE"><img src="https://img.shields.io/npm/l/opencodehub-cli.svg?style=flat-square" alt="license" /></a> |
19 | - <a href="https://github.com/swadhinbiswas/OpencodeHub"><img src="https://img.shields.io/github/stars/swadhinbiswas/OpenCodeHub?style=flat-square" alt="GitHub stars" /></a> | |
20 | 15 | </p> |
21 | 16 | |
22 | -<p align="center"> | |
23 | - <a href="#-installation">Installation</a> โข | |
24 | - <a href="#-features">Features</a> โข | |
25 | - <a href="#-commands">Commands</a> โข | |
26 | - <a href="#-examples">Examples</a> โข | |
27 | - <a href="#-contributing">Contributing</a> | |
28 | -</p> | |
29 | - | |
17 | +## Why OCH | |
30 | 18 | |
31 | -## โจ Features | |
19 | +- Fast command-line workflows for repositories, pull requests, issues, and reviews. | |
20 | +- Stack-first branch/PR flow for multi-PR delivery. | |
21 | +- Secure authentication model with OS credential storage support. | |
22 | +- Built-in API tooling for scripting and automation. | |
32 | 23 | |
33 | -- ๐จ **Beautiful UI** - GitHub-like progress indicators, ASCII art, and colored output | |
34 | -- ๐ฆ **Git Push/Pull** - Fast repository operations with real-time progress | |
35 | -- ๐ **Simple Commands** - `och push`, `och clone`, `och create` - that's it! | |
36 | -- ๐ **Progress Tracking** - See object enumeration, compression, and upload speeds | |
37 | -- ๐ฏ **Production Ready** - Professional output that rivals GitHub's CLI | |
38 | -- โจ **Spinners & Boxes** - Beautiful feedback for every operation | |
39 | - | |
40 | -## ๐ฆ Installation | |
24 | +## Installation | |
41 | 25 | |
42 | 26 | ```bash |
43 | -# Install globally with npm | |
44 | 27 | npm install -g opencodehub-cli |
28 | +``` | |
45 | 29 | |
46 | -# Or with yarn | |
47 | -yarn global add opencodehub-cli | |
48 | - | |
49 | -# Or with pnpm | |
50 | -pnpm add -g opencodehub-cli | |
30 | +Verify: | |
51 | 31 | |
52 | -# Verify installation | |
32 | +```bash | |
53 | 33 | och --version |
34 | +och --help | |
54 | 35 | ``` |
55 | 36 | |
56 | -## ๐ Quick Start | |
37 | +## Quick Start | |
57 | 38 | |
58 | 39 | ```bash |
59 | -# 1. Login to your OpenCodeHub instance | |
60 | -och auth login | |
40 | +# 1) Authenticate | |
41 | +och auth login --url https://git.example.com | |
61 | 42 | |
62 | -# 2. Navigate to your repository | |
63 | -cd your-repo | |
43 | +# 2) Inspect your setup | |
44 | +och config doctor | |
64 | 45 | |
65 | -# 3. Push code with beautiful progress indicators | |
66 | -och push -b master | |
46 | +# 3) Push and open PR workflow | |
47 | +cd your-repo | |
48 | +och repo push | |
67 | 49 | |
68 | -# That's it! ๐ | |
50 | +# 4) Create a pull request | |
51 | +och pr create --base main --title "feat: add onboarding" | |
69 | 52 | ``` |
70 | 53 | |
71 | -## ๐ Commands | |
54 | +## Core Commands | |
72 | 55 | |
73 | -### Authentication | |
56 | +- `och auth` authentication (`login`, `logout`, `status`) | |
57 | +- `och config` CLI settings and diagnostics (`list`, `set`, `doctor`) | |
58 | +- `och repo` repository operations (`create`, `clone`, `push`, `list`) | |
59 | +- `och pr` pull request lifecycle (`create`, `list`, `view`, `merge`, ...) | |
60 | +- `och stack` stacked branch/PR workflows (`create`, `submit`, `sync`, ...) | |
61 | +- `och review` code review + AI review flows | |
62 | +- `och issue` issue management | |
63 | +- `och ci`, `och queue`, `och metrics`, `och insights`, `och notify`, `och automate` | |
64 | +- `och api` direct API requests (useful for scripts) | |
74 | 65 | |
75 | -```bash | |
76 | -# Interactive login | |
77 | -och auth login | |
66 | +Run `och <command> --help` for full command options. | |
78 | 67 | |
79 | -# Login with token (for CI/CD) | |
80 | -och auth login --token YOUR_TOKEN | |
68 | +## Authentication and Security | |
81 | 69 | |
82 | -# Check current user | |
83 | -och auth whoami | |
70 | +### Login | |
84 | 71 | |
85 | -# Logout | |
86 | -och auth logout | |
72 | +```bash | |
73 | +och auth login --url https://git.example.com | |
87 | 74 | ``` |
88 | 75 | |
89 | -### Repository Operations | |
76 | +For non-interactive environments: | |
90 | 77 | |
91 | 78 | ```bash |
92 | -# Push current repository | |
93 | -och push # Push current branch | |
94 | -och push -b feature-branch # Push specific branch | |
95 | -och push --force # Force push | |
96 | - | |
97 | -# Clone a repository | |
98 | -och clone owner/repo # Clone to ./repo | |
99 | -och clone owner/repo mydir # Clone to ./mydir | |
100 | - | |
101 | -# Create new repository | |
102 | -och create myrepo # Create public repo | |
103 | -och create myrepo --private # Create private repo | |
104 | -och create myrepo --description "..." # With description | |
105 | - | |
106 | -# List repositories | |
107 | -och repo list | |
79 | +och auth login --url https://git.example.com --token <PERSONAL_ACCESS_TOKEN> | |
108 | 80 | ``` |
109 | 81 | |
110 | -## ๐จ Beautiful Output Examples | |
82 | +### Token storage behavior | |
111 | 83 | |
112 | -### Push Command | |
84 | +- `OCH_TOKEN` env var always takes precedence. | |
85 | +- macOS: Keychain (`security`) | |
86 | +- Linux: Secret Service (`secret-tool`) | |
87 | +- Windows: DPAPI-encrypted storage via PowerShell | |
88 | +- Fallback: local CLI config storage if secure backend is unavailable | |
113 | 89 | |
114 | -``` | |
115 | -โน Pushing to swadhinbiswas/myrepo | |
116 | - Branch: master | |
117 | - | |
118 | -โ Objects prepared | |
119 | - Enumerating objects: 159, done. | |
120 | - Counting objects: 100% (159/159), done. | |
121 | - Delta compression using up to 20 threads | |
122 | - Compressing objects: 100% (76/76), done. | |
123 | - Writing objects: 100% (90/90), 49.17 KiB | 8.20 MiB/s, done. | |
124 | - Total 90 (delta 45), reused 0 (delta 0), pack-reused 0 | |
125 | - | |
126 | -โ Uploaded 49.17 KB in 0.52s (94.56 KB/s) | |
127 | - | |
128 | -remote: Processing: 100% (90/90), done. | |
129 | -remote: | |
130 | -To https://opencodehub.com/swadhinbiswas/myrepo.git | |
131 | - abc1234..def5678 master -> master | |
132 | - | |
133 | -โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ | |
134 | -โ โ | |
135 | -โ โจ Push Successful! โ | |
136 | -โ โ | |
137 | -โ Repository: swadhinbiswas/myrepo โ | |
138 | -โ Branch: master โ | |
139 | -โ Size: 49.17 KB โ | |
140 | -โ โ | |
141 | -โ View at: https://opencodehub.com/... โ | |
142 | -โ โ | |
143 | -โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ | |
144 | -``` | |
90 | +### Helpful environment variables | |
145 | 91 | |
146 | -### Clone Command | |
92 | +- `OCH_TOKEN`: inject token from environment | |
93 | +- `OCH_HTTP_TIMEOUT_MS`: HTTP timeout in ms (default `15000`) | |
94 | +- `OCH_DISABLE_KEYCHAIN=1`: disable credential backend (useful in CI/tests) | |
147 | 95 | |
148 | -``` | |
149 | -โน Cloning swadhinbiswas/awesome-project | |
150 | - | |
151 | -โ Repository found | |
152 | - | |
153 | -โ Cloning into awesome-project/... | |
154 | -Cloning into 'awesome-project'... | |
155 | -remote: Enumerating objects: 234, done. | |
156 | -remote: Total 234 (delta 0), reused 0 (delta 0) | |
157 | -Receiving objects: 100% (234/234), 1.23 MiB | 2.45 MiB/s, done. | |
158 | - | |
159 | -โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ | |
160 | -โ โ | |
161 | -โ โจ Clone Successful! โ | |
162 | -โ โ | |
163 | -โ Repository: swadhinbiswas/project โ | |
164 | -โ Location: awesome-project/ โ | |
165 | -โ โ | |
166 | -โ cd awesome-project && och push โ | |
167 | -โ โ | |
168 | -โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ | |
169 | -``` | |
96 | +## Configuration | |
170 | 97 | |
171 | -### Create Command | |
98 | +Inspect current configuration: | |
172 | 99 | |
173 | -``` | |
174 | -โน Creating ๐ my-new-repo | |
175 | - Description: An awesome new project | |
176 | - | |
177 | -โ Repository created | |
178 | - | |
179 | - โจ SUCCESS! โจ | |
180 | - | |
181 | - Repository swadhinbiswas/my-new-repo is ready! | |
182 | - | |
183 | -โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ | |
184 | -โ โ | |
185 | -โ ๐ Repository Created! โ | |
186 | -โ โ | |
187 | -โ Repository: swadhinbiswas/my-new-repo โ | |
188 | -โ Visibility: ๐ Public โ | |
189 | -โ Description: An awesome new project โ | |
190 | -โ โ | |
191 | -โ Clone URL: https://... โ | |
192 | -โ โ | |
193 | -โ โ Added remote 'opencode' โ | |
194 | -โ โ | |
195 | -โ View at: https://opencodehub.com/... โ | |
196 | -โ โ | |
197 | -โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ | |
100 | +```bash | |
101 | +och config list | |
102 | +och config path | |
103 | +och config doctor | |
198 | 104 | ``` |
199 | 105 | |
200 | -## ๐ง CI/CD Usage | |
106 | +Set values: | |
201 | 107 | |
202 | -```yaml | |
203 | -# .github/workflows/deploy.yml | |
204 | -name: Deploy | |
108 | +```bash | |
109 | +och config set serverUrl https://git.example.com | |
110 | +och config set defaultBranch main | |
111 | +och config set insecure false | |
112 | +``` | |
205 | 113 | |
206 | -on: push | |
114 | +## Common Workflows | |
207 | 115 | |
208 | -jobs: | |
209 | - deploy: | |
210 | - runs-on: ubuntu-latest | |
211 | - steps: | |
212 | - - uses: actions/checkout@v4 | |
116 | +### Repository lifecycle | |
213 | 117 | |
214 | - - name: Setup Node | |
215 | - uses: actions/setup-node@v4 | |
216 | - with: | |
217 | - node-version: "20" | |
118 | +```bash | |
119 | +# Create a remote repository | |
120 | +och repo create my-service --description "Internal API service" | |
218 | 121 | |
219 | - - name: Install CLI | |
220 | - run: npm install -g opencodehub-cli | |
122 | +# Clone repository | |
123 | +och repo clone acme/my-service | |
221 | 124 | |
222 | - - name: Push to OpenCodeHub | |
223 | - run: | | |
224 | - och auth login --token ${{ secrets.OCH_TOKEN }} | |
225 | - och push -b main | |
125 | +# Push local repository | |
126 | +och repo push --branch main | |
226 | 127 | ``` |
227 | 128 | |
228 | -## โ๏ธ Configuration | |
229 | - | |
230 | -OCH CLI stores configuration in `~/.ochrc`. | |
129 | +### Pull request lifecycle | |
231 | 130 | |
232 | 131 | ```bash |
233 | -# View current configuration | |
234 | -och config list | |
132 | +# Create PR from current branch | |
133 | +och pr create --base main --title "feat: add API pagination" | |
235 | 134 | |
236 | -# Set server URL | |
237 | -och config set serverUrl https://git.yourcompany.com | |
135 | +# List open PRs | |
136 | +och pr list --state open | |
238 | 137 | |
239 | -# Set default branch | |
240 | -och config set defaultBranch main | |
138 | +# View PR details | |
139 | +och pr view 42 | |
140 | + | |
141 | +# Merge PR | |
142 | +och pr merge 42 | |
241 | 143 | ``` |
242 | 144 | |
243 | -## ๐จ UI Features | |
145 | +### Stack workflow | |
244 | 146 | |
245 | -- **ASCII Art Logos** - Gradient-colored branding | |
246 | -- **Progress Indicators** - GitHub-style object counting and compression | |
247 | -- **Upload Speeds** - Real-time speed indicators | |
248 | -- **Colored Output** - Green for success, red for errors, cyan for info | |
249 | -- **Spinners** - Smooth animations for long operations | |
250 | -- **Boxed Messages** - Beautiful bordered success/error boxes | |
251 | -- **Ref Updates** - Color-coded branch update notifications | |
147 | +```bash | |
148 | +# Create first stack branch | |
149 | +och stack create auth-foundation | |
150 | + | |
151 | +# Create subsequent branch | |
152 | +och stack create auth-ui | |
153 | + | |
154 | +# Submit stack and create/update PRs | |
155 | +och stack submit | |
156 | +``` | |
252 | 157 | |
253 | -## ๐ฆ What's New in v1.1.0 | |
158 | +### AI review workflow | |
254 | 159 | |
255 | -โจ **Production-Grade UI Overhaul** | |
160 | +```bash | |
161 | +# Trigger AI review | |
162 | +och review ai 42 | |
256 | 163 | |
257 | -- GitHub-like progress indicators | |
258 | -- Beautiful ASCII art and gradients | |
259 | -- Real-time upload/download speeds | |
260 | -- Professional boxed messages | |
261 | -- Color-coded output throughout | |
164 | +# Wait for completion | |
165 | +och review ai 42 --wait | |
262 | 166 | |
263 | -## ๐ค Contributing | |
167 | +# Check latest review status | |
168 | +och review status 42 | |
169 | +``` | |
264 | 170 | |
265 | -We welcome contributions! Please see our [Contributing Guide](https://github.com/swadhinbiswas/OpencodeHub/blob/main/CONTRIBUTING.md). | |
171 | +### API mode for scripting | |
266 | 172 | |
267 | 173 | ```bash |
268 | -# Clone the repo | |
269 | -git clone https://github.com/swadhinbiswas/OpencodeHub.git | |
270 | -cd OpenCodeHub/cli | |
174 | +# Read current user | |
175 | +och api /user | |
271 | 176 | |
272 | -# Install dependencies | |
273 | -npm install | |
177 | +# Create issue via API | |
178 | +och api /repos/acme/platform/issues -X POST -F title="Bug: timeout" -F body="Steps to reproduce" | |
179 | +``` | |
274 | 180 | |
275 | -# Build | |
276 | -npm run build | |
181 | +## CI Usage Example | |
182 | + | |
183 | +```yaml | |
184 | +name: OCH Automation | |
185 | +on: [push] | |
277 | 186 | |
278 | -# Run in development | |
279 | -npm run dev | |
187 | +jobs: | |
188 | + sync: | |
189 | + runs-on: ubuntu-latest | |
190 | + steps: | |
191 | + - uses: actions/checkout@v4 | |
192 | + - uses: actions/setup-node@v4 | |
193 | + with: | |
194 | + node-version: "20" | |
195 | + - run: npm install -g opencodehub-cli | |
196 | + - run: | | |
197 | + export OCH_TOKEN="${{ secrets.OCH_TOKEN }}" | |
198 | + och config set serverUrl https://git.example.com | |
199 | + och config doctor | |
200 | + och repo push --branch main | |
280 | 201 | ``` |
281 | 202 | |
282 | -## ๐ License | |
203 | +## Troubleshooting | |
283 | 204 | |
284 | -MIT License - see [LICENSE](./LICENSE) for details. | |
205 | +### `Not logged in. Run 'och auth login' first.` | |
285 | 206 | |
286 | -## ๐ Links | |
207 | +- Run `och auth login --url <server>`. | |
208 | +- Or export `OCH_TOKEN` for automation. | |
287 | 209 | |
288 | -- [OpenCodeHub](https://github.com/swadhinbiswas/OpencodeHub) - The main project | |
289 | -- [Documentation](https://github.com/swadhinbiswas/OpencodeHub#readme) | |
290 | -- [Report Issues](https://github.com/swadhinbiswas/OpencodeHub/issues) | |
291 | -- [npm Package](https://www.npmjs.com/package/opencodehub-cli) | |
210 | +### `Server URL not configured` | |
292 | 211 | |
212 | +- Run `och config set serverUrl https://git.example.com`. | |
293 | 213 | |
294 | -<p align="center"> | |
295 | - Made with โค๏ธ by <a href="https://github.com/swadhinbiswas">Swadhin Biswas</a> | |
296 | -</p> | |
214 | +### TLS / certificate issues | |
215 | + | |
216 | +- Use `och config set caFile /path/to/ca.pem` for custom CA. | |
217 | +- Use `och config set insecure true` only for temporary debugging. | |
218 | + | |
219 | +### Validate setup end-to-end | |
220 | + | |
221 | +```bash | |
222 | +och config doctor | |
223 | +``` | |
224 | + | |
225 | +## Development | |
226 | + | |
227 | +```bash | |
228 | +cd cli | |
229 | +npm install | |
230 | +npm run build | |
231 | +npm run test | |
232 | +``` | |
233 | + | |
234 | +## License | |
235 | + | |
236 | +MIT | |
297 | 237 | |
โผcli/assets/opencodehub-cli-logo.svg
+33-0
| @@ -0,0 +1,33 @@ | ||
1 | +<svg width="1200" height="320" viewBox="0 0 1200 320" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenCodeHub CLI logo"> | |
2 | + <defs> | |
3 | + <linearGradient id="bg" x1="0" y1="0" x2="1200" y2="320" gradientUnits="userSpaceOnUse"> | |
4 | + <stop stop-color="#0B172A"/> | |
5 | + <stop offset="1" stop-color="#101F3D"/> | |
6 | + </linearGradient> | |
7 | + <linearGradient id="accent" x1="130" y1="48" x2="330" y2="248" gradientUnits="userSpaceOnUse"> | |
8 | + <stop stop-color="#00D1B2"/> | |
9 | + <stop offset="1" stop-color="#4EA8FF"/> | |
10 | + </linearGradient> | |
11 | + <linearGradient id="text" x1="392" y1="90" x2="920" y2="240" gradientUnits="userSpaceOnUse"> | |
12 | + <stop stop-color="#EAF3FF"/> | |
13 | + <stop offset="1" stop-color="#BBD2F6"/> | |
14 | + </linearGradient> | |
15 | + </defs> | |
16 | + | |
17 | + <rect width="1200" height="320" rx="20" fill="url(#bg)"/> | |
18 | + | |
19 | + <rect x="40" y="40" width="300" height="240" rx="18" fill="#081126" stroke="#1A325E"/> | |
20 | + <path d="M96 96H284" stroke="#1A325E" stroke-width="2"/> | |
21 | + <circle cx="82" cy="96" r="6" fill="#FF5E6C"/> | |
22 | + <circle cx="64" cy="96" r="6" fill="#FFC857"/> | |
23 | + <circle cx="46" cy="96" r="6" fill="#00D1B2"/> | |
24 | + | |
25 | + <rect x="96" y="128" width="188" height="118" rx="14" fill="#0F2247" stroke="#244C8A"/> | |
26 | + <path d="M130 176L154 200L130 224" stroke="url(#accent)" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/> | |
27 | + <path d="M250 176L226 200L250 224" stroke="url(#accent)" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/> | |
28 | + <path d="M176 236L208 164" stroke="url(#accent)" stroke-width="10" stroke-linecap="round"/> | |
29 | + | |
30 | + <text x="392" y="145" font-family="'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="76" font-weight="750" fill="url(#text)">OpenCodeHub</text> | |
31 | + <text x="392" y="210" font-family="'Consolas', 'SFMono-Regular', 'Menlo', monospace" font-size="56" font-weight="700" fill="#00D1B2">CLI</text> | |
32 | + <text x="392" y="252" font-family="'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="28" fill="#9CB8E8">Stack-first Git workflows from your terminal</text> | |
33 | +</svg> | |
0 | 34 | |
โผcli/bin/och.ts
+13-1
| @@ -136,7 +136,19 @@ program.helpInformation = function () { | ||
136 | 136 | "\n" + |
137 | 137 | chalk.hex("#50fa7b").bold("\nCommands:") + |
138 | 138 | "\n" + |
139 | - buildCommandTable() | |
139 | + buildCommandTable() + | |
140 | + "\n" + | |
141 | + chalk.hex("#50fa7b").bold("\nQuick Examples:") + | |
142 | + "\n" + | |
143 | + chalk.hex("#f8f8f2")(" och auth login --url https://git.example.com") + | |
144 | + "\n" + | |
145 | + chalk.hex("#f8f8f2")(" och config doctor") + | |
146 | + "\n" + | |
147 | + chalk.hex("#f8f8f2")(" och repo push --branch main") + | |
148 | + "\n" + | |
149 | + chalk.hex("#f8f8f2")(" och pr create --base main --title \"feat: example\"") + | |
150 | + "\n" + | |
151 | + chalk.hex("#f8f8f2")(" och stack submit") | |
140 | 152 | ); |
141 | 153 | }; |
142 | 154 | |
143 | 155 | |
โผcli/package-lock.json
+2-2
| @@ -1,12 +1,12 @@ | ||
1 | 1 | { |
2 | 2 | "name": "opencodehub-cli", |
3 | - "version": "1.1.0", | |
3 | + "version": "1.1.2", | |
4 | 4 | "lockfileVersion": 3, |
5 | 5 | "requires": true, |
6 | 6 | "packages": { |
7 | 7 | "": { |
8 | 8 | "name": "opencodehub-cli", |
9 | - "version": "1.1.0", | |
9 | + "version": "1.1.2", | |
10 | 10 | "license": "MIT", |
11 | 11 | "dependencies": { |
12 | 12 | "boxen": "^7.1.1", |
13 | 13 | |
โผcli/package.json
+3-2
| @@ -1,6 +1,6 @@ | ||
1 | 1 | { |
2 | 2 | "name": "opencodehub-cli", |
3 | - "version": "1.1.0", | |
3 | + "version": "1.1.2", | |
4 | 4 | "description": "Stack-first PR workflows from your terminal - CLI for OpenCodeHub", |
5 | 5 | "author": "Swadhin Biswas <[email protected]>", |
6 | 6 | "license": "MIT", |
| @@ -11,6 +11,7 @@ | ||
11 | 11 | "files": [ |
12 | 12 | "bin", |
13 | 13 | "dist", |
14 | + "assets", | |
14 | 15 | "README.md", |
15 | 16 | "LICENSE" |
16 | 17 | ], |
| @@ -69,4 +70,4 @@ | ||
69 | 70 | "workflow", |
70 | 71 | "opencodehub" |
71 | 72 | ] |
72 | -} | |
73 | +} | |
73 | 74 | |
โผcli/src/commands/api/index.ts
+10-2
| @@ -51,6 +51,14 @@ export const apiCommands = new Command("api") | ||
51 | 51 | } |
52 | 52 | |
53 | 53 | try { |
54 | + const method = String(options.method || "GET").toUpperCase(); | |
55 | + const allowedMethods = new Set(["GET", "POST", "PATCH", "PUT", "DELETE"]); | |
56 | + if (!allowedMethods.has(method)) { | |
57 | + console.error(chalk.red(`Unsupported method: ${options.method}`)); | |
58 | + console.error(chalk.dim("Allowed methods: GET, POST, PATCH, PUT, DELETE")); | |
59 | + process.exit(1); | |
60 | + } | |
61 | + | |
54 | 62 | // Normalize endpoint |
55 | 63 | if (!endpoint.startsWith("/")) { |
56 | 64 | endpoint = "/" + endpoint; |
| @@ -98,12 +106,12 @@ export const apiCommands = new Command("api") | ||
98 | 106 | const url = `${config.serverUrl}${endpoint}`; |
99 | 107 | |
100 | 108 | if (!options.quiet) { |
101 | - console.error(chalk.dim(`${options.method} ${url}\n`)); | |
109 | + console.error(chalk.dim(`${method} ${url}\n`)); | |
102 | 110 | } |
103 | 111 | |
104 | 112 | applyTlsConfig(); |
105 | 113 | const response = await fetch(url, { |
106 | - method: options.method, | |
114 | + method, | |
107 | 115 | headers, |
108 | 116 | body, |
109 | 117 | }); |
110 | 118 | |
โผcli/src/commands/auth.test.ts
+65-0
| @@ -0,0 +1,65 @@ | ||
1 | +import { mkdtempSync } from "node:fs"; | |
2 | +import os from "node:os"; | |
3 | +import path from "node:path"; | |
4 | +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; | |
5 | + | |
6 | +type AuthModule = typeof import("./auth.js"); | |
7 | +type ConfigModule = typeof import("../lib/config.js"); | |
8 | + | |
9 | +let authMod: AuthModule; | |
10 | +let configMod: ConfigModule; | |
11 | + | |
12 | +beforeAll(async () => { | |
13 | + const tmpDir = mkdtempSync(path.join(os.tmpdir(), "och-cli-auth-test-")); | |
14 | + process.env.XDG_CONFIG_HOME = tmpDir; | |
15 | + process.env.XDG_DATA_HOME = tmpDir; | |
16 | + process.env.OCH_DISABLE_KEYCHAIN = "1"; | |
17 | + process.env.CI = "1"; | |
18 | + | |
19 | + configMod = await import("../lib/config.js"); | |
20 | + authMod = await import("./auth.js"); | |
21 | +}); | |
22 | + | |
23 | +beforeEach(() => { | |
24 | + configMod.clearConfig(); | |
25 | + vi.restoreAllMocks(); | |
26 | +}); | |
27 | + | |
28 | +describe("auth commands", () => { | |
29 | + it("status reports unauthenticated when token is missing", async () => { | |
30 | + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); | |
31 | + | |
32 | + await authMod.authCommands.parseAsync(["status"], { | |
33 | + from: "user", | |
34 | + }); | |
35 | + | |
36 | + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Not authenticated")); | |
37 | + }); | |
38 | + | |
39 | + it("login with token stores username and token", async () => { | |
40 | + const fetchMock = vi.fn(async () => | |
41 | + new Response(JSON.stringify({ data: { username: "alice" } }), { | |
42 | + status: 200, | |
43 | + headers: { "content-type": "application/json" }, | |
44 | + }), | |
45 | + ); | |
46 | + vi.stubGlobal("fetch", fetchMock); | |
47 | + | |
48 | + await authMod.authCommands.parseAsync( | |
49 | + [ | |
50 | + "login", | |
51 | + "--url", | |
52 | + "https://git.example.com", | |
53 | + "--token", | |
54 | + "token_12345678", | |
55 | + ], | |
56 | + { from: "user" }, | |
57 | + ); | |
58 | + | |
59 | + const cfg = configMod.getConfig(); | |
60 | + expect(cfg.serverUrl).toBe("https://git.example.com"); | |
61 | + expect(cfg.username).toBe("alice"); | |
62 | + expect(cfg.token).toBe("token_12345678"); | |
63 | + expect(fetchMock).toHaveBeenCalled(); | |
64 | + }); | |
65 | +}); | |
0 | 66 | |
โผcli/src/commands/auth.ts
+16-4
| @@ -8,7 +8,12 @@ import { Command } from "commander"; | ||
8 | 8 | import inquirer from "inquirer"; |
9 | 9 | import ora from "ora"; |
10 | 10 | import { applyTlsConfig } from "../lib/api.js"; |
11 | -import { getConfig, saveConfig } from "../lib/config.js"; | |
11 | +import { | |
12 | + getConfig, | |
13 | + normalizeServerUrl, | |
14 | + saveConfig, | |
15 | + supportsSecureTokenStorage, | |
16 | +} from "../lib/config.js"; | |
12 | 17 | |
13 | 18 | export const authCommands = new Command("auth").description( |
14 | 19 | "Authentication commands", |
| @@ -45,6 +50,7 @@ authCommands | ||
45 | 50 | ]); |
46 | 51 | serverUrl = answers.serverUrl; |
47 | 52 | } |
53 | + serverUrl = normalizeServerUrl(serverUrl); | |
48 | 54 | |
49 | 55 | if (options.token) { |
50 | 56 | // Token provided directly via CLI |
| @@ -109,9 +115,9 @@ authCommands | ||
109 | 115 | // Validate token |
110 | 116 | const spinner = ora("Validating token...").start(); |
111 | 117 | |
112 | - if (!token.startsWith("och_")) { | |
118 | + if (token.trim().length < 8) { | |
113 | 119 | spinner.fail("Invalid token format"); |
114 | - console.error(chalk.red("Token should start with 'och_'")); | |
120 | + console.error(chalk.red("Token looks too short")); | |
115 | 121 | process.exit(1); |
116 | 122 | } |
117 | 123 | |
| @@ -150,7 +156,13 @@ authCommands | ||
150 | 156 | }); |
151 | 157 | |
152 | 158 | console.log(chalk.green("\nโ Logged in to " + serverUrl)); |
153 | - console.log(chalk.dim(" Token saved to ~/.opencodehub/config.json")); | |
159 | + console.log( | |
160 | + chalk.dim( | |
161 | + supportsSecureTokenStorage() | |
162 | + ? " Token saved to system keychain" | |
163 | + : " Token saved to CLI config fallback (set OCH_TOKEN to avoid local persistence)", | |
164 | + ), | |
165 | + ); | |
154 | 166 | } catch (error) { |
155 | 167 | console.error(chalk.red("Login failed")); |
156 | 168 | console.error( |
157 | 169 | |
โผcli/src/commands/ci/index.ts
+3-3
| @@ -211,11 +211,11 @@ ciCommands | ||
211 | 211 | |
212 | 212 | // CI Trace (stream logs) |
213 | 213 | ciCommands |
214 | - .command("trace <job-id>") | |
214 | + .command("trace <run-id> <job-id>") | |
215 | 215 | .alias("logs") |
216 | 216 | .description("Stream job logs") |
217 | 217 | .option("-f, --follow", "Follow log output") |
218 | - .action(async (jobId: string, options) => { | |
218 | + .action(async (runId: string, jobId: string, options) => { | |
219 | 219 | const config = getConfig(); |
220 | 220 | if (!config.token) { |
221 | 221 | console.error(chalk.red("Not logged in. Run 'och auth login' first.")); |
| @@ -232,7 +232,7 @@ ciCommands | ||
232 | 232 | } |
233 | 233 | |
234 | 234 | const result = await getWithAuth<{ data: { logs: string } }>( |
235 | - `/api/repos/${repoInfo.owner}/${repoInfo.repo}/actions/jobs/${jobId}/logs`, | |
235 | + `/api/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs/${runId}/jobs/${jobId}/logs`, | |
236 | 236 | ); |
237 | 237 | |
238 | 238 | spinner.stop(); |
239 | 239 | |
โผcli/src/commands/config-command.test.ts
+71-0
| @@ -0,0 +1,71 @@ | ||
1 | +import { mkdtempSync } from "node:fs"; | |
2 | +import os from "node:os"; | |
3 | +import path from "node:path"; | |
4 | +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; | |
5 | + | |
6 | +type ConfigCommandModule = typeof import("./config/index.js"); | |
7 | +type ConfigModule = typeof import("../lib/config.js"); | |
8 | + | |
9 | +let configCommandMod: ConfigCommandModule; | |
10 | +let configMod: ConfigModule; | |
11 | + | |
12 | +beforeAll(async () => { | |
13 | + const tmpDir = mkdtempSync(path.join(os.tmpdir(), "och-cli-config-cmd-test-")); | |
14 | + process.env.XDG_CONFIG_HOME = tmpDir; | |
15 | + process.env.XDG_DATA_HOME = tmpDir; | |
16 | + process.env.OCH_DISABLE_KEYCHAIN = "1"; | |
17 | + | |
18 | + configMod = await import("../lib/config.js"); | |
19 | + configCommandMod = await import("./config/index.js"); | |
20 | +}); | |
21 | + | |
22 | +beforeEach(() => { | |
23 | + configMod.clearConfig(); | |
24 | + vi.restoreAllMocks(); | |
25 | +}); | |
26 | + | |
27 | +describe("config doctor command", () => { | |
28 | + it("exits with code 1 when required config is missing", async () => { | |
29 | + const exitSpy = vi | |
30 | + .spyOn(process, "exit") | |
31 | + .mockImplementation(((code?: number) => { | |
32 | + throw new Error(`EXIT_${code ?? 0}`); | |
33 | + }) as any); | |
34 | + | |
35 | + await expect( | |
36 | + configCommandMod.configCommands.parseAsync( | |
37 | + ["doctor"], | |
38 | + { from: "user" }, | |
39 | + ), | |
40 | + ).rejects.toThrow("EXIT_1"); | |
41 | + | |
42 | + expect(exitSpy).toHaveBeenCalledWith(1); | |
43 | + }); | |
44 | + | |
45 | + it("succeeds when server and token are valid", async () => { | |
46 | + configMod.saveConfig({ | |
47 | + serverUrl: "https://git.example.com", | |
48 | + token: "token_12345678", | |
49 | + }); | |
50 | + | |
51 | + const fetchMock = vi.fn(async () => | |
52 | + new Response(JSON.stringify({ data: { username: "alice" } }), { | |
53 | + status: 200, | |
54 | + headers: { "content-type": "application/json" }, | |
55 | + }), | |
56 | + ); | |
57 | + vi.stubGlobal("fetch", fetchMock); | |
58 | + | |
59 | + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { | |
60 | + throw new Error("UNEXPECTED_EXIT"); | |
61 | + }) as any); | |
62 | + | |
63 | + await configCommandMod.configCommands.parseAsync( | |
64 | + ["doctor"], | |
65 | + { from: "user" }, | |
66 | + ); | |
67 | + | |
68 | + expect(fetchMock).toHaveBeenCalled(); | |
69 | + expect(exitSpy).not.toHaveBeenCalled(); | |
70 | + }); | |
71 | +}); | |
0 | 72 | |
โผcli/src/commands/config/index.ts
+73-53
| @@ -5,32 +5,17 @@ | ||
5 | 5 | |
6 | 6 | import chalk from "chalk"; |
7 | 7 | import { Command } from "commander"; |
8 | -import Conf from "conf"; | |
9 | 8 | import fs from "fs"; |
10 | 9 | import { applyTlsConfig } from "../../lib/api.js"; |
11 | - | |
12 | -interface OchConfig { | |
13 | - serverUrl: string; | |
14 | - token: string; | |
15 | - defaultBranch: string; | |
16 | - editor: string; | |
17 | - pager: string; | |
18 | - caFile: string; | |
19 | - insecure: boolean; | |
20 | -} | |
21 | - | |
22 | -const config = new Conf<OchConfig>({ | |
23 | - projectName: "opencodehub-cli", | |
24 | - defaults: { | |
25 | - serverUrl: "", | |
26 | - token: "", | |
27 | - defaultBranch: "main", | |
28 | - editor: "", | |
29 | - pager: "", | |
30 | - caFile: "", | |
31 | - insecure: false, | |
32 | - }, | |
33 | -}); | |
10 | +import { | |
11 | + clearConfig, | |
12 | + getConfig, | |
13 | + getConfigPath, | |
14 | + getTokenStorageMode, | |
15 | + saveConfig, | |
16 | + supportsSecureTokenStorage, | |
17 | + unsetConfigKey, | |
18 | +} from "../../lib/config.js"; | |
34 | 19 | |
35 | 20 | const configDescriptions: Record<string, string> = { |
36 | 21 | serverUrl: "OpenCodeHub server URL", |
| @@ -52,19 +37,13 @@ configCommands | ||
52 | 37 | .alias("ls") |
53 | 38 | .description("List all configuration values") |
54 | 39 | .action(() => { |
40 | + const currentConfig = getConfig(); | |
55 | 41 | console.log(chalk.bold("\nโ๏ธ CLI Configuration\n")); |
56 | 42 | |
57 | - const keys = [ | |
58 | - "serverUrl", | |
59 | - "defaultBranch", | |
60 | - "editor", | |
61 | - "pager", | |
62 | - "caFile", | |
63 | - "insecure", | |
64 | - ]; | |
43 | + const keys = ["serverUrl", "defaultBranch", "editor", "pager", "caFile", "insecure"] as const; | |
65 | 44 | |
66 | 45 | for (const key of keys) { |
67 | - const value = config.get(key as keyof OchConfig); | |
46 | + const value = currentConfig[key]; | |
68 | 47 | const desc = configDescriptions[key] || ""; |
69 | 48 | const displayValue = |
70 | 49 | typeof value === "boolean" |
| @@ -78,13 +57,29 @@ configCommands | ||
78 | 57 | } |
79 | 58 | |
80 | 59 | // Show token status (not the actual token) |
81 | - const hasToken = !!config.get("token"); | |
60 | + const hasToken = !!currentConfig.token; | |
61 | + const storageMode = getTokenStorageMode(currentConfig.serverUrl); | |
62 | + const storageLabel = | |
63 | + storageMode === "env" | |
64 | + ? "env (OCH_TOKEN)" | |
65 | + : storageMode === "keychain" | |
66 | + ? "secure keychain" | |
67 | + : storageMode === "file" | |
68 | + ? "config file fallback" | |
69 | + : "not set"; | |
82 | 70 | console.log( |
83 | 71 | `${chalk.cyan("token")}: ${hasToken ? chalk.green("โ") + " configured" : chalk.dim("(not set)")}`, |
84 | 72 | ); |
85 | - console.log(chalk.dim(" Personal access token")); | |
73 | + console.log(chalk.dim(` Personal access token (${storageLabel})`)); | |
86 | 74 | |
87 | - console.log(chalk.dim(`\nConfig file: ${config.path}`)); | |
75 | + console.log(chalk.dim(`\nConfig file: ${getConfigPath()}`)); | |
76 | + if (!supportsSecureTokenStorage()) { | |
77 | + console.log( | |
78 | + chalk.yellow( | |
79 | + "Secure keychain backend unavailable on this host. Token uses config file fallback unless OCH_TOKEN is set.", | |
80 | + ), | |
81 | + ); | |
82 | + } | |
88 | 83 | console.log(""); |
89 | 84 | }); |
90 | 85 | |
| @@ -93,25 +88,43 @@ configCommands | ||
93 | 88 | .command("get <key>") |
94 | 89 | .description("Get a configuration value") |
95 | 90 | .action((key: string) => { |
96 | - const value = config.get(key as keyof OchConfig); | |
91 | + const currentConfig = getConfig(); | |
97 | 92 | |
98 | 93 | if (key === "token") { |
99 | - if (typeof value === "string" && value) { | |
100 | - console.log(value.slice(0, 12) + "..."); | |
94 | + if (typeof currentConfig.token === "string" && currentConfig.token) { | |
95 | + console.log(currentConfig.token.slice(0, 12) + "..."); | |
101 | 96 | } else { |
102 | 97 | console.log(chalk.dim("(not set)")); |
103 | 98 | } |
104 | - } else if (value !== undefined) { | |
105 | - console.log(value); | |
106 | - } else { | |
99 | + return; | |
100 | + } | |
101 | + | |
102 | + const validKeys = [ | |
103 | + "serverUrl", | |
104 | + "defaultBranch", | |
105 | + "editor", | |
106 | + "pager", | |
107 | + "caFile", | |
108 | + "insecure", | |
109 | + "username", | |
110 | + ]; | |
111 | + | |
112 | + if (!validKeys.includes(key)) { | |
107 | 113 | console.error(chalk.red(`Unknown config key: ${key}`)); |
108 | 114 | console.log( |
109 | 115 | chalk.dim( |
110 | - "Available keys: serverUrl, token, defaultBranch, editor, pager, caFile, insecure", | |
116 | + "Available keys: serverUrl, token, defaultBranch, editor, pager, caFile, insecure, username", | |
111 | 117 | ), |
112 | 118 | ); |
113 | 119 | process.exit(1); |
114 | 120 | } |
121 | + | |
122 | + const value = currentConfig[key as keyof typeof currentConfig]; | |
123 | + if (value !== undefined) { | |
124 | + console.log(String(value)); | |
125 | + } else { | |
126 | + console.log(chalk.dim("(not set)")); | |
127 | + } | |
115 | 128 | }); |
116 | 129 | |
117 | 130 | // Config Set |
| @@ -135,10 +148,16 @@ configCommands | ||
135 | 148 | process.exit(1); |
136 | 149 | } |
137 | 150 | |
138 | - const normalizedValue = | |
139 | - key === "insecure" ? value === "true" || value === "1" : value; | |
151 | + const normalizedValue = key === "insecure" ? value === "true" || value === "1" : value; | |
140 | 152 | |
141 | - config.set(key as keyof OchConfig, normalizedValue as any); | |
153 | + try { | |
154 | + saveConfig({ [key]: normalizedValue } as any); | |
155 | + } catch (error) { | |
156 | + console.error( | |
157 | + chalk.red(error instanceof Error ? error.message : "Invalid configuration value"), | |
158 | + ); | |
159 | + process.exit(1); | |
160 | + } | |
142 | 161 | const displayValue = |
143 | 162 | key === "token" && typeof normalizedValue === "string" |
144 | 163 | ? `${normalizedValue.slice(0, 12)}...` |
| @@ -167,7 +186,7 @@ configCommands | ||
167 | 186 | process.exit(1); |
168 | 187 | } |
169 | 188 | |
170 | - config.delete(key as keyof OchConfig); | |
189 | + unsetConfigKey(key as any); | |
171 | 190 | console.log(chalk.green(`โ Unset ${key}`)); |
172 | 191 | }); |
173 | 192 | |
| @@ -176,7 +195,7 @@ configCommands | ||
176 | 195 | .command("path") |
177 | 196 | .description("Show config file path") |
178 | 197 | .action(() => { |
179 | - console.log(config.path); | |
198 | + console.log(getConfigPath()); | |
180 | 199 | }); |
181 | 200 | |
182 | 201 | // Config Reset |
| @@ -201,7 +220,7 @@ configCommands | ||
201 | 220 | } |
202 | 221 | } |
203 | 222 | |
204 | - config.clear(); | |
223 | + clearConfig(); | |
205 | 224 | console.log(chalk.green("โ Configuration reset to defaults")); |
206 | 225 | }); |
207 | 226 | |
| @@ -214,10 +233,11 @@ configCommands | ||
214 | 233 | .action(async () => { |
215 | 234 | console.log(chalk.bold("\n๐ฉบ Config Doctor\n")); |
216 | 235 | |
217 | - const serverUrl = config.get("serverUrl"); | |
218 | - const token = config.get("token"); | |
219 | - const caFile = config.get("caFile"); | |
220 | - const insecure = config.get("insecure"); | |
236 | + const currentConfig = getConfig(); | |
237 | + const serverUrl = currentConfig.serverUrl; | |
238 | + const token = currentConfig.token; | |
239 | + const caFile = currentConfig.caFile; | |
240 | + const insecure = currentConfig.insecure; | |
221 | 241 | |
222 | 242 | let hasIssues = false; |
223 | 243 | |
224 | 244 | |
โผcli/src/commands/insights/index.ts
+1-1
| @@ -41,7 +41,7 @@ insightsCommand | ||
41 | 41 | } |
42 | 42 | |
43 | 43 | const res = await getWithAuth( |
44 | - `/api/metrics/user?period=${options.period}`, | |
44 | + `/api/user/metrics?period=${options.period}`, | |
45 | 45 | ); |
46 | 46 | |
47 | 47 | spinner.stop(); |
48 | 48 | |
โผcli/src/commands/notify/index.ts
+218-202
| @@ -3,229 +3,245 @@ | ||
3 | 3 | * Manage notifications and preferences |
4 | 4 | */ |
5 | 5 | |
6 | -import { Command } from "commander"; | |
7 | 6 | import chalk from "chalk"; |
8 | -import ora from "ora"; | |
9 | 7 | import Table from "cli-table3"; |
8 | +import { Command } from "commander"; | |
10 | 9 | import inquirer from "inquirer"; |
11 | -import { getWithAuth, postWithAuth, patchWithAuth } from "../../lib/api.js"; | |
10 | +import ora from "ora"; | |
11 | +import { getWithAuth, postWithAuth } from "../../lib/api.js"; | |
12 | 12 | import { getConfig } from "../../lib/config.js"; |
13 | 13 | |
14 | -export const notifyCommand = new Command("notify") | |
15 | - .description("Manage notifications and preferences"); | |
14 | +export const notifyCommand = new Command("notify").description( | |
15 | + "Manage notifications and preferences", | |
16 | +); | |
16 | 17 | |
17 | 18 | // List notifications |
18 | 19 | notifyCommand |
19 | - .command("list") | |
20 | - .description("List your notifications") | |
21 | - .option("-u, --unread", "Show unread only") | |
22 | - .option("-n, --limit <n>", "Limit results", "20") | |
23 | - .option("--json", "Output as JSON") | |
24 | - .action(async (options) => { | |
25 | - const spinner = ora("Loading notifications...").start(); | |
26 | - | |
27 | - try { | |
28 | - const config = getConfig(); | |
29 | - if (!config.token) { | |
30 | - spinner.fail("Not authenticated. Run 'och auth login' first."); | |
31 | - process.exit(1); | |
32 | - } | |
33 | - | |
34 | - const filter = options.unread ? "unread" : "all"; | |
35 | - const res = await getWithAuth(`/api/notifications?filter=${filter}&limit=${options.limit}`); | |
36 | - const notifications = res.notifications || []; | |
37 | - | |
38 | - spinner.stop(); | |
39 | - | |
40 | - if (options.json) { | |
41 | - console.log(JSON.stringify(notifications, null, 2)); | |
42 | - return; | |
43 | - } | |
44 | - | |
45 | - if (notifications.length === 0) { | |
46 | - console.log(chalk.gray("\n ๐ No notifications\n")); | |
47 | - return; | |
48 | - } | |
49 | - | |
50 | - console.log(chalk.bold("\n๐ Notifications\n")); | |
51 | - | |
52 | - for (const notif of notifications) { | |
53 | - const icon = getNotificationIcon(notif.type); | |
54 | - const time = timeAgo(notif.createdAt); | |
55 | - const readIndicator = notif.isRead ? "" : chalk.cyan("โ"); | |
56 | - | |
57 | - console.log( | |
58 | - ` ${icon} ${readIndicator} ${chalk.white(notif.title)} ${chalk.gray(`(${time})`)}` | |
59 | - ); | |
60 | - if (notif.body) { | |
61 | - console.log(chalk.gray(` ${notif.body.substring(0, 60)}...`)); | |
62 | - } | |
63 | - } | |
64 | - | |
65 | - console.log(); | |
66 | - } catch (error: any) { | |
67 | - spinner.fail(`Failed to load notifications: ${error.message}`); | |
68 | - process.exit(1); | |
20 | + .command("list") | |
21 | + .description("List your notifications") | |
22 | + .option("-u, --unread", "Show unread only") | |
23 | + .option("-n, --limit <n>", "Limit results", "20") | |
24 | + .option("--json", "Output as JSON") | |
25 | + .action(async (options) => { | |
26 | + const spinner = ora("Loading notifications...").start(); | |
27 | + | |
28 | + try { | |
29 | + const config = getConfig(); | |
30 | + if (!config.token) { | |
31 | + spinner.fail("Not authenticated. Run 'och auth login' first."); | |
32 | + process.exit(1); | |
33 | + } | |
34 | + | |
35 | + const filter = options.unread ? "unread" : "all"; | |
36 | + const res = await getWithAuth( | |
37 | + `/api/notifications?filter=${filter}&limit=${options.limit}`, | |
38 | + ); | |
39 | + const notifications = res.notifications || []; | |
40 | + | |
41 | + spinner.stop(); | |
42 | + | |
43 | + if (options.json) { | |
44 | + console.log(JSON.stringify(notifications, null, 2)); | |
45 | + return; | |
46 | + } | |
47 | + | |
48 | + if (notifications.length === 0) { | |
49 | + console.log(chalk.gray("\n ๐ No notifications\n")); | |
50 | + return; | |
51 | + } | |
52 | + | |
53 | + console.log(chalk.bold("\n๐ Notifications\n")); | |
54 | + | |
55 | + for (const notif of notifications) { | |
56 | + const icon = getNotificationIcon(notif.type); | |
57 | + const time = timeAgo(notif.createdAt); | |
58 | + const readIndicator = notif.isRead ? "" : chalk.cyan("โ"); | |
59 | + | |
60 | + console.log( | |
61 | + ` ${icon} ${readIndicator} ${chalk.white(notif.title)} ${chalk.gray(`(${time})`)}`, | |
62 | + ); | |
63 | + if (notif.body) { | |
64 | + console.log(chalk.gray(` ${notif.body.substring(0, 60)}...`)); | |
69 | 65 | } |
70 | - }); | |
66 | + } | |
67 | + | |
68 | + console.log(); | |
69 | + } catch (error: any) { | |
70 | + spinner.fail(`Failed to load notifications: ${error.message}`); | |
71 | + process.exit(1); | |
72 | + } | |
73 | + }); | |
71 | 74 | |
72 | 75 | // Mark notification as read |
73 | 76 | notifyCommand |
74 | - .command("read [id]") | |
75 | - .description("Mark notification(s) as read") | |
76 | - .option("-a, --all", "Mark all as read") | |
77 | - .action(async (id, options) => { | |
78 | - const spinner = ora("Marking as read...").start(); | |
79 | - | |
80 | - try { | |
81 | - if (options.all) { | |
82 | - await postWithAuth("/api/notifications/mark-all-read", {}); | |
83 | - spinner.succeed("Marked all notifications as read"); | |
84 | - } else if (id) { | |
85 | - await patchWithAuth(`/api/notifications/${id}`, { isRead: true }); | |
86 | - spinner.succeed("Marked notification as read"); | |
87 | - } else { | |
88 | - spinner.fail("Specify notification ID or use --all"); | |
89 | - } | |
90 | - } catch (error: any) { | |
91 | - spinner.fail(`Failed: ${error.message}`); | |
92 | - } | |
93 | - }); | |
77 | + .command("read [id]") | |
78 | + .description("Mark notification(s) as read") | |
79 | + .option("-a, --all", "Mark all as read") | |
80 | + .action(async (id, options) => { | |
81 | + const spinner = ora("Marking as read...").start(); | |
82 | + | |
83 | + try { | |
84 | + if (options.all) { | |
85 | + await postWithAuth("/api/notifications/mark-all-read", {}); | |
86 | + spinner.succeed("Marked all notifications as read"); | |
87 | + } else if (id) { | |
88 | + await postWithAuth(`/api/notifications/${id}/read`, {}); | |
89 | + spinner.succeed("Marked notification as read"); | |
90 | + } else { | |
91 | + spinner.fail("Specify notification ID or use --all"); | |
92 | + } | |
93 | + } catch (error: any) { | |
94 | + spinner.fail(`Failed: ${error.message}`); | |
95 | + } | |
96 | + }); | |
94 | 97 | |
95 | 98 | // View/update notification settings |
96 | 99 | notifyCommand |
97 | - .command("settings") | |
98 | - .description("View or update notification preferences") | |
99 | - .option("--show", "Show current settings") | |
100 | - .action(async (options) => { | |
101 | - try { | |
102 | - const config = getConfig(); | |
103 | - if (!config.token) { | |
104 | - console.log(chalk.red("Not authenticated. Run 'och auth login' first.")); | |
105 | - process.exit(1); | |
106 | - } | |
107 | - | |
108 | - // Get current settings | |
109 | - const spinner = ora("Loading settings...").start(); | |
110 | - let currentSettings; | |
111 | - try { | |
112 | - currentSettings = await getWithAuth("/api/user/notification-preferences"); | |
113 | - } catch { | |
114 | - currentSettings = { preferences: {}, quietHours: null }; | |
115 | - } | |
116 | - spinner.stop(); | |
117 | - | |
118 | - if (options.show) { | |
119 | - console.log(chalk.bold("\nโ๏ธ Notification Settings\n")); | |
120 | - | |
121 | - const eventTypes = [ | |
122 | - "mention", "assign", "review_request", "pr_approved", | |
123 | - "pr_merged", "ci_passed", "ci_failed" | |
124 | - ]; | |
125 | - | |
126 | - const table = new Table({ | |
127 | - head: [chalk.gray("Event"), chalk.gray("Email"), chalk.gray("In-App")], | |
128 | - }); | |
129 | - | |
130 | - for (const event of eventTypes) { | |
131 | - const pref = currentSettings.preferences?.[event] || {}; | |
132 | - table.push([ | |
133 | - event, | |
134 | - pref.emailEnabled ? chalk.green("โ") : chalk.red("โ"), | |
135 | - pref.inAppEnabled ? chalk.green("โ") : chalk.red("โ"), | |
136 | - ]); | |
137 | - } | |
138 | - | |
139 | - console.log(table.toString()); | |
140 | - | |
141 | - if (currentSettings.quietHours?.isEnabled) { | |
142 | - console.log( | |
143 | - chalk.yellow( | |
144 | - `\n ๐ Quiet hours: ${currentSettings.quietHours.startTime} - ${currentSettings.quietHours.endTime}` | |
145 | - ) | |
146 | - ); | |
147 | - } | |
148 | - | |
149 | - console.log(); | |
150 | - return; | |
151 | - } | |
152 | - | |
153 | - // Interactive settings update | |
154 | - const { category } = await inquirer.prompt([ | |
155 | - { | |
156 | - type: "list", | |
157 | - name: "category", | |
158 | - message: "What would you like to configure?", | |
159 | - choices: [ | |
160 | - { name: "Email notifications", value: "email" }, | |
161 | - { name: "Quiet hours", value: "quietHours" }, | |
162 | - { name: "Cancel", value: "cancel" }, | |
163 | - ], | |
164 | - }, | |
165 | - ]); | |
166 | - | |
167 | - if (category === "cancel") return; | |
168 | - | |
169 | - if (category === "quietHours") { | |
170 | - const hours = await inquirer.prompt([ | |
171 | - { | |
172 | - type: "confirm", | |
173 | - name: "enabled", | |
174 | - message: "Enable quiet hours?", | |
175 | - default: currentSettings.quietHours?.isEnabled || false, | |
176 | - }, | |
177 | - { | |
178 | - type: "input", | |
179 | - name: "start", | |
180 | - message: "Start time (HH:MM):", | |
181 | - default: currentSettings.quietHours?.startTime || "22:00", | |
182 | - when: (answers) => answers.enabled, | |
183 | - }, | |
184 | - { | |
185 | - type: "input", | |
186 | - name: "end", | |
187 | - message: "End time (HH:MM):", | |
188 | - default: currentSettings.quietHours?.endTime || "08:00", | |
189 | - when: (answers) => answers.enabled, | |
190 | - }, | |
191 | - ]); | |
192 | - | |
193 | - const saveSpinner = ora("Saving settings...").start(); | |
194 | - await postWithAuth("/api/user/notification-preferences", { | |
195 | - quietHours: { | |
196 | - isEnabled: hours.enabled, | |
197 | - startTime: hours.start, | |
198 | - endTime: hours.end, | |
199 | - }, | |
200 | - }); | |
201 | - saveSpinner.succeed("Quiet hours updated"); | |
202 | - } | |
203 | - } catch (error: any) { | |
204 | - console.error(chalk.red(`Failed: ${error.message}`)); | |
100 | + .command("settings") | |
101 | + .description("View or update notification preferences") | |
102 | + .option("--show", "Show current settings") | |
103 | + .action(async (options) => { | |
104 | + try { | |
105 | + const config = getConfig(); | |
106 | + if (!config.token) { | |
107 | + console.log( | |
108 | + chalk.red("Not authenticated. Run 'och auth login' first."), | |
109 | + ); | |
110 | + process.exit(1); | |
111 | + } | |
112 | + | |
113 | + // Get current settings | |
114 | + const spinner = ora("Loading settings...").start(); | |
115 | + let currentSettings; | |
116 | + try { | |
117 | + currentSettings = await getWithAuth( | |
118 | + "/api/user/notification-preferences", | |
119 | + ); | |
120 | + } catch { | |
121 | + currentSettings = { preferences: {}, quietHours: null }; | |
122 | + } | |
123 | + spinner.stop(); | |
124 | + | |
125 | + if (options.show) { | |
126 | + console.log(chalk.bold("\nโ๏ธ Notification Settings\n")); | |
127 | + | |
128 | + const eventTypes = [ | |
129 | + "mention", | |
130 | + "assign", | |
131 | + "review_request", | |
132 | + "pr_approved", | |
133 | + "pr_merged", | |
134 | + "ci_passed", | |
135 | + "ci_failed", | |
136 | + ]; | |
137 | + | |
138 | + const table = new Table({ | |
139 | + head: [ | |
140 | + chalk.gray("Event"), | |
141 | + chalk.gray("Email"), | |
142 | + chalk.gray("In-App"), | |
143 | + ], | |
144 | + }); | |
145 | + | |
146 | + for (const event of eventTypes) { | |
147 | + const pref = currentSettings.preferences?.[event] || {}; | |
148 | + table.push([ | |
149 | + event, | |
150 | + pref.emailEnabled ? chalk.green("โ") : chalk.red("โ"), | |
151 | + pref.inAppEnabled ? chalk.green("โ") : chalk.red("โ"), | |
152 | + ]); | |
205 | 153 | } |
206 | - }); | |
154 | + | |
155 | + console.log(table.toString()); | |
156 | + | |
157 | + if (currentSettings.quietHours?.isEnabled) { | |
158 | + console.log( | |
159 | + chalk.yellow( | |
160 | + `\n ๐ Quiet hours: ${currentSettings.quietHours.startTime} - ${currentSettings.quietHours.endTime}`, | |
161 | + ), | |
162 | + ); | |
163 | + } | |
164 | + | |
165 | + console.log(); | |
166 | + return; | |
167 | + } | |
168 | + | |
169 | + // Interactive settings update | |
170 | + const { category } = await inquirer.prompt([ | |
171 | + { | |
172 | + type: "list", | |
173 | + name: "category", | |
174 | + message: "What would you like to configure?", | |
175 | + choices: [ | |
176 | + { name: "Email notifications", value: "email" }, | |
177 | + { name: "Quiet hours", value: "quietHours" }, | |
178 | + { name: "Cancel", value: "cancel" }, | |
179 | + ], | |
180 | + }, | |
181 | + ]); | |
182 | + | |
183 | + if (category === "cancel") return; | |
184 | + | |
185 | + if (category === "quietHours") { | |
186 | + const hours = await inquirer.prompt([ | |
187 | + { | |
188 | + type: "confirm", | |
189 | + name: "enabled", | |
190 | + message: "Enable quiet hours?", | |
191 | + default: currentSettings.quietHours?.isEnabled || false, | |
192 | + }, | |
193 | + { | |
194 | + type: "input", | |
195 | + name: "start", | |
196 | + message: "Start time (HH:MM):", | |
197 | + default: currentSettings.quietHours?.startTime || "22:00", | |
198 | + when: (answers) => answers.enabled, | |
199 | + }, | |
200 | + { | |
201 | + type: "input", | |
202 | + name: "end", | |
203 | + message: "End time (HH:MM):", | |
204 | + default: currentSettings.quietHours?.endTime || "08:00", | |
205 | + when: (answers) => answers.enabled, | |
206 | + }, | |
207 | + ]); | |
208 | + | |
209 | + const saveSpinner = ora("Saving settings...").start(); | |
210 | + await postWithAuth("/api/user/notification-preferences", { | |
211 | + quietHours: { | |
212 | + isEnabled: hours.enabled, | |
213 | + startTime: hours.start, | |
214 | + endTime: hours.end, | |
215 | + }, | |
216 | + }); | |
217 | + saveSpinner.succeed("Quiet hours updated"); | |
218 | + } | |
219 | + } catch (error: any) { | |
220 | + console.error(chalk.red(`Failed: ${error.message}`)); | |
221 | + } | |
222 | + }); | |
207 | 223 | |
208 | 224 | function getNotificationIcon(type: string): string { |
209 | - const icons: Record<string, string> = { | |
210 | - mention: "๐ฌ", | |
211 | - assign: "๐ค", | |
212 | - review_request: "๐", | |
213 | - pr_approved: "โ
", | |
214 | - pr_changes_requested: "โ ๏ธ", | |
215 | - pr_merged: "๐", | |
216 | - ci_passed: "โ
", | |
217 | - ci_failed: "โ", | |
218 | - comment: "๐ฌ", | |
219 | - }; | |
220 | - return icons[type] || "๐"; | |
225 | + const icons: Record<string, string> = { | |
226 | + mention: "๐ฌ", | |
227 | + assign: "๐ค", | |
228 | + review_request: "๐", | |
229 | + pr_approved: "โ
", | |
230 | + pr_changes_requested: "โ ๏ธ", | |
231 | + pr_merged: "๐", | |
232 | + ci_passed: "โ
", | |
233 | + ci_failed: "โ", | |
234 | + comment: "๐ฌ", | |
235 | + }; | |
236 | + return icons[type] || "๐"; | |
221 | 237 | } |
222 | 238 | |
223 | 239 | function timeAgo(date: string): string { |
224 | - const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000); | |
225 | - if (seconds < 60) return "just now"; | |
226 | - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; | |
227 | - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; | |
228 | - return `${Math.floor(seconds / 86400)}d ago`; | |
240 | + const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000); | |
241 | + if (seconds < 60) return "just now"; | |
242 | + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; | |
243 | + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; | |
244 | + return `${Math.floor(seconds / 86400)}d ago`; | |
229 | 245 | } |
230 | 246 | |
231 | 247 | export default notifyCommand; |
232 | 248 | |
โผcli/src/commands/queue/index.ts
+4-4
| @@ -82,7 +82,7 @@ queueCommands | ||
82 | 82 | } |
83 | 83 | |
84 | 84 | const result = await postWithAuth<{ data: QueueItem }>( |
85 | - `/api/repos/${repoInfo.owner}/${repoInfo.repo}/merge-queue`, | |
85 | + `/api/repos/${repoInfo.owner}/${repoInfo.repo}/queue`, | |
86 | 86 | { |
87 | 87 | pullRequestNumber: parseInt(prNumber), |
88 | 88 | priority: parseInt(options.priority), |
| @@ -131,7 +131,7 @@ queueCommands | ||
131 | 131 | } |
132 | 132 | |
133 | 133 | const result = await getWithAuth<{ data: QueueItem[] }>( |
134 | - `/api/repos/${repoInfo.owner}/${repoInfo.repo}/merge-queue`, | |
134 | + `/api/repos/${repoInfo.owner}/${repoInfo.repo}/queue`, | |
135 | 135 | ); |
136 | 136 | |
137 | 137 | spinner.stop(); |
| @@ -196,7 +196,7 @@ queueCommands | ||
196 | 196 | } |
197 | 197 | |
198 | 198 | await deleteWithAuth( |
199 | - `/api/repos/${repoInfo.owner}/${repoInfo.repo}/merge-queue/${prNumber}`, | |
199 | + `/api/repos/${repoInfo.owner}/${repoInfo.repo}/queue/${prNumber}`, | |
200 | 200 | ); |
201 | 201 | |
202 | 202 | spinner.succeed(`Removed PR #${prNumber} from merge queue`); |
| @@ -247,7 +247,7 @@ queueCommands | ||
247 | 247 | |
248 | 248 | // Get queue status |
249 | 249 | const result = await getWithAuth<{ data: QueueItem[] }>( |
250 | - `/api/repos/${repoInfo.owner}/${repoInfo.repo}/merge-queue`, | |
250 | + `/api/repos/${repoInfo.owner}/${repoInfo.repo}/queue`, | |
251 | 251 | ); |
252 | 252 | |
253 | 253 | spinner.stop(); |
254 | 254 | |
โผcli/src/commands/review/index.ts
+2-2
| @@ -93,7 +93,7 @@ reviewCommands | ||
93 | 93 | while (review.status === "pending" || review.status === "running") { |
94 | 94 | await new Promise((resolve) => setTimeout(resolve, 3000)); |
95 | 95 | const statusResult = await getWithAuth<{ data: AIReview }>( |
96 | - `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/ai-review/latest`, | |
96 | + `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/ai-review`, | |
97 | 97 | ); |
98 | 98 | review = statusResult.data; |
99 | 99 | } |
| @@ -140,7 +140,7 @@ reviewCommands | ||
140 | 140 | } |
141 | 141 | |
142 | 142 | const result = await getWithAuth<{ data: AIReview }>( |
143 | - `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/ai-review/latest`, | |
143 | + `/api/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/ai-review`, | |
144 | 144 | ); |
145 | 145 | |
146 | 146 | spinner.stop(); |
147 | 147 | |
โผcli/src/commands/search/index.ts
+284-226
| @@ -3,261 +3,319 @@ | ||
3 | 3 | * Search repositories, issues, and PRs from the CLI |
4 | 4 | */ |
5 | 5 | |
6 | -import { Command } from "commander"; | |
7 | 6 | import chalk from "chalk"; |
7 | +import { Command } from "commander"; | |
8 | 8 | import ora from "ora"; |
9 | -import { getConfig } from "../../lib/config.js"; | |
10 | 9 | import { getWithAuth } from "../../lib/api.js"; |
10 | +import { getConfig } from "../../lib/config.js"; | |
11 | 11 | |
12 | 12 | interface SearchResult<T> { |
13 | - total: number; | |
14 | - items: T[]; | |
13 | + total: number; | |
14 | + items: T[]; | |
15 | 15 | } |
16 | 16 | |
17 | 17 | interface RepoResult { |
18 | - id: string; | |
19 | - fullName: string; | |
20 | - description?: string; | |
21 | - visibility: string; | |
22 | - stars: number; | |
23 | - forks: number; | |
24 | - updatedAt: string; | |
18 | + id: string; | |
19 | + fullName: string; | |
20 | + description?: string; | |
21 | + visibility: string; | |
22 | + stars: number; | |
23 | + forks: number; | |
24 | + updatedAt: string; | |
25 | 25 | } |
26 | 26 | |
27 | 27 | interface IssueResult { |
28 | - id: string; | |
29 | - number: number; | |
30 | - title: string; | |
31 | - state: string; | |
32 | - repository: { fullName: string }; | |
33 | - author: { username: string }; | |
34 | - createdAt: string; | |
28 | + id: string; | |
29 | + number: number; | |
30 | + title: string; | |
31 | + state: string; | |
32 | + repository: { fullName: string }; | |
33 | + author: { username: string }; | |
34 | + createdAt: string; | |
35 | 35 | } |
36 | 36 | |
37 | 37 | interface PRResult { |
38 | - id: string; | |
39 | - number: number; | |
40 | - title: string; | |
41 | - state: string; | |
42 | - repository: { fullName: string }; | |
43 | - author: { username: string }; | |
44 | - sourceBranch: string; | |
45 | - targetBranch: string; | |
46 | - createdAt: string; | |
38 | + id: string; | |
39 | + number: number; | |
40 | + title: string; | |
41 | + state: string; | |
42 | + repository: { fullName: string }; | |
43 | + author: { username: string }; | |
44 | + sourceBranch: string; | |
45 | + targetBranch: string; | |
46 | + createdAt: string; | |
47 | 47 | } |
48 | 48 | |
49 | -export const searchCommands = new Command("search") | |
50 | - .description("Search repositories, issues, and pull requests"); | |
49 | +export const searchCommands = new Command("search").description( | |
50 | + "Search repositories, issues, and pull requests", | |
51 | +); | |
51 | 52 | |
52 | 53 | // Search Repos |
53 | 54 | searchCommands |
54 | - .command("repos <query>") | |
55 | - .alias("repo") | |
56 | - .description("Search repositories") | |
57 | - .option("-L, --limit <n>", "Maximum results", "20") | |
58 | - .option("--sort <field>", "Sort by (stars, forks, updated)", "stars") | |
59 | - .option("--order <direction>", "Sort order (asc, desc)", "desc") | |
60 | - .action(async (query: string, options) => { | |
61 | - const config = getConfig(); | |
62 | - if (!config.token) { | |
63 | - console.error(chalk.red("Not logged in. Run 'och auth login' first.")); | |
64 | - process.exit(1); | |
55 | + .command("repos <query>") | |
56 | + .alias("repo") | |
57 | + .description("Search repositories") | |
58 | + .option("-L, --limit <n>", "Maximum results", "20") | |
59 | + .option("--sort <field>", "Sort by (stars, forks, updated)", "stars") | |
60 | + .option("--order <direction>", "Sort order (asc, desc)", "desc") | |
61 | + .action(async (query: string, options) => { | |
62 | + const config = getConfig(); | |
63 | + if (!config.token) { | |
64 | + console.error(chalk.red("Not logged in. Run 'och auth login' first.")); | |
65 | + process.exit(1); | |
66 | + } | |
67 | + | |
68 | + const spinner = ora(`Searching repositories for "${query}"...`).start(); | |
69 | + | |
70 | + try { | |
71 | + const params = new URLSearchParams(); | |
72 | + params.append("q", query); | |
73 | + params.append("limit", options.limit); | |
74 | + params.append("sort", options.sort); | |
75 | + params.append("order", options.order); | |
76 | + | |
77 | + const result = await getWithAuth<{ data: SearchResult<RepoResult> }>( | |
78 | + `/api/search?type=repos&${params}`, | |
79 | + ); | |
80 | + | |
81 | + spinner.stop(); | |
82 | + | |
83 | + if (result.data.items.length === 0) { | |
84 | + console.log(chalk.dim(`No repositories found matching "${query}"`)); | |
85 | + return; | |
86 | + } | |
87 | + | |
88 | + console.log( | |
89 | + chalk.bold( | |
90 | + `\n๐ Repositories (${result.data.total} total, showing ${result.data.items.length})\n`, | |
91 | + ), | |
92 | + ); | |
93 | + | |
94 | + for (const repo of result.data.items) { | |
95 | + const icon = | |
96 | + repo.visibility === "private" | |
97 | + ? chalk.yellow("๐") | |
98 | + : chalk.green("๐ฆ"); | |
99 | + console.log(`${icon} ${chalk.bold(repo.fullName)}`); | |
100 | + if (repo.description) { | |
101 | + console.log( | |
102 | + chalk.dim( | |
103 | + ` ${repo.description.slice(0, 80)}${repo.description.length > 80 ? "..." : ""}`, | |
104 | + ), | |
105 | + ); | |
65 | 106 | } |
66 | - | |
67 | - const spinner = ora(`Searching repositories for "${query}"...`).start(); | |
68 | - | |
69 | - try { | |
70 | - const params = new URLSearchParams(); | |
71 | - params.append("q", query); | |
72 | - params.append("limit", options.limit); | |
73 | - params.append("sort", options.sort); | |
74 | - params.append("order", options.order); | |
75 | - | |
76 | - const result = await getWithAuth<{ data: SearchResult<RepoResult> }>( | |
77 | - `/api/search/repos?${params}` | |
78 | - ); | |
79 | - | |
80 | - spinner.stop(); | |
81 | - | |
82 | - if (result.data.items.length === 0) { | |
83 | - console.log(chalk.dim(`No repositories found matching "${query}"`)); | |
84 | - return; | |
85 | - } | |
86 | - | |
87 | - console.log(chalk.bold(`\n๐ Repositories (${result.data.total} total, showing ${result.data.items.length})\n`)); | |
88 | - | |
89 | - for (const repo of result.data.items) { | |
90 | - const icon = repo.visibility === "private" ? chalk.yellow("๐") : chalk.green("๐ฆ"); | |
91 | - console.log(`${icon} ${chalk.bold(repo.fullName)}`); | |
92 | - if (repo.description) { | |
93 | - console.log(chalk.dim(` ${repo.description.slice(0, 80)}${repo.description.length > 80 ? "..." : ""}`)); | |
94 | - } | |
95 | - console.log(chalk.dim(` โญ ${repo.stars} ๐ด ${repo.forks} Updated ${new Date(repo.updatedAt).toLocaleDateString()}`)); | |
96 | - } | |
97 | - | |
98 | - console.log(""); | |
99 | - } catch (error) { | |
100 | - spinner.fail("Search failed"); | |
101 | - console.error(chalk.red(error instanceof Error ? error.message : "Unknown error")); | |
102 | - process.exit(1); | |
103 | - } | |
104 | - }); | |
107 | + console.log( | |
108 | + chalk.dim( | |
109 | + ` โญ ${repo.stars} ๐ด ${repo.forks} Updated ${new Date(repo.updatedAt).toLocaleDateString()}`, | |
110 | + ), | |
111 | + ); | |
112 | + } | |
113 | + | |
114 | + console.log(""); | |
115 | + } catch (error) { | |
116 | + spinner.fail("Search failed"); | |
117 | + console.error( | |
118 | + chalk.red(error instanceof Error ? error.message : "Unknown error"), | |
119 | + ); | |
120 | + process.exit(1); | |
121 | + } | |
122 | + }); | |
105 | 123 | |
106 | 124 | // Search Issues |
107 | 125 | searchCommands |
108 | - .command("issues <query>") | |
109 | - .alias("issue") | |
110 | - .description("Search issues") | |
111 | - .option("-L, --limit <n>", "Maximum results", "20") | |
112 | - .option("-s, --state <state>", "Filter by state (open, closed, all)", "open") | |
113 | - .option("-r, --repo <repo>", "Limit to specific repository (owner/name)") | |
114 | - .action(async (query: string, options) => { | |
115 | - const config = getConfig(); | |
116 | - if (!config.token) { | |
117 | - console.error(chalk.red("Not logged in. Run 'och auth login' first.")); | |
118 | - process.exit(1); | |
119 | - } | |
120 | - | |
121 | - const spinner = ora(`Searching issues for "${query}"...`).start(); | |
122 | - | |
123 | - try { | |
124 | - const params = new URLSearchParams(); | |
125 | - params.append("q", query); | |
126 | - params.append("limit", options.limit); | |
127 | - if (options.state !== "all") params.append("state", options.state); | |
128 | - if (options.repo) params.append("repo", options.repo); | |
129 | - | |
130 | - const result = await getWithAuth<{ data: SearchResult<IssueResult> }>( | |
131 | - `/api/search/issues?${params}` | |
132 | - ); | |
133 | - | |
134 | - spinner.stop(); | |
135 | - | |
136 | - if (result.data.items.length === 0) { | |
137 | - console.log(chalk.dim(`No issues found matching "${query}"`)); | |
138 | - return; | |
139 | - } | |
140 | - | |
141 | - console.log(chalk.bold(`\n๐ Issues (${result.data.total} total, showing ${result.data.items.length})\n`)); | |
142 | - | |
143 | - for (const issue of result.data.items) { | |
144 | - const icon = issue.state === "open" ? chalk.green("โ") : chalk.red("โ"); | |
145 | - console.log(`${icon} ${chalk.cyan(issue.repository.fullName)}#${issue.number} ${issue.title}`); | |
146 | - console.log(chalk.dim(` @${issue.author.username} โข ${new Date(issue.createdAt).toLocaleDateString()}`)); | |
147 | - } | |
148 | - | |
149 | - console.log(""); | |
150 | - } catch (error) { | |
151 | - spinner.fail("Search failed"); | |
152 | - console.error(chalk.red(error instanceof Error ? error.message : "Unknown error")); | |
153 | - process.exit(1); | |
154 | - } | |
155 | - }); | |
126 | + .command("issues <query>") | |
127 | + .alias("issue") | |
128 | + .description("Search issues") | |
129 | + .option("-L, --limit <n>", "Maximum results", "20") | |
130 | + .option("-s, --state <state>", "Filter by state (open, closed, all)", "open") | |
131 | + .option("-r, --repo <repo>", "Limit to specific repository (owner/name)") | |
132 | + .action(async (query: string, options) => { | |
133 | + const config = getConfig(); | |
134 | + if (!config.token) { | |
135 | + console.error(chalk.red("Not logged in. Run 'och auth login' first.")); | |
136 | + process.exit(1); | |
137 | + } | |
138 | + | |
139 | + const spinner = ora(`Searching issues for "${query}"...`).start(); | |
140 | + | |
141 | + try { | |
142 | + const params = new URLSearchParams(); | |
143 | + params.append("q", query); | |
144 | + params.append("limit", options.limit); | |
145 | + if (options.state !== "all") params.append("state", options.state); | |
146 | + if (options.repo) params.append("repo", options.repo); | |
147 | + | |
148 | + const result = await getWithAuth<{ data: SearchResult<IssueResult> }>( | |
149 | + `/api/search?type=issues&${params}`, | |
150 | + ); | |
151 | + | |
152 | + spinner.stop(); | |
153 | + | |
154 | + if (result.data.items.length === 0) { | |
155 | + console.log(chalk.dim(`No issues found matching "${query}"`)); | |
156 | + return; | |
157 | + } | |
158 | + | |
159 | + console.log( | |
160 | + chalk.bold( | |
161 | + `\n๐ Issues (${result.data.total} total, showing ${result.data.items.length})\n`, | |
162 | + ), | |
163 | + ); | |
164 | + | |
165 | + for (const issue of result.data.items) { | |
166 | + const icon = issue.state === "open" ? chalk.green("โ") : chalk.red("โ"); | |
167 | + console.log( | |
168 | + `${icon} ${chalk.cyan(issue.repository.fullName)}#${issue.number} ${issue.title}`, | |
169 | + ); | |
170 | + console.log( | |
171 | + chalk.dim( | |
172 | + ` @${issue.author.username} โข ${new Date(issue.createdAt).toLocaleDateString()}`, | |
173 | + ), | |
174 | + ); | |
175 | + } | |
176 | + | |
177 | + console.log(""); | |
178 | + } catch (error) { | |
179 | + spinner.fail("Search failed"); | |
180 | + console.error( | |
181 | + chalk.red(error instanceof Error ? error.message : "Unknown error"), | |
182 | + ); | |
183 | + process.exit(1); | |
184 | + } | |
185 | + }); | |
156 | 186 | |
157 | 187 | // Search PRs |
158 | 188 | searchCommands |
159 | - .command("prs <query>") | |
160 | - .alias("pr") | |
161 | - .description("Search pull requests") | |
162 | - .option("-L, --limit <n>", "Maximum results", "20") | |
163 | - .option("-s, --state <state>", "Filter by state (open, closed, merged, all)", "open") | |
164 | - .option("-r, --repo <repo>", "Limit to specific repository (owner/name)") | |
165 | - .action(async (query: string, options) => { | |
166 | - const config = getConfig(); | |
167 | - if (!config.token) { | |
168 | - console.error(chalk.red("Not logged in. Run 'och auth login' first.")); | |
169 | - process.exit(1); | |
170 | - } | |
171 | - | |
172 | - const spinner = ora(`Searching pull requests for "${query}"...`).start(); | |
173 | - | |
174 | - try { | |
175 | - const params = new URLSearchParams(); | |
176 | - params.append("q", query); | |
177 | - params.append("limit", options.limit); | |
178 | - if (options.state !== "all") params.append("state", options.state); | |
179 | - if (options.repo) params.append("repo", options.repo); | |
180 | - | |
181 | - const result = await getWithAuth<{ data: SearchResult<PRResult> }>( | |
182 | - `/api/search/prs?${params}` | |
183 | - ); | |
184 | - | |
185 | - spinner.stop(); | |
186 | - | |
187 | - if (result.data.items.length === 0) { | |
188 | - console.log(chalk.dim(`No pull requests found matching "${query}"`)); | |
189 | - return; | |
190 | - } | |
191 | - | |
192 | - console.log(chalk.bold(`\n๐ Pull Requests (${result.data.total} total, showing ${result.data.items.length})\n`)); | |
193 | - | |
194 | - for (const pr of result.data.items) { | |
195 | - const icon = pr.state === "open" ? chalk.green("โ") : | |
196 | - pr.state === "merged" ? chalk.magenta("โ") : chalk.red("โ"); | |
197 | - console.log(`${icon} ${chalk.cyan(pr.repository.fullName)}#${pr.number} ${pr.title}`); | |
198 | - console.log(chalk.dim(` ${pr.sourceBranch} โ ${pr.targetBranch} โข @${pr.author.username}`)); | |
199 | - } | |
200 | - | |
201 | - console.log(""); | |
202 | - } catch (error) { | |
203 | - spinner.fail("Search failed"); | |
204 | - console.error(chalk.red(error instanceof Error ? error.message : "Unknown error")); | |
205 | - process.exit(1); | |
206 | - } | |
207 | - }); | |
189 | + .command("prs <query>") | |
190 | + .alias("pr") | |
191 | + .description("Search pull requests") | |
192 | + .option("-L, --limit <n>", "Maximum results", "20") | |
193 | + .option( | |
194 | + "-s, --state <state>", | |
195 | + "Filter by state (open, closed, merged, all)", | |
196 | + "open", | |
197 | + ) | |
198 | + .option("-r, --repo <repo>", "Limit to specific repository (owner/name)") | |
199 | + .action(async (query: string, options) => { | |
200 | + const config = getConfig(); | |
201 | + if (!config.token) { | |
202 | + console.error(chalk.red("Not logged in. Run 'och auth login' first.")); | |
203 | + process.exit(1); | |
204 | + } | |
205 | + | |
206 | + const spinner = ora(`Searching pull requests for "${query}"...`).start(); | |
207 | + | |
208 | + try { | |
209 | + const params = new URLSearchParams(); | |
210 | + params.append("q", query); | |
211 | + params.append("limit", options.limit); | |
212 | + if (options.state !== "all") params.append("state", options.state); | |
213 | + if (options.repo) params.append("repo", options.repo); | |
214 | + | |
215 | + const result = await getWithAuth<{ data: SearchResult<PRResult> }>( | |
216 | + `/api/search?type=prs&${params}`, | |
217 | + ); | |
218 | + | |
219 | + spinner.stop(); | |
220 | + | |
221 | + if (result.data.items.length === 0) { | |
222 | + console.log(chalk.dim(`No pull requests found matching "${query}"`)); | |
223 | + return; | |
224 | + } | |
225 | + | |
226 | + console.log( | |
227 | + chalk.bold( | |
228 | + `\n๐ Pull Requests (${result.data.total} total, showing ${result.data.items.length})\n`, | |
229 | + ), | |
230 | + ); | |
231 | + | |
232 | + for (const pr of result.data.items) { | |
233 | + const icon = | |
234 | + pr.state === "open" | |
235 | + ? chalk.green("โ") | |
236 | + : pr.state === "merged" | |
237 | + ? chalk.magenta("โ") | |
238 | + : chalk.red("โ"); | |
239 | + console.log( | |
240 | + `${icon} ${chalk.cyan(pr.repository.fullName)}#${pr.number} ${pr.title}`, | |
241 | + ); | |
242 | + console.log( | |
243 | + chalk.dim( | |
244 | + ` ${pr.sourceBranch} โ ${pr.targetBranch} โข @${pr.author.username}`, | |
245 | + ), | |
246 | + ); | |
247 | + } | |
248 | + | |
249 | + console.log(""); | |
250 | + } catch (error) { | |
251 | + spinner.fail("Search failed"); | |
252 | + console.error( | |
253 | + chalk.red(error instanceof Error ? error.message : "Unknown error"), | |
254 | + ); | |
255 | + process.exit(1); | |
256 | + } | |
257 | + }); | |
208 | 258 | |
209 | 259 | // Search Code (bonus) |
210 | 260 | searchCommands |
211 | - .command("code <query>") | |
212 | - .description("Search code") | |
213 | - .option("-L, --limit <n>", "Maximum results", "20") | |
214 | - .option("-r, --repo <repo>", "Limit to specific repository (owner/name)") | |
215 | - .option("-l, --language <lang>", "Filter by language") | |
216 | - .action(async (query: string, options) => { | |
217 | - const config = getConfig(); | |
218 | - if (!config.token) { | |
219 | - console.error(chalk.red("Not logged in. Run 'och auth login' first.")); | |
220 | - process.exit(1); | |
221 | - } | |
222 | - | |
223 | - const spinner = ora(`Searching code for "${query}"...`).start(); | |
224 | - | |
225 | - try { | |
226 | - const params = new URLSearchParams(); | |
227 | - params.append("q", query); | |
228 | - params.append("limit", options.limit); | |
229 | - if (options.repo) params.append("repo", options.repo); | |
230 | - if (options.language) params.append("language", options.language); | |
231 | - | |
232 | - const result = await getWithAuth<{ | |
233 | - data: SearchResult<{ | |
234 | - path: string; | |
235 | - repository: { fullName: string }; | |
236 | - matches: { line: number; content: string }[]; | |
237 | - }> | |
238 | - }>(`/api/search/code?${params}`); | |
239 | - | |
240 | - spinner.stop(); | |
241 | - | |
242 | - if (result.data.items.length === 0) { | |
243 | - console.log(chalk.dim(`No code found matching "${query}"`)); | |
244 | - return; | |
245 | - } | |
246 | - | |
247 | - console.log(chalk.bold(`\n๐ Code (${result.data.total} total, showing ${result.data.items.length})\n`)); | |
248 | - | |
249 | - for (const item of result.data.items) { | |
250 | - console.log(`${chalk.cyan(item.repository.fullName)}/${chalk.bold(item.path)}`); | |
251 | - for (const match of item.matches.slice(0, 3)) { | |
252 | - console.log(chalk.dim(` ${match.line}: `) + match.content.trim()); | |
253 | - } | |
254 | - console.log(""); | |
255 | - } | |
256 | - } catch (error) { | |
257 | - spinner.fail("Search failed"); | |
258 | - console.error(chalk.red(error instanceof Error ? error.message : "Unknown error")); | |
259 | - process.exit(1); | |
261 | + .command("code <query>") | |
262 | + .description("Search code") | |
263 | + .option("-L, --limit <n>", "Maximum results", "20") | |
264 | + .option("-r, --repo <repo>", "Limit to specific repository (owner/name)") | |
265 | + .option("-l, --language <lang>", "Filter by language") | |
266 | + .action(async (query: string, options) => { | |
267 | + const config = getConfig(); | |
268 | + if (!config.token) { | |
269 | + console.error(chalk.red("Not logged in. Run 'och auth login' first.")); | |
270 | + process.exit(1); | |
271 | + } | |
272 | + | |
273 | + const spinner = ora(`Searching code for "${query}"...`).start(); | |
274 | + | |
275 | + try { | |
276 | + const params = new URLSearchParams(); | |
277 | + params.append("q", query); | |
278 | + params.append("limit", options.limit); | |
279 | + if (options.repo) params.append("repo", options.repo); | |
280 | + if (options.language) params.append("language", options.language); | |
281 | + | |
282 | + const result = await getWithAuth<{ | |
283 | + data: SearchResult<{ | |
284 | + path: string; | |
285 | + repository: { fullName: string }; | |
286 | + matches: { line: number; content: string }[]; | |
287 | + }>; | |
288 | + }>(`/api/search?type=code&${params}`); | |
289 | + | |
290 | + spinner.stop(); | |
291 | + | |
292 | + if (result.data.items.length === 0) { | |
293 | + console.log(chalk.dim(`No code found matching "${query}"`)); | |
294 | + return; | |
295 | + } | |
296 | + | |
297 | + console.log( | |
298 | + chalk.bold( | |
299 | + `\n๐ Code (${result.data.total} total, showing ${result.data.items.length})\n`, | |
300 | + ), | |
301 | + ); | |
302 | + | |
303 | + for (const item of result.data.items) { | |
304 | + console.log( | |
305 | + `${chalk.cyan(item.repository.fullName)}/${chalk.bold(item.path)}`, | |
306 | + ); | |
307 | + for (const match of item.matches.slice(0, 3)) { | |
308 | + console.log(chalk.dim(` ${match.line}: `) + match.content.trim()); | |
260 | 309 | } |
261 | - }); | |
310 | + console.log(""); | |
311 | + } | |
312 | + } catch (error) { | |
313 | + spinner.fail("Search failed"); | |
314 | + console.error( | |
315 | + chalk.red(error instanceof Error ? error.message : "Unknown error"), | |
316 | + ); | |
317 | + process.exit(1); | |
318 | + } | |
319 | + }); | |
262 | 320 | |
263 | 321 | export default searchCommands; |
264 | 322 | |
โผcli/src/commands/ssh-key/index.ts
+208-189
| @@ -3,218 +3,237 @@ | ||
3 | 3 | * Manage SSH keys from the CLI |
4 | 4 | */ |
5 | 5 | |
6 | -import { Command } from "commander"; | |
7 | 6 | import chalk from "chalk"; |
8 | -import ora from "ora"; | |
9 | -import inquirer from "inquirer"; | |
7 | +import { Command } from "commander"; | |
10 | 8 | import * as fs from "fs"; |
9 | +import inquirer from "inquirer"; | |
10 | +import ora from "ora"; | |
11 | 11 | import * as os from "os"; |
12 | 12 | import * as path from "path"; |
13 | +import { deleteWithAuth, getWithAuth, postWithAuth } from "../../lib/api.js"; | |
13 | 14 | import { getConfig } from "../../lib/config.js"; |
14 | -import { getWithAuth, postWithAuth, deleteWithAuth } from "../../lib/api.js"; | |
15 | 15 | |
16 | 16 | interface SSHKey { |
17 | - id: string; | |
18 | - title: string; | |
19 | - key: string; | |
20 | - fingerprint: string; | |
21 | - createdAt: string; | |
22 | - lastUsedAt?: string; | |
17 | + id: string; | |
18 | + title: string; | |
19 | + key: string; | |
20 | + fingerprint: string; | |
21 | + createdAt: string; | |
22 | + lastUsedAt?: string; | |
23 | 23 | } |
24 | 24 | |
25 | -export const sshKeyCommands = new Command("ssh-key") | |
26 | - .description("Manage SSH keys"); | |
25 | +export const sshKeyCommands = new Command("ssh-key").description( | |
26 | + "Manage SSH keys", | |
27 | +); | |
27 | 28 | |
28 | 29 | // SSH Key Add |
29 | 30 | sshKeyCommands |
30 | - .command("add") | |
31 | - .description("Add an SSH key to your account") | |
32 | - .option("-t, --title <title>", "Key title") | |
33 | - .option("-f, --file <path>", "Path to public key file") | |
34 | - .action(async (options) => { | |
35 | - const config = getConfig(); | |
36 | - if (!config.token) { | |
37 | - console.error(chalk.red("Not logged in. Run 'och auth login' first.")); | |
38 | - process.exit(1); | |
31 | + .command("add") | |
32 | + .description("Add an SSH key to your account") | |
33 | + .option("-t, --title <title>", "Key title") | |
34 | + .option("-f, --file <path>", "Path to public key file") | |
35 | + .action(async (options) => { | |
36 | + const config = getConfig(); | |
37 | + if (!config.token) { | |
38 | + console.error(chalk.red("Not logged in. Run 'och auth login' first.")); | |
39 | + process.exit(1); | |
40 | + } | |
41 | + | |
42 | + try { | |
43 | + let publicKey: string; | |
44 | + let title = options.title; | |
45 | + | |
46 | + if (options.file) { | |
47 | + // Read from file | |
48 | + const keyPath = options.file.replace("~", os.homedir()); | |
49 | + if (!fs.existsSync(keyPath)) { | |
50 | + console.error(chalk.red(`File not found: ${keyPath}`)); | |
51 | + process.exit(1); | |
52 | + } | |
53 | + publicKey = fs.readFileSync(keyPath, "utf-8").trim(); | |
54 | + } else { | |
55 | + // Look for default keys | |
56 | + const sshDir = path.join(os.homedir(), ".ssh"); | |
57 | + const defaultKeys = ["id_ed25519.pub", "id_rsa.pub", "id_ecdsa.pub"]; | |
58 | + const availableKeys: { name: string; path: string }[] = []; | |
59 | + | |
60 | + for (const keyFile of defaultKeys) { | |
61 | + const keyPath = path.join(sshDir, keyFile); | |
62 | + if (fs.existsSync(keyPath)) { | |
63 | + availableKeys.push({ name: keyFile, path: keyPath }); | |
64 | + } | |
39 | 65 | } |
40 | 66 | |
41 | - try { | |
42 | - let publicKey: string; | |
43 | - let title = options.title; | |
44 | - | |
45 | - if (options.file) { | |
46 | - // Read from file | |
47 | - const keyPath = options.file.replace("~", os.homedir()); | |
48 | - if (!fs.existsSync(keyPath)) { | |
49 | - console.error(chalk.red(`File not found: ${keyPath}`)); | |
50 | - process.exit(1); | |
51 | - } | |
52 | - publicKey = fs.readFileSync(keyPath, "utf-8").trim(); | |
53 | - } else { | |
54 | - // Look for default keys | |
55 | - const sshDir = path.join(os.homedir(), ".ssh"); | |
56 | - const defaultKeys = ["id_ed25519.pub", "id_rsa.pub", "id_ecdsa.pub"]; | |
57 | - const availableKeys: { name: string; path: string }[] = []; | |
58 | - | |
59 | - for (const keyFile of defaultKeys) { | |
60 | - const keyPath = path.join(sshDir, keyFile); | |
61 | - if (fs.existsSync(keyPath)) { | |
62 | - availableKeys.push({ name: keyFile, path: keyPath }); | |
63 | - } | |
64 | - } | |
65 | - | |
66 | - if (availableKeys.length === 0) { | |
67 | - console.log(chalk.yellow("No SSH keys found in ~/.ssh/")); | |
68 | - console.log(chalk.dim("Generate one with: ssh-keygen -t ed25519")); | |
69 | - process.exit(1); | |
70 | - } | |
71 | - | |
72 | - const { selectedKey } = await inquirer.prompt([ | |
73 | - { | |
74 | - type: "list", | |
75 | - name: "selectedKey", | |
76 | - message: "Select SSH key to add:", | |
77 | - choices: availableKeys.map(k => ({ | |
78 | - name: k.name, | |
79 | - value: k.path, | |
80 | - })), | |
81 | - }, | |
82 | - ]); | |
83 | - | |
84 | - publicKey = fs.readFileSync(selectedKey, "utf-8").trim(); | |
85 | - } | |
86 | - | |
87 | - // Parse key type and validate | |
88 | - if (!publicKey.startsWith("ssh-") && !publicKey.startsWith("ecdsa-")) { | |
89 | - console.error(chalk.red("Invalid SSH public key format")); | |
90 | - process.exit(1); | |
91 | - } | |
92 | - | |
93 | - // Get title if not provided | |
94 | - if (!title) { | |
95 | - const defaultTitle = `${os.userInfo().username}@${os.hostname()}`; | |
96 | - const answers = await inquirer.prompt([ | |
97 | - { | |
98 | - type: "input", | |
99 | - name: "title", | |
100 | - message: "Key title:", | |
101 | - default: defaultTitle, | |
102 | - }, | |
103 | - ]); | |
104 | - title = answers.title; | |
105 | - } | |
106 | - | |
107 | - const spinner = ora("Adding SSH key...").start(); | |
108 | - | |
109 | - const result = await postWithAuth<{ data: SSHKey }>( | |
110 | - "/api/user/ssh-keys", | |
111 | - { title, key: publicKey } | |
112 | - ); | |
113 | - | |
114 | - spinner.succeed(`Added SSH key: ${chalk.cyan(title)}`); | |
115 | - console.log(chalk.dim(` Fingerprint: ${result.data.fingerprint}`)); | |
116 | - } catch (error) { | |
117 | - console.error(chalk.red("Failed to add SSH key")); | |
118 | - console.error(chalk.red(error instanceof Error ? error.message : "Unknown error")); | |
119 | - process.exit(1); | |
67 | + if (availableKeys.length === 0) { | |
68 | + console.log(chalk.yellow("No SSH keys found in ~/.ssh/")); | |
69 | + console.log(chalk.dim("Generate one with: ssh-keygen -t ed25519")); | |
70 | + process.exit(1); | |
120 | 71 | } |
121 | - }); | |
72 | + | |
73 | + const { selectedKey } = await inquirer.prompt([ | |
74 | + { | |
75 | + type: "list", | |
76 | + name: "selectedKey", | |
77 | + message: "Select SSH key to add:", | |
78 | + choices: availableKeys.map((k) => ({ | |
79 | + name: k.name, | |
80 | + value: k.path, | |
81 | + })), | |
82 | + }, | |
83 | + ]); | |
84 | + | |
85 | + publicKey = fs.readFileSync(selectedKey, "utf-8").trim(); | |
86 | + } | |
87 | + | |
88 | + // Parse key type and validate | |
89 | + if (!publicKey.startsWith("ssh-") && !publicKey.startsWith("ecdsa-")) { | |
90 | + console.error(chalk.red("Invalid SSH public key format")); | |
91 | + process.exit(1); | |
92 | + } | |
93 | + | |
94 | + // Get title if not provided | |
95 | + if (!title) { | |
96 | + const defaultTitle = `${os.userInfo().username}@${os.hostname()}`; | |
97 | + const answers = await inquirer.prompt([ | |
98 | + { | |
99 | + type: "input", | |
100 | + name: "title", | |
101 | + message: "Key title:", | |
102 | + default: defaultTitle, | |
103 | + }, | |
104 | + ]); | |
105 | + title = answers.title; | |
106 | + } | |
107 | + | |
108 | + const spinner = ora("Adding SSH key...").start(); | |
109 | + | |
110 | + const result = await postWithAuth<{ data: SSHKey }>("/api/user/keys", { | |
111 | + title, | |
112 | + key: publicKey, | |
113 | + }); | |
114 | + | |
115 | + spinner.succeed(`Added SSH key: ${chalk.cyan(title)}`); | |
116 | + console.log(chalk.dim(` Fingerprint: ${result.data.fingerprint}`)); | |
117 | + } catch (error) { | |
118 | + console.error(chalk.red("Failed to add SSH key")); | |
119 | + console.error( | |
120 | + chalk.red(error instanceof Error ? error.message : "Unknown error"), | |
121 | + ); | |
122 | + process.exit(1); | |
123 | + } | |
124 | + }); | |
122 | 125 | |
123 | 126 | // SSH Key List |
124 | 127 | sshKeyCommands |
125 | - .command("list") | |
126 | - .alias("ls") | |
127 | - .description("List your SSH keys") | |
128 | - .action(async () => { | |
129 | - const config = getConfig(); | |
130 | - if (!config.token) { | |
131 | - console.error(chalk.red("Not logged in. Run 'och auth login' first.")); | |
132 | - process.exit(1); | |
133 | - } | |
134 | - | |
135 | - const spinner = ora("Fetching SSH keys...").start(); | |
136 | - | |
137 | - try { | |
138 | - const result = await getWithAuth<{ data: SSHKey[] }>("/api/user/ssh-keys"); | |
139 | - | |
140 | - spinner.stop(); | |
141 | - | |
142 | - if (result.data.length === 0) { | |
143 | - console.log(chalk.dim("No SSH keys found.")); | |
144 | - console.log(chalk.dim("Add one with: och ssh-key add")); | |
145 | - return; | |
146 | - } | |
147 | - | |
148 | - console.log(chalk.bold(`\n๐ SSH Keys (${result.data.length})\n`)); | |
149 | - | |
150 | - for (const key of result.data) { | |
151 | - const keyType = key.key.split(" ")[0].replace("ssh-", "").toUpperCase(); | |
152 | - console.log(` ${chalk.cyan(key.title)} ${chalk.dim(`(${keyType})`)}`); | |
153 | - console.log(chalk.dim(` ID: ${key.id}`)); | |
154 | - console.log(chalk.dim(` Fingerprint: ${key.fingerprint}`)); | |
155 | - console.log(chalk.dim(` Added: ${new Date(key.createdAt).toLocaleDateString()}`)); | |
156 | - if (key.lastUsedAt) { | |
157 | - console.log(chalk.dim(` Last used: ${new Date(key.lastUsedAt).toLocaleDateString()}`)); | |
158 | - } | |
159 | - console.log(""); | |
160 | - } | |
161 | - } catch (error) { | |
162 | - spinner.fail("Failed to list SSH keys"); | |
163 | - console.error(chalk.red(error instanceof Error ? error.message : "Unknown error")); | |
164 | - process.exit(1); | |
128 | + .command("list") | |
129 | + .alias("ls") | |
130 | + .description("List your SSH keys") | |
131 | + .action(async () => { | |
132 | + const config = getConfig(); | |
133 | + if (!config.token) { | |
134 | + console.error(chalk.red("Not logged in. Run 'och auth login' first.")); | |
135 | + process.exit(1); | |
136 | + } | |
137 | + | |
138 | + const spinner = ora("Fetching SSH keys...").start(); | |
139 | + | |
140 | + try { | |
141 | + const result = await getWithAuth<{ data: SSHKey[] }>( | |
142 | + "/api/user/ssh-keys", | |
143 | + ); | |
144 | + | |
145 | + spinner.stop(); | |
146 | + | |
147 | + if (result.data.length === 0) { | |
148 | + console.log(chalk.dim("No SSH keys found.")); | |
149 | + console.log(chalk.dim("Add one with: och ssh-key add")); | |
150 | + return; | |
151 | + } | |
152 | + | |
153 | + console.log(chalk.bold(`\n๐ SSH Keys (${result.data.length})\n`)); | |
154 | + | |
155 | + for (const key of result.data) { | |
156 | + const keyType = key.key.split(" ")[0].replace("ssh-", "").toUpperCase(); | |
157 | + console.log(` ${chalk.cyan(key.title)} ${chalk.dim(`(${keyType})`)}`); | |
158 | + console.log(chalk.dim(` ID: ${key.id}`)); | |
159 | + console.log(chalk.dim(` Fingerprint: ${key.fingerprint}`)); | |
160 | + console.log( | |
161 | + chalk.dim( | |
162 | + ` Added: ${new Date(key.createdAt).toLocaleDateString()}`, | |
163 | + ), | |
164 | + ); | |
165 | + if (key.lastUsedAt) { | |
166 | + console.log( | |
167 | + chalk.dim( | |
168 | + ` Last used: ${new Date(key.lastUsedAt).toLocaleDateString()}`, | |
169 | + ), | |
170 | + ); | |
165 | 171 | } |
166 | - }); | |
172 | + console.log(""); | |
173 | + } | |
174 | + } catch (error) { | |
175 | + spinner.fail("Failed to list SSH keys"); | |
176 | + console.error( | |
177 | + chalk.red(error instanceof Error ? error.message : "Unknown error"), | |
178 | + ); | |
179 | + process.exit(1); | |
180 | + } | |
181 | + }); | |
167 | 182 | |
168 | 183 | // SSH Key Delete |
169 | 184 | sshKeyCommands |
170 | - .command("delete <id>") | |
171 | - .alias("rm") | |
172 | - .description("Delete an SSH key") | |
173 | - .option("-y, --yes", "Skip confirmation") | |
174 | - .action(async (id: string, options) => { | |
175 | - const config = getConfig(); | |
176 | - if (!config.token) { | |
177 | - console.error(chalk.red("Not logged in. Run 'och auth login' first.")); | |
178 | - process.exit(1); | |
185 | + .command("delete <id>") | |
186 | + .alias("rm") | |
187 | + .description("Delete an SSH key") | |
188 | + .option("-y, --yes", "Skip confirmation") | |
189 | + .action(async (id: string, options) => { | |
190 | + const config = getConfig(); | |
191 | + if (!config.token) { | |
192 | + console.error(chalk.red("Not logged in. Run 'och auth login' first.")); | |
193 | + process.exit(1); | |
194 | + } | |
195 | + | |
196 | + try { | |
197 | + // Get key info first | |
198 | + const spinner = ora("Fetching key info...").start(); | |
199 | + const result = await getWithAuth<{ data: SSHKey[] }>( | |
200 | + "/api/user/ssh-keys", | |
201 | + ); | |
202 | + const key = result.data.find((k) => k.id === id); | |
203 | + spinner.stop(); | |
204 | + | |
205 | + if (!key) { | |
206 | + console.error(chalk.red(`SSH key not found: ${id}`)); | |
207 | + process.exit(1); | |
208 | + } | |
209 | + | |
210 | + if (!options.yes) { | |
211 | + const { confirm } = await inquirer.prompt([ | |
212 | + { | |
213 | + type: "confirm", | |
214 | + name: "confirm", | |
215 | + message: `Delete SSH key "${key.title}"?`, | |
216 | + default: false, | |
217 | + }, | |
218 | + ]); | |
219 | + if (!confirm) { | |
220 | + console.log(chalk.dim("Cancelled.")); | |
221 | + return; | |
179 | 222 | } |
223 | + } | |
180 | 224 | |
181 | - try { | |
182 | - // Get key info first | |
183 | - const spinner = ora("Fetching key info...").start(); | |
184 | - const result = await getWithAuth<{ data: SSHKey[] }>("/api/user/ssh-keys"); | |
185 | - const key = result.data.find(k => k.id === id); | |
186 | - spinner.stop(); | |
187 | - | |
188 | - if (!key) { | |
189 | - console.error(chalk.red(`SSH key not found: ${id}`)); | |
190 | - process.exit(1); | |
191 | - } | |
192 | - | |
193 | - if (!options.yes) { | |
194 | - const { confirm } = await inquirer.prompt([ | |
195 | - { | |
196 | - type: "confirm", | |
197 | - name: "confirm", | |
198 | - message: `Delete SSH key "${key.title}"?`, | |
199 | - default: false, | |
200 | - }, | |
201 | - ]); | |
202 | - if (!confirm) { | |
203 | - console.log(chalk.dim("Cancelled.")); | |
204 | - return; | |
205 | - } | |
206 | - } | |
207 | - | |
208 | - const deleteSpinner = ora("Deleting SSH key...").start(); | |
209 | - | |
210 | - await deleteWithAuth(`/api/user/ssh-keys/${id}`); | |
211 | - | |
212 | - deleteSpinner.succeed(`Deleted SSH key: ${chalk.cyan(key.title)}`); | |
213 | - } catch (error) { | |
214 | - console.error(chalk.red("Failed to delete SSH key")); | |
215 | - console.error(chalk.red(error instanceof Error ? error.message : "Unknown error")); | |
216 | - process.exit(1); | |
217 | - } | |
218 | - }); | |
225 | + const deleteSpinner = ora("Deleting SSH key...").start(); | |
226 | + | |
227 | + await deleteWithAuth(`/api/user/keys/${id}`); | |
228 | + | |
229 | + deleteSpinner.succeed(`Deleted SSH key: ${chalk.cyan(key.title)}`); | |
230 | + } catch (error) { | |
231 | + console.error(chalk.red("Failed to delete SSH key")); | |
232 | + console.error( | |
233 | + chalk.red(error instanceof Error ? error.message : "Unknown error"), | |
234 | + ); | |
235 | + process.exit(1); | |
236 | + } | |
237 | + }); | |
219 | 238 | |
220 | 239 | export default sshKeyCommands; |
221 | 240 | |
โผcli/src/lib/api-error.ts
+30-0
| @@ -0,0 +1,30 @@ | ||
1 | +export function parseApiErrorMessage( | |
2 | + status: number, | |
3 | + data: unknown, | |
4 | + fallbackPrefix = "API error", | |
5 | +) { | |
6 | + if (typeof data === "string" && data.trim()) return data.trim(); | |
7 | + if (!data || typeof data !== "object") return `${fallbackPrefix}: ${status}`; | |
8 | + | |
9 | + const record = data as Record<string, unknown>; | |
10 | + const directMessage = record.message || record.error || record.detail; | |
11 | + if (typeof directMessage === "string" && directMessage.trim()) { | |
12 | + return directMessage.trim(); | |
13 | + } | |
14 | + | |
15 | + if (record.errors && Array.isArray(record.errors) && record.errors.length > 0) { | |
16 | + const messages = record.errors | |
17 | + .map((entry) => { | |
18 | + if (typeof entry === "string") return entry; | |
19 | + if (entry && typeof entry === "object") { | |
20 | + const msg = (entry as Record<string, unknown>).message; | |
21 | + if (typeof msg === "string") return msg; | |
22 | + } | |
23 | + return null; | |
24 | + }) | |
25 | + .filter((entry): entry is string => !!entry); | |
26 | + if (messages.length > 0) return messages.join("; "); | |
27 | + } | |
28 | + | |
29 | + return `${fallbackPrefix}: ${status}`; | |
30 | +} | |
0 | 31 | |
โผcli/src/lib/api.test.ts
+26-0
| @@ -0,0 +1,26 @@ | ||
1 | +import { describe, expect, it } from "vitest"; | |
2 | +import { parseApiErrorMessage } from "./api-error.js"; | |
3 | + | |
4 | +describe("parseApiErrorMessage", () => { | |
5 | + it("uses plain string error body", () => { | |
6 | + expect(parseApiErrorMessage(400, "Bad request")).toBe("Bad request"); | |
7 | + }); | |
8 | + | |
9 | + it("reads common message fields", () => { | |
10 | + expect(parseApiErrorMessage(403, { message: "Forbidden" })).toBe("Forbidden"); | |
11 | + expect(parseApiErrorMessage(401, { error: "Unauthorized" })).toBe("Unauthorized"); | |
12 | + expect(parseApiErrorMessage(422, { detail: "Validation failed" })).toBe("Validation failed"); | |
13 | + }); | |
14 | + | |
15 | + it("flattens array style errors", () => { | |
16 | + expect( | |
17 | + parseApiErrorMessage(422, { | |
18 | + errors: [{ message: "name is required" }, "email is invalid"], | |
19 | + }), | |
20 | + ).toBe("name is required; email is invalid"); | |
21 | + }); | |
22 | + | |
23 | + it("falls back when no message can be extracted", () => { | |
24 | + expect(parseApiErrorMessage(500, { foo: "bar" })).toBe("API error: 500"); | |
25 | + }); | |
26 | +}); | |
0 | 27 | |
โผcli/src/lib/api.ts
+67-14
| @@ -4,9 +4,11 @@ | ||
4 | 4 | */ |
5 | 5 | |
6 | 6 | import fs from "fs"; |
7 | +import { parseApiErrorMessage } from "./api-error.js"; | |
7 | 8 | import { getConfig } from "./config.js"; |
8 | 9 | |
9 | 10 | let tlsConfigured = false; |
11 | +const DEFAULT_TIMEOUT_MS = 15000; | |
10 | 12 | |
11 | 13 | export function applyTlsConfig() { |
12 | 14 | if (tlsConfigured) return; |
| @@ -35,6 +37,49 @@ function shouldRetry(status: number) { | ||
35 | 37 | return status === 429 || status === 502 || status === 503 || status === 504; |
36 | 38 | } |
37 | 39 | |
40 | +function getRequestTimeoutMs() { | |
41 | + const raw = process.env.OCH_HTTP_TIMEOUT_MS; | |
42 | + if (!raw) return DEFAULT_TIMEOUT_MS; | |
43 | + const parsed = Number(raw); | |
44 | + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_TIMEOUT_MS; | |
45 | + return parsed; | |
46 | +} | |
47 | + | |
48 | +function isRetriableNetworkError(error: unknown): boolean { | |
49 | + if (!error || typeof error !== "object") return false; | |
50 | + const record = error as Record<string, unknown>; | |
51 | + const message = String(record.message || "").toLowerCase(); | |
52 | + const code = String(record.code || "").toUpperCase(); | |
53 | + return ( | |
54 | + code === "ETIMEDOUT" || | |
55 | + code === "ECONNRESET" || | |
56 | + code === "ECONNREFUSED" || | |
57 | + code === "ENOTFOUND" || | |
58 | + code === "EAI_AGAIN" || | |
59 | + message.includes("timed out") || | |
60 | + message.includes("network") || | |
61 | + message.includes("fetch failed") || | |
62 | + message.includes("aborted") | |
63 | + ); | |
64 | +} | |
65 | + | |
66 | +async function parseResponseBody(response: Response): Promise<unknown> { | |
67 | + const rawText = await response.text(); | |
68 | + const contentType = response.headers.get("content-type") || ""; | |
69 | + if (!rawText.trim()) return null; | |
70 | + | |
71 | + if (contentType.includes("application/json")) { | |
72 | + try { | |
73 | + return JSON.parse(rawText); | |
74 | + } catch { | |
75 | + // Return text fallback for malformed JSON payloads. | |
76 | + return rawText; | |
77 | + } | |
78 | + } | |
79 | + | |
80 | + return rawText; | |
81 | +} | |
82 | + | |
38 | 83 | export async function apiCall<T = any>( |
39 | 84 | serverUrl: string, |
40 | 85 | endpoint: string, |
| @@ -53,7 +98,9 @@ export async function apiCall<T = any>( | ||
53 | 98 | } |
54 | 99 | |
55 | 100 | const headers: Record<string, string> = { |
101 | + Accept: "application/json, text/plain;q=0.9, */*;q=0.8", | |
56 | 102 | "Content-Type": "application/json", |
103 | + "User-Agent": "opencodehub-cli/1.1.0", | |
57 | 104 | }; |
58 | 105 | |
59 | 106 | if (config.token) { |
| @@ -65,16 +112,25 @@ export async function apiCall<T = any>( | ||
65 | 112 | |
66 | 113 | while (true) { |
67 | 114 | try { |
68 | - const response = await fetch(`${url}${endpoint}`, { | |
69 | - method, | |
70 | - headers, | |
71 | - body: body ? JSON.stringify(body) : undefined, | |
72 | - }); | |
115 | + const controller = new AbortController(); | |
116 | + const timeoutHandle = setTimeout( | |
117 | + () => controller.abort(), | |
118 | + getRequestTimeoutMs(), | |
119 | + ); | |
120 | + | |
121 | + let response: Response; | |
122 | + try { | |
123 | + response = await fetch(`${url}${endpoint}`, { | |
124 | + method, | |
125 | + headers, | |
126 | + body: body ? JSON.stringify(body) : undefined, | |
127 | + signal: controller.signal, | |
128 | + }); | |
129 | + } finally { | |
130 | + clearTimeout(timeoutHandle); | |
131 | + } | |
73 | 132 | |
74 | - const contentType = response.headers.get("content-type") || ""; | |
75 | - const data = contentType.includes("application/json") | |
76 | - ? await response.json() | |
77 | - : await response.text(); | |
133 | + const data = await parseResponseBody(response); | |
78 | 134 | |
79 | 135 | if (!response.ok) { |
80 | 136 | if (attempt < maxRetries && shouldRetry(response.status)) { |
| @@ -89,16 +145,13 @@ export async function apiCall<T = any>( | ||
89 | 145 | continue; |
90 | 146 | } |
91 | 147 | |
92 | - const message = | |
93 | - typeof data === "string" | |
94 | - ? data | |
95 | - : data?.error || `API error: ${response.status}`; | |
148 | + const message = parseApiErrorMessage(response.status, data); | |
96 | 149 | throw new Error(message); |
97 | 150 | } |
98 | 151 | |
99 | 152 | return data as T; |
100 | 153 | } catch (error) { |
101 | - if (attempt < maxRetries) { | |
154 | + if (attempt < maxRetries && isRetriableNetworkError(error)) { | |
102 | 155 | attempt += 1; |
103 | 156 | await sleep(300 * Math.pow(2, attempt - 1)); |
104 | 157 | continue; |
105 | 158 | |
โผcli/src/lib/config.test.ts
+56-0
| @@ -0,0 +1,56 @@ | ||
1 | +import { mkdtempSync } from "node:fs"; | |
2 | +import os from "node:os"; | |
3 | +import path from "node:path"; | |
4 | +import { beforeAll, describe, expect, it } from "vitest"; | |
5 | + | |
6 | +type ConfigModule = typeof import("./config.js"); | |
7 | + | |
8 | +let mod: ConfigModule; | |
9 | + | |
10 | +beforeAll(async () => { | |
11 | + const tmpDir = mkdtempSync(path.join(os.tmpdir(), "och-cli-config-test-")); | |
12 | + process.env.XDG_CONFIG_HOME = tmpDir; | |
13 | + process.env.XDG_DATA_HOME = tmpDir; | |
14 | + process.env.OCH_TOKEN = ""; | |
15 | + process.env.OCH_DISABLE_KEYCHAIN = "1"; | |
16 | + mod = await import("./config.js"); | |
17 | +}); | |
18 | + | |
19 | +describe("config library", () => { | |
20 | + it("persists and unsets non-secret config keys", () => { | |
21 | + mod.clearConfig(); | |
22 | + mod.saveConfig({ serverUrl: "https://git.example.com", defaultBranch: "develop" }); | |
23 | + | |
24 | + const loaded = mod.getConfig(); | |
25 | + expect(loaded.serverUrl).toBe("https://git.example.com"); | |
26 | + expect(loaded.defaultBranch).toBe("develop"); | |
27 | + | |
28 | + mod.unsetConfigKey("serverUrl"); | |
29 | + expect(mod.getConfig().serverUrl).toBe(""); | |
30 | + }); | |
31 | + | |
32 | + it("prefers OCH_TOKEN environment variable over persisted token", () => { | |
33 | + mod.clearConfig(); | |
34 | + process.env.OCH_TOKEN = "env_token_override_123"; | |
35 | + mod.saveConfig({ token: "file_token_123456" }); | |
36 | + | |
37 | + const loaded = mod.getConfig(); | |
38 | + expect(loaded.token).toBe("env_token_override_123"); | |
39 | + expect(mod.getTokenStorageMode(loaded.serverUrl)).toBe("env"); | |
40 | + }); | |
41 | + | |
42 | + it("normalizes and validates server URLs", () => { | |
43 | + expect(mod.normalizeServerUrl(" https://git.example.com/ ")).toBe( | |
44 | + "https://git.example.com", | |
45 | + ); | |
46 | + expect(mod.normalizeServerUrl("https://git.example.com/base///")).toBe( | |
47 | + "https://git.example.com/base", | |
48 | + ); | |
49 | + expect(() => mod.normalizeServerUrl("git.example.com")).toThrow( | |
50 | + /Invalid server URL/, | |
51 | + ); | |
52 | + expect(() => mod.normalizeServerUrl("ftp://git.example.com")).toThrow( | |
53 | + /Invalid server URL protocol/, | |
54 | + ); | |
55 | + }); | |
56 | +}); | |
0 | 57 | |
โผcli/src/lib/config.ts
+305-10
| @@ -4,44 +4,305 @@ | ||
4 | 4 | */ |
5 | 5 | |
6 | 6 | import Conf from "conf"; |
7 | +import { execFileSync } from "node:child_process"; | |
7 | 8 | |
8 | -interface OchConfig { | |
9 | +export interface OchConfig { | |
9 | 10 | serverUrl: string; |
10 | - token: string; | |
11 | + token?: string; | |
11 | 12 | username?: string; |
12 | 13 | defaultBranch?: string; |
14 | + editor?: string; | |
15 | + pager?: string; | |
13 | 16 | caFile?: string; |
14 | 17 | insecure?: boolean; |
18 | + windowsEncryptedTokens?: Record<string, string>; | |
19 | +} | |
20 | + | |
21 | +export function normalizeServerUrl(rawUrl: string): string { | |
22 | + const trimmed = rawUrl.trim(); | |
23 | + if (!trimmed) return ""; | |
24 | + | |
25 | + let parsed: URL; | |
26 | + try { | |
27 | + parsed = new URL(trimmed); | |
28 | + } catch { | |
29 | + throw new Error("Invalid server URL. Use a full URL like https://git.example.com"); | |
30 | + } | |
31 | + | |
32 | + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { | |
33 | + throw new Error("Invalid server URL protocol. Only http:// or https:// are supported"); | |
34 | + } | |
35 | + | |
36 | + parsed.hash = ""; | |
37 | + parsed.search = ""; | |
38 | + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); | |
39 | + return parsed.toString().replace(/\/$/, ""); | |
15 | 40 | } |
16 | 41 | |
17 | 42 | const config = new Conf<OchConfig>({ |
18 | 43 | projectName: "opencodehub-cli", |
19 | 44 | defaults: { |
20 | 45 | serverUrl: "", |
21 | - token: "", | |
22 | 46 | defaultBranch: "main", |
47 | + editor: "", | |
48 | + pager: "", | |
23 | 49 | caFile: "", |
24 | 50 | insecure: false, |
25 | 51 | }, |
26 | 52 | }); |
27 | 53 | |
54 | +function sanitizeAccountName(input: string) { | |
55 | + return input.replace(/[^a-zA-Z0-9._:@/-]/g, "_"); | |
56 | +} | |
57 | + | |
58 | +function keychainAccount(serverUrl?: string) { | |
59 | + return sanitizeAccountName(serverUrl?.trim() || "default"); | |
60 | +} | |
61 | + | |
62 | +function hasCommand(command: string) { | |
63 | + if (process.env.OCH_DISABLE_KEYCHAIN === "1") { | |
64 | + return false; | |
65 | + } | |
66 | + try { | |
67 | + if (process.platform === "win32") { | |
68 | + execFileSync("where", [command], { stdio: "ignore", timeout: 500 }); | |
69 | + } else { | |
70 | + execFileSync("sh", ["-lc", `command -v ${command} >/dev/null 2>&1`], { | |
71 | + stdio: "ignore", | |
72 | + timeout: 500, | |
73 | + }); | |
74 | + } | |
75 | + return true; | |
76 | + } catch { | |
77 | + return false; | |
78 | + } | |
79 | +} | |
80 | + | |
81 | +function getWindowsEncryptedTokens(): Record<string, string> { | |
82 | + return (config.get("windowsEncryptedTokens") || {}) as Record<string, string>; | |
83 | +} | |
84 | + | |
85 | +function encryptWindowsToken(token: string): string | null { | |
86 | + if (!hasCommand("powershell")) return null; | |
87 | + try { | |
88 | + return execFileSync( | |
89 | + "powershell", | |
90 | + [ | |
91 | + "-NoProfile", | |
92 | + "-NonInteractive", | |
93 | + "-Command", | |
94 | + "$ErrorActionPreference='Stop';$plain=[Console]::In.ReadToEnd();$sec=ConvertTo-SecureString $plain -AsPlainText -Force;$enc=$sec | ConvertFrom-SecureString;[Console]::Out.Write($enc)", | |
95 | + ], | |
96 | + { | |
97 | + input: token, | |
98 | + encoding: "utf8", | |
99 | + stdio: ["pipe", "pipe", "ignore"], | |
100 | + timeout: 2000, | |
101 | + }, | |
102 | + ).trim(); | |
103 | + } catch { | |
104 | + return null; | |
105 | + } | |
106 | +} | |
107 | + | |
108 | +function decryptWindowsToken(encryptedValue: string): string | null { | |
109 | + if (!hasCommand("powershell")) return null; | |
110 | + try { | |
111 | + return execFileSync( | |
112 | + "powershell", | |
113 | + [ | |
114 | + "-NoProfile", | |
115 | + "-NonInteractive", | |
116 | + "-Command", | |
117 | + "$ErrorActionPreference='Stop';$enc=[Console]::In.ReadToEnd();$sec=ConvertTo-SecureString $enc;$bstr=[System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec);try{[System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)}finally{[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)}", | |
118 | + ], | |
119 | + { | |
120 | + input: encryptedValue, | |
121 | + encoding: "utf8", | |
122 | + stdio: ["pipe", "pipe", "ignore"], | |
123 | + timeout: 2000, | |
124 | + }, | |
125 | + ).trim(); | |
126 | + } catch { | |
127 | + return null; | |
128 | + } | |
129 | +} | |
130 | + | |
131 | +function getTokenFromKeychain(serverUrl?: string): string | null { | |
132 | + const account = keychainAccount(serverUrl); | |
133 | + | |
134 | + try { | |
135 | + if (process.platform === "win32") { | |
136 | + const encrypted = getWindowsEncryptedTokens()[account]; | |
137 | + if (!encrypted) return null; | |
138 | + return decryptWindowsToken(encrypted); | |
139 | + } | |
140 | + | |
141 | + if (process.platform === "darwin" && hasCommand("security")) { | |
142 | + return execFileSync( | |
143 | + "security", | |
144 | + [ | |
145 | + "find-generic-password", | |
146 | + "-s", | |
147 | + "opencodehub-cli", | |
148 | + "-a", | |
149 | + account, | |
150 | + "-w", | |
151 | + ], | |
152 | + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }, | |
153 | + ).trim(); | |
154 | + } | |
155 | + | |
156 | + if (process.platform === "linux" && hasCommand("secret-tool")) { | |
157 | + const value = execFileSync( | |
158 | + "secret-tool", | |
159 | + ["lookup", "service", "opencodehub-cli", "account", account], | |
160 | + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }, | |
161 | + ).trim(); | |
162 | + return value || null; | |
163 | + } | |
164 | + } catch { | |
165 | + return null; | |
166 | + } | |
167 | + | |
168 | + return null; | |
169 | +} | |
170 | + | |
171 | +function saveTokenToKeychain(serverUrl: string | undefined, token: string): boolean { | |
172 | + const account = keychainAccount(serverUrl); | |
173 | + | |
174 | + try { | |
175 | + if (process.platform === "win32") { | |
176 | + const encrypted = encryptWindowsToken(token); | |
177 | + if (!encrypted) return false; | |
178 | + const current = getWindowsEncryptedTokens(); | |
179 | + current[account] = encrypted; | |
180 | + config.set("windowsEncryptedTokens", current); | |
181 | + return true; | |
182 | + } | |
183 | + | |
184 | + if (process.platform === "darwin" && hasCommand("security")) { | |
185 | + execFileSync( | |
186 | + "security", | |
187 | + [ | |
188 | + "add-generic-password", | |
189 | + "-U", | |
190 | + "-s", | |
191 | + "opencodehub-cli", | |
192 | + "-a", | |
193 | + account, | |
194 | + "-w", | |
195 | + token, | |
196 | + ], | |
197 | + { stdio: "ignore", timeout: 1000 }, | |
198 | + ); | |
199 | + return true; | |
200 | + } | |
201 | + | |
202 | + if (process.platform === "linux" && hasCommand("secret-tool")) { | |
203 | + execFileSync( | |
204 | + "secret-tool", | |
205 | + [ | |
206 | + "store", | |
207 | + "--label=OpenCodeHub CLI Token", | |
208 | + "service", | |
209 | + "opencodehub-cli", | |
210 | + "account", | |
211 | + account, | |
212 | + ], | |
213 | + { input: token, stdio: ["pipe", "ignore", "ignore"], timeout: 1000 }, | |
214 | + ); | |
215 | + return true; | |
216 | + } | |
217 | + } catch { | |
218 | + return false; | |
219 | + } | |
220 | + | |
221 | + return false; | |
222 | +} | |
223 | + | |
224 | +function removeTokenFromKeychain(serverUrl?: string): void { | |
225 | + const account = keychainAccount(serverUrl); | |
226 | + | |
227 | + try { | |
228 | + if (process.platform === "win32") { | |
229 | + const current = getWindowsEncryptedTokens(); | |
230 | + if (current[account]) { | |
231 | + delete current[account]; | |
232 | + config.set("windowsEncryptedTokens", current); | |
233 | + } | |
234 | + return; | |
235 | + } | |
236 | + | |
237 | + if (process.platform === "darwin" && hasCommand("security")) { | |
238 | + execFileSync( | |
239 | + "security", | |
240 | + ["delete-generic-password", "-s", "opencodehub-cli", "-a", account], | |
241 | + { stdio: "ignore", timeout: 1000 }, | |
242 | + ); | |
243 | + return; | |
244 | + } | |
245 | + | |
246 | + if (process.platform === "linux" && hasCommand("secret-tool")) { | |
247 | + execFileSync( | |
248 | + "secret-tool", | |
249 | + ["clear", "service", "opencodehub-cli", "account", account], | |
250 | + { stdio: "ignore", timeout: 1000 }, | |
251 | + ); | |
252 | + } | |
253 | + } catch { | |
254 | + // No-op: if key is absent or provider is unavailable we still clear fallback storage. | |
255 | + } | |
256 | +} | |
257 | + | |
258 | +function getToken(serverUrl?: string): string { | |
259 | + const envToken = process.env.OCH_TOKEN?.trim(); | |
260 | + if (envToken) { | |
261 | + return envToken; | |
262 | + } | |
263 | + | |
264 | + const keychainToken = getTokenFromKeychain(serverUrl); | |
265 | + if (keychainToken) { | |
266 | + return keychainToken; | |
267 | + } | |
268 | + | |
269 | + return config.get("token") || ""; | |
270 | +} | |
271 | + | |
28 | 272 | export function getConfig(): OchConfig { |
29 | 273 | return { |
30 | - serverUrl: config.get("serverUrl"), | |
31 | - token: config.get("token"), | |
274 | + serverUrl: config.get("serverUrl") || "", | |
275 | + token: getToken(config.get("serverUrl")), | |
32 | 276 | username: config.get("username"), |
33 | - defaultBranch: config.get("defaultBranch"), | |
34 | - caFile: config.get("caFile"), | |
35 | - insecure: config.get("insecure"), | |
277 | + defaultBranch: config.get("defaultBranch") || "main", | |
278 | + editor: config.get("editor") || "", | |
279 | + pager: config.get("pager") || "", | |
280 | + caFile: config.get("caFile") || "", | |
281 | + insecure: config.get("insecure") ?? false, | |
36 | 282 | }; |
37 | 283 | } |
38 | 284 | |
39 | 285 | export function saveConfig(updates: Partial<OchConfig>): void { |
40 | 286 | if (updates.serverUrl !== undefined) { |
41 | - config.set("serverUrl", updates.serverUrl); | |
287 | + config.set("serverUrl", normalizeServerUrl(updates.serverUrl)); | |
42 | 288 | } |
43 | 289 | if (updates.token !== undefined) { |
44 | - config.set("token", updates.token); | |
290 | + const token = updates.token.trim(); | |
291 | + if (!token) { | |
292 | + removeTokenFromKeychain(updates.serverUrl || config.get("serverUrl")); | |
293 | + config.delete("token"); | |
294 | + } else { | |
295 | + const storedInKeychain = saveTokenToKeychain( | |
296 | + updates.serverUrl || config.get("serverUrl"), | |
297 | + token, | |
298 | + ); | |
299 | + | |
300 | + if (storedInKeychain) { | |
301 | + config.delete("token"); | |
302 | + } else { | |
303 | + config.set("token", token); | |
304 | + } | |
305 | + } | |
45 | 306 | } |
46 | 307 | if (updates.username !== undefined) { |
47 | 308 | config.set("username", updates.username); |
| @@ -49,6 +310,12 @@ export function saveConfig(updates: Partial<OchConfig>): void { | ||
49 | 310 | if (updates.defaultBranch !== undefined) { |
50 | 311 | config.set("defaultBranch", updates.defaultBranch); |
51 | 312 | } |
313 | + if (updates.editor !== undefined) { | |
314 | + config.set("editor", updates.editor); | |
315 | + } | |
316 | + if (updates.pager !== undefined) { | |
317 | + config.set("pager", updates.pager); | |
318 | + } | |
52 | 319 | if (updates.caFile !== undefined) { |
53 | 320 | config.set("caFile", updates.caFile); |
54 | 321 | } |
| @@ -58,5 +325,33 @@ export function saveConfig(updates: Partial<OchConfig>): void { | ||
58 | 325 | } |
59 | 326 | |
60 | 327 | export function clearConfig(): void { |
328 | + removeTokenFromKeychain(config.get("serverUrl")); | |
61 | 329 | config.clear(); |
62 | 330 | } |
331 | + | |
332 | +export function unsetConfigKey( | |
333 | + key: "serverUrl" | "token" | "username" | "defaultBranch" | "editor" | "pager" | "caFile" | "insecure", | |
334 | +): void { | |
335 | + if (key === "token") { | |
336 | + removeTokenFromKeychain(config.get("serverUrl")); | |
337 | + } | |
338 | + config.delete(key); | |
339 | +} | |
340 | + | |
341 | +export function getConfigPath(): string { | |
342 | + return config.path; | |
343 | +} | |
344 | + | |
345 | +export function getTokenStorageMode(serverUrl?: string): "env" | "keychain" | "file" | "none" { | |
346 | + if (process.env.OCH_TOKEN?.trim()) return "env"; | |
347 | + if (getTokenFromKeychain(serverUrl || config.get("serverUrl"))) return "keychain"; | |
348 | + if (config.get("token")) return "file"; | |
349 | + return "none"; | |
350 | +} | |
351 | + | |
352 | +export function supportsSecureTokenStorage(): boolean { | |
353 | + if (process.platform === "win32") return hasCommand("powershell"); | |
354 | + if (process.platform === "darwin") return hasCommand("security"); | |
355 | + if (process.platform === "linux") return hasCommand("secret-tool"); | |
356 | + return false; | |
357 | +} | |
63 | 358 | |
โผcli/src/lib/git.test.ts
+22-0
| @@ -0,0 +1,22 @@ | ||
1 | +import { describe, expect, it } from "vitest"; | |
2 | +import { parseRepoFromRemoteUrl } from "./git.js"; | |
3 | + | |
4 | +describe("parseRepoFromRemoteUrl", () => { | |
5 | + it("parses https remotes", () => { | |
6 | + expect(parseRepoFromRemoteUrl("https://git.example.com/acme/platform.git")).toEqual({ | |
7 | + owner: "acme", | |
8 | + repo: "platform", | |
9 | + }); | |
10 | + }); | |
11 | + | |
12 | + it("parses ssh remotes", () => { | |
13 | + expect(parseRepoFromRemoteUrl("[email protected]:acme/platform.git")).toEqual({ | |
14 | + owner: "acme", | |
15 | + repo: "platform", | |
16 | + }); | |
17 | + }); | |
18 | + | |
19 | + it("returns null for invalid remote format", () => { | |
20 | + expect(parseRepoFromRemoteUrl("git.example.com")).toBeNull(); | |
21 | + }); | |
22 | +}); | |
0 | 23 | |