launchpd 1.0.3 → 1.0.6

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.
@@ -3,592 +3,586 @@
3
3
  * login, logout, register, whoami
4
4
  */
5
5
 
6
- import { exec } from 'node:child_process';
7
- import { promptSecret, prompt } from '../utils/prompt.js';
8
- import { config } from '../config.js';
9
- import { getCredentials, saveCredentials, clearCredentials, isLoggedIn } from '../utils/credentials.js';
10
- import { success, error, errorWithSuggestions, info, warning, spinner, log } from '../utils/logger.js';
11
- import { formatBytes } from '../utils/quota.js';
12
- import { handleCommonError, MaintenanceError } from '../utils/errors.js';
13
- import { serverLogout, resendVerification } from '../utils/api.js';
14
- import chalk from 'chalk';
15
-
16
- const API_BASE_URL = config.apiUrl;
17
- const REGISTER_URL = `https://${config.domain}/`;
18
-
6
+ import { execFile } from 'node:child_process'
7
+ import { promptSecret } from '../utils/prompt.js'
8
+ import { config } from '../config.js'
9
+ import {
10
+ getCredentials,
11
+ saveCredentials,
12
+ clearCredentials,
13
+ isLoggedIn
14
+ } from '../utils/credentials.js'
15
+ import {
16
+ success,
17
+ error,
18
+ errorWithSuggestions,
19
+ info,
20
+ warning,
21
+ spinner,
22
+ log
23
+ } from '../utils/logger.js'
24
+ import { formatBytes } from '../utils/quota.js'
25
+ import { handleCommonError } from '../utils/errors.js'
26
+ import {
27
+ resendVerification,
28
+ createFetchTimeout,
29
+ API_TIMEOUT_MS
30
+ } from '../utils/api.js'
31
+ import chalk from 'chalk'
32
+
33
+ const API_BASE_URL = config.apiUrl
34
+ const REGISTER_URL = `https://${config.domain}/`
19
35
 
20
36
  /**
21
- * Validate API key with the server
37
+ * Validate API key format
38
+ * Returns true if the key matches expected format: lpd_ followed by alphanumeric/special chars
22
39
  */
