@zoobbe/cli 1.1.1 → 1.2.1

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.
@@ -0,0 +1,234 @@
1
+ const { Command } = require('commander');
2
+ const chalk = require('chalk');
3
+ const client = require('../lib/client');
4
+ const { output, success, error } = require('../lib/output');
5
+ const { withSpinner } = require('../utils/spinner');
6
+ const { formatDuration, formatRelativeTime } = require('../utils/format');
7
+
8
+ const timer = new Command('timer')
9
+ .alias('t')
10
+ .description('Card timer management commands');
11
+
12
+ timer
13
+ .command('start <cardId>')
14
+ .description('Start a timer on a card')
15
+ .option('--mode <mode>', 'Timer mode (stopwatch|pomodoro)', 'stopwatch')
16
+ .action(async (cardId, options) => {
17
+ try {
18
+ await withSpinner('Starting timer...', () =>
19
+ client.post(`/timers/cards/${cardId}/timer/start`, { mode: options.mode })
20
+ );
21
+ success(`Timer started on card ${cardId} (${options.mode} mode)`);
22
+ } catch (err) {
23
+ error(`Failed to start timer: ${err.message}`);
24
+ }
25
+ });
26
+
27
+ timer
28
+ .command('pause <cardId>')
29
+ .description('Pause a running timer')
30
+ .action(async (cardId) => {
31
+ try {
32
+ await withSpinner('Pausing timer...', () =>
33
+ client.post(`/timers/cards/${cardId}/timer/pause`)
34
+ );
35
+ success('Timer paused');
36
+ } catch (err) {
37
+ error(`Failed to pause timer: ${err.message}`);
38
+ }
39
+ });
40
+
41
+ timer
42
+ .command('resume <cardId>')
43
+ .description('Resume a paused timer')
44
+ .action(async (cardId) => {
45
+ try {
46
+ await withSpinner('Resuming timer...', () =>
47
+ client.post(`/timers/cards/${cardId}/timer/resume`)
48
+ );
49
+ success('Timer resumed');
50
+ } catch (err) {
51
+ error(`Failed to resume timer: ${err.message}`);
52
+ }
53
+ });
54
+
55
+ timer
56
+ .command('stop <cardId>')
57
+ .description('Stop a timer and save the session')
58
+ .action(async (cardId) => {
59
+ try {
60
+ const data = await withSpinner('Stopping timer...', () =>
61
+ client.post(`/timers/cards/${cardId}/timer/stop`)
62
+ );
63
+ const duration = data.duration || data.elapsed;
64
+ success(`Timer stopped${duration ? ` — ${formatDuration(duration)}` : ''}`);
65
+ } catch (err) {
66
+ error(`Failed to stop timer: ${err.message}`);
67
+ }
68
+ });
69
+
70
+ timer
71
+ .command('reset <cardId>')
72
+ .description('Reset a timer')
73
+ .action(async (cardId) => {
74
+ try {
75
+ await withSpinner('Resetting timer...', () =>
76
+ client.post(`/timers/cards/${cardId}/timer/reset`)
77
+ );
78
+ success('Timer reset');
79
+ } catch (err) {
80
+ error(`Failed to reset timer: ${err.message}`);
81
+ }
82
+ });
83
+
84
+ timer
85
+ .command('mode <cardId> <mode>')
86
+ .description('Switch timer mode (stopwatch|pomodoro)')
87
+ .action(async (cardId, mode) => {
88
+ try {
89
+ await withSpinner('Switching mode...', () =>
90
+ client.post(`/timers/cards/${cardId}/timer/mode`, { mode })
91
+ );
92
+ success(`Timer mode set to ${chalk.bold(mode)}`);
93
+ } catch (err) {
94
+ error(`Failed to switch mode: ${err.message}`);
95
+ }
96
+ });
97
+
98
+ timer
99
+ .command('get <cardId>')
100
+ .description('Get current timer state for a card')
101
+ .action(async (cardId) => {
102
+ try {
103
+ const data = await withSpinner('Fetching timer...', () =>
104
+ client.get(`/timers/cards/${cardId}/timer`)
105
+ );
106
+
107
+ const t = data.timer || data.data || data;
108
+ console.log();
109
+ console.log(chalk.bold(' Status: '), t.status || t.state || 'none');
110
+ console.log(chalk.bold(' Mode: '), t.mode || 'stopwatch');
111
+ console.log(chalk.bold(' Elapsed: '), t.elapsed ? formatDuration(t.elapsed) : '0s');
112
+ if (t.startedAt) {
113
+ console.log(chalk.bold(' Started: '), formatRelativeTime(t.startedAt));
114
+ }
115
+ console.log();
116
+ } catch (err) {
117
+ error(`Failed to get timer: ${err.message}`);
118
+ }
119
+ });
120
+
121
+ timer
122
+ .command('sessions <cardId>')
123
+ .description('List timer sessions for a card')
124
+ .option('-f, --format <format>', 'Output format')
125
+ .action(async (cardId, options) => {
126
+ try {
127
+ const data = await withSpinner('Fetching sessions...', () =>
128
+ client.get(`/timers/cards/${cardId}/timer/sessions`)
129
+ );
130
+
131
+ const sessions = data.sessions || data.data || data;
132
+ const rows = (Array.isArray(sessions) ? sessions : []).map(s => ({
133
+ date: s.startedAt ? new Date(s.startedAt).toLocaleDateString() : '-',
134
+ duration: s.duration ? formatDuration(s.duration) : '-',
135
+ mode: s.mode || 'stopwatch',
136
+ id: s._id,
137
+ }));
138
+
139
+ output(rows, {
140
+ headers: ['Date', 'Duration', 'Mode', 'ID'],
141
+ format: options.format,
142
+ });
143
+ } catch (err) {
144
+ error(`Failed to list sessions: ${err.message}`);
145
+ }
146
+ });
147
+
148
+ timer
149
+ .command('status')
150
+ .description('Show overall timer status')
151
+ .action(async () => {
152
+ try {
153
+ const data = await withSpinner('Fetching status...', () =>
154
+ client.get('/timers/status')
155
+ );
156
+
157
+ const status = data.status || data.data || data;
158
+ console.log();
159
+ if (status.activeCard) {
160
+ console.log(chalk.bold(' Active Card: '), status.activeCard.title || status.activeCard);
161
+ console.log(chalk.bold(' Elapsed: '), status.elapsed ? formatDuration(status.elapsed) : '0s');
162
+ console.log(chalk.bold(' Mode: '), status.mode || 'stopwatch');
163
+ } else {
164
+ console.log(' No active timer');
165
+ }
166
+ console.log();
167
+ } catch (err) {
168
+ error(`Failed to get status: ${err.message}`);
169
+ }
170
+ });
171
+
172
+ timer
173
+ .command('running')
174
+ .description('Show all running timers')
175
+ .option('-f, --format <format>', 'Output format')
176
+ .action(async (options) => {
177
+ try {
178
+ const data = await withSpinner('Fetching running timers...', () =>
179
+ client.get('/timers/running')
180
+ );
181
+
182
+ const timers = data.timers || data.data || data;
183
+ const rows = (Array.isArray(timers) ? timers : []).map(t => ({
184
+ card: t.card?.title || t.cardId || 'N/A',
185
+ elapsed: t.elapsed ? formatDuration(t.elapsed) : '-',
186
+ mode: t.mode || 'stopwatch',
187
+ started: t.startedAt ? formatRelativeTime(t.startedAt) : '-',
188
+ }));
189
+
190
+ output(rows, {
191
+ headers: ['Card', 'Elapsed', 'Mode', 'Started'],
192
+ format: options.format,
193
+ });
194
+ } catch (err) {
195
+ error(`Failed to list running timers: ${err.message}`);
196
+ }
197
+ });
198
+
199
+ timer
200
+ .command('stats')
201
+ .description('Show timer statistics')
202
+ .action(async () => {
203
+ try {
204
+ const data = await withSpinner('Fetching stats...', () =>
205
+ client.get('/timers/sessions/stats')
206
+ );
207
+
208
+ const stats = data.stats || data.data || data;
209
+ console.log();
210
+ console.log(chalk.bold(' Total Sessions: '), stats.totalSessions || 0);
211
+ console.log(chalk.bold(' Total Time: '), stats.totalTime ? formatDuration(stats.totalTime) : '0s');
212
+ console.log(chalk.bold(' Avg Session: '), stats.avgDuration ? formatDuration(stats.avgDuration) : '0s');
213
+ console.log(chalk.bold(' This Week: '), stats.thisWeek ? formatDuration(stats.thisWeek) : '0s');
214
+ console.log();
215
+ } catch (err) {
216
+ error(`Failed to get stats: ${err.message}`);
217
+ }
218
+ });
219
+
220
+ timer
221
+ .command('delete <cardId>')
222
+ .description('Delete all timer data for a card')
223
+ .action(async (cardId) => {
224
+ try {
225
+ await withSpinner('Deleting timer...', () =>
226
+ client.delete(`/timers/card/${cardId}`)
227
+ );
228
+ success('Timer data deleted');
229
+ } catch (err) {
230
+ error(`Failed to delete timer: ${err.message}`);
231
+ }
232
+ });
233
+
234
+ module.exports = timer;
@@ -0,0 +1,141 @@
1
+ const { Command } = require('commander');
2
+ const chalk = require('chalk');
3
+ const client = require('../lib/client');
4
+ const { output, success, error } = require('../lib/output');
5
+ const { withSpinner } = require('../utils/spinner');
6
+ const { confirmAction } = require('../utils/prompts');
7
+ const { formatRelativeTime } = require('../utils/format');
8
+
9
+ const webhook = new Command('webhook')
10
+ .alias('wh')
11
+ .description('Webhook management commands');
12
+
13
+ webhook
14
+ .command('list')
15
+ .alias('ls')
16
+ .description('List webhooks')
17
+ .option('-f, --format <format>', 'Output format')
18
+ .action(async (options) => {
19
+ try {
20
+ const data = await withSpinner('Fetching webhooks...', () =>
21
+ client.get('/webhooks')
22
+ );
23
+
24
+ const webhooks = data.webhooks || data.data || data;
25
+ const rows = (Array.isArray(webhooks) ? webhooks : []).map(w => ({
26
+ name: w.name || w.description || 'Unnamed',
27
+ url: (w.url || '').substring(0, 50),
28
+ events: (w.events || []).join(', ') || 'all',
29
+ status: w.active !== false ? chalk.green('active') : chalk.yellow('inactive'),
30
+ id: w._id,
31
+ }));
32
+
33
+ output(rows, {
34
+ headers: ['Name', 'URL', 'Events', 'Status', 'ID'],
35
+ format: options.format,
36
+ });
37
+ } catch (err) {
38
+ error(`Failed to list webhooks: ${err.message}`);
39
+ }
40
+ });
41
+
42
+ webhook
43
+ .command('create')
44
+ .description('Create a webhook')
45
+ .option('--url <url>', 'Webhook URL (required)')
46
+ .option('--events <events>', 'Comma-separated event types')
47
+ .option('--name <name>', 'Webhook name')
48
+ .action(async (options) => {
49
+ try {
50
+ if (!options.url) return error('URL is required. Use --url <url>.');
51
+
52
+ const body = { url: options.url };
53
+ if (options.name) body.name = options.name;
54
+ if (options.events) body.events = options.events.split(',').map(e => e.trim());
55
+
56
+ const data = await withSpinner('Creating webhook...', () =>
57
+ client.post('/webhooks', body)
58
+ );
59
+
60
+ const w = data.webhook || data.data || data;
61
+ success(`Created webhook: ${chalk.bold(options.name || options.url)} (${w._id})`);
62
+ } catch (err) {
63
+ error(`Failed to create webhook: ${err.message}`);
64
+ }
65
+ });
66
+
67
+ webhook
68
+ .command('update <webhookId>')
69
+ .description('Update a webhook')
70
+ .option('--url <url>', 'New URL')
71
+ .option('--events <events>', 'Comma-separated event types')
72
+ .option('--name <name>', 'New name')
73
+ .action(async (webhookId, options) => {
74
+ try {
75
+ const body = {};
76
+ if (options.url) body.url = options.url;
77
+ if (options.name) body.name = options.name;
78
+ if (options.events) body.events = options.events.split(',').map(e => e.trim());
79
+ if (Object.keys(body).length === 0) return error('Provide --url, --events, or --name.');
80
+
81
+ await withSpinner('Updating webhook...', () =>
82
+ client.put(`/webhooks/${webhookId}`, body)
83
+ );
84
+ success(`Updated webhook ${webhookId}`);
85
+ } catch (err) {
86
+ error(`Failed to update webhook: ${err.message}`);
87
+ }
88
+ });
89
+
90
+ webhook
91
+ .command('delete <webhookId>')
92
+ .description('Delete a webhook')
93
+ .option('--force', 'Skip confirmation')
94
+ .action(async (webhookId, options) => {
95
+ try {
96
+ if (!options.force) {
97
+ const confirmed = await confirmAction('Delete this webhook?');
98
+ if (!confirmed) return;
99
+ }
100
+
101
+ await withSpinner('Deleting webhook...', () =>
102
+ client.delete(`/webhooks/${webhookId}`)
103
+ );
104
+ success('Deleted webhook');
105
+ } catch (err) {
106
+ error(`Failed to delete webhook: ${err.message}`);
107
+ }
108
+ });
109
+
110
+ webhook
111
+ .command('logs <webhookId>')
112
+ .description('Show webhook delivery logs')
113
+ .option('--limit <n>', 'Number of logs', '20')
114
+ .option('-f, --format <format>', 'Output format')
115
+ .action(async (webhookId, options) => {
116
+ try {
117
+ const data = await withSpinner('Fetching delivery logs...', () =>
118
+ client.get(`/webhooks/${webhookId}/deliveries`)
119
+ );
120
+
121
+ const deliveries = data.deliveries || data.data || data;
122
+ const delArr = Array.isArray(deliveries) ? deliveries : [];
123
+ const limited = delArr.slice(0, parseInt(options.limit));
124
+
125
+ const rows = limited.map(d => ({
126
+ event: d.event || d.type || 'N/A',
127
+ status: d.statusCode ? (d.statusCode < 400 ? chalk.green(d.statusCode) : chalk.red(d.statusCode)) : '-',
128
+ response: (d.response || d.body || '').substring(0, 40) || '-',
129
+ time: formatRelativeTime(d.createdAt || d.deliveredAt),
130
+ }));
131
+
132
+ output(rows, {
133
+ headers: ['Event', 'Status', 'Response', 'Time'],
134
+ format: options.format,
135
+ });
136
+ } catch (err) {
137
+ error(`Failed to fetch delivery logs: ${err.message}`);
138
+ }
139
+ });
140
+
141
+ module.exports = webhook;
@@ -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')
@@ -148,4 +149,178 @@ workspace
148
149
  }
