@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.
- package/README.md +232 -34
- package/package.json +1 -1
- package/src/commands/activity.js +102 -0
- package/src/commands/analytics.js +281 -0
- package/src/commands/api-key.js +140 -0
- package/src/commands/auth.js +199 -30
- package/src/commands/automation.js +255 -0
- package/src/commands/board.js +296 -2
- package/src/commands/card.js +367 -0
- package/src/commands/checklist.js +173 -0
- package/src/commands/import.js +101 -0
- package/src/commands/list.js +213 -0
- package/src/commands/notification.js +92 -0
- package/src/commands/page.js +254 -2
- package/src/commands/timer.js +234 -0
- package/src/commands/webhook.js +141 -0
- package/src/commands/workspace.js +177 -2
- package/src/index.js +10 -0
- package/src/lib/client.js +45 -4
- package/src/lib/config.js +21 -0
- package/src/utils/format.js +39 -0
- package/src/utils/prompts.js +40 -0
- package/src/utils/resolve.js +67 -0
|
@@ -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 (
|
|
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
|
-
|
|
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;
|