23
- async function validateApiKey(apiKey) {
24
- try {
25
- const response = await fetch(`${API_BASE_URL}/api/quota`, {
26
- headers: {
27
- 'X-API-Key': apiKey,
28
- },
29
- });
30
-
31
- if (!response.ok) {
32
- return null;
33
- }
34
-
35
- const data = await response.json();
36
- if (data.authenticated) {
37
- return data;
38
- }
39
- return null;
40
- } catch {
41
- return null;
42
- }
40
+ function isValidApiKeyFormat (apiKey) {
41
+ if (!apiKey || typeof apiKey !== 'string') {
42
+ return false
43
+ }
44
+ // API keys must start with lpd_ and contain only safe characters
45
+ // This validation ensures we don't send arbitrary file data to the network
46
+ return /^lpd_[a-zA-Z0-9_-]{16,64}$/.test(apiKey)
43
47
  }
44
48
 
45
49
  /**
46
- * Background update credentials if new data (like apiSecret) is available
50
+ * Validate API key with the server
47
51
  */
48
- async function updateCredentialsIfNeeded(creds, result) {
49
- if (result.user?.api_secret && !creds.apiSecret) {
50
- await saveCredentials({
51
- ...creds,
52
- apiSecret: result.user.api_secret,
53
- userId: result.user.id || creds.userId,
54
- email: result.user.email || creds.email,
55
- });
52
+ async function validateApiKey (apiKey) {
53
+ // Validate API key format before sending to network
54
+ // This ensures we only send properly formatted keys, not arbitrary file data
55
+ if (!isValidApiKeyFormat(apiKey)) {
56
+ return null
57
+ }
58
+
59
+ const { signal, clear } = createFetchTimeout(API_TIMEOUT_MS)
60
+ try {
61
+ const response = await fetch(`${API_BASE_URL}/api/quota`, {
62
+ headers: {
63
+ 'X-API-Key': apiKey
64
+ },
65
+ signal
66
+ })
67
+
68
+ if (response.status === 401) {
69
+ const data = await response.json().catch(() => ({}))
70
+ if (data.requires_2fa) {
71
+ return { requires_2fa: true, two_factor_type: data.two_factor_type }
72
+ }
73
+ return null
56
74
  }
57
- }
58
75
 
59
- /**
60
- * Login with email and password (supports 2FA)
61
- */
62
- async function loginWithEmailPassword() {
63
- const email = await prompt('Email: ');
64
- if (!email) {
65
- error('Email is required');
66
- return null;
76
+ if (!response.ok) {
77
+ return null
67
78
  }
68
79
 
69
- const password = await promptSecret('Password: ');
70
- if (!password) {
71
- error('Password is required');
72
- return null;
80
+ const data = await response.json()
81
+ if (data.authenticated) {
82
+ return data
73
83
  }
74
-
75
- const loginSpinner = spinner('Authenticating...');
76
-
77
- try {
78
- // First login attempt
79
- let response = await fetch(`${API_BASE_URL}/api/auth/login`, {
80
- method: 'POST',
81
- headers: { 'Content-Type': 'application/json' },
82
- body: JSON.stringify({ email, password }),
83
- });
84
-
85
- // Handle maintenance mode
86
- if (response.status === 503) {
87
- const data = await response.json().catch(() => ({}));
88
- if (data.maintenance_mode) {
89
- loginSpinner.fail('Service unavailable');
90
- throw new MaintenanceError(data.message);
91
- }
92
- }
93
-
94
- let data = await response.json();
95
-
96
- // Handle 2FA requirement
97
- if (data.requires_2fa) {
98
- loginSpinner.stop();
99
-
100
- const codeType = data.two_factor_type === 'email'
101
- ? 'email verification code'
102
- : 'authenticator code';
103
-
104
- if (data.two_factor_type === 'email') {
105
- log('');
106
- info('📧 A verification code has been sent to your email');
107
- } else {
108
- log('');
109
- info('🔐 Two-factor authentication required');
110
- }
111
-
112
- const twoFactorCode = await prompt(`Enter ${codeType}: `);
113
-
114
- if (!twoFactorCode) {
115
- error('Verification code is required');
116
- return null;
117
- }
118
-
119
- const verifySpinner = spinner('Verifying code...');
120
-
121
- // Retry with 2FA code
122
- response = await fetch(`${API_BASE_URL}/api/auth/login`, {
123
- method: 'POST',
124
- headers: { 'Content-Type': 'application/json' },
125
- body: JSON.stringify({
126
- email,
127
- password,
128
- two_factor_code: twoFactorCode
129
- }),
130
- });
131
-
132
- data = await response.json();
133
-
134
- if (!data.success) {
135
- verifySpinner.fail('Verification failed');
136
- error(data.message || 'Invalid verification code');
137
- return null;
138
- }
139
-
140
- verifySpinner.succeed('Verified!');
141
- } else if (data.success) {
142
- loginSpinner.succeed('Authenticated!');
143
- } else {
144
- loginSpinner.fail('Authentication failed');
145
- error(data.message || 'Invalid email or password');
146
- return null;
147
- }
148
-
149
- return data;
150
-
151
- } catch (err) {
152
- if (handleCommonError(err, { error, info, warning })) {
153
- return null;
154
- }
155
- if (err.message.includes('fetch failed') || err.message.includes('ENOTFOUND')) {
156
- error('Unable to connect to LaunchPd servers');
157
- info('Check your internet connection');
158
- return null;
159
- }
160
- throw err;
84
+ return null
85
+ } catch (err) {
86
+ if (err.name === 'AbortError') {
87
+ return { timeout: true }
161
88
  }
89
+ return null
90
+ } finally {
91
+ clear()
92
+ }
162
93
  }
163
94
 
164
95
  /**
165
- * Login with API key (original method)
96
+ * Background update credentials if new data (like apiSecret) is available
166
97
  */
167
- async function loginWithApiKey() {
168
- log('Enter your API key from the dashboard.');
169
- log(`Don't have one? Run ${chalk.cyan('"launchpd register"')} first.\n`);
170
-
171
- const apiKey = await promptSecret('API Key: ');
172
-
173
- if (!apiKey) {
174
- errorWithSuggestions('API key is required', [
175
- 'Get your API key from the dashboard',
176
- `Visit: https://${config.domain}/settings`,
177
- 'Run "launchpd register" if you don\'t have an account',
178
- ]);
179
- return null;
180
- }
181
-
182
- const validateSpinner = spinner('Validating API key...');
183
-
184
- const result = await validateApiKey(apiKey);
185
-
186
- if (!result) {
187
- validateSpinner.fail('Invalid API key');
188
- errorWithSuggestions('Please check and try again.', [
189
- `Get your API key at: https://portal.${config.domain}/api-keys`,
190
- 'Make sure you copied the full key',
191
- 'API keys start with "lpd_"',
192
- ]);
193
- return null;
194
- }
195
-
196
- validateSpinner.succeed('Logged in successfully!');
98
+ async function updateCredentialsIfNeeded (creds, result) {
99
+ if (result.user?.api_secret && !creds.apiSecret) {
100
+ await saveCredentials({
101
+ ...creds,
102
+ apiSecret: result.user.api_secret,
103
+ userId: result.user.id || creds.userId,
104
+ email: result.user.email || creds.email
105
+ })
106
+ }
107
+ }
197
108
 
198
- return {
199
- ...result,
200
- apiKey, // Include the API key for saving
201
- };
109
+ /**
110
+ * Login with API key (original method)
111
+ */
112
+ async function loginWithApiKey () {
113
+ log('Enter your API key from the dashboard.')
114
+ log(`Don't have one? Run ${chalk.cyan('"launchpd register"')} first.\n`)
115
+
116
+ const apiKey = await promptSecret('API Key: ')
117
+
118
+ if (!apiKey) {
119
+ errorWithSuggestions('API key is required', [
120
+ 'Get your API key from the dashboard',
121
+ `Visit: https://${config.domain}/settings`,
122
+ 'Run "launchpd register" if you don\'t have an account'
123
+ ])
124
+ return null
125
+ }
126
+
127
+ const validateSpinner = spinner('Validating API key...')
128
+
129
+ const result = await validateApiKey(apiKey)
130
+
131
+ if (!result) {
132
+ validateSpinner.fail('Invalid API key')
133
+ errorWithSuggestions('Please check and try again.', [
134
+ `Get your API key at: https://portal.${config.domain}/api-keys`,
135
+ 'Make sure you copied the full key',
136
+ 'API keys start with "lpd_"'
137
+ ])
138
+ return null
139
+ }
140
+
141
+ if (result.timeout) {
142
+ validateSpinner.fail('Request timed out')
143
+ errorWithSuggestions('The server did not respond in time.', [
144
+ 'Check your internet connection',
145
+ 'Try again later',
146
+ 'If the problem persists, check https://status.launchpd.cloud'
147
+ ])
148
+ return null
149
+ }
150
+
151
+ if (result.requires_2fa) {
152
+ validateSpinner.fail('2FA Required')
153
+ info(
154
+ '2FA is required for your account. Please log in via the browser or use an authenticator app.'
155
+ )
156
+ return null
157
+ }
158
+
159
+ validateSpinner.succeed('Logged in successfully!')
160
+
161
+ return {
162
+ ...result,
163
+ apiKey // Include the API key for saving
164
+ }
202
165
  }
203
166
 
204
167
  /**
205
168
  * Login command - prompts for API key and validates it
206
169
  */
207
- export async function login() {
208
- // Check if already logged in
209
- if (await isLoggedIn()) {
210
- const creds = await getCredentials();
211
- warning(`Already logged in as ${chalk.cyan(creds.email || creds.userId)}`);
212
- info('Run "launchpd logout" to switch accounts');
213
- return;
214
- }
215
-
216
- log('\nLaunchpd Login\n');
217
- log('Choose login method:');
218
- log(` ${chalk.cyan('1.')} API Key ${chalk.gray('(from dashboard)')}`);
219
- log(` ${chalk.cyan('2.')} Email & Password ${chalk.gray('(supports 2FA)')}`);
220
- log('');
221
-
222
- const choice = await prompt('Enter choice (1 or 2): ');
223
-
224
- let result;
225
- let apiKey;
226
-
227
- if (choice === '2') {
228
- result = await loginWithEmailPassword();
229
- if (!result) {
230
- process.exit(1);
231
- }
232
- // Extract API key from user data
233
- apiKey = result.user?.api_key;
234
- } else {
235
- result = await loginWithApiKey();
236
- if (!result) {
237
- process.exit(1);
238
- }
239
- apiKey = result.apiKey;
240
- }
241
-
242
- // Save credentials
243
- await saveCredentials({
244
- apiKey,
245
- apiSecret: result.user?.api_secret,
246
- userId: result.user?.id,
247
- email: result.user?.email,
248
- tier: result.tier,
249
- });
250
-
251
- log('');
252
- log(` ${chalk.gray('Email:')} ${chalk.cyan(result.user?.email || 'N/A')}`);
253
- log(` ${chalk.gray('Tier:')} ${chalk.green(result.tier || 'registered')}`);
254
- if (result.usage) {
255
- log(` ${chalk.gray('Sites:')} ${result.usage?.siteCount || 0}/${result.limits?.maxSites || '?'}`);
256
- log(` ${chalk.gray('Storage:')} ${result.usage?.storageUsedMB || 0}MB/${result.limits?.maxStorageMB || '?'}MB`);
257
- }
258
-
259
- // Warn if email not verified
260
- if (result.user?.email && !result.user?.email_verified) {
261
- log('');
262
- warning('⚠️ Your email is not verified');
263
- info(`Verify at: https://${config.domain}/auth/verify-pending`);
264
- }
265
-
266
- log('');
170
+ export async function login () {
171
+ // Check if already logged in
172
+ if (await isLoggedIn()) {
173
+ const creds = await getCredentials()
174
+ warning(`Already logged in as ${chalk.cyan(creds.email || creds.userId)}`)
175
+ info('Run "launchpd logout" to switch accounts')
176
+ return
177
+ }
178
+
179
+ log('\nLaunchpd Login\n')
180
+
181
+ const result = await loginWithApiKey()
182
+ if (!result) {
183
+ process.exit(1)
184
+ }
185
+ const apiKey = result.apiKey
186
+
187
+ // Save credentials
188
+ await saveCredentials({
189
+ apiKey,
190
+ apiSecret: result.user?.api_secret,
191
+ userId: result.user?.id,
192
+ email: result.user?.email,
193
+ tier: result.tier
194
+ })
195
+
196
+ log('')
197
+ log(` ${chalk.gray('Email:')} ${chalk.cyan(result.user?.email || 'N/A')}`)
198
+ log(` ${chalk.gray('Tier:')} ${chalk.green(result.tier || 'registered')}`)
199
+ if (result.usage) {
200
+ log(
201
+ ` ${chalk.gray('Sites:')} ${result.usage?.siteCount || 0}/${result.limits?.maxSites || '?'}`
202
+ )
203
+ const storageUsed =
204
+ result.usage?.storageUsed ||
205
+ (result.usage?.storageUsedMB || 0) * 1024 * 1024
206
+ const storageMax =
207
+ result.limits?.maxStorageBytes ||
208
+ (result.limits?.maxStorageMB || 0) * 1024 * 1024
209
+ log(
210
+ ` ${chalk.gray('Storage:')} ${formatBytes(storageUsed)}/${formatBytes(storageMax)}`
211
+ )
212
+ }
213
+
214
+ // Warn if email not verified
215
+ if (result.user?.email && !result.user?.email_verified) {
216
+ log('')
217
+ warning('Your email is not verified')
218
+ info(`Verify at: https://${config.domain}/auth/verify-pending`)
219
+ }
220
+
221
+ log('')
267
222
  }
268
223
 
269
224
  /**
270
225
  * Logout command - clears stored credentials and invalidates server session
271
226
  */
272
- export async function logout() {
273
- const loggedIn = await isLoggedIn();
274
-
275
- if (!loggedIn) {
276
- warning('Not currently logged in');
277
- return;
278
- }
227
+ export async function logout () {
228
+ const loggedIn = await isLoggedIn()
279
229
 
280
- const creds = await getCredentials();
230
+ if (!loggedIn) {
231
+ warning('Not currently logged in')
232
+ return
233
+ }
281
234
 
282
- // Try server-side logout (best effort - don't fail if it doesn't work)
283
- try {
284
- await serverLogout();
285
- } catch {
286
- // Ignore server logout errors - we'll clear local creds anyway
287
- }
235
+ const creds = await getCredentials()
288
236
 
289
- await clearCredentials();
237
+ await clearCredentials()
290
238
 
291
- success('Logged out successfully');
292
- if (creds?.email) {
293
- info(`Was logged in as: ${chalk.cyan(creds.email)}`);
294
- }
295
- log(`\nYou can still deploy anonymously (limited to ${chalk.yellow('3 sites')}, ${chalk.yellow('50MB')}).`);
239
+ success('Logged out successfully')
240
+ if (creds?.email) {
241
+ info(`Was logged in as: ${chalk.cyan(creds.email)}`)
242
+ }
243
+ log(
244
+ `\nYou can still deploy anonymously (limited to ${chalk.yellow('3 sites')}, ${chalk.yellow('50MB')}).`
245
+ )
296
246
  }
297
247
 
298
248
  /**
299
249
  * Register command - opens browser to registration page
300
250
  */
301
- export async function register() {
302
- log('\nRegister for Launchpd\n');
303
- log(`Opening registration page: ${chalk.cyan(REGISTER_URL)}\n`);
304
-
305
- // Open browser based on platform
306
- const platform = process.platform;
307
- let cmd;
308
-
309
- if (platform === 'darwin') {
310
- cmd = `open "${REGISTER_URL}"`;
311
- } else if (platform === 'win32') {
312
- cmd = `start "" "${REGISTER_URL}"`;
313
- } else {
314
- cmd = `xdg-open "${REGISTER_URL}"`;
251
+ export function register () {
252
+ log('\nRegister for Launchpd\n')
253
+ log(`Opening registration page: ${chalk.cyan(REGISTER_URL)}\n`)
254
+
255
+ // Open browser based on platform
256
+ const platform = process.platform
257
+ let command = 'xdg-open'
258
+ let args = [REGISTER_URL]
259
+
260
+ if (platform === 'darwin') {
261
+ command = 'open'
262
+ } else if (platform === 'win32') {
263
+ command = 'cmd'
264
+ args = ['/c', 'start', '', REGISTER_URL]
265
+ }
266
+
267
+ execFile(command, args, (err) => {
268
+ if (err) {
269
+ log(
270
+ `Please open this URL in your browser:\n ${chalk.cyan(REGISTER_URL)}\n`
271
+ )
315
272
  }
316
-
317
- exec(cmd, (err) => {
318
- if (err) {
319
- log(`Please open this URL in your browser:\n ${chalk.cyan(REGISTER_URL)}\n`);
320
- }
321
- });
322
-
323
- log('After registering:');
324
- log(` 1. Get your API key from the dashboard`);
325
- log(` 2. Run: ${chalk.cyan('launchpd login')}`);
326
- log('');
327
-
328
- info('Registration benefits:');
329
- log(` ${chalk.green('✓')} ${chalk.white('10 sites')} ${chalk.gray('(instead of 3)')}`);
330
- log(` ${chalk.green('✓')} ${chalk.white('100MB storage')} ${chalk.gray('(instead of 50MB)')}`);
331
- log(` ${chalk.green('✓')} ${chalk.white('30-day retention')} ${chalk.gray('(instead of 7 days)')}`);
332
- log(` ${chalk.green('✓')} ${chalk.white('10 versions per site')}`);
333
- log('');
273
+ })
274
+
275
+ log('After registering:')
276
+ log(' 1. Get your API key from the dashboard')
277
+ log(` 2. Run: ${chalk.cyan('launchpd login')}`)
278
+ log('')
279
+
280
+ info('Registration benefits:')
281
+ log(
282
+ ` ${chalk.green('✓')} ${chalk.white('10 sites')} ${chalk.gray('(instead of 3)')}`
283
+ )
284
+ log(
285
+ ` ${chalk.green('✓')} ${chalk.white('100MB storage')} ${chalk.gray('(instead of 50MB)')}`
286
+ )
287
+ log(
288
+ ` ${chalk.green('✓')} ${chalk.white('30-day retention')} ${chalk.gray('(instead of 7 days)')}`
289
+ )
290
+ log(` ${chalk.green('')} ${chalk.white('10 versions per site')}`)
291
+ log('')
334
292
  }
335
293
 
336
294
  /**
337
295
  * Whoami command - shows current user info and quota status
338
296
  */
339
- export async function whoami() {
340
- const creds = await getCredentials();
341
-
342
- if (!creds) {
343
- log('\n👤 Not logged in (anonymous mode)\n');
344
- log('Anonymous limits:');
345
- log(` • ${chalk.white('3 sites')} maximum`);
346
- log(` • ${chalk.white('50MB')} total storage`);
347
- log(` • ${chalk.white('7-day')} retention`);
348
- log(` • ${chalk.white('1 version')} per site`);
349
- log(`\nRun ${chalk.cyan('"launchpd login"')} to authenticate`);
350
- log(`Run ${chalk.cyan('"launchpd register"')} to create an account\n`);
351
- return;
352
- }
353
-
354
- info('Fetching account status...');
355
-
356
- // Validate and get current quota
357
- const result = await validateApiKey(creds.apiKey);
358
-
359
- if (!result) {
360
- warning('Session expired or API key invalid');
361
- await clearCredentials();
362
- error('Please login again with: launchpd login');
363
- process.exit(1);
364
- }
365
-
366
- // Background upgrade to apiSecret if missing
367
- await updateCredentialsIfNeeded(creds, result);
368
-
369
- log(`\nLogged in as: ${result.user?.email || result.user?.id}\n`);
370
-
371
- log('Account Info:');
372
- log(` User ID: ${result.user?.id}`);
373
-
374
- // Email with verification status
375
- const emailStatus = result.user?.email_verified
376
- ? chalk.green('✓ Verified')
377
- : chalk.yellow('⚠ Unverified');
378
- log(` Email: ${result.user?.email || 'Not set'} ${emailStatus}`);
379
-
380
- // Enhanced 2FA display
381
- const hasTOTP = result.user?.is_2fa_enabled;
382
- const hasEmail2FA = result.user?.is_email_2fa_enabled;
383
-
384
- if (hasTOTP && hasEmail2FA) {
385
- log(` 2FA: ${chalk.green('✓ Enabled')} ${chalk.gray('(App + Email)')}`);
386
- } else if (hasTOTP) {
387
- log(` 2FA: ${chalk.green('✓ Enabled')} ${chalk.gray('(Authenticator App)')}`);
388
- } else if (hasEmail2FA) {
389
- log(` 2FA: ${chalk.green('✓ Enabled')} ${chalk.gray('(Email)')}`);
390
- } else {
391
- log(` 2FA: ${chalk.gray('Not enabled')}`);
392
- }
393
-
394
- log(` Tier: ${result.tier}`);
395
- log('');
396
-
397
- log('Usage:');
398
- log(` Sites: ${result.usage?.siteCount || 0} / ${result.limits?.maxSites}`);
399
- log(` Storage: ${result.usage?.storageUsedMB || 0}MB / ${result.limits?.maxStorageMB}MB`);
400
- log(` Sites remaining: ${result.usage?.sitesRemaining || 0}`);
401
- log(` Storage remaining: ${result.usage?.storageRemainingMB || 0}MB`);
402
- log('');
403
-
404
- log('Limits:');
405
- log(` Max versions per site: ${result.limits?.maxVersionsPerSite}`);
406
- log(` Retention: ${result.limits?.retentionDays} days`);
407
- log('');
408
-
409
- // Show warnings
410
- if (result.warnings && result.warnings.length > 0) {
411
- log('⚠️ Warnings:');
412
- result.warnings.forEach(w => log(` ${w}`));
413
- log('');
414
- }
415
-
416
- if (!result.canCreateNewSite) {
417
- warning('You cannot create new sites (limit reached)');
418
- info('You can still update existing sites');
419
- }
420
-
421
- // Email verification warning
422
- if (result.user?.email && !result.user?.email_verified) {
423
- log('');
424
- warning('⚠️ Your email is not verified');
425
- info(`Verify at: https://${config.domain}/auth/verify-pending`);
426
- info('Some features may be limited until verified');
427
- }
428
-
429
- // 2FA recommendation if not enabled
430
- if (!result.user?.is_2fa_enabled && !result.user?.is_email_2fa_enabled) {
431
- log('');
432
- info('💡 Tip: Enable 2FA for better security');
433
- const securityUrl = `https://${config.domain}/settings/security`;
434
- log(` ${chalk.gray('Visit: ' + securityUrl)}`);
435
- }
297
+ export async function whoami () {
298
+ const creds = await getCredentials()
299
+
300
+ if (!creds) {
301
+ log('\n👤 Not logged in (anonymous mode)\n')
302
+ log('Anonymous limits:')
303
+ log(` • ${chalk.white('3 sites')} maximum`)
304
+ log(` • ${chalk.white('50MB')} total storage`)
305
+ log(` • ${chalk.white('7-day')} retention`)
306
+ log(` • ${chalk.white('1 version')} per site`)
307
+ log(`\nRun ${chalk.cyan('"launchpd login"')} to authenticate`)
308
+ log(`Run ${chalk.cyan('"launchpd register"')} to create an account\n`)
309
+ return
310
+ }
311
+
312
+ info('Fetching account status...')
313
+
314
+ // Validate and get current quota
315
+ const result = await validateApiKey(creds.apiKey)
316
+
317
+ if (!result) {
318
+ warning('Session expired or API key invalid')
319
+ await clearCredentials()
320
+ error('Please login again with: launchpd login')
321
+ process.exit(1)
322
+ }
323
+
324
+ // Background upgrade to apiSecret if missing
325
+ await updateCredentialsIfNeeded(creds, result)
326
+
327
+ log(`\nLogged in as: ${result.user?.email || result.user?.id}\n`)
328
+
329
+ log('Account Info:')
330
+ log(` User ID: ${result.user?.id}`)
331
+
332
+ // Email with verification status
333
+ const emailStatus = result.user?.email_verified
334
+ ? chalk.green('✓ Verified')
335
+ : chalk.yellow('⚠ Unverified')
336
+ log(` Email: ${result.user?.email || 'Not set'} ${emailStatus}`)
337
+
338
+ // Enhanced 2FA display
339
+ const hasTOTP = result.user?.is_2fa_enabled
340
+ const hasEmail2FA = result.user?.is_email_2fa_enabled
341
+
342
+ if (hasTOTP && hasEmail2FA) {
343
+ log(` 2FA: ${chalk.green('✓ Enabled')} ${chalk.gray('(App + Email)')}`)
344
+ } else if (hasTOTP) {
345
+ log(
346
+ ` 2FA: ${chalk.green('✓ Enabled')} ${chalk.gray('(Authenticator App)')}`
347
+ )
348
+ } else if (hasEmail2FA) {
349
+ log(` 2FA: ${chalk.green(' Enabled')} ${chalk.gray('(Email)')}`)
350
+ } else {
351
+ log(` 2FA: ${chalk.gray('Not enabled')}`)
352
+ }
353
+
354
+ log(` Tier: ${result.tier}`)
355
+ log('')
356
+
357
+ log('Usage:')
358
+ log(` Sites: ${result.usage?.siteCount || 0} / ${result.limits?.maxSites}`)
359
+ const storageUsed =
360
+ result.usage?.storageUsed ||
361
+ (result.usage?.storageUsedMB || 0) * 1024 * 1024
362
+ const storageMax =
363
+ result.limits?.maxStorageBytes ||
364
+ (result.limits?.maxStorageMB || 0) * 1024 * 1024
365
+ log(` Storage: ${formatBytes(storageUsed)} / ${formatBytes(storageMax)}`)
366
+ log(` Sites remaining: ${result.usage?.sitesRemaining || 0}`)
367
+ const storageRemaining =
368
+ result.usage?.storageRemaining ||
369
+ (result.usage?.storageRemainingMB || 0) * 1024 * 1024
370
+ log(` Storage remaining: ${formatBytes(storageRemaining)}`)
371
+ log('')
372
+
373
+ log('Limits:')
374
+ log(` Max versions per site: ${result.limits?.maxVersionsPerSite}`)
375
+ log(` Retention: ${result.limits?.retentionDays} days`)
376
+ log('')
377
+
378
+ // Show warnings
379
+ if (result.warnings && result.warnings.length > 0) {
380
+ log('Warnings:')
381
+ result.warnings.forEach((w) => log(` ${w}`))
382
+ log('')
383
+ }
384
+
385
+ if (!result.canCreateNewSite) {
386
+ warning('You cannot create new sites (limit reached)')
387
+ info('You can still update existing sites')
388
+ }
389
+
390
+ // Email verification warning
391
+ if (result.user?.email && !result.user?.email_verified) {
392
+ log('')
393
+ warning('Your email is not verified')
394
+ info(`Verify at: https://${config.domain}/auth/verify-pending`)
395
+ info('Some features may be limited until verified')
396
+ }
397
+
398
+ // 2FA recommendation if not enabled
399
+ if (!result.user?.is_2fa_enabled && !result.user?.is_email_2fa_enabled) {
400
+ log('')
401
+ info('Tip: Enable 2FA for better security')
402
+ const securityUrl = `https://${config.domain}/settings/security`
403
+ const securityUrlMsg = `Visit: ${securityUrl}`
404
+ log(` ${chalk.gray(securityUrlMsg)}`)
405
+ }
436
406
  }
437
407
 
438
408
  /**
439
409
  * Quota command - shows detailed quota information
440
410
  */
441
- export async function quota() {
442
- const creds = await getCredentials();
443
-
444
- if (!creds) {
445
- log(`\n${chalk.bold('Anonymous Quota Status')}\n`);
446
- log(chalk.gray('You are not logged in.'));
447
- log('');
448
- log(chalk.bold('Anonymous tier limits:'));
449
- log(chalk.gray(' ┌─────────────────────────────────┐'));
450
- log(chalk.gray(' │') + ` Sites: ${chalk.white('3 maximum')} ` + chalk.gray('│'));
451
- log(chalk.gray(' │') + ` Storage: ${chalk.white('50MB total')} ` + chalk.gray('│'));
452
- log(chalk.gray(' │') + ` Retention: ${chalk.white('7 days')} ` + chalk.gray('│'));
453
- log(chalk.gray(' │') + ` Versions: ${chalk.white('1 per site')} ` + chalk.gray('│'));
454
- log(chalk.gray(' └─────────────────────────────────┘'));
455
- log('');
456
- log(`${chalk.cyan('Register for FREE')} to unlock more:`);
457
- log(` ${chalk.green('')} ${chalk.white('10 sites')}`);
458
- log(` ${chalk.green('→')} ${chalk.white('100MB storage')}`);
459
- log(` ${chalk.green('→')} ${chalk.white('30-day retention')}`);
460
- log(` ${chalk.green('')} ${chalk.white('10 versions per site')}`);
461
- log('');
462
- log(`Run: ${chalk.cyan('launchpd register')}`);
463
- log('');
464
- return;
465
- }
466
-
467
- const fetchSpinner = spinner('Fetching quota status...');
468
-
469
- const result = await validateApiKey(creds.apiKey);
470
-
471
- if (!result) {
472
- fetchSpinner.fail('Failed to fetch quota');
473
- errorWithSuggestions('API key may be invalid.', [
474
- 'Run "launchpd login" to re-authenticate',
475
- 'Check your internet connection',
476
- ]);
477
- process.exit(1);
478
- }
479
-
480
- // Background upgrade to apiSecret if missing
481
- await updateCredentialsIfNeeded(creds, result);
482
-
483
- fetchSpinner.succeed('Quota fetched');
484
- log(`\n${chalk.bold('Quota Status for:')} ${chalk.cyan(result.user?.email || creds.email)}\n`);
485
-
486
- // Sites usage
487
- const sitesUsed = result.usage?.siteCount || 0;
488
- const sitesMax = result.limits?.maxSites || 10;
489
- const sitesPercent = Math.round((sitesUsed / sitesMax) * 100);
490
- const sitesBar = createProgressBar(sitesUsed, sitesMax);
491
-
492
- log(`${chalk.gray('Sites:')} ${sitesBar} ${chalk.white(sitesUsed)}/${sitesMax} (${getPercentColor(sitesPercent)})`);
493
-
494
- // Storage usage
495
- const storageBytes = result.usage?.storageUsed || 0;
496
- const storageMaxBytes = result.limits?.maxStorageBytes || (result.limits?.maxStorageMB || 100) * 1024 * 1024;
497
- const storagePercent = Math.round((storageBytes / storageMaxBytes) * 100);
498
- const storageBar = createProgressBar(storageBytes, storageMaxBytes);
499
-
500
- log(`${chalk.gray('Storage:')} ${storageBar} ${chalk.white(formatBytes(storageBytes))}/${formatBytes(storageMaxBytes)} (${getPercentColor(storagePercent)})`);
501
-
502
- log('');
503
- log(`${chalk.gray('Tier:')} ${chalk.green(result.tier || 'free')}`);
504
- log(`${chalk.gray('Retention:')} ${chalk.white(result.limits?.retentionDays || 30)} days`);
505
- log(`${chalk.gray('Max versions:')} ${chalk.white(result.limits?.maxVersionsPerSite || 10)} per site`);
506
- log('');
507
-
508
- // Status indicators
509
- if (result.canCreateNewSite === false) {
510
- warning('Site limit reached - cannot create new sites');
511
- }
512
-
513
- if (storagePercent > 80) {
514
- warning(`Storage ${storagePercent}% used - consider cleaning up old deployments`);
515
- }
516
-
517
- log('');
411
+ export async function quota () {
412
+ const creds = await getCredentials()
413
+
414
+ if (!creds) {
415
+ log(`\n${chalk.bold('Anonymous Quota Status')}\n`)
416
+ log(chalk.gray('You are not logged in.'))
417
+ log('')
418
+ log(chalk.bold('Anonymous tier limits:'))
419
+ log(chalk.gray(' ┌─────────────────────────────────┐'))
420
+ log(
421
+ `${chalk.gray(' │')} Sites: ${chalk.white('3 maximum')} ${chalk.gray('│')}`
422
+ )
423
+ log(
424
+ `${chalk.gray(' ')} Storage: ${chalk.white('50MB total')} ${chalk.gray('│')}`
425
+ )
426
+ log(
427
+ `${chalk.gray('')} Retention: ${chalk.white('7 days')} ${chalk.gray('│')}`
428
+ )
429
+ log(
430
+ `${chalk.gray('')} Versions: ${chalk.white('1 per site')} ${chalk.gray('│')}`
431
+ )
432
+ log(chalk.gray(' └─────────────────────────────────┘'))
433
+ log('')
434
+ log(`${chalk.cyan('Register for FREE')} to unlock more:`)
435
+ log(` ${chalk.green('→')} ${chalk.white('10 sites')}`)
436
+ log(` ${chalk.green('→')} ${chalk.white('100MB storage')}`)
437
+ log(` ${chalk.green('→')} ${chalk.white('30-day retention')}`)
438
+ log(` ${chalk.green('→')} ${chalk.white('10 versions per site')}`)
439
+ log('')
440
+ log(`Run: ${chalk.cyan('launchpd register')}`)
441
+ log('')
442
+ return
443
+ }
444
+
445
+ const fetchSpinner = spinner('Fetching quota status...')
446
+
447
+ const result = await validateApiKey(creds.apiKey)
448
+
449
+ if (!result) {
450
+ fetchSpinner.fail('Failed to fetch quota')
451
+ errorWithSuggestions('API key may be invalid.', [
452
+ 'Run "launchpd login" to re-authenticate',
453
+ 'Check your internet connection'
454
+ ])
455
+ process.exit(1)
456
+ }
457
+
458
+ // Background upgrade to apiSecret if missing
459
+ await updateCredentialsIfNeeded(creds, result)
460
+
461
+ fetchSpinner.succeed('Quota fetched')
462
+ log(
463
+ `\n${chalk.bold('Quota Status for:')} ${chalk.cyan(result.user?.email || creds.email)}\n`
464
+ )
465
+
466
+ // Sites usage
467
+ const sitesUsed = result.usage?.siteCount || 0
468
+ const sitesMax = result.limits?.maxSites || 10
469
+ const sitesPercent = Math.round((sitesUsed / sitesMax) * 100)
470
+ const sitesBar = createProgressBar(sitesUsed, sitesMax)
471
+
472
+ log(
473
+ `${chalk.gray('Sites:')} ${sitesBar} ${chalk.white(sitesUsed)}/${sitesMax} (${getPercentColor(sitesPercent)})`
474
+ )
475
+
476
+ // Storage usage
477
+ const storageBytes = result.usage?.storageUsed || 0
478
+ const storageMaxBytes =
479
+ result.limits?.maxStorageBytes ||
480
+ (result.limits?.maxStorageMB || 100) * 1024 * 1024
481
+ const storagePercent = Math.round((storageBytes / storageMaxBytes) * 100)
482
+ const storageBar = createProgressBar(storageBytes, storageMaxBytes)
483
+
484
+ log(
485
+ `${chalk.gray('Storage:')} ${storageBar} ${chalk.white(formatBytes(storageBytes))}/${formatBytes(storageMaxBytes)} (${getPercentColor(storagePercent)})`
486
+ )
487
+
488
+ log('')
489
+ log(`${chalk.gray('Tier:')} ${chalk.green(result.tier || 'free')}`)
490
+ log(
491
+ `${chalk.gray('Retention:')} ${chalk.white(result.limits?.retentionDays || 30)} days`
492
+ )
493
+ log(
494
+ `${chalk.gray('Max versions:')} ${chalk.white(result.limits?.maxVersionsPerSite || 10)} per site`
495
+ )
496
+ log('')
497
+
498
+ // Status indicators
499
+ if (result.canCreateNewSite === false) {
500
+ warning('Site limit reached - cannot create new sites')
501
+ }
502
+
503
+ if (storagePercent > 80) {
504
+ warning(
505
+ `Storage ${storagePercent}% used - consider cleaning up old deployments`
506
+ )
507
+ }
508
+
509
+ log('')
518
510
  }
519
511
 
520
512
  /**
521
513
  * Create a simple progress bar with color coding
522
514
  */
523
- function createProgressBar(current, max, width = 20) {
524
- const filled = Math.round((current / max) * width);
525
- const empty = width - filled;
526
- const percent = (current / max) * 100;
527
-
528
- const filledChar = '█';
529
- let barColor;
530
-
531
- if (percent >= 90) {
532
- barColor = chalk.red;
533
- } else if (percent >= 70) {
534
- barColor = chalk.yellow;
535
- } else {
536
- barColor = chalk.green;
537
- }
538
-
539
- const bar = barColor(filledChar.repeat(filled)) + chalk.gray('░'.repeat(empty));
540
- return `[${bar}]`;
515
+ function createProgressBar (current, max, width = 20) {
516
+ const filled = Math.round((current / max) * width)
517
+ const empty = width - filled
518
+ const percent = (current / max) * 100
519
+
520
+ const filledChar = '█'
521
+ let barColor
522
+
523
+ if (percent >= 90) {
524
+ barColor = chalk.red
525
+ } else if (percent >= 70) {
526
+ barColor = chalk.yellow
527
+ } else {
528
+ barColor = chalk.green
529
+ }
530
+
531
+ const bar = `${barColor(filledChar.repeat(filled))}${chalk.gray('░'.repeat(empty))}`
532
+ return `[${bar}]`
541
533
  }
542
534
 
543
535
  /**
544
536
  * Get colored percentage text
545
537
  */
546
- function getPercentColor(percent) {
547
- if (percent >= 90) {
548
- return chalk.red(`${percent}%`);
549
- } else if (percent >= 70) {
550
- return chalk.yellow(`${percent}%`);
551
- }
552
- return chalk.green(`${percent}%`);
538
+ function getPercentColor (percent) {
539
+ if (percent >= 90) {
540
+ return chalk.red(`${percent}%`)
541
+ } else if (percent >= 70) {
542
+ return chalk.yellow(`${percent}%`)
543
+ }
544
+ return chalk.green(`${percent}%`)
553
545
  }
554
546
 
555
547
  /**
556
548
  * Resend email verification command
557
549
  */
558
- export async function resendEmailVerification() {
559
- const loggedIn = await isLoggedIn();
550
+ export async function resendEmailVerification () {
551
+ const loggedIn = await isLoggedIn()
560
552
 
561
- if (!loggedIn) {
562
- error('Not logged in');
563
- info(`Run ${chalk.cyan('"launchpd login"')} first`);
564
- process.exit(1);
565
- }
553
+ if (!loggedIn) {
554
+ error('Not logged in')
555
+ info(`Run ${chalk.cyan('"launchpd login"')} first`)
556
+ process.exit(1)
557
+ }
566
558
 
567
- const sendSpinner = spinner('Requesting verification email...');
568
-
569
- try {
570
- const result = await resendVerification();
571
-
572
- if (result.success) {
573
- sendSpinner.succeed('Verification email sent!');
574
- info('Check your inbox and spam folder');
575
- } else {
576
- sendSpinner.fail('Failed to send verification email');
577
- error(result.message || 'Unknown error');
578
- if (result.seconds_remaining) {
579
- info(`Please wait ${result.seconds_remaining} seconds before trying again`);
580
- }
581
- }
582
- } catch (err) {
583
- sendSpinner.fail('Request failed');
584
- if (handleCommonError(err, { error, info, warning })) {
585
- process.exit(1);
586
- }
587
- if (err.message?.includes('already verified')) {
588
- success('Your email is already verified!');
589
- return;
590
- }
591
- error(err.message || 'Failed to resend verification email');
592
- process.exit(1);
559
+ const sendSpinner = spinner('Requesting verification email...')
560
+
561
+ try {
562
+ const result = await resendVerification()
563
+
564
+ if (result.success) {
565
+ sendSpinner.succeed('Verification email sent!')
566
+ info('Check your inbox and spam folder')
567
+ } else {
568
+ sendSpinner.fail('Failed to send verification email')
569
+ error(result.message || 'Unknown error')
570
+ if (result.seconds_remaining) {
571
+ info(
572
+ `Please wait ${result.seconds_remaining} seconds before trying again`
573
+ )
574
+ }
575
+ }
576
+ } catch (err) {
577
+ sendSpinner.fail('Request failed')
578
+ if (handleCommonError(err, { error, info, warning })) {
579
+ process.exit(1)
580
+ }
581
+ if (err.message?.includes('already verified')) {
582
+ success('Your email is already verified!')
583
+ return
593
584
  }
585
+ error(err.message || 'Failed to resend verification email')
586
+ process.exit(1)
587
+ }
594
588
  }