149
150
  });
150
151
 
152
+ // ── Extended commands ──
153
+
154
+ const { confirmAction } = require('../utils/prompts');
155
+ const { resolveWorkspaceObjectId } = require('../utils/resolve');
156
+
157
+ workspace
158
+ .command('update')
159
+ .description('Update active workspace name')
160
+ .option('--name <name>', 'New workspace name (required)')
161
+ .action(async (options) => {
162
+ try {
163
+ if (!options.name) return error('Workspace name is required. Use --name <name>.');
164
+
165
+ const workspaceId = config.get('activeWorkspace');
166
+ if (!workspaceId) return error('No active workspace.');
167
+
168
+ const wsId = await resolveWorkspaceObjectId(workspaceId);
169
+ await withSpinner('Updating workspace...', () =>
170
+ client.put(`/workspaces/${wsId}`, { name: options.name })
171
+ );
172
+ config.set('activeWorkspaceName', options.name);
173
+ success(`Workspace renamed to: ${chalk.bold(options.name)}`);
174
+ } catch (err) {
175
+ error(`Failed to update workspace: ${err.message}`);
176
+ }
177
+ });
178
+
179
+ workspace
180
+ .command('delete')
181
+ .description('Delete the active workspace')
182
+ .option('--force', 'Skip confirmation')
183
+ .action(async (options) => {
184
+ try {
185
+ const workspaceId = config.get('activeWorkspace');
186
+ const workspaceName = config.get('activeWorkspaceName');
187
+ if (!workspaceId) return error('No active workspace.');
188
+
189
+ if (!options.force) {
190
+ const confirmed = await confirmAction(`Permanently delete workspace "${workspaceName}" and all its data?`);
191
+ if (!confirmed) return;
192
+ }
193
+
194
+ const wsId = await resolveWorkspaceObjectId(workspaceId);
195
+ await withSpinner('Deleting workspace...', () =>
196
+ client.post(`/workspaces/${wsId}`)
197
+ );
198
+ config.set('activeWorkspace', '');
199
+ config.set('activeWorkspaceName', '');
200
+ success(`Deleted workspace: ${chalk.bold(workspaceName)}`);
201
+ } catch (err) {
202
+ error(`Failed to delete workspace: ${err.message}`);
203
+ }
204
+ });
205
+
206
+ workspace
207
+ .command('invite')
208
+ .description('Invite a user to the active workspace by email')
209
+ .option('-e, --email <email>', 'Email address (required)')
210
+ .option('--role <role>', 'Role (admin|member)', 'member')
211
+ .action(async (options) => {
212
+ try {
213
+ if (!options.email) return error('Email is required. Use -e <email>.');
214
+
215
+ const workspaceId = config.get('activeWorkspace');
216
+ if (!workspaceId) return error('No active workspace.');
217
+
218
+ const wsId = await resolveWorkspaceObjectId(workspaceId);
219
+ await withSpinner('Sending invitation...', () =>
220
+ client.post(`/workspace/${wsId}/invite`, {
221
+ email: options.email,
222
+ role: options.role,
223
+ })
224
+ );
225
+ success(`Invitation sent to ${chalk.bold(options.email)}`);
226
+ } catch (err) {
227
+ error(`Failed to invite: ${err.message}`);
228
+ }
229
+ });
230
+
231
+ workspace
232
+ .command('join-link')
233
+ .description('Generate or delete workspace join link')
234
+ .option('--delete', 'Delete the existing join link')
235
+ .action(async (options) => {
236
+ try {
237
+ const workspaceId = config.get('activeWorkspace');
238
+ if (!workspaceId) return error('No active workspace.');
239
+
240
+ const wsId = await resolveWorkspaceObjectId(workspaceId);
241
+
242
+ if (options.delete) {
243
+ await withSpinner('Deleting join link...', () =>
244
+ client.delete(`/workspace/${wsId}/delete-join-link`)
245
+ );
246
+ success('Join link deleted');
247
+ } else {
248
+ const data = await withSpinner('Generating join link...', () =>
249
+ client.post(`/workspace/${wsId}/generate-join-link`)
250
+ );
251
+ const link = data.joinLink || data.link || data.data;
252
+ success(`Join link: ${chalk.bold(link)}`);
253
+ }
254
+ } catch (err) {
255
+ error(`Failed to manage join link: ${err.message}`);
256
+ }
257
+ });
258
+
259
+ workspace
260
+ .command('add-member')
261
+ .description('Add a member to the active workspace')
262
+ .option('-u, --user <userId>', 'User ID (required)')
263
+ .action(async (options) => {
264
+ try {
265
+ if (!options.user) return error('User ID is required. Use -u <userId>.');
266
+
267
+ const workspaceId = config.get('activeWorkspace');
268
+ if (!workspaceId) return error('No active workspace.');
269
+
270
+ const wsId = await resolveWorkspaceObjectId(workspaceId);
271
+ await withSpinner('Adding member...', () =>
272
+ client.post(`/workspace/${wsId}/member`, { userId: options.user })
273
+ );
274
+ success('Member added to workspace');
275
+ } catch (err) {
276
+ error(`Failed to add member: ${err.message}`);
277
+ }
278
+ });
279
+
280
+ workspace
281
+ .command('remove-member')
282
+ .description('Remove a member from the active workspace')
283
+ .option('-u, --user <userId>', 'User ID (required)')
284
+ .action(async (options) => {
285
+ try {
286
+ if (!options.user) return error('User ID is required. Use -u <userId>.');
287
+
288
+ const workspaceId = config.get('activeWorkspace');
289
+ if (!workspaceId) return error('No active workspace.');
290
+
291
+ const wsId = await resolveWorkspaceObjectId(workspaceId);
292
+ await withSpinner('Removing member...', () =>
293
+ client.delete(`/workspace/${wsId}/member`, { userId: options.user })
294
+ );
295
+ success('Member removed from workspace');
296
+ } catch (err) {
297
+ error(`Failed to remove member: ${err.message}`);
298
+ }
299
+ });
300
+
301
+ workspace
302
+ .command('member-role')
303
+ .description('Change a member\'s role in the active workspace')
304
+ .option('-u, --user <userId>', 'User ID (required)')
305
+ .option('--role <role>', 'New role (admin|member)', 'member')
306
+ .action(async (options) => {
307
+ try {
308
+ if (!options.user) return error('User ID is required. Use -u <userId>.');
309
+
310
+ const workspaceId = config.get('activeWorkspace');
311
+ if (!workspaceId) return error('No active workspace.');
312
+
313
+ const wsId = await resolveWorkspaceObjectId(workspaceId);
314
+ await withSpinner('Updating role...', () =>
315
+ client.put(`/workspace/${wsId}/member`, {
316
+ userId: options.user,
317
+ role: options.role,
318
+ })
319
+ );
320
+ success(`Member role updated to ${chalk.bold(options.role)}`);
321
+ } catch (err) {
322
+ error(`Failed to update role: ${err.message}`);
323
+ }
324
+ });
325
+
151
326
  module.exports = workspace;
package/src/index.js CHANGED
@@ -16,7 +16,17 @@ program.addCommand(require('./commands/auth'));
16
16
  program.addCommand(require('./commands/workspace'));
17
17
  program.addCommand(require('./commands/board'));
18
18
  program.addCommand(require('./commands/card'));
19
+ program.addCommand(require('./commands/list'));
20
+ program.addCommand(require('./commands/checklist'));
19
21
  program.addCommand(require('./commands/page'));
22
+ program.addCommand(require('./commands/timer'));
23
+ program.addCommand(require('./commands/notification'));
24
+ program.addCommand(require('./commands/activity'));
25
+ program.addCommand(require('./commands/analytics'));
26
+ program.addCommand(require('./commands/automation'));
27
+ program.addCommand(require('./commands/webhook'));
28
+ program.addCommand(require('./commands/api-key'));
29
+ program.addCommand(require('./commands/import'));
20
30
  program.addCommand(require('./commands/search'));
21
31
  program.addCommand(require('./commands/ai'));
22
32
  program.addCommand(require('./commands/status'));
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
- if (path.includes('..') || path.includes('//')) {
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
 
@@ -71,8 +72,48 @@ class ZoobbeClient {
71
72
  return this.request('PATCH', path, body);
72
73
  }
73
74
 
74
- async delete(path) {
75
- return this.request('DELETE', path);
75
+ async delete(path, body) {
76
+ return this.request('DELETE', path, body);
77
+ }
78
+
79
+ async upload(path, filePath, fieldName = 'file') {
80
+ this.ensureAuth();
81
+ const fs = require('fs');
82
+ const nodePath = require('path');
83
+
84
+ const decoded = decodeURIComponent(path);
85
+ if (decoded.includes('..') || decoded.includes('//') || /\x00/.test(decoded)) {
86
+ throw new Error('Invalid request path');
87
+ }
88
+
89
+ const url = `${this.baseUrl}/v1${path}`;
90
+ const boundary = `----ZoobbeUpload${Date.now()}`;
91
+ const fileName = nodePath.basename(filePath).replace(/["\r\n\\]/g, '_');
92
+ const fileBuffer = fs.readFileSync(filePath);
93
+
94
+ const header = `--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
95
+ const footer = `\r\n--${boundary}--\r\n`;
96
+
97
+ const body = Buffer.concat([Buffer.from(header), fileBuffer, Buffer.from(footer)]);
98
+
99
+ const response = await fetch(url, {
100
+ method: 'POST',
101
+ headers: {
102
+ 'Authorization': `Bearer ${this.apiKey}`,
103
+ 'User-Agent': 'Zoobbe-CLI/1.0',
104
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
105
+ },
106
+ body,
107
+ });
108
+
109
+ const data = await response.json();
110
+ if (!response.ok) {
111
+ const msg = data.error || data.message || `Upload failed with status ${response.status}`;
112
+ const error = new Error(msg);
113
+ error.status = response.status;
114
+ throw error;
115
+ }
116
+ return data;
76
117
  }
77
118
  }
78
119
 
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;