@zibby/cli 0.1.5

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,406 @@
1
+ /**
2
+ * CLI Login Flow
3
+ * Implements OAuth device flow for CLI authentication
4
+ */
5
+
6
+ import chalk from 'chalk';
7
+ import ora from 'ora';
8
+ import { spawn } from 'child_process';
9
+ import { getApiUrl } from '../config/environments.js';
10
+ import {
11
+ saveSessionToken,
12
+ saveUserInfo,
13
+ getSessionToken,
14
+ getUserInfo,
15
+ clearSession
16
+ } from '../config/config.js';
17
+
18
+ /**
19
+ * Open URL in default browser (safe: no shell interpolation)
20
+ */
21
+ function openBrowser(url) {
22
+ const platform = process.platform;
23
+
24
+ try {
25
+ let cmd, args;
26
+ if (platform === 'darwin') {
27
+ cmd = 'open';
28
+ args = [url];
29
+ } else if (platform === 'win32') {
30
+ cmd = 'cmd';
31
+ args = ['/c', 'start', '', url];
32
+ } else {
33
+ cmd = 'xdg-open';
34
+ args = [url];
35
+ }
36
+ spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
37
+ return true;
38
+ } catch (_error) {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Check if user is already logged in
45
+ */
46
+ export function checkExistingSession() {
47
+ const token = getSessionToken();
48
+ const user = getUserInfo();
49
+
50
+ if (token && user) {
51
+ return { loggedIn: true, user, token };
52
+ }
53
+
54
+ return { loggedIn: false };
55
+ }
56
+
57
+ /**
58
+ * Initiate CLI login flow
59
+ */
60
+ export async function loginCli() {
61
+ try {
62
+ console.log(chalk.cyan('\n🔐 Initiating login...\n'));
63
+
64
+ // Check if already logged in
65
+ const existingSession = checkExistingSession();
66
+ if (existingSession.loggedIn) {
67
+ console.log(chalk.green('✅ Already logged in!'));
68
+ console.log(chalk.gray(`User: ${existingSession.user.email}`));
69
+ console.log(chalk.gray(`Name: ${existingSession.user.name}\n`));
70
+
71
+ // Ask if user wants to continue with existing session
72
+ const { createInterface } = await import('readline');
73
+ const rl = createInterface({
74
+ input: process.stdin,
75
+ output: process.stdout,
76
+ });
77
+
78
+ return new Promise((resolve, reject) => {
79
+ // Handle Ctrl+C gracefully
80
+ const cleanup = () => {
81
+ rl.close();
82
+ if (process.stdin.isTTY) {
83
+ process.stdin.setRawMode(false);
84
+ }
85
+ };
86
+
87
+ const sigintHandler = () => {
88
+ console.log(chalk.yellow('\n\n⚠️ Login cancelled\n'));
89
+ cleanup();
90
+ process.exit(0);
91
+ };
92
+
93
+ process.on('SIGINT', sigintHandler);
94
+
95
+ rl.question(chalk.yellow('Continue with this session? (Y/n): '), async (answer) => {
96
+ process.removeListener('SIGINT', sigintHandler);
97
+ cleanup();
98
+
99
+ try {
100
+ if (answer.toLowerCase() === 'n' || answer.toLowerCase() === 'no') {
101
+ console.log(chalk.gray('Starting new login...\n'));
102
+ const result = await performLogin();
103
+ resolve(result);
104
+ } else {
105
+ console.log(chalk.green('Using existing session.\n'));
106
+ resolve({ success: true, ...existingSession });
107
+ }
108
+ } catch (err) {
109
+ reject(err);
110
+ }
111
+ });
112
+ });
113
+ }
114
+
115
+ // No existing session, start login flow
116
+ return await performLogin();
117
+ } catch (err) {
118
+ console.error(chalk.red('\n❌ Login failed:', err.message));
119
+ return { success: false, error: err.message };
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Perform the actual login flow
125
+ */
126
+ async function performLogin() {
127
+ const apiUrl = getApiUrl();
128
+ // Step 1: Request device code from backend
129
+ const spinner = ora('Requesting login code...').start();
130
+
131
+ const initResponse = await fetch(`${apiUrl}/cli/login/initiate`, {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ });
135
+
136
+ if (!initResponse.ok) {
137
+ spinner.fail('Failed to request login code');
138
+ const errorData = await initResponse.json();
139
+ throw new Error(errorData.error || 'Failed to initiate login');
140
+ }
141
+
142
+ const { deviceCode, userCode: _userCode, verificationUrl, expiresIn, interval } = await initResponse.json();
143
+ spinner.succeed('Login code generated');
144
+
145
+ // Step 2: Display instructions to user
146
+ console.log('');
147
+ console.log(chalk.cyan('╔════════════════════════════════════════╗'));
148
+ console.log(chalk.cyan('║') + chalk.white.bold(' Complete login in your browser ') + chalk.cyan('║'));
149
+ console.log(chalk.cyan('╚════════════════════════════════════════╝'));
150
+ console.log('');
151
+ console.log(chalk.white('Opening browser to login page...'));
152
+ console.log(chalk.gray(`Code expires in ${Math.floor(expiresIn / 60)} minutes`));
153
+ console.log('');
154
+
155
+ // Step 3: Open browser to /connect page with device code
156
+ const opened = await openBrowser(verificationUrl);
157
+ if (!opened) {
158
+ console.log(chalk.yellow('⚠️ Could not open browser automatically.'));
159
+ console.log(chalk.white('Please open this URL manually: ') + chalk.blue(verificationUrl));
160
+ console.log('');
161
+ }
162
+
163
+ // Step 4: Poll for authorization
164
+ const pollSpinner = ora('Waiting for authorization...').start();
165
+
166
+ const pollInterval = (interval || 3) * 1000;
167
+ const maxAttempts = Math.floor(expiresIn / (interval || 3));
168
+ let attempts = 0;
169
+ let cancelled = false;
170
+
171
+ // Handle Ctrl+C during polling
172
+ const sigintHandler = () => {
173
+ cancelled = true;
174
+ pollSpinner.stop();
175
+ console.log(chalk.yellow('\n\n⚠️ Login cancelled\n'));
176
+ process.exit(0);
177
+ };
178
+
179
+ process.on('SIGINT', sigintHandler);
180
+
181
+ try {
182
+ while (attempts < maxAttempts && !cancelled) {
183
+ await sleep(pollInterval);
184
+ attempts++;
185
+
186
+ const pollResponse = await fetch(`${apiUrl}/cli/login/poll`, {
187
+ method: 'POST',
188
+ headers: { 'Content-Type': 'application/json' },
189
+ body: JSON.stringify({ deviceCode }),
190
+ });
191
+
192
+ if (pollResponse.status === 202) {
193
+ continue;
194
+ }
195
+
196
+ if (!pollResponse.ok) {
197
+ pollSpinner.fail('Authorization failed');
198
+ const errorData = await pollResponse.json();
199
+ throw new Error(errorData.error || 'Authorization failed');
200
+ }
201
+
202
+ const result = await pollResponse.json();
203
+
204
+ if (result.status === 'authorized') {
205
+ pollSpinner.succeed(chalk.white('Authorization successful!'));
206
+
207
+ // Save session locally
208
+ saveSessionToken(result.token);
209
+ saveUserInfo(result.user);
210
+
211
+ console.log('');
212
+ console.log(chalk.gray(`User: ${result.user.email}`));
213
+ console.log(chalk.gray(`Session saved to: ~/.zibby/config.json\n`));
214
+
215
+ return {
216
+ success: true,
217
+ loggedIn: true,
218
+ user: result.user,
219
+ token: result.token
220
+ };
221
+ }
222
+
223
+ if (result.status === 'denied') {
224
+ pollSpinner.fail('Authorization denied');
225
+ throw new Error('User denied authorization');
226
+ }
227
+ }
228
+
229
+ pollSpinner.fail('Login timeout');
230
+ throw new Error('Login timed out - please try again');
231
+ } finally {
232
+ process.removeListener('SIGINT', sigintHandler);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Logout - clear saved session
238
+ */
239
+ export function logoutCli() {
240
+ const existingSession = checkExistingSession();
241
+
242
+ if (!existingSession.loggedIn) {
243
+ console.log(chalk.yellow('\n⚠️ Not logged in.\n'));
244
+ return;
245
+ }
246
+
247
+ clearSession();
248
+ console.log(chalk.green('✔') + chalk.white(' Logged out successfully!'));
249
+ console.log(chalk.gray('Session cleared from ~/.zibby/config.json\n'));
250
+ }
251
+
252
+ /**
253
+ * Show current login status with detailed information
254
+ */
255
+ export async function showLoginStatus(options = {}) {
256
+ const existingSession = checkExistingSession();
257
+ const envToken = process.env.ZIBBY_USER_TOKEN;
258
+ const apiUrl = getApiUrl();
259
+ const token = envToken || existingSession.token;
260
+
261
+ // JSON output for scripts
262
+ if (options.json) {
263
+ const status = {
264
+ authenticated: !!(existingSession.loggedIn || envToken),
265
+ user: existingSession.user || null,
266
+ tokenSource: envToken ? 'environment' : (existingSession.token ? 'session' : null),
267
+ tokenType: envToken ? 'PAT' : (existingSession.token ? 'JWT' : null),
268
+ apiUrl,
269
+ configPath: existingSession.loggedIn ? '~/.zibby/config.json' : null
270
+ };
271
+
272
+ // Verify token
273
+ if (token) {
274
+ try {
275
+ const fetch = (await import('node-fetch')).default;
276
+ const response = await fetch(`${apiUrl}/projects`, {
277
+ headers: { 'Authorization': `Bearer ${token}` }
278
+ });
279
+
280
+ status.tokenValid = response.ok;
281
+ if (response.ok) {
282
+ const data = await response.json();
283
+ status.projectCount = (data.projects || []).length;
284
+ }
285
+ } catch (error) {
286
+ status.tokenValid = false;
287
+ status.error = error.message;
288
+ }
289
+ } else {
290
+ status.tokenValid = false;
291
+ }
292
+
293
+ console.log(JSON.stringify(status, null, 2));
294
+ return;
295
+ }
296
+
297
+ // Human-readable output
298
+ console.log('');
299
+
300
+ if (!existingSession.loggedIn && !envToken) {
301
+ console.log(chalk.yellow('⚠️ Not authenticated'));
302
+ console.log('');
303
+ console.log(chalk.white('To authenticate:'));
304
+ console.log(chalk.gray(' Local: zibby login'));
305
+ console.log(chalk.gray(' CI/CD: Set ZIBBY_USER_TOKEN env variable\n'));
306
+ return;
307
+ }
308
+
309
+ console.log(chalk.green('✅ Authenticated'));
310
+ console.log('');
311
+
312
+ // User Details
313
+ console.log(chalk.bold.white('User Details:'));
314
+ if (existingSession.user) {
315
+ console.log(chalk.gray(` Email: ${existingSession.user.email}`));
316
+ if (existingSession.user.userId) {
317
+ console.log(chalk.gray(` User ID: ${existingSession.user.userId}`));
318
+ }
319
+ if (existingSession.user.name) {
320
+ console.log(chalk.gray(` Name: ${existingSession.user.name}`));
321
+ }
322
+ } else if (envToken) {
323
+ console.log(chalk.gray(' (User details not available with PAT token)'));
324
+ }
325
+ console.log('');
326
+
327
+ // Token Source
328
+ console.log(chalk.bold.white('Token Source:'));
329
+ if (envToken) {
330
+ console.log(chalk.gray(' Type: Personal Access Token (PAT)'));
331
+ console.log(chalk.gray(' Location: ZIBBY_USER_TOKEN environment variable'));
332
+ console.log(chalk.gray(` Preview: ${envToken.substring(0, 8)}••••`));
333
+ } else {
334
+ console.log(chalk.gray(' Type: Session Token (JWT)'));
335
+ console.log(chalk.gray(' Location: ~/.zibby/config.json'));
336
+ if (existingSession.token) {
337
+ console.log(chalk.gray(` Preview: ${existingSession.token.substring(0, 8)}••••`));
338
+ }
339
+ }
340
+ console.log('');
341
+
342
+ // Verify token by fetching projects
343
+ if (token) {
344
+ try {
345
+ const oraSpinner = (await import('ora')).default;
346
+ const fetch = (await import('node-fetch')).default;
347
+
348
+ const spinner = oraSpinner('Verifying authentication...').start();
349
+
350
+ const response = await fetch(`${apiUrl}/projects`, {
351
+ headers: {
352
+ 'Authorization': `Bearer ${token}`,
353
+ },
354
+ });
355
+
356
+ if (response.ok) {
357
+ const data = await response.json();
358
+ const projectCount = (data.projects || []).length;
359
+ spinner.succeed(chalk.white('Token verified'));
360
+ console.log(chalk.gray(` Projects: ${projectCount} accessible`));
361
+ } else {
362
+ spinner.fail(chalk.white('Token verification failed'));
363
+ console.log(chalk.yellow(` Status: Invalid or expired (HTTP ${response.status})`));
364
+ }
365
+ } catch (error) {
366
+ console.log(chalk.yellow(` Status: Could not verify (${error.message})`));
367
+ }
368
+ }
369
+
370
+ console.log('');
371
+ console.log(chalk.gray('💡 Run \'zibby list\' to see your projects'));
372
+ console.log('');
373
+ }
374
+
375
+ /**
376
+ * Validate current session token
377
+ */
378
+ export async function validateSession() {
379
+ const token = getSessionToken();
380
+ if (!token) {
381
+ return { valid: false };
382
+ }
383
+
384
+ const apiUrl = getApiUrl();
385
+
386
+ try {
387
+ // Try to make an authenticated request to verify token
388
+ const response = await fetch(`${apiUrl}/projects`, {
389
+ headers: { Authorization: `Bearer ${token}` },
390
+ });
391
+
392
+ if (response.ok) {
393
+ return { valid: true };
394
+ }
395
+
396
+ // Token invalid or expired, clear it
397
+ clearSession();
398
+ return { valid: false };
399
+ } catch {
400
+ return { valid: false };
401
+ }
402
+ }
403
+
404
+ function sleep(ms) {
405
+ return new Promise(resolve => setTimeout(resolve, ms));
406
+ }