@zoobbe/cli 1.1.0 → 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 CHANGED
@@ -13,11 +13,10 @@ Requires Node.js 18 or later.
13
13
  ## Quick Start
14
14
 
15
15
  ```bash
16
- # Authenticate with your API key
17
- zoobbe auth login --token zb_live_xxxxx
16
+ # Login via browser (recommended)
17
+ zoobbe auth login
18
18
 
19
- # Or point to a self-hosted instance
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
- Generate an API key from your workspace settings at **Settings > API Keys**, then:
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
- Check your login status:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zoobbe/cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Zoobbe CLI - Manage boards, cards, and projects from the terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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">&#10003;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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">&#10007;</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,47 +187,52 @@ auth
16
187
  .action(async (options) => {
17
188
  try {
18
189
  if (options.url) {
19
- config.set('apiUrl', options.url);
190
+ try {
191
+ const parsed = new URL(options.url);
192
+ const isLocal = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname);
193
+ if (parsed.protocol !== 'https:' && !isLocal) {
194
+ return error('API URL must use HTTPS. Only localhost is allowed over HTTP.');
195
+ }
196
+ config.set('apiUrl', options.url);
197
+ } catch {
198
+ return error('Invalid URL format.');
199
+ }
20
200
  }
21
201
 
22
202
  let apiKey = options.token;
23
203
 
24
204
  if (!apiKey) {
25
- // Interactive login — open browser for auth
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();
26
211
  try {
27
- const open = require('open');
28
- const apiUrl = config.get('apiUrl');
29
- const authUrl = `${apiUrl.replace('api.', '')}/cli/auth`;
30
- info(`Opening browser for authentication...`);
31
- info(`If it doesn't open, visit: ${chalk.underline(authUrl)}`);
32
- await open(authUrl);
33
- } catch {
34
- // open may fail in headless environments
35
- }
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 */ }
36
218
 
37
- const answers = await inquirer.prompt([{
38
- type: 'input',
39
- name: 'apiKey',
40
- message: 'Paste your API key:',
41
- validate: v => v.startsWith('zb_live_') ? true : 'Invalid API key format (should start with zb_live_)',
42
- }]);
43
- apiKey = answers.apiKey;
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
+ }
44
229
  }
45
230
 
46
- if (!apiKey.startsWith('zb_live_')) {
231
+ if (!apiKey || !apiKey.startsWith('zb_live_')) {
47
232
  return error('Invalid API key format. Keys start with "zb_live_".');
48
233
  }
49
234
 
50
- config.set('apiKey', apiKey);
51
-
52
- // Verify the key works
53
- const userData = await client.get('/users/me');
54
- const user = userData.user || userData.data || userData;
55
-
56
- config.set('userId', user._id || user.id || '');
57
- config.set('userName', user.name || user.userName || '');
58
- config.set('email', user.email || '');
59
-
235
+ const user = await verifyAndStore(apiKey);
60
236
  success(`Logged in as ${chalk.bold(user.name || user.email)}`);
61
237
  } catch (err) {
62
238
  config.set('apiKey', '');
@@ -107,7 +283,7 @@ auth
107
283
  .action(() => {
108
284
  const apiKey = config.get('apiKey');
109
285
  if (apiKey) {
110
- success(`Authenticated (key: ${apiKey.substring(0, 16)}...)`);
286
+ success(`Authenticated (key: ${apiKey.substring(0, 10)}...)`);
111
287
  info(`API URL: ${config.get('apiUrl')}`);
112
288
  if (config.get('activeWorkspaceName')) {
113
289
  info(`Workspace: ${config.get('activeWorkspaceName')}`);
@@ -89,8 +89,7 @@ board
89
89
  .action(async (nameOrId) => {
90
90
  try {
91
91
  const open = require('open');
92
- const apiUrl = config.get('apiUrl');
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
@@ -67,8 +67,7 @@ page
67
67
  .action(async (pageId) => {
68
68
  try {
69
69
  const open = require('open');
70
- const apiUrl = config.get('apiUrl');
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 (nameOrId) => {
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,6 +26,12 @@ class ZoobbeClient {
26
26
  async request(method, path, body = null) {
27
27
  this.ensureAuth();
28
28
 
29
+ // Reject path traversal attempts (check decoded form too)
30
+ const decoded = decodeURIComponent(path);
31
+ if (decoded.includes('..') || decoded.includes('//') || /\x00/.test(decoded)) {
32
+ throw new Error('Invalid request path');
33
+ }
34
+
29
35
  const url = `${this.baseUrl}/v1${path}`;
30
36
  const options = {
31
37
  method,
package/src/lib/config.js CHANGED
@@ -1,13 +1,22 @@
1
1
  const Conf = require('conf');
2
2
  const path = require('path');
3
3
  const os = require('os');
4
+ const fs = require('fs');
5
+
6
+ const configDir = path.join(os.homedir(), '.zoobbe');
7
+
8
+ // Ensure config directory exists with restricted permissions (owner-only)
9
+ if (!fs.existsSync(configDir)) {
10
+ fs.mkdirSync(configDir, { mode: 0o700, recursive: true });
11
+ }
4
12
 
5
13
  const config = new Conf({
6
14
  projectName: 'zoobbe',
7
- cwd: path.join(os.homedir(), '.zoobbe'),
15
+ cwd: configDir,
8
16
  schema: {
9
17
  apiKey: { type: 'string', default: '' },
10
18
  apiUrl: { type: 'string', default: 'https://api.zoobbe.com' },
19
+ webUrl: { type: 'string', default: '' },
11
20
  activeWorkspace: { type: 'string', default: '' },
12
21
  activeWorkspaceName: { type: 'string', default: '' },
13
22
  format: { type: 'string', enum: ['table', 'json', 'plain'], default: 'table' },
@@ -17,4 +26,31 @@ const config = new Conf({
17
26
  },
18
27
  });
19
28
 
29
+ // Lock down the config file (owner read/write only)
30
+ try {
31
+ fs.chmodSync(config.path, 0o600);
32
+ } catch {
33
+ // Ignore if chmod fails (e.g., Windows)
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
+
20
56
  module.exports = config;