@zoobbe/cli 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -15
- package/package.json +1 -1
- package/src/commands/auth.js +197 -30
- package/src/commands/board.js +1 -2
- package/src/commands/page.js +1 -2
- package/src/commands/workspace.js +3 -2
- package/src/lib/client.js +3 -2
- package/src/lib/config.js +21 -0
package/README.md
CHANGED
|
@@ -13,11 +13,10 @@ Requires Node.js 18 or later.
|
|
|
13
13
|
## Quick Start
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
#
|
|
17
|
-
zoobbe auth login
|
|
16
|
+
# Login via browser (recommended)
|
|
17
|
+
zoobbe auth login
|
|
18
18
|
|
|
19
|
-
# Or
|
|
20
|
-
zoobbe config set apiUrl https://your-instance.com
|
|
19
|
+
# Or authenticate directly with an API key
|
|
21
20
|
zoobbe auth login --token zb_live_xxxxx
|
|
22
21
|
|
|
23
22
|
# Set your active workspace
|
|
@@ -31,13 +30,35 @@ zoobbe card list --board <board-id>
|
|
|
31
30
|
|
|
32
31
|
## Authentication
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
### Browser Login (Recommended)
|
|
34
|
+
|
|
35
|
+
Simply run `zoobbe auth login` — the CLI opens your browser where you authorize access. The token is sent back automatically via a secure localhost callback.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
$ zoobbe auth login
|
|
39
|
+
Opening browser for authentication...
|
|
40
|
+
✓ Logged in as Akash M
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
If the browser doesn't open or the callback times out after 2 minutes, the CLI falls back to a manual paste prompt.
|
|
44
|
+
|
|
45
|
+
### Direct Token Login
|
|
46
|
+
|
|
47
|
+
You can also pass an API key directly. Generate one from **Settings > API Keys** in your workspace:
|
|
35
48
|
|
|
36
49
|
```bash
|
|
37
50
|
zoobbe auth login --token zb_live_your_api_key_here
|
|
38
51
|
```
|
|
39
52
|
|
|
40
|
-
|
|
53
|
+
### Self-Hosted Instances
|
|
54
|
+
|
|
55
|
+
For self-hosted Zoobbe instances, set your API URL first:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
zoobbe auth login --url https://api.your-instance.com
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Auth Commands
|
|
41
62
|
|
|
42
63
|
```bash
|
|
43
64
|
zoobbe auth whoami # Show current user
|
|
@@ -158,15 +179,6 @@ zoobbe b ls # Same as: zoobbe board list
|
|
|
158
179
|
zoobbe c ls -b <id> # Same as: zoobbe card list --board <id>
|
|
159
180
|
```
|
|
160
181
|
|
|
161
|
-
## Self-Hosted
|
|
162
|
-
|
|
163
|
-
For self-hosted Zoobbe instances, set your API URL before logging in:
|
|
164
|
-
|
|
165
|
-
```bash
|
|
166
|
-
zoobbe config set apiUrl https://your-instance.com
|
|
167
|
-
zoobbe auth login --token zb_live_xxxxx
|
|
168
|
-
```
|
|
169
|
-
|
|
170
182
|
## License
|
|
171
183
|
|
|
172
184
|
MIT
|
package/package.json
CHANGED
package/src/commands/auth.js
CHANGED
|
@@ -1,10 +1,181 @@
|
|
|
1
1
|
const { Command } = require('commander');
|
|
2
2
|
const chalk = require('chalk');
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const crypto = require('crypto');
|
|
3
5
|
const inquirer = require('inquirer');
|
|
4
6
|
const config = require('../lib/config');
|
|
5
7
|
const client = require('../lib/client');
|
|
6
8
|
const { success, error, info } = require('../lib/output');
|
|
7
9
|
|
|
10
|
+
const LOGIN_TIMEOUT = 120_000; // 2 minutes
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Start a temporary localhost HTTP server to receive the OAuth callback.
|
|
14
|
+
* Returns a promise that resolves with the API key token.
|
|
15
|
+
*/
|
|
16
|
+
function waitForCallback(state, webUrl) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
let resolved = false;
|
|
19
|
+
const server = http.createServer((req, res) => {
|
|
20
|
+
const url = new URL(req.url, `http://127.0.0.1`);
|
|
21
|
+
|
|
22
|
+
if (url.pathname !== '/callback') {
|
|
23
|
+
res.writeHead(404);
|
|
24
|
+
res.end('Not found');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Prevent duplicate callbacks
|
|
29
|
+
if (resolved) {
|
|
30
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
31
|
+
res.end(errorPage('Already authenticated.'));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const token = url.searchParams.get('token');
|
|
36
|
+
const returnedState = url.searchParams.get('state') || '';
|
|
37
|
+
|
|
38
|
+
// Timing-safe CSRF state validation
|
|
39
|
+
const stateMatch = returnedState.length === state.length &&
|
|
40
|
+
crypto.timingSafeEqual(Buffer.from(returnedState), Buffer.from(state));
|
|
41
|
+
if (!stateMatch) {
|
|
42
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
43
|
+
res.end(errorPage('State mismatch. Please try logging in again from the CLI.'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!token || !/^zb_live_[a-zA-Z0-9_\-]{16,}$/.test(token)) {
|
|
48
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
49
|
+
res.end(errorPage('Invalid token received. Please try again.'));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Send success page and resolve
|
|
54
|
+
resolved = true;
|
|
55
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
56
|
+
res.end(successPage());
|
|
57
|
+
|
|
58
|
+
server.close();
|
|
59
|
+
resolve(token);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Listen on random available port
|
|
63
|
+
server.listen(0, '127.0.0.1', () => {
|
|
64
|
+
const port = server.address().port;
|
|
65
|
+
const authUrl = `${webUrl}/cli/auth?state=${encodeURIComponent(state)}&port=${port}`;
|
|
66
|
+
|
|
67
|
+
info('Opening browser for authentication...');
|
|
68
|
+
info(`If it doesn't open, visit: ${chalk.underline(authUrl)}`);
|
|
69
|
+
|
|
70
|
+
require('open')(authUrl).catch(() => {});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
server.on('error', (err) => {
|
|
74
|
+
reject(new Error(`Failed to start auth server: ${err.message}`));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Timeout — shut down and fall back to manual
|
|
78
|
+
const timeout = setTimeout(() => {
|
|
79
|
+
server.close();
|
|
80
|
+
reject(new Error('TIMEOUT'));
|
|
81
|
+
}, LOGIN_TIMEOUT);
|
|
82
|
+
|
|
83
|
+
// Clean up timeout if resolved
|
|
84
|
+
const origResolve = resolve;
|
|
85
|
+
resolve = (val) => {
|
|
86
|
+
clearTimeout(timeout);
|
|
87
|
+
origResolve(val);
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pageStyles() {
|
|
93
|
+
return `
|
|
94
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
95
|
+
body {
|
|
96
|
+
font-family: "DM Sans", -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
97
|
+
display: flex; align-items: center; justify-content: center;
|
|
98
|
+
min-height: 100vh; background: #181717; color: rgba(255,255,255,0.8);
|
|
99
|
+
}
|
|
100
|
+
.card {
|
|
101
|
+
text-align: center; background: #181717; padding: 48px 40px;
|
|
102
|
+
border-radius: 12px; border: 1px solid #242526;
|
|
103
|
+
box-shadow: 0 4px 24px rgba(0,0,0,0.3); max-width: 420px; width: 90%;
|
|
104
|
+
}
|
|
105
|
+
.icon { font-size: 48px; margin-bottom: 20px; }
|
|
106
|
+
.icon-success { color: #10b981; }
|
|
107
|
+
.icon-error { color: #ef4444; }
|
|
108
|
+
h1 { font-size: 20px; font-weight: 600; margin: 0 0 8px; }
|
|
109
|
+
.subtitle { color: #9aa0a6; font-size: 14px; line-height: 1.5; }
|
|
110
|
+
.hint { color: #6c757d; font-size: 12px; margin-top: 16px; }
|
|
111
|
+
.brand { color: #0966ff; font-weight: 600; }
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function successPage() {
|
|
116
|
+
return `<!DOCTYPE html>
|
|
117
|
+
<html><head><title>Zoobbe CLI — Authorized</title>
|
|
118
|
+
<style>${pageStyles()}</style></head>
|
|
119
|
+
<body>
|
|
120
|
+
<div class="card">
|
|
121
|
+
<div class="icon icon-success">✓</div>
|
|
122
|
+
<h1>Authorization Successful</h1>
|
|
123
|
+
<p class="subtitle">Your CLI is now connected to <span class="brand">Zoobbe</span>.</p>
|
|
124
|
+
<p class="hint">You can close this tab and return to the terminal.</p>
|
|
125
|
+
</div>
|
|
126
|
+
</body></html>`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function escapeHtml(str) {
|
|
130
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function errorPage(message) {
|
|
134
|
+
return `<!DOCTYPE html>
|
|
135
|
+
<html><head><title>Zoobbe CLI — Error</title>
|
|
136
|
+
<style>${pageStyles()}</style></head>
|
|
137
|
+
<body>
|
|
138
|
+
<div class="card">
|
|
139
|
+
<div class="icon icon-error">✗</div>
|
|
140
|
+
<h1 style="color:#ef4444">Authorization Failed</h1>
|
|
141
|
+
<p class="subtitle">${escapeHtml(message)}</p>
|
|
142
|
+
<p class="hint">Please return to the terminal and try again.</p>
|
|
143
|
+
</div>
|
|
144
|
+
</body></html>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Verify the API key and store user info.
|
|
149
|
+
*/
|
|
150
|
+
async function verifyAndStore(apiKey) {
|
|
151
|
+
config.set('apiKey', apiKey);
|
|
152
|
+
|
|
153
|
+
const userData = await client.get('/users/me');
|
|
154
|
+
const user = userData.user || userData.data || userData;
|
|
155
|
+
|
|
156
|
+
config.set('userId', user._id || user.id || '');
|
|
157
|
+
config.set('userName', user.name || user.userName || '');
|
|
158
|
+
config.set('email', user.email || '');
|
|
159
|
+
|
|
160
|
+
return user;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Manual paste fallback when browser callback times out.
|
|
165
|
+
*/
|
|
166
|
+
async function manualLogin() {
|
|
167
|
+
info('Falling back to manual entry...');
|
|
168
|
+
const answers = await inquirer.prompt([{
|
|
169
|
+
type: 'input',
|
|
170
|
+
name: 'apiKey',
|
|
171
|
+
message: 'Paste your API key:',
|
|
172
|
+
validate: v => v.startsWith('zb_live_') ? true : 'Invalid API key format (should start with zb_live_)',
|
|
173
|
+
}]);
|
|
174
|
+
return answers.apiKey;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Commands ────────────────────────────────────────────────
|
|
178
|
+
|
|
8
179
|
const auth = new Command('auth')
|
|
9
180
|
.description('Authentication commands');
|
|
10
181
|
|
|
@@ -16,10 +187,10 @@ auth
|
|
|
16
187
|
.action(async (options) => {
|
|
17
188
|
try {
|
|
18
189
|
if (options.url) {
|
|
19
|
-
// Validate URL format and enforce HTTPS
|
|
20
190
|
try {
|
|
21
191
|
const parsed = new URL(options.url);
|
|
22
|
-
|
|
192
|
+
const isLocal = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname);
|
|
193
|
+
if (parsed.protocol !== 'https:' && !isLocal) {
|
|
23
194
|
return error('API URL must use HTTPS. Only localhost is allowed over HTTP.');
|
|
24
195
|
}
|
|
25
196
|
config.set('apiUrl', options.url);
|
|
@@ -31,41 +202,37 @@ auth
|
|
|
31
202
|
let apiKey = options.token;
|
|
32
203
|
|
|
33
204
|
if (!apiKey) {
|
|
34
|
-
//
|
|
205
|
+
// Try automatic browser callback flow
|
|
206
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
207
|
+
|
|
208
|
+
// Resolve the frontend URL before opening the browser
|
|
209
|
+
const apiUrl = config.get('apiUrl').replace(/\/$/, '');
|
|
210
|
+
let webUrl = config.getWebUrl();
|
|
35
211
|
try {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
} catch {
|
|
43
|
-
// open may fail in headless environments
|
|
44
|
-
}
|
|
212
|
+
const cfgRes = await fetch(`${apiUrl}/v1/cli/config`);
|
|
213
|
+
if (cfgRes.ok) {
|
|
214
|
+
const cfgData = await cfgRes.json();
|
|
215
|
+
if (cfgData.webUrl) webUrl = cfgData.webUrl;
|
|
216
|
+
}
|
|
217
|
+
} catch { /* use derived webUrl as fallback */ }
|
|
45
218
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
message
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
219
|
+
try {
|
|
220
|
+
apiKey = await waitForCallback(state, webUrl);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (err.message === 'TIMEOUT') {
|
|
223
|
+
// Browser flow timed out — fall back to manual paste
|
|
224
|
+
apiKey = await manualLogin();
|
|
225
|
+
} else {
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
53
229
|
}
|
|
54
230
|
|
|
55
|
-
if (!apiKey.startsWith('zb_live_')) {
|
|
231
|
+
if (!apiKey || !apiKey.startsWith('zb_live_')) {
|
|
56
232
|
return error('Invalid API key format. Keys start with "zb_live_".');
|
|
57
233
|
}
|
|
58
234
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// Verify the key works
|
|
62
|
-
const userData = await client.get('/users/me');
|
|
63
|
-
const user = userData.user || userData.data || userData;
|
|
64
|
-
|
|
65
|
-
config.set('userId', user._id || user.id || '');
|
|
66
|
-
config.set('userName', user.name || user.userName || '');
|
|
67
|
-
config.set('email', user.email || '');
|
|
68
|
-
|
|
235
|
+
const user = await verifyAndStore(apiKey);
|
|
69
236
|
success(`Logged in as ${chalk.bold(user.name || user.email)}`);
|
|
70
237
|
} catch (err) {
|
|
71
238
|
config.set('apiKey', '');
|
package/src/commands/board.js
CHANGED
|
@@ -89,8 +89,7 @@ board
|
|
|
89
89
|
.action(async (nameOrId) => {
|
|
90
90
|
try {
|
|
91
91
|
const open = require('open');
|
|
92
|
-
const
|
|
93
|
-
const baseUrl = apiUrl.replace('api.', '');
|
|
92
|
+
const baseUrl = config.getWebUrl();
|
|
94
93
|
const workspaceName = config.get('activeWorkspaceName');
|
|
95
94
|
|
|
96
95
|
// Try to find the board to get its shortId
|
package/src/commands/page.js
CHANGED
|
@@ -67,8 +67,7 @@ page
|
|
|
67
67
|
.action(async (pageId) => {
|
|
68
68
|
try {
|
|
69
69
|
const open = require('open');
|
|
70
|
-
const
|
|
71
|
-
const baseUrl = apiUrl.replace('api.', '');
|
|
70
|
+
const baseUrl = config.getWebUrl();
|
|
72
71
|
await open(`${baseUrl}/page/${pageId}`);
|
|
73
72
|
success('Opened page in browser');
|
|
74
73
|
} catch (err) {
|
|
@@ -41,9 +41,10 @@ workspace
|
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
workspace
|
|
44
|
-
.command('switch <nameOrId
|
|
44
|
+
.command('switch <nameOrId...>')
|
|
45
45
|
.description('Set the active workspace')
|
|
46
|
-
.action(async (
|
|
46
|
+
.action(async (nameOrIdParts) => {
|
|
47
|
+
const nameOrId = nameOrIdParts.join(' ');
|
|
47
48
|
try {
|
|
48
49
|
const data = await withSpinner('Fetching workspaces...', () =>
|
|
49
50
|
client.get('/workspaces/me')
|
package/src/lib/client.js
CHANGED
|
@@ -26,8 +26,9 @@ class ZoobbeClient {
|
|
|
26
26
|
async request(method, path, body = null) {
|
|
27
27
|
this.ensureAuth();
|
|
28
28
|
|
|
29
|
-
// Reject path traversal attempts
|
|
30
|
-
|
|
29
|
+
// Reject path traversal attempts (check decoded form too)
|
|
30
|
+
const decoded = decodeURIComponent(path);
|
|
31
|
+
if (decoded.includes('..') || decoded.includes('//') || /\x00/.test(decoded)) {
|
|
31
32
|
throw new Error('Invalid request path');
|
|
32
33
|
}
|
|
33
34
|
|
package/src/lib/config.js
CHANGED
|
@@ -16,6 +16,7 @@ const config = new Conf({
|
|
|
16
16
|
schema: {
|
|
17
17
|
apiKey: { type: 'string', default: '' },
|
|
18
18
|
apiUrl: { type: 'string', default: 'https://api.zoobbe.com' },
|
|
19
|
+
webUrl: { type: 'string', default: '' },
|
|
19
20
|
activeWorkspace: { type: 'string', default: '' },
|
|
20
21
|
activeWorkspaceName: { type: 'string', default: '' },
|
|
21
22
|
format: { type: 'string', enum: ['table', 'json', 'plain'], default: 'table' },
|
|
@@ -32,4 +33,24 @@ try {
|
|
|
32
33
|
// Ignore if chmod fails (e.g., Windows)
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Get the frontend web URL. Uses webUrl config if set,
|
|
38
|
+
* otherwise derives from apiUrl (api.zoobbe.com → zoobbe.com).
|
|
39
|
+
*/
|
|
40
|
+
config.getWebUrl = function () {
|
|
41
|
+
const explicit = this.get('webUrl');
|
|
42
|
+
if (explicit) return explicit.replace(/\/$/, '');
|
|
43
|
+
const apiUrl = this.get('apiUrl').replace(/\/$/, '');
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(apiUrl);
|
|
46
|
+
// For production: api.zoobbe.com → zoobbe.com
|
|
47
|
+
if (url.hostname.startsWith('api.')) {
|
|
48
|
+
url.hostname = url.hostname.slice(4);
|
|
49
|
+
return url.toString().replace(/\/$/, '');
|
|
50
|
+
}
|
|
51
|
+
} catch { /* ignore */ }
|
|
52
|
+
// Fallback: return apiUrl as-is (backend /cli/auth redirect handles it)
|
|
53
|
+
return apiUrl;
|
|
54
|
+
};
|
|
55
|
+
|
|
35
56
|
module.exports = config;
|