launchpd 1.0.2 → 1.0.3

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/bin/cli.js CHANGED
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import fs from 'node:fs';
3
4
  import { Command } from 'commander';
4
- import { deploy, list, rollback, versions, init, status, login, logout, register, whoami, quota } from '../src/commands/index.js';
5
+ import updateNotifier from 'update-notifier';
6
+ import { deploy, list, rollback, versions, init, status, login, logout, register, whoami, quota, resendEmailVerification } from '../src/commands/index.js';
7
+
8
+ const packageJson = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url)));
9
+ updateNotifier({ pkg: packageJson }).notify();
5
10
 
6
11
 
7
12
  const program = new Command();
@@ -9,7 +14,7 @@ const program = new Command();
9
14
  program
10
15
  .name('launchpd')
11
16
  .description('Deploy static sites instantly to a live URL')
12
- .version('0.1.12');
17
+ .version(packageJson.version);
13
18
 
14
19
  program
15
20
  .command('deploy')
@@ -22,6 +27,7 @@ program
22
27
  .option('--force', 'Force deployment even with warnings')
23
28
  .option('-o, --open', 'Open the site URL in the default browser after deployment')
24
29
  .option('--verbose', 'Show detailed error information')
30
+ .option('--qr', 'Show QR code for deployment')
25
31
  .action(async (folder, options) => {
26
32
  await deploy(folder || '.', options);
27
33
  });
@@ -108,4 +114,11 @@ program
108
114
  await quota();
109
115
  });
110
116
 
117
+ program
118
+ .command('verify')
119
+ .description('Resend email verification')
120
+ .action(async () => {
121
+ await resendEmailVerification();
122
+ });
123
+
111
124
  program.parseAsync();
package/bin/setup.js CHANGED
@@ -1,5 +1,3 @@
1
- #!/usr/bin/env node
2
-
3
1
  import { config } from '../src/config.js';
4
2
  import { info, success } from '../src/utils/logger.js';
5
3
  import chalk from 'chalk';
@@ -34,7 +32,9 @@ async function setup() {
34
32
  success('No configuration needed - just deploy!');
35
33
  }
36
34
 
37
- setup().catch(err => {
35
+ try {
36
+ await setup();
37
+ } catch (err) {
38
38
  console.error(`Setup failed: ${err.message}`);
39
39
  process.exit(1);
40
- });
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchpd",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Deploy static sites instantly to a live URL",
5
5
  "keywords": [
6
6
  "static",
@@ -54,7 +54,9 @@
54
54
  "launchpd": "^1.0.0",
55
55
  "mime-types": "^2.1.35",
56
56
  "nanoid": "^5.1.0",
57
- "ora": "^8.0.1"
57
+ "ora": "^8.0.1",
58
+ "qrcode": "^1.5.4",
59
+ "update-notifier": "^7.3.1"
58
60
  },
59
61
  "devDependencies": {
60
62
  "@eslint/js": "^9.39.2",
@@ -4,11 +4,13 @@
4
4
  */
5
5
 
6
6
  import { exec } from 'node:child_process';
7
- import { promptSecret } from '../utils/prompt.js';
7
+ import { promptSecret, prompt } from '../utils/prompt.js';
8
8
  import { config } from '../config.js';
9
9
  import { getCredentials, saveCredentials, clearCredentials, isLoggedIn } from '../utils/credentials.js';
10
- import { success, error, errorWithSuggestions, info, warning, spinner } from '../utils/logger.js';
10
+ import { success, error, errorWithSuggestions, info, warning, spinner, log } from '../utils/logger.js';
11
11
  import { formatBytes } from '../utils/quota.js';
12
+ import { handleCommonError, MaintenanceError } from '../utils/errors.js';
13
+ import { serverLogout, resendVerification } from '../utils/api.js';
12
14
  import chalk from 'chalk';
13
15
 
14
16
  const API_BASE_URL = config.apiUrl;
@@ -55,20 +57,116 @@ async function updateCredentialsIfNeeded(creds, result) {
55
57
  }
56
58
 
57
59
  /**
58
- * Login command - prompts for API key and validates it
60
+ * Login with email and password (supports 2FA)
59
61
  */
60
- export async function login() {
61
- // Check if already logged in
62
- if (await isLoggedIn()) {
63
- const creds = await getCredentials();
64
- warning(`Already logged in as ${chalk.cyan(creds.email || creds.userId)}`);
65
- info('Run "launchpd logout" to switch accounts');
66
- return;
62
+ async function loginWithEmailPassword() {
63
+ const email = await prompt('Email: ');
64
+ if (!email) {
65
+ error('Email is required');
66
+ return null;
67
+ }
68
+
69
+ const password = await promptSecret('Password: ');
70
+ if (!password) {
71
+ error('Password is required');
72
+ return null;
73
+ }
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;
67
161
  }
162
+ }
68
163
 
69
- console.log('\nLaunchpd Login\n');
70
- console.log('Enter your API key from the dashboard.');
71
- console.log(`Don't have one? Run ${chalk.cyan('"launchpd register"')} first.\n`);
164
+ /**
165
+ * Login with API key (original method)
166
+ */
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`);
72
170
 
73
171
  const apiKey = await promptSecret('API Key: ');
74
172
 
@@ -78,7 +176,7 @@ export async function login() {
78
176
  `Visit: https://${config.domain}/settings`,
79
177
  'Run "launchpd register" if you don\'t have an account',
80
178
  ]);
81
- process.exit(1);
179
+ return null;
82
180
  }
83
181
 
84
182
  const validateSpinner = spinner('Validating API key...');
@@ -92,7 +190,53 @@ export async function login() {
92
190
  'Make sure you copied the full key',
93
191
  'API keys start with "lpd_"',
94
192
  ]);
95
- process.exit(1);
193
+ return null;
194
+ }
195
+
196
+ validateSpinner.succeed('Logged in successfully!');
197
+
198
+ return {
199
+ ...result,
200
+ apiKey, // Include the API key for saving
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Login command - prompts for API key and validates it
206
+ */
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;
96
240
  }
97
241
 
98
242
  // Save credentials
@@ -104,17 +248,26 @@ export async function login() {
104
248
  tier: result.tier,
105
249
  });
106
250
 
107
- validateSpinner.succeed('Logged in successfully!');
108
- console.log('');
109
- console.log(` ${chalk.gray('Email:')} ${chalk.cyan(result.user?.email || 'N/A')}`);
110
- console.log(` ${chalk.gray('Tier:')} ${chalk.green(result.tier)}`);
111
- console.log(` ${chalk.gray('Sites:')} ${result.usage?.siteCount || 0}/${result.limits?.maxSites || '?'}`);
112
- console.log(` ${chalk.gray('Storage:')} ${result.usage?.storageUsedMB || 0}MB/${result.limits?.maxStorageMB || '?'}MB`);
113
- console.log('');
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('');
114
267
  }
115
268
 
116
269
  /**
117
- * Logout command - clears stored credentials
270
+ * Logout command - clears stored credentials and invalidates server session
118
271
  */
119
272
  export async function logout() {
120
273
  const loggedIn = await isLoggedIn();
@@ -125,21 +278,29 @@ export async function logout() {
125
278
  }
126
279
 
127
280
  const creds = await getCredentials();
281
+
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
+ }
288
+
128
289
  await clearCredentials();
129
290
 
130
291
  success('Logged out successfully');
131
292
  if (creds?.email) {
132
293
  info(`Was logged in as: ${chalk.cyan(creds.email)}`);
133
294
  }
134
- console.log(`\nYou can still deploy anonymously (limited to ${chalk.yellow('3 sites')}, ${chalk.yellow('50MB')}).`);
295
+ log(`\nYou can still deploy anonymously (limited to ${chalk.yellow('3 sites')}, ${chalk.yellow('50MB')}).`);
135
296
  }
136
297
 
137
298
  /**
138
299
  * Register command - opens browser to registration page
139
300
  */
140
301
  export async function register() {
141
- console.log('\nRegister for Launchpd\n');
142
- console.log(`Opening registration page: ${chalk.cyan(REGISTER_URL)}\n`);
302
+ log('\nRegister for Launchpd\n');
303
+ log(`Opening registration page: ${chalk.cyan(REGISTER_URL)}\n`);
143
304
 
144
305
  // Open browser based on platform
145
306
  const platform = process.platform;
@@ -155,21 +316,21 @@ export async function register() {
155
316
 
156
317
  exec(cmd, (err) => {
157
318
  if (err) {
158
- console.log(`Please open this URL in your browser:\n ${chalk.cyan(REGISTER_URL)}\n`);
319
+ log(`Please open this URL in your browser:\n ${chalk.cyan(REGISTER_URL)}\n`);
159
320
  }
160
321
  });
161
322
 
162
- console.log('After registering:');
163
- console.log(` 1. Get your API key from the dashboard`);
164
- console.log(` 2. Run: ${chalk.cyan('launchpd login')}`);
165
- console.log('');
323
+ log('After registering:');
324
+ log(` 1. Get your API key from the dashboard`);
325
+ log(` 2. Run: ${chalk.cyan('launchpd login')}`);
326
+ log('');
166
327
 
167
328
  info('Registration benefits:');
168
- console.log(` ${chalk.green('✓')} ${chalk.white('10 sites')} ${chalk.gray('(instead of 3)')}`);
169
- console.log(` ${chalk.green('✓')} ${chalk.white('100MB storage')} ${chalk.gray('(instead of 50MB)')}`);
170
- console.log(` ${chalk.green('✓')} ${chalk.white('30-day retention')} ${chalk.gray('(instead of 7 days)')}`);
171
- console.log(` ${chalk.green('✓')} ${chalk.white('10 versions per site')}`);
172
- console.log('');
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('');
173
334
  }
174
335
 
175
336
  /**
@@ -179,14 +340,14 @@ export async function whoami() {
179
340
  const creds = await getCredentials();
180
341
 
181
342
  if (!creds) {
182
- console.log('\n👤 Not logged in (anonymous mode)\n');
183
- console.log('Anonymous limits:');
184
- console.log(` • ${chalk.white('3 sites')} maximum`);
185
- console.log(` • ${chalk.white('50MB')} total storage`);
186
- console.log(` • ${chalk.white('7-day')} retention`);
187
- console.log(` • ${chalk.white('1 version')} per site`);
188
- console.log(`\nRun ${chalk.cyan('"launchpd login"')} to authenticate`);
189
- console.log(`Run ${chalk.cyan('"launchpd register"')} to create an account\n`);
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`);
190
351
  return;
191
352
  }
192
353
 
@@ -205,38 +366,73 @@ export async function whoami() {
205
366
  // Background upgrade to apiSecret if missing
206
367
  await updateCredentialsIfNeeded(creds, result);
207
368
 
208
- console.log(`\nLogged in as: ${result.user?.email || result.user?.id}\n`);
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;
209
383
 
210
- console.log('Account Info:');
211
- console.log(` User ID: ${result.user?.id}`);
212
- console.log(` Email: ${result.user?.email || 'Not set'} ${result.user?.email_verified ? chalk.green('(Verified)') : chalk.yellow('(Unverified)')}`);
213
- console.log(` 2FA: ${result.user?.is_2fa_enabled ? chalk.green('Enabled') : chalk.gray('Disabled')}`);
214
- console.log(` Tier: ${result.tier}`);
215
- console.log('');
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('');
216
396
 
217
- console.log('Usage:');
218
- console.log(` Sites: ${result.usage?.siteCount || 0} / ${result.limits?.maxSites}`);
219
- console.log(` Storage: ${result.usage?.storageUsedMB || 0}MB / ${result.limits?.maxStorageMB}MB`);
220
- console.log(` Sites remaining: ${result.usage?.sitesRemaining || 0}`);
221
- console.log(` Storage remaining: ${result.usage?.storageRemainingMB || 0}MB`);
222
- console.log('');
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('');
223
403
 
224
- console.log('Limits:');
225
- console.log(` Max versions per site: ${result.limits?.maxVersionsPerSite}`);
226
- console.log(` Retention: ${result.limits?.retentionDays} days`);
227
- console.log('');
404
+ log('Limits:');
405
+ log(` Max versions per site: ${result.limits?.maxVersionsPerSite}`);
406
+ log(` Retention: ${result.limits?.retentionDays} days`);
407
+ log('');
228
408
 
229
409
  // Show warnings
230
410
  if (result.warnings && result.warnings.length > 0) {
231
- console.log('⚠️ Warnings:');
232
- result.warnings.forEach(w => console.log(` ${w}`));
233
- console.log('');
411
+ log('⚠️ Warnings:');
412
+ result.warnings.forEach(w => log(` ${w}`));
413
+ log('');
234
414
  }
235
415
 
236
416
  if (!result.canCreateNewSite) {
237
417
  warning('You cannot create new sites (limit reached)');
238
418
  info('You can still update existing sites');
239
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
+ }
240
436
  }
241
437
 
242
438
  /**
@@ -246,25 +442,25 @@ export async function quota() {
246
442
  const creds = await getCredentials();
247
443
 
248
444
  if (!creds) {
249
- console.log(`\n${chalk.bold('Anonymous Quota Status')}\n`);
250
- console.log(chalk.gray('You are not logged in.'));
251
- console.log('');
252
- console.log(chalk.bold('Anonymous tier limits:'));
253
- console.log(chalk.gray(' ┌─────────────────────────────────┐'));
254
- console.log(chalk.gray(' │') + ` Sites: ${chalk.white('3 maximum')} ` + chalk.gray('│'));
255
- console.log(chalk.gray(' │') + ` Storage: ${chalk.white('50MB total')} ` + chalk.gray('│'));
256
- console.log(chalk.gray(' │') + ` Retention: ${chalk.white('7 days')} ` + chalk.gray('│'));
257
- console.log(chalk.gray(' │') + ` Versions: ${chalk.white('1 per site')} ` + chalk.gray('│'));
258
- console.log(chalk.gray(' └─────────────────────────────────┘'));
259
- console.log('');
260
- console.log(`${chalk.cyan('Register for FREE')} to unlock more:`);
261
- console.log(` ${chalk.green('→')} ${chalk.white('10 sites')}`);
262
- console.log(` ${chalk.green('→')} ${chalk.white('100MB storage')}`);
263
- console.log(` ${chalk.green('→')} ${chalk.white('30-day retention')}`);
264
- console.log(` ${chalk.green('→')} ${chalk.white('10 versions per site')}`);
265
- console.log('');
266
- console.log(`Run: ${chalk.cyan('launchpd register')}`);
267
- console.log('');
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('');
268
464
  return;
269
465
  }
270
466
 
@@ -285,7 +481,7 @@ export async function quota() {
285
481
  await updateCredentialsIfNeeded(creds, result);
286
482
 
287
483
  fetchSpinner.succeed('Quota fetched');
288
- console.log(`\n${chalk.bold('Quota Status for:')} ${chalk.cyan(result.user?.email || creds.email)}\n`);
484
+ log(`\n${chalk.bold('Quota Status for:')} ${chalk.cyan(result.user?.email || creds.email)}\n`);
289
485
 
290
486
  // Sites usage
291
487
  const sitesUsed = result.usage?.siteCount || 0;
@@ -293,7 +489,7 @@ export async function quota() {
293
489
  const sitesPercent = Math.round((sitesUsed / sitesMax) * 100);
294
490
  const sitesBar = createProgressBar(sitesUsed, sitesMax);
295
491
 
296
- console.log(`${chalk.gray('Sites:')} ${sitesBar} ${chalk.white(sitesUsed)}/${sitesMax} (${getPercentColor(sitesPercent)})`);
492
+ log(`${chalk.gray('Sites:')} ${sitesBar} ${chalk.white(sitesUsed)}/${sitesMax} (${getPercentColor(sitesPercent)})`);
297
493
 
298
494
  // Storage usage
299
495
  const storageBytes = result.usage?.storageUsed || 0;
@@ -301,13 +497,13 @@ export async function quota() {
301
497
  const storagePercent = Math.round((storageBytes / storageMaxBytes) * 100);
302
498
  const storageBar = createProgressBar(storageBytes, storageMaxBytes);
303
499
 
304
- console.log(`${chalk.gray('Storage:')} ${storageBar} ${chalk.white(formatBytes(storageBytes))}/${formatBytes(storageMaxBytes)} (${getPercentColor(storagePercent)})`);
500
+ log(`${chalk.gray('Storage:')} ${storageBar} ${chalk.white(formatBytes(storageBytes))}/${formatBytes(storageMaxBytes)} (${getPercentColor(storagePercent)})`);
305
501
 
306
- console.log('');
307
- console.log(`${chalk.gray('Tier:')} ${chalk.green(result.tier || 'free')}`);
308
- console.log(`${chalk.gray('Retention:')} ${chalk.white(result.limits?.retentionDays || 30)} days`);
309
- console.log(`${chalk.gray('Max versions:')} ${chalk.white(result.limits?.maxVersionsPerSite || 10)} per site`);
310
- console.log('');
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('');
311
507
 
312
508
  // Status indicators
313
509
  if (result.canCreateNewSite === false) {
@@ -318,7 +514,7 @@ export async function quota() {
318
514
  warning(`Storage ${storagePercent}% used - consider cleaning up old deployments`);
319
515
  }
320
516
 
321
- console.log('');
517
+ log('');
322
518
  }
323
519
 
324
520
  /**
@@ -355,3 +551,44 @@ function getPercentColor(percent) {
355
551
  }
356
552
  return chalk.green(`${percent}%`);
357
553
  }
554
+
555
+ /**
556
+ * Resend email verification command
557
+ */
558
+ export async function resendEmailVerification() {
559
+ const loggedIn = await isLoggedIn();
560
+
561
+ if (!loggedIn) {
562
+ error('Not logged in');
563
+ info(`Run ${chalk.cyan('"launchpd login"')} first`);
564
+ process.exit(1);
565
+ }
566
+
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);
593
+ }
594
+ }