@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,281 @@
|
|
|
1
|
+
const { Command } = require('commander');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
const config = require('../lib/config');
|
|
4
|
+
const client = require('../lib/client');
|
|
5
|
+
const { output, success, error } = require('../lib/output');
|
|
6
|
+
const { withSpinner } = require('../utils/spinner');
|
|
7
|
+
const { formatDuration } = require('../utils/format');
|
|
8
|
+
|
|
9
|
+
const analytics = new Command('analytics')
|
|
10
|
+
.alias('an')
|
|
11
|
+
.description('Analytics and reporting commands');
|
|
12
|
+
|
|
13
|
+
analytics
|
|
14
|
+
.command('board <boardId>')
|
|
15
|
+
.description('Show board analytics overview')
|
|
16
|
+
.action(async (boardId) => {
|
|
17
|
+
try {
|
|
18
|
+
const data = await withSpinner('Fetching analytics...', () =>
|
|
19
|
+
client.get(`/analytics/board/${boardId}`)
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const a = data.analytics || data.data || data;
|
|
23
|
+
console.log();
|
|
24
|
+
console.log(chalk.bold(' Board Analytics'));
|
|
25
|
+
console.log(chalk.bold(' ─────────────────'));
|
|
26
|
+
console.log(chalk.bold(' Total Cards: '), a.totalCards ?? 0);
|
|
27
|
+
console.log(chalk.bold(' Completed: '), a.completedCards ?? 0);
|
|
28
|
+
console.log(chalk.bold(' In Progress: '), a.inProgressCards ?? 0);
|
|
29
|
+
console.log(chalk.bold(' Overdue: '), a.overdueCards ?? 0);
|
|
30
|
+
console.log(chalk.bold(' Members: '), a.totalMembers ?? 0);
|
|
31
|
+
console.log(chalk.bold(' Completion Rate: '), `${a.completionRate ?? 0}%`);
|
|
32
|
+
console.log();
|
|
33
|
+
} catch (err) {
|
|
34
|
+
error(`Failed to fetch analytics: ${err.message}`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
analytics
|
|
39
|
+
.command('metrics <boardId>')
|
|
40
|
+
.description('Show board metrics')
|
|
41
|
+
.option('--range <range>', 'Time range (7d|30d|90d)', '30d')
|
|
42
|
+
.option('-f, --format <format>', 'Output format')
|
|
43
|
+
.action(async (boardId, options) => {
|
|
44
|
+
try {
|
|
45
|
+
const data = await withSpinner('Fetching metrics...', () =>
|
|
46
|
+
client.get(`/analytics/board/${boardId}/metrics?range=${options.range}`)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const metrics = data.metrics || data.data || data;
|
|
50
|
+
if (Array.isArray(metrics)) {
|
|
51
|
+
const rows = metrics.map(m => ({
|
|
52
|
+
metric: m.name || m.metric || 'N/A',
|
|
53
|
+
value: String(m.value ?? 0),
|
|
54
|
+
change: m.change ? `${m.change > 0 ? '+' : ''}${m.change}%` : '-',
|
|
55
|
+
}));
|
|
56
|
+
output(rows, {
|
|
57
|
+
headers: ['Metric', 'Value', 'Change'],
|
|
58
|
+
format: options.format,
|
|
59
|
+
});
|
|
60
|
+
} else {
|
|
61
|
+
console.log();
|
|
62
|
+
for (const [key, val] of Object.entries(metrics)) {
|
|
63
|
+
console.log(chalk.bold(` ${key}: `), val);
|
|
64
|
+
}
|
|
65
|
+
console.log();
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
error(`Failed to fetch metrics: ${err.message}`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
analytics
|
|
73
|
+
.command('time <boardId>')
|
|
74
|
+
.description('Show time analytics for a board')
|
|
75
|
+
.option('-f, --format <format>', 'Output format')
|
|
76
|
+
.action(async (boardId, options) => {
|
|
77
|
+
try {
|
|
78
|
+
const data = await withSpinner('Fetching time analytics...', () =>
|
|
79
|
+
client.get(`/analytics/board/${boardId}/time-analytics`)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const time = data.analytics || data.data || data;
|
|
83
|
+
if (Array.isArray(time)) {
|
|
84
|
+
const rows = time.map(t => ({
|
|
85
|
+
list: t.list || t.name || 'N/A',
|
|
86
|
+
avgTime: t.avgTime ? formatDuration(t.avgTime) : '-',
|
|
87
|
+
totalCards: String(t.totalCards ?? 0),
|
|
88
|
+
}));
|
|
89
|
+
output(rows, {
|
|
90
|
+
headers: ['List', 'Avg Time', 'Cards'],
|
|
91
|
+
format: options.format,
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
console.log();
|
|
95
|
+
console.log(chalk.bold(' Avg Completion: '), time.avgCompletion ? formatDuration(time.avgCompletion) : 'N/A');
|
|
96
|
+
console.log(chalk.bold(' Total Tracked: '), time.totalTracked ? formatDuration(time.totalTracked) : '0s');
|
|
97
|
+
console.log();
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
error(`Failed to fetch time analytics: ${err.message}`);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
analytics
|
|
105
|
+
.command('timeline <boardId>')
|
|
106
|
+
.description('Show full timeline for a board')
|
|
107
|
+
.option('-f, --format <format>', 'Output format')
|
|
108
|
+
.action(async (boardId, options) => {
|
|
109
|
+
try {
|
|
110
|
+
const data = await withSpinner('Fetching timeline...', () =>
|
|
111
|
+
client.get(`/analytics/board/${boardId}/full-timeline`)
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const timeline = data.timeline || data.data || data;
|
|
115
|
+
const rows = (Array.isArray(timeline) ? timeline : []).map(t => ({
|
|
116
|
+
date: t.date || 'N/A',
|
|
117
|
+
created: String(t.created ?? 0),
|
|
118
|
+
completed: String(t.completed ?? 0),
|
|
119
|
+
active: String(t.active ?? 0),
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
output(rows, {
|
|
123
|
+
headers: ['Date', 'Created', 'Completed', 'Active'],
|
|
124
|
+
format: options.format,
|
|
125
|
+
});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
error(`Failed to fetch timeline: ${err.message}`);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
analytics
|
|
132
|
+
.command('productivity <boardId>')
|
|
133
|
+
.description('Show user productivity for a board')
|
|
134
|
+
.option('-f, --format <format>', 'Output format')
|
|
135
|
+
.action(async (boardId, options) => {
|
|
136
|
+
try {
|
|
137
|
+
const data = await withSpinner('Fetching productivity...', () =>
|
|
138
|
+
client.get(`/analytics/board/${boardId}/user-productivity`)
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const users = data.users || data.data || data;
|
|
142
|
+
const rows = (Array.isArray(users) ? users : []).map(u => ({
|
|
143
|
+
user: u.userName || u.name || 'N/A',
|
|
144
|
+
completed: String(u.completedCards ?? 0),
|
|
145
|
+
assigned: String(u.assignedCards ?? 0),
|
|
146
|
+
timeTracked: u.timeTracked ? formatDuration(u.timeTracked) : '-',
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
output(rows, {
|
|
150
|
+
headers: ['User', 'Completed', 'Assigned', 'Time Tracked'],
|
|
151
|
+
format: options.format,
|
|
152
|
+
});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
error(`Failed to fetch productivity: ${err.message}`);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
analytics
|
|
159
|
+
.command('workflow <boardId>')
|
|
160
|
+
.description('Show workflow analytics for a board')
|
|
161
|
+
.option('-f, --format <format>', 'Output format')
|
|
162
|
+
.action(async (boardId, options) => {
|
|
163
|
+
try {
|
|
164
|
+
const data = await withSpinner('Fetching workflow analytics...', () =>
|
|
165
|
+
client.get(`/analytics/board/${boardId}/workflow-analytics`)
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const workflow = data.workflow || data.data || data;
|
|
169
|
+
const rows = (Array.isArray(workflow) ? workflow : []).map(w => ({
|
|
170
|
+
list: w.list || w.name || 'N/A',
|
|
171
|
+
cards: String(w.cardCount ?? 0),
|
|
172
|
+
avgTime: w.avgTime ? formatDuration(w.avgTime) : '-',
|
|
173
|
+
throughput: String(w.throughput ?? 0),
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
output(rows, {
|
|
177
|
+
headers: ['List', 'Cards', 'Avg Time', 'Throughput'],
|
|
178
|
+
format: options.format,
|
|
179
|
+
});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
error(`Failed to fetch workflow analytics: ${err.message}`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
analytics
|
|
186
|
+
.command('workspace')
|
|
187
|
+
.description('Show workspace analytics')
|
|
188
|
+
.action(async () => {
|
|
189
|
+
try {
|
|
190
|
+
const workspaceId = config.get('activeWorkspace');
|
|
191
|
+
if (!workspaceId) return error('No active workspace.');
|
|
192
|
+
|
|
193
|
+
const data = await withSpinner('Fetching workspace analytics...', () =>
|
|
194
|
+
client.get(`/analytics/workspace/${workspaceId}`)
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const a = data.analytics || data.data || data;
|
|
198
|
+
console.log();
|
|
199
|
+
console.log(chalk.bold(' Workspace Analytics'));
|
|
200
|
+
console.log(chalk.bold(' ─────────────────────'));
|
|
201
|
+
console.log(chalk.bold(' Total Boards: '), a.totalBoards ?? 0);
|
|
202
|
+
console.log(chalk.bold(' Total Cards: '), a.totalCards ?? 0);
|
|
203
|
+
console.log(chalk.bold(' Active Members:'), a.activeMembers ?? 0);
|
|
204
|
+
console.log(chalk.bold(' Completion: '), `${a.completionRate ?? 0}%`);
|
|
205
|
+
console.log();
|
|
206
|
+
} catch (err) {
|
|
207
|
+
error(`Failed to fetch workspace analytics: ${err.message}`);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
analytics
|
|
212
|
+
.command('user')
|
|
213
|
+
.description('Show your personal analytics')
|
|
214
|
+
.action(async () => {
|
|
215
|
+
try {
|
|
216
|
+
const userId = config.get('userId');
|
|
217
|
+
const data = await withSpinner('Fetching user analytics...', () =>
|
|
218
|
+
client.get(`/analytics/user/${userId}`)
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const a = data.analytics || data.data || data;
|
|
222
|
+
console.log();
|
|
223
|
+
console.log(chalk.bold(' Your Analytics'));
|
|
224
|
+
console.log(chalk.bold(' ──────────────'));
|
|
225
|
+
console.log(chalk.bold(' Cards Completed: '), a.completedCards ?? 0);
|
|
226
|
+
console.log(chalk.bold(' Cards Assigned: '), a.assignedCards ?? 0);
|
|
227
|
+
console.log(chalk.bold(' Time Tracked: '), a.timeTracked ? formatDuration(a.timeTracked) : '0s');
|
|
228
|
+
console.log(chalk.bold(' Active Boards: '), a.activeBoards ?? 0);
|
|
229
|
+
console.log();
|
|
230
|
+
} catch (err) {
|
|
231
|
+
error(`Failed to fetch user analytics: ${err.message}`);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
analytics
|
|
236
|
+
.command('trends')
|
|
237
|
+
.description('Show analytics trends')
|
|
238
|
+
.option('-f, --format <format>', 'Output format')
|
|
239
|
+
.action(async (options) => {
|
|
240
|
+
try {
|
|
241
|
+
const data = await withSpinner('Fetching trends...', () =>
|
|
242
|
+
client.get('/analytics/trends')
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const trends = data.trends || data.data || data;
|
|
246
|
+
const rows = (Array.isArray(trends) ? trends : []).map(t => ({
|
|
247
|
+
period: t.period || t.date || 'N/A',
|
|
248
|
+
cards: String(t.cardsCreated ?? 0),
|
|
249
|
+
completed: String(t.cardsCompleted ?? 0),
|
|
250
|
+
members: String(t.activeMembers ?? 0),
|
|
251
|
+
}));
|
|
252
|
+
|
|
253
|
+
output(rows, {
|
|
254
|
+
headers: ['Period', 'Created', 'Completed', 'Active Members'],
|
|
255
|
+
format: options.format,
|
|
256
|
+
});
|
|
257
|
+
} catch (err) {
|
|
258
|
+
error(`Failed to fetch trends: ${err.message}`);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
analytics
|
|
263
|
+
.command('export <boardId>')
|
|
264
|
+
.description('Export board analytics')
|
|
265
|
+
.option('-o, --output <file>', 'Output file path', 'analytics-export.json')
|
|
266
|
+
.action(async (boardId, options) => {
|
|
267
|
+
try {
|
|
268
|
+
const data = await withSpinner('Exporting analytics...', () =>
|
|
269
|
+
client.get(`/analytics/export/${boardId}`)
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const fs = require('fs');
|
|
273
|
+
const exportData = data.export || data.data || data;
|
|
274
|
+
fs.writeFileSync(options.output, JSON.stringify(exportData, null, 2));
|
|
275
|
+
success(`Analytics exported to ${chalk.bold(options.output)}`);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
error(`Failed to export analytics: ${err.message}`);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
module.exports = analytics;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const { Command } = require('commander');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
const client = require('../lib/client');
|
|
4
|
+
const { output, success, error, warn } = require('../lib/output');
|
|
5
|
+
const { withSpinner } = require('../utils/spinner');
|
|
6
|
+
const { confirmAction } = require('../utils/prompts');
|
|
7
|
+
|
|
8
|
+
const apiKey = new Command('api-key')
|
|
9
|
+
.alias('ak')
|
|
10
|
+
.description('API key management commands (some operations require session auth)');
|
|
11
|
+
|
|
12
|
+
const AUTH_NOTE = 'Note: API key management may be restricted when authenticated via API key. Use browser session if this fails.';
|
|
13
|
+
|
|
14
|
+
apiKey
|
|
15
|
+
.command('list')
|
|
16
|
+
.alias('ls')
|
|
17
|
+
.description('List your API keys')
|
|
18
|
+
.option('-f, --format <format>', 'Output format')
|
|
19
|
+
.action(async (options) => {
|
|
20
|
+
try {
|
|
21
|
+
const data = await withSpinner('Fetching API keys...', () =>
|
|
22
|
+
client.get('/api-keys')
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const keys = data.apiKeys || data.data || data;
|
|
26
|
+
const rows = (Array.isArray(keys) ? keys : []).map(k => ({
|
|
27
|
+
name: k.name || 'Unnamed',
|
|
28
|
+
prefix: k.prefix || k.key?.substring(0, 8) || 'N/A',
|
|
29
|
+
created: new Date(k.createdAt).toLocaleDateString(),
|
|
30
|
+
lastUsed: k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : 'Never',
|
|
31
|
+
id: k._id,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
output(rows, {
|
|
35
|
+
headers: ['Name', 'Prefix', 'Created', 'Last Used', 'ID'],
|
|
36
|
+
format: options.format,
|
|
37
|
+
});
|
|
38
|
+
} catch (err) {
|
|
39
|
+
warn(AUTH_NOTE);
|
|
40
|
+
error(`Failed to list API keys: ${err.message}`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
apiKey
|
|
45
|
+
.command('create')
|
|
46
|
+
.description('Create a new API key')
|
|
47
|
+
.option('--name <name>', 'Key name (required)')
|
|
48
|
+
.action(async (options) => {
|
|
49
|
+
try {
|
|
50
|
+
if (!options.name) return error('Key name is required. Use --name <name>.');
|
|
51
|
+
|
|
52
|
+
const data = await withSpinner('Creating API key...', () =>
|
|
53
|
+
client.post('/api-keys', { name: options.name })
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const key = data.apiKey || data.key || data.data || data;
|
|
57
|
+
console.log();
|
|
58
|
+
success(`Created API key: ${chalk.bold(options.name)}`);
|
|
59
|
+
if (key.key || key.token) {
|
|
60
|
+
console.log();
|
|
61
|
+
console.log(chalk.yellow(' Save this key — it won\'t be shown again:'));
|
|
62
|
+
console.log(chalk.bold(` ${key.key || key.token}`));
|
|
63
|
+
console.log();
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
warn(AUTH_NOTE);
|
|
67
|
+
error(`Failed to create API key: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
apiKey
|
|
72
|
+
.command('delete <keyId>')
|
|
73
|
+
.description('Delete an API key')
|
|
74
|
+
.option('--force', 'Skip confirmation')
|
|
75
|
+
.action(async (keyId, options) => {
|
|
76
|
+
try {
|
|
77
|
+
if (!options.force) {
|
|
78
|
+
const confirmed = await confirmAction('Delete this API key? Any integrations using it will stop working.');
|
|
79
|
+
if (!confirmed) return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await withSpinner('Deleting API key...', () =>
|
|
83
|
+
client.delete(`/api-keys/${keyId}`)
|
|
84
|
+
);
|
|
85
|
+
success('Deleted API key');
|
|
86
|
+
} catch (err) {
|
|
87
|
+
warn(AUTH_NOTE);
|
|
88
|
+
error(`Failed to delete API key: ${err.message}`);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
apiKey
|
|
93
|
+
.command('rename <keyId>')
|
|
94
|
+
.description('Rename an API key')
|
|
95
|
+
.option('--name <name>', 'New name (required)')
|
|
96
|
+
.action(async (keyId, options) => {
|
|
97
|
+
try {
|
|
98
|
+
if (!options.name) return error('Name is required. Use --name <name>.');
|
|
99
|
+
|
|
100
|
+
await withSpinner('Renaming API key...', () =>
|
|
101
|
+
client.put(`/api-keys/${keyId}`, { name: options.name })
|
|
102
|
+
);
|
|
103
|
+
success(`Renamed API key to: ${chalk.bold(options.name)}`);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
warn(AUTH_NOTE);
|
|
106
|
+
error(`Failed to rename API key: ${err.message}`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
apiKey
|
|
111
|
+
.command('regenerate <keyId>')
|
|
112
|
+
.description('Regenerate an API key')
|
|
113
|
+
.option('--force', 'Skip confirmation')
|
|
114
|
+
.action(async (keyId, options) => {
|
|
115
|
+
try {
|
|
116
|
+
if (!options.force) {
|
|
117
|
+
const confirmed = await confirmAction('Regenerate this API key? The old key will stop working immediately.');
|
|
118
|
+
if (!confirmed) return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const data = await withSpinner('Regenerating API key...', () =>
|
|
122
|
+
client.post(`/api-keys/${keyId}/regenerate`)
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const key = data.apiKey || data.key || data.data || data;
|
|
126
|
+
console.log();
|
|
127
|
+
success('API key regenerated');
|
|
128
|
+
if (key.key || key.token) {
|
|
129
|
+
console.log();
|
|
130
|
+
console.log(chalk.yellow(' Save this key — it won\'t be shown again:'));
|
|
131
|
+
console.log(chalk.bold(` ${key.key || key.token}`));
|
|
132
|
+
console.log();
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
warn(AUTH_NOTE);
|
|
136
|
+
error(`Failed to regenerate API key: ${err.message}`);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
module.exports = apiKey;
|
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', '');
|
|
@@ -78,6 +245,8 @@ auth
|
|
|
78
245
|
.description('Clear stored credentials')
|
|
79
246
|
.action(() => {
|
|
80
247
|
config.set('apiKey', '');
|
|
248
|
+
config.set('apiUrl', 'https://api.zoobbe.com');
|
|
249
|
+
config.set('webUrl', '');
|
|
81
250
|
config.set('userId', '');
|
|
82
251
|
config.set('userName', '');
|
|
83
252
|
config.set('email', '');
|