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
โ–ผcli/.npmrc
+1-0
1
+//registry.npmjs.org/:_authToken=${NPM_TOKEN}
0
2
โ–ผcli/README.md
+155-215
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
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
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
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
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
11
"files": [
12
12
"bin",
13
13
"dist",
14
+ "assets",
14
15
"README.md",
15
16
"LICENSE"
16
17
],
69
70
"workflow",
70
71
"opencodehub"
71
72
]
72
-}
73
+}
73
74
โ–ผcli/src/commands/api/index.ts
+10-2
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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