launchpd 0.1.4 → 0.1.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.
package/bin/cli.js CHANGED
@@ -21,6 +21,7 @@ program
21
21
  .option('--dry-run', 'Simulate deployment without uploading to R2')
22
22
  .option('--name <subdomain>', 'Use a custom subdomain (optional)')
23
23
  .option('--expires <time>', 'Auto-delete after time (e.g., 30m, 2h, 1d). Minimum: 30m')
24
+ .option('--verbose', 'Show detailed error information')
24
25
  .action(async (folder, options) => {
25
26
  await deploy(folder, options);
26
27
  });
@@ -29,6 +30,8 @@ program
29
30
  .command('list')
30
31
  .description('List your past deployments')
31
32
  .option('--json', 'Output as JSON')
33
+ .option('--local', 'Only show local deployments')
34
+ .option('--verbose', 'Show detailed error information')
32
35
  .action(async (options) => {
33
36
  await list(options);
34
37
  });
@@ -38,6 +41,7 @@ program
38
41
  .description('List all versions for a subdomain')
39
42
  .argument('<subdomain>', 'The subdomain to list versions for')
40
43
  .option('--json', 'Output as JSON')
44
+ .option('--verbose', 'Show detailed error information')
41
45
  .action(async (subdomain, options) => {
42
46
  await versions(subdomain, options);
43
47
  });
@@ -47,6 +51,7 @@ program
47
51
  .description('Rollback a subdomain to a previous version')
48
52
  .argument('<subdomain>', 'The subdomain to rollback')
49
53
  .option('--to <n>', 'Specific version number to rollback to')
54
+ .option('--verbose', 'Show detailed error information')
50
55
  .action(async (subdomain, options) => {
51
56
  await rollback(subdomain, options);
52
57
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchpd",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Deploy static sites instantly to a live URL",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,7 +53,8 @@
53
53
  "chalk": "^5.4.0",
54
54
  "commander": "^14.0.0",
55
55
  "mime-types": "^2.1.35",
56
- "nanoid": "^5.1.0"
56
+ "nanoid": "^5.1.0",
57
+ "ora": "^8.0.1"
57
58
  },
58
59
  "devDependencies": {
59
60
  "@eslint/js": "^9.39.2",
@@ -7,7 +7,8 @@ import { createInterface } from 'node:readline';
7
7
  import { exec } from 'node:child_process';
8
8
  import { config } from '../config.js';
9
9
  import { getCredentials, saveCredentials, clearCredentials, isLoggedIn } from '../utils/credentials.js';
10
- import { success, error, info, warning } from '../utils/logger.js';
10
+ import { success, error, errorWithSuggestions, info, warning, spinner } from '../utils/logger.js';
11
+ import chalk from 'chalk';
11
12
 
12
13
  const API_BASE_URL = `https://api.${config.domain}`;
13
14
  const REGISTER_URL = `https://portal.${config.domain}/auth/register`;
@@ -49,8 +50,7 @@ async function validateApiKey(apiKey) {
49
50
  return data;
50
51
  }
51
52
  return null;
52
- } catch (err) {
53
- error(`Failed to validate API key: ${err.message}`);
53
+ } catch {
54
54
  return null;
55
55
  }
56
56
  }
@@ -62,29 +62,37 @@ export async function login() {
62
62
  // Check if already logged in
63
63
  if (await isLoggedIn()) {
64
64
  const creds = await getCredentials();
65
- warning(`Already logged in as ${creds.email || creds.userId}`);
65
+ warning(`Already logged in as ${chalk.cyan(creds.email || creds.userId)}`);
66
66
  info('Run "launchpd logout" to switch accounts');
67
67
  return;
68
68
  }
69
69
 
70
- console.log('\nšŸ” Launchpd Login\n');
70
+ console.log('\nLaunchpd Login\n');
71
71
  console.log('Enter your API key from the dashboard.');
72
- console.log(`Don't have one? Run "launchpd register" first.\n`);
72
+ console.log(`Don't have one? Run ${chalk.cyan('"launchpd register"')} first.\n`);
73
73
 
74
74
  const apiKey = await prompt('API Key: ');
75
75
 
76
76
  if (!apiKey) {
77
- error('API key is required');
77
+ errorWithSuggestions('API key is required', [
78
+ 'Get your API key from the dashboard',
79
+ `Visit: https://portal.${config.domain}/api-keys`,
80
+ 'Run "launchpd register" if you don\'t have an account',
81
+ ]);
78
82
  process.exit(1);
79
83
  }
80
84
 
81
- info('Validating API key...');
85
+ const validateSpinner = spinner('Validating API key...');
82
86
 
83
87
  const result = await validateApiKey(apiKey);
84
88
 
85
89
  if (!result) {
86
- error('Invalid API key. Please check and try again.');
87
- console.log(`\nGet your API key at: https://portal.${config.domain}/api-keys`);
90
+ validateSpinner.fail('Invalid API key');
91
+ errorWithSuggestions('Please check and try again.', [
92
+ `Get your API key at: https://portal.${config.domain}/api-keys`,
93
+ 'Make sure you copied the full key',
94
+ 'API keys start with "lpd_"',
95
+ ]);
88
96
  process.exit(1);
89
97
  }
90
98
 
@@ -96,11 +104,13 @@ export async function login() {
96
104
  tier: result.tier,
97
105
  });
98
106
 
99
- success(`Logged in successfully!`);
100
- console.log(`\n Email: ${result.user?.email || 'N/A'}`);
101
- console.log(` Tier: ${result.tier}`);
102
- console.log(` Sites: ${result.usage?.siteCount || 0}/${result.limits?.maxSites || '?'}`);
103
- console.log(` Storage: ${result.usage?.storageUsedMB || 0}MB/${result.limits?.maxStorageMB || '?'}MB\n`);
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('');
104
114
  }
105
115
 
106
116
  /**
@@ -117,19 +127,19 @@ export async function logout() {
117
127
  const creds = await getCredentials();
118
128
  await clearCredentials();
119
129
 
120
- success(`Logged out successfully`);
130
+ success('Logged out successfully');
121
131
  if (creds?.email) {
122
- info(`Was logged in as: ${creds.email}`);
132
+ info(`Was logged in as: ${chalk.cyan(creds.email)}`);
123
133
  }
124
- console.log(`\nYou can still deploy anonymously (limited to 3 sites, 50MB).`);
134
+ console.log(`\nYou can still deploy anonymously (limited to ${chalk.yellow('3 sites')}, ${chalk.yellow('50MB')}).`);
125
135
  }
126
136
 
127
137
  /**
128
138
  * Register command - opens browser to registration page
129
139
  */
130
140
  export async function register() {
131
- console.log('\nšŸš€ Register for Launchpd\n');
132
- console.log(`Opening registration page: ${REGISTER_URL}\n`);
141
+ console.log('\nRegister for Launchpd\n');
142
+ console.log(`Opening registration page: ${chalk.cyan(REGISTER_URL)}\n`);
133
143
 
134
144
  // Open browser based on platform
135
145
  const platform = process.platform;
@@ -145,20 +155,20 @@ export async function register() {
145
155
 
146
156
  exec(cmd, (err) => {
147
157
  if (err) {
148
- console.log(`Please open this URL in your browser:\n ${REGISTER_URL}\n`);
158
+ console.log(`Please open this URL in your browser:\n ${chalk.cyan(REGISTER_URL)}\n`);
149
159
  }
150
160
  });
151
161
 
152
162
  console.log('After registering:');
153
- console.log(' 1. Get your API key from the dashboard');
154
- console.log(' 2. Run: launchpd login');
163
+ console.log(` 1. Get your API key from the dashboard`);
164
+ console.log(` 2. Run: ${chalk.cyan('launchpd login')}`);
155
165
  console.log('');
156
166
 
157
167
  info('Registration benefits:');
158
- console.log(' āœ“ 10 sites (instead of 3)');
159
- console.log(' āœ“ 100MB storage (instead of 50MB)');
160
- console.log(' āœ“ 30-day retention (instead of 7 days)');
161
- console.log(' āœ“ 10 versions per site');
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')}`);
162
172
  console.log('');
163
173
  }
164
174
 
@@ -171,12 +181,12 @@ export async function whoami() {
171
181
  if (!creds) {
172
182
  console.log('\nšŸ‘¤ Not logged in (anonymous mode)\n');
173
183
  console.log('Anonymous limits:');
174
- console.log(' • 3 sites maximum');
175
- console.log(' • 50MB total storage');
176
- console.log(' • 7-day retention');
177
- console.log(' • 1 version per site');
178
- console.log(`\nRun "launchpd login" to authenticate`);
179
- console.log(`Run "launchpd register" to create an account\n`);
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`);
180
190
  return;
181
191
  }
182
192
 
@@ -192,7 +202,7 @@ export async function whoami() {
192
202
  process.exit(1);
193
203
  }
194
204
 
195
- console.log(`\nšŸ‘¤ Logged in as: ${result.user?.email || result.user?.id}\n`);
205
+ console.log(`\nLogged in as: ${result.user?.email || result.user?.id}\n`);
196
206
 
197
207
  console.log('Account Info:');
198
208
  console.log(` User ID: ${result.user?.id}`);
@@ -232,38 +242,43 @@ export async function quota() {
232
242
  const creds = await getCredentials();
233
243
 
234
244
  if (!creds) {
235
- console.log('\nšŸ“Š Anonymous Quota Status\n');
236
- console.log('You are not logged in.');
245
+ console.log(`\n${chalk.bold('Anonymous Quota Status')}\n`);
246
+ console.log(chalk.gray('You are not logged in.'));
237
247
  console.log('');
238
- console.log('Anonymous tier limits:');
239
- console.log(' ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”');
240
- console.log(' │ Sites: 3 maximum │');
241
- console.log(' │ Storage: 50MB total │');
242
- console.log(' │ Retention: 7 days │');
243
- console.log(' │ Versions: 1 per site │');
244
- console.log(' ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜');
248
+ console.log(chalk.bold('Anonymous tier limits:'));
249
+ console.log(chalk.gray(' ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”'));
250
+ console.log(chalk.gray(' │') + ` Sites: ${chalk.white('3 maximum')} ` + chalk.gray('│'));
251
+ console.log(chalk.gray(' │') + ` Storage: ${chalk.white('50MB total')} ` + chalk.gray('│'));
252
+ console.log(chalk.gray(' │') + ` Retention: ${chalk.white('7 days')} ` + chalk.gray('│'));
253
+ console.log(chalk.gray(' │') + ` Versions: ${chalk.white('1 per site')} ` + chalk.gray('│'));
254
+ console.log(chalk.gray(' ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜'));
245
255
  console.log('');
246
- console.log('šŸ’” Register for FREE to unlock more:');
247
- console.log(' → 10 sites');
248
- console.log(' → 100MB storage');
249
- console.log(' → 30-day retention');
250
- console.log(' → 10 versions per site');
256
+ console.log(`${chalk.cyan('Register for FREE')} to unlock more:`);
257
+ console.log(` ${chalk.green('→')} ${chalk.white('10 sites')}`);
258
+ console.log(` ${chalk.green('→')} ${chalk.white('100MB storage')}`);
259
+ console.log(` ${chalk.green('→')} ${chalk.white('30-day retention')}`);
260
+ console.log(` ${chalk.green('→')} ${chalk.white('10 versions per site')}`);
251
261
  console.log('');
252
- console.log('Run: launchpd register');
262
+ console.log(`Run: ${chalk.cyan('launchpd register')}`);
253
263
  console.log('');
254
264
  return;
255
265
  }
256
266
 
257
- info('Fetching quota status...');
267
+ const fetchSpinner = spinner('Fetching quota status...');
258
268
 
259
269
  const result = await validateApiKey(creds.apiKey);
260
270
 
261
271
  if (!result) {
262
- error('Failed to fetch quota. API key may be invalid.');
272
+ fetchSpinner.fail('Failed to fetch quota');
273
+ errorWithSuggestions('API key may be invalid.', [
274
+ 'Run "launchpd login" to re-authenticate',
275
+ 'Check your internet connection',
276
+ ]);
263
277
  process.exit(1);
264
278
  }
265
279
 
266
- console.log(`\nšŸ“Š Quota Status for: ${result.user?.email || creds.email}\n`);
280
+ fetchSpinner.succeed('Quota fetched');
281
+ console.log(`\n${chalk.bold('Quota Status for:')} ${chalk.cyan(result.user?.email || creds.email)}\n`);
267
282
 
268
283
  // Sites usage
269
284
  const sitesUsed = result.usage?.siteCount || 0;
@@ -271,7 +286,7 @@ export async function quota() {
271
286
  const sitesPercent = Math.round((sitesUsed / sitesMax) * 100);
272
287
  const sitesBar = createProgressBar(sitesUsed, sitesMax);
273
288
 
274
- console.log(`Sites: ${sitesBar} ${sitesUsed}/${sitesMax} (${sitesPercent}%)`);
289
+ console.log(`${chalk.gray('Sites:')} ${sitesBar} ${chalk.white(sitesUsed)}/${sitesMax} (${getPercentColor(sitesPercent)})`);
275
290
 
276
291
  // Storage usage
277
292
  const storageMB = result.usage?.storageUsedMB || 0;
@@ -279,46 +294,61 @@ export async function quota() {
279
294
  const storagePercent = Math.round((storageMB / storageMaxMB) * 100);
280
295
  const storageBar = createProgressBar(storageMB, storageMaxMB);
281
296
 
282
- console.log(`Storage: ${storageBar} ${storageMB}MB/${storageMaxMB}MB (${storagePercent}%)`);
297
+ console.log(`${chalk.gray('Storage:')} ${storageBar} ${chalk.white(storageMB)}MB/${storageMaxMB}MB (${getPercentColor(storagePercent)})`);
283
298
 
284
299
  console.log('');
285
- console.log(`Tier: ${result.tier || 'free'}`);
286
- console.log(`Retention: ${result.limits?.retentionDays || 30} days`);
287
- console.log(`Max versions: ${result.limits?.maxVersionsPerSite || 10} per site`);
300
+ console.log(`${chalk.gray('Tier:')} ${chalk.green(result.tier || 'free')}`);
301
+ console.log(`${chalk.gray('Retention:')} ${chalk.white(result.limits?.retentionDays || 30)} days`);
302
+ console.log(`${chalk.gray('Max versions:')} ${chalk.white(result.limits?.maxVersionsPerSite || 10)} per site`);
288
303
  console.log('');
289
304
 
290
305
  // Status indicators
291
306
  if (result.canCreateNewSite === false) {
292
- warning('āš ļø Site limit reached - cannot create new sites');
307
+ warning('Site limit reached - cannot create new sites');
293
308
  }
294
309
 
295
310
  if (storagePercent > 80) {
296
- warning(`āš ļø Storage ${storagePercent}% used - consider cleaning up old deployments`);
311
+ warning(`Storage ${storagePercent}% used - consider cleaning up old deployments`);
297
312
  }
298
313
 
299
314
  if (result.tier === 'free') {
300
315
  console.log('');
301
- info('šŸ’Ž Upgrade to Pro for 50 sites, 1GB storage, and 50 versions');
316
+ info(`Upgrade to ${chalk.magenta('Pro')} for 50 sites, 1GB storage, and 50 versions`);
302
317
  }
303
318
  console.log('');
304
319
  }
305
320
 
306
321
  /**
307
- * Create a simple progress bar
322
+ * Create a simple progress bar with color coding
308
323
  */
309
324
  function createProgressBar(current, max, width = 20) {
310
325
  const filled = Math.round((current / max) * width);
311
326
  const empty = width - filled;
312
327
  const percent = (current / max) * 100;
313
328
 
314
- let bar = '';
329
+ const filledChar = 'ā–ˆ';
330
+ let barColor;
331
+
315
332
  if (percent >= 90) {
316
- bar = 'ā–ˆ'.repeat(filled) + 'ā–‘'.repeat(empty);
333
+ barColor = chalk.red;
317
334
  } else if (percent >= 70) {
318
- bar = 'ā–ˆ'.repeat(filled) + 'ā–‘'.repeat(empty);
335
+ barColor = chalk.yellow;
319
336
  } else {
320
- bar = 'ā–ˆ'.repeat(filled) + 'ā–‘'.repeat(empty);
337
+ barColor = chalk.green;
321
338
  }
322
339
 
340
+ const bar = barColor(filledChar.repeat(filled)) + chalk.gray('ā–‘'.repeat(empty));
323
341
  return `[${bar}]`;
324
342
  }
343
+
344
+ /**
345
+ * Get colored percentage text
346
+ */
347
+ function getPercentColor(percent) {
348
+ if (percent >= 90) {
349
+ return chalk.red(`${percent}%`);
350
+ } else if (percent >= 70) {
351
+ return chalk.yellow(`${percent}%`);
352
+ }
353
+ return chalk.green(`${percent}%`);
354
+ }
@@ -6,7 +6,7 @@ import { uploadFolder, finalizeUpload } from '../utils/upload.js';
6
6
  import { getNextVersion } from '../utils/metadata.js';
7
7
  import { saveLocalDeployment } from '../utils/localConfig.js';
8
8
  import { getNextVersionFromAPI } from '../utils/api.js';
9
- import { success, error, info, warning } from '../utils/logger.js';
9
+ import { success, errorWithSuggestions, info, warning, spinner, formatSize } from '../utils/logger.js';
10
10
  import { calculateExpiresAt, formatTimeRemaining } from '../utils/expiration.js';
11
11
  import { checkQuota, displayQuotaWarnings } from '../utils/quota.js';
12
12
  import { getCredentials } from '../utils/credentials.js';
@@ -42,9 +42,11 @@ async function calculateFolderSize(folderPath) {
42
42
  * @param {boolean} options.dryRun - Skip actual upload
43
43
  * @param {string} options.name - Custom subdomain
44
44
  * @param {string} options.expires - Expiration time (e.g., "30m", "2h", "1d")
45
+ * @param {boolean} options.verbose - Show verbose error details
45
46
  */
46
47
  export async function deploy(folder, options) {
47
48
  const folderPath = resolve(folder);
49
+ const verbose = options.verbose || false;
48
50
 
49
51
  // Parse expiration if provided
50
52
  let expiresAt = null;
@@ -52,41 +54,58 @@ export async function deploy(folder, options) {
52
54
  try {
53
55
  expiresAt = calculateExpiresAt(options.expires);
54
56
  } catch (err) {
55
- error(err.message);
57
+ errorWithSuggestions(err.message, [
58
+ 'Use format like: 30m, 2h, 1d, 7d',
59
+ 'Minimum expiration is 30 minutes',
60
+ 'Examples: --expires 1h, --expires 2d',
61
+ ], { verbose, cause: err });
56
62
  process.exit(1);
57
63
  }
58
64
  }
59
65
 
60
66
  // Validate folder exists
61
67
  if (!existsSync(folderPath)) {
62
- error(`Folder not found: ${folderPath}`);
68
+ errorWithSuggestions(`Folder not found: ${folderPath}`, [
69
+ 'Check the path is correct',
70
+ 'Use an absolute path or path relative to current directory',
71
+ `Current directory: ${process.cwd()}`,
72
+ ], { verbose });
63
73
  process.exit(1);
64
74
  }
65
75
 
66
76
  // Check folder is not empty
77
+ const scanSpinner = spinner('Scanning folder...');
67
78
  const files = await readdir(folderPath, { recursive: true, withFileTypes: true });
68
79
  const fileCount = files.filter(f => f.isFile()).length;
69
80
 
70
81
  if (fileCount === 0) {
71
- error('Folder is empty. Nothing to deploy.');
82
+ scanSpinner.fail('Folder is empty');
83
+ errorWithSuggestions('Nothing to deploy.', [
84
+ 'Add some files to your folder',
85
+ 'Make sure index.html exists for static sites',
86
+ ], { verbose });
72
87
  process.exit(1);
73
88
  }
89
+ scanSpinner.succeed(`Found ${fileCount} file(s)`);
74
90
 
75
91
  // Generate or use provided subdomain
76
92
  const subdomain = options.name || generateSubdomain();
77
93
  const url = `https://${subdomain}.launchpd.cloud`;
78
94
 
79
95
  // Calculate estimated upload size
96
+ const sizeSpinner = spinner('Calculating folder size...');
80
97
  const estimatedBytes = await calculateFolderSize(folderPath);
98
+ sizeSpinner.succeed(`Size: ${formatSize(estimatedBytes)}`);
81
99
 
82
100
  // Check quota before deploying
83
- info('Checking quota...');
101
+ const quotaSpinner = spinner('Checking quota...');
84
102
  const quotaCheck = await checkQuota(subdomain, estimatedBytes);
85
103
 
86
104
  if (!quotaCheck.allowed) {
87
- error('Deployment blocked due to quota limits');
105
+ quotaSpinner.fail('Deployment blocked due to quota limits');
88
106
  process.exit(1);
89
107
  }
108
+ quotaSpinner.succeed('Quota check passed');
90
109
 
91
110
  // Display any warnings
92
111
  displayQuotaWarnings(quotaCheck.warnings);
@@ -101,7 +120,6 @@ export async function deploy(folder, options) {
101
120
 
102
121
  info(`Deploying ${fileCount} file(s) from ${folderPath}`);
103
122
  info(`Target: ${url}`);
104
- info(`Size: ${(estimatedBytes / 1024 / 1024).toFixed(2)}MB`);
105
123
 
106
124
  if (options.dryRun) {
107
125
  warning('Dry run mode - skipping upload');
@@ -127,7 +145,7 @@ export async function deploy(folder, options) {
127
145
  ? (quotaCheck.quota.usage?.siteCount || 0) + 1
128
146
  : quotaCheck.quota.usage?.siteCount || 0;
129
147
  console.log(` Sites: ${sitesAfter}/${quotaCheck.quota.limits.maxSites}`);
130
- console.log(` Storage: ${(storageAfter / 1024 / 1024).toFixed(1)}MB/${quotaCheck.quota.limits.maxStorageMB}MB`);
148
+ console.log(` Storage: ${formatSize(storageAfter)}/${quotaCheck.quota.limits.maxStorageMB}MB`);
131
149
  console.log('');
132
150
  }
133
151
  return;
@@ -136,18 +154,25 @@ export async function deploy(folder, options) {
136
154
  // Perform actual upload
137
155
  try {
138
156
  // Get next version number for this subdomain (try API first, fallback to local)
157
+ const versionSpinner = spinner('Fetching version info...');
139
158
  let version = await getNextVersionFromAPI(subdomain);
140
159
  if (version === null) {
141
160
  version = await getNextVersion(subdomain);
142
161
  }
143
- info(`Deploying as version ${version}...`);
162
+ versionSpinner.succeed(`Deploying as version ${version}`);
144
163
 
145
164
  // Upload all files via API proxy
146
165
  const folderName = basename(folderPath);
147
- const { totalBytes } = await uploadFolder(folderPath, subdomain, version);
166
+ const uploadSpinner = spinner(`Uploading files... 0/${fileCount}`);
167
+
168
+ const { totalBytes } = await uploadFolder(folderPath, subdomain, version, (uploaded, total, fileName) => {
169
+ uploadSpinner.update(`Uploading files... ${uploaded}/${total} (${fileName})`);
170
+ });
171
+
172
+ uploadSpinner.succeed(`Uploaded ${fileCount} files (${formatSize(totalBytes)})`);
148
173
 
149
174
  // Finalize upload: set active version and record metadata
150
- info('Finalizing deployment...');
175
+ const finalizeSpinner = spinner('Finalizing deployment...');
151
176
  await finalizeUpload(
152
177
  subdomain,
153
178
  version,
@@ -156,6 +181,7 @@ export async function deploy(folder, options) {
156
181
  folderName,
157
182
  expiresAt?.toISOString() || null
158
183
  );
184
+ finalizeSpinner.succeed('Deployment finalized');
159
185
 
160
186
  // Save locally for quick access
161
187
  await saveLocalDeployment({
@@ -169,13 +195,33 @@ export async function deploy(folder, options) {
169
195
  });
170
196
 
171
197
  success(`Deployed successfully! (v${version})`);
172
- console.log(`\n šŸš€ ${url}`);
198
+ console.log(`\n${url}`);
173
199
  if (expiresAt) {
174
- warning(` ā° Expires: ${formatTimeRemaining(expiresAt)}`);
200
+ warning(`Expires: ${formatTimeRemaining(expiresAt)}`);
175
201
  }
176
202
  console.log('');
177
203
  } catch (err) {
178
- error(`Upload failed: ${err.message}`);
204
+ const suggestions = [];
205
+
206
+ // Provide context-specific suggestions
207
+ if (err.message.includes('fetch failed') || err.message.includes('ENOTFOUND')) {
208
+ suggestions.push('Check your internet connection');
209
+ suggestions.push('The API server may be temporarily unavailable');
210
+ } else if (err.message.includes('401') || err.message.includes('Unauthorized')) {
211
+ suggestions.push('Run "launchpd login" to authenticate');
212
+ suggestions.push('Your API key may have expired');
213
+ } else if (err.message.includes('413') || err.message.includes('too large')) {
214
+ suggestions.push('Try deploying fewer or smaller files');
215
+ suggestions.push('Check your storage quota with "launchpd quota"');
216
+ } else if (err.message.includes('429') || err.message.includes('rate limit')) {
217
+ suggestions.push('Wait a few minutes and try again');
218
+ suggestions.push('You may be deploying too frequently');
219
+ } else {
220
+ suggestions.push('Try running with --verbose for more details');
221
+ suggestions.push('Check https://status.launchpd.cloud for service status');
222
+ }
223
+
224
+ errorWithSuggestions(`Upload failed: ${err.message}`, suggestions, { verbose, cause: err });
179
225
  process.exit(1);
180
226
  }
181
227
  }
@@ -1,6 +1,6 @@
1
1
  import { getLocalDeployments } from '../utils/localConfig.js';
2
2
  import { listDeployments as listFromAPI } from '../utils/api.js';
3
- import { error, info, warning } from '../utils/logger.js';
3
+ import { errorWithSuggestions, info, spinner, formatSize } from '../utils/logger.js';
4
4
  import { formatTimeRemaining, isExpired } from '../utils/expiration.js';
5
5
  import chalk from 'chalk';
6
6
 
@@ -9,12 +9,17 @@ import chalk from 'chalk';
9
9
  * @param {object} options - Command options
10
10
  * @param {boolean} options.json - Output as JSON
11
11
  * @param {boolean} options.local - Only show local deployments
12
+ * @param {boolean} options.verbose - Show verbose error details
12
13
  */
13
14
  export async function list(options) {
15
+ const verbose = options.verbose || false;
16
+
14
17
  try {
15
18
  let deployments = [];
16
19
  let source = 'local';
17
20
 
21
+ const fetchSpinner = spinner('Fetching deployments...');
22
+
18
23
  // Try API first unless --local flag is set
19
24
  if (!options.local) {
20
25
  const apiResult = await listFromAPI();
@@ -40,11 +45,13 @@ export async function list(options) {
40
45
  }
41
46
 
42
47
  if (deployments.length === 0) {
43
- warning('No deployments found.');
44
- info('Deploy a folder with: launchpd deploy ./my-folder');
48
+ fetchSpinner.warn('No deployments found');
49
+ info('Deploy a folder with: ' + chalk.cyan('launchpd deploy ./my-folder'));
45
50
  return;
46
51
  }
47
52
 
53
+ fetchSpinner.succeed(`Found ${deployments.length} deployment(s)`);
54
+
48
55
  if (options.json) {
49
56
  console.log(JSON.stringify(deployments, null, 2));
50
57
  return;
@@ -53,7 +60,7 @@ export async function list(options) {
53
60
  // Display as table
54
61
  console.log('');
55
62
  console.log(chalk.bold('Your Deployments:'));
56
- console.log(chalk.gray('─'.repeat(95)));
63
+ console.log(chalk.gray('─'.repeat(100)));
57
64
 
58
65
  // Header
59
66
  console.log(
@@ -61,43 +68,58 @@ export async function list(options) {
61
68
  padRight('URL', 40) +
62
69
  padRight('Folder', 15) +
63
70
  padRight('Files', 7) +
71
+ padRight('Size', 12) +
64
72
  padRight('Date', 12) +
65
73
  'Status'
66
74
  )
67
75
  );
68
- console.log(chalk.gray('─'.repeat(95)));
76
+ console.log(chalk.gray('─'.repeat(100)));
69
77
 
70
78
  // Rows (most recent first)
71
79
  const sorted = [...deployments].reverse();
72
80
  for (const dep of sorted) {
73
81
  const url = `https://${dep.subdomain}.launchpd.cloud`;
74
82
  const date = new Date(dep.timestamp).toLocaleDateString();
83
+ const size = dep.totalBytes ? formatSize(dep.totalBytes) : '-';
75
84
 
76
- // Determine status
77
- let status = chalk.green('active');
85
+ // Determine status with colors
86
+ let status;
78
87
  if (dep.expiresAt) {
79
88
  if (isExpired(dep.expiresAt)) {
80
- status = chalk.red('expired');
89
+ status = chalk.red.bold('ā— expired');
81
90
  } else {
82
- status = chalk.yellow(formatTimeRemaining(dep.expiresAt));
91
+ status = chalk.yellow(`ā± ${formatTimeRemaining(dep.expiresAt)}`);
83
92
  }
93
+ } else {
94
+ status = chalk.green.bold('ā— active');
84
95
  }
85
96
 
97
+ // Version badge
98
+ const versionBadge = chalk.magenta(`v${dep.version || 1}`);
99
+
86
100
  console.log(
87
101
  chalk.cyan(padRight(url, 40)) +
88
- padRight(dep.folderName || '-', 15) +
89
- padRight(String(dep.fileCount), 7) +
102
+ chalk.white(padRight(dep.folderName || '-', 15)) +
103
+ chalk.white(padRight(String(dep.fileCount), 7)) +
104
+ chalk.white(padRight(size, 12)) +
90
105
  chalk.gray(padRight(date, 12)) +
91
- status
106
+ status + ' ' + versionBadge
92
107
  );
93
108
  }
94
109
 
95
- console.log(chalk.gray('─'.repeat(95)));
96
- console.log(chalk.gray(`Total: ${deployments.length} deployment(s)`) + (source === 'api' ? chalk.green(' (synced)') : chalk.yellow(' (local only)')));
110
+ console.log(chalk.gray('─'.repeat(100)));
111
+ const syncStatus = source === 'api'
112
+ ? chalk.green(' āœ“ synced')
113
+ : chalk.yellow(' ⚠ local only');
114
+ console.log(chalk.gray(`Total: ${deployments.length} deployment(s)`) + syncStatus);
97
115
  console.log('');
98
116
 
99
117
  } catch (err) {
100
- error(`Failed to list deployments: ${err.message}`);
118
+ errorWithSuggestions(`Failed to list deployments: ${err.message}`, [
119
+ 'Check your internet connection',
120
+ 'Use --local flag to show local deployments only',
121
+ 'Try running with --verbose for more details',
122
+ ], { verbose, cause: err });
101
123
  process.exit(1);
102
124
  }
103
125
  }
@@ -1,16 +1,20 @@
1
1
  import { getVersionsForSubdomain, setActiveVersion, getActiveVersion } from '../utils/metadata.js';
2
2
  import { getVersions as getVersionsFromAPI, rollbackVersion as rollbackViaAPI } from '../utils/api.js';
3
- import { success, error, info, warning } from '../utils/logger.js';
3
+ import { error, errorWithSuggestions, info, warning, spinner } from '../utils/logger.js';
4
+ import chalk from 'chalk';
4
5
 
5
6
  /**
6
7
  * Rollback a subdomain to a previous version
7
8
  * @param {string} subdomain - Subdomain to rollback
8
9
  * @param {object} options - Command options
9
10
  * @param {number} options.to - Specific version to rollback to (optional)
11
+ * @param {boolean} options.verbose - Show verbose error details
10
12
  */
11
13
  export async function rollback(subdomain, options) {
14
+ const verbose = options.verbose || false;
15
+
12
16
  try {
13
- info(`Checking versions for ${subdomain}...`);
17
+ const fetchSpinner = spinner(`Checking versions for ${subdomain}...`);
14
18
 
15
19
  // Get all versions for this subdomain (try API first)
16
20
  let versions = [];
@@ -33,16 +37,22 @@ export async function rollback(subdomain, options) {
33
37
  }
34
38
 
35
39
  if (versions.length === 0) {
36
- error(`No deployments found for subdomain: ${subdomain}`);
40
+ fetchSpinner.fail('No deployments found');
41
+ errorWithSuggestions(`No deployments found for subdomain: ${subdomain}`, [
42
+ 'Check the subdomain name is correct',
43
+ 'Run "launchpd list" to see your deployments',
44
+ ], { verbose });
37
45
  process.exit(1);
38
46
  }
39
47
 
40
48
  if (versions.length === 1) {
41
- warning('Only one version exists. Nothing to rollback to.');
49
+ fetchSpinner.warn('Only one version exists');
50
+ warning('Nothing to rollback to.');
42
51
  process.exit(1);
43
52
  }
44
53
 
45
- info(`Current active version: v${currentActive}`);
54
+ fetchSpinner.succeed(`Found ${versions.length} versions`);
55
+ info(`Current active version: ${chalk.cyan(`v${currentActive}`)}`);
46
56
 
47
57
  // Determine target version
48
58
  let targetVersion;
@@ -51,9 +61,12 @@ export async function rollback(subdomain, options) {
51
61
  const versionExists = versions.some(v => v.version === targetVersion);
52
62
  if (!versionExists) {
53
63
  error(`Version ${targetVersion} does not exist.`);
64
+ console.log('');
54
65
  info('Available versions:');
55
66
  versions.forEach(v => {
56
- info(` v${v.version} - ${v.timestamp}`);
67
+ const isActive = v.version === currentActive;
68
+ const marker = isActive ? chalk.green(' (active)') : '';
69
+ console.log(` ${chalk.cyan(`v${v.version}`)} - ${chalk.gray(v.timestamp)}${marker}`);
57
70
  });
58
71
  process.exit(1);
59
72
  }
@@ -69,18 +82,18 @@ export async function rollback(subdomain, options) {
69
82
  }
70
83
 
71
84
  if (targetVersion === currentActive) {
72
- warning(`Version ${targetVersion} is already active.`);
85
+ warning(`Version ${chalk.cyan(`v${targetVersion}`)} is already active.`);
73
86
  process.exit(0);
74
87
  }
75
88
 
76
- info(`Rolling back from v${currentActive} to v${targetVersion}...`);
89
+ const rollbackSpinner = spinner(`Rolling back from v${currentActive} to v${targetVersion}...`);
77
90
 
78
91
  // Set the target version as active
79
92
  if (useAPI) {
80
93
  // Use API for centralized rollback (updates both D1 and R2)
81
94
  const result = await rollbackViaAPI(subdomain, targetVersion);
82
95
  if (!result) {
83
- warning('API unavailable, falling back to local rollback');
96
+ rollbackSpinner.warn('API unavailable, using local rollback');
84
97
  await setActiveVersion(subdomain, targetVersion);
85
98
  }
86
99
  } else {
@@ -90,12 +103,16 @@ export async function rollback(subdomain, options) {
90
103
  // Find the target version's deployment record for file count
91
104
  const targetDeployment = versions.find(v => v.version === targetVersion);
92
105
 
93
- success(`Rolled back to v${targetVersion} successfully!`);
106
+ rollbackSpinner.succeed(`Rolled back to ${chalk.cyan(`v${targetVersion}`)}`);
94
107
  console.log(`\n šŸ”„ https://${subdomain}.launchpd.cloud\n`);
95
- info(`Restored deployment from: ${targetDeployment?.timestamp || 'unknown'}`);
108
+ info(`Restored deployment from: ${chalk.gray(targetDeployment?.timestamp || 'unknown')}`);
96
109
 
97
110
  } catch (err) {
98
- error(`Rollback failed: ${err.message}`);
111
+ errorWithSuggestions(`Rollback failed: ${err.message}`, [
112
+ 'Check your internet connection',
113
+ 'Verify the subdomain and version exist',
114
+ 'Run "launchpd versions <subdomain>" to see available versions',
115
+ ], { verbose, cause: err });
99
116
  process.exit(1);
100
117
  }
101
118
  }
@@ -1,16 +1,20 @@
1
1
  import { getVersionsForSubdomain, getActiveVersion } from '../utils/metadata.js';
2
2
  import { getVersions as getVersionsFromAPI } from '../utils/api.js';
3
- import { success, error, info } from '../utils/logger.js';
3
+ import { success, errorWithSuggestions, info, spinner, formatSize } from '../utils/logger.js';
4
+ import chalk from 'chalk';
4
5
 
5
6
  /**
6
7
  * List all versions for a subdomain
7
8
  * @param {string} subdomain - Subdomain to list versions for
8
9
  * @param {object} options - Command options
9
10
  * @param {boolean} options.json - Output as JSON
11
+ * @param {boolean} options.verbose - Show verbose error details
10
12
  */
11
13
  export async function versions(subdomain, options) {
14
+ const verbose = options.verbose || false;
15
+
12
16
  try {
13
- info(`Fetching versions for ${subdomain}...`);
17
+ const fetchSpinner = spinner(`Fetching versions for ${subdomain}...`);
14
18
 
15
19
  let versionList = [];
16
20
  let activeVersion = 1;
@@ -32,10 +36,17 @@ export async function versions(subdomain, options) {
32
36
  }
33
37
 
34
38
  if (versionList.length === 0) {
35
- error(`No deployments found for subdomain: ${subdomain}`);
39
+ fetchSpinner.fail(`No deployments found for: ${subdomain}`);
40
+ errorWithSuggestions(`No deployments found for subdomain: ${subdomain}`, [
41
+ 'Check the subdomain name is correct',
42
+ 'Run "launchpd list" to see your deployments',
43
+ 'Deploy a new site with "launchpd deploy ./folder"',
44
+ ], { verbose });
36
45
  process.exit(1);
37
46
  }
38
47
 
48
+ fetchSpinner.succeed(`Found ${versionList.length} version(s)`);
49
+
39
50
  if (options.json) {
40
51
  console.log(JSON.stringify({
41
52
  subdomain,
@@ -52,24 +63,37 @@ export async function versions(subdomain, options) {
52
63
  }
53
64
 
54
65
  console.log('');
55
- success(`Versions for ${subdomain}.launchpd.cloud:`);
66
+ success(`Versions for ${chalk.cyan(subdomain)}.launchpd.cloud:`);
56
67
  console.log('');
57
68
 
69
+ // Table header
70
+ console.log(chalk.gray(' Version Date Files Size Status'));
71
+ console.log(chalk.gray(' ' + '─'.repeat(70)));
72
+
58
73
  for (const v of versionList) {
59
74
  const isActive = v.version === activeVersion;
60
- const activeMarker = isActive ? ' ← active' : '';
61
- const sizeKB = v.totalBytes ? `${(v.totalBytes / 1024).toFixed(1)} KB` : 'unknown size';
62
- const date = new Date(v.timestamp).toLocaleString();
75
+ const versionStr = chalk.bold.cyan(`v${v.version}`);
76
+ const date = chalk.gray(new Date(v.timestamp).toLocaleString());
77
+ const files = chalk.white(`${v.fileCount} files`);
78
+ const size = v.totalBytes ? chalk.white(formatSize(v.totalBytes)) : chalk.gray('unknown');
79
+ const status = isActive
80
+ ? chalk.green.bold('ā— active')
81
+ : chalk.gray('ā—‹ inactive');
63
82
 
64
- console.log(` v${v.version} │ ${date} │ ${v.fileCount} files │ ${sizeKB}${activeMarker}`);
83
+ console.log(` ${versionStr.padEnd(18)}${date.padEnd(30)}${files.padEnd(12)}${size.padEnd(14)}${status}`);
65
84
  }
66
85
 
86
+ console.log(chalk.gray(' ' + '─'.repeat(70)));
67
87
  console.log('');
68
- info(`Use 'launchpd rollback ${subdomain} --to <n>' to restore a version.`);
88
+ info(`Use ${chalk.cyan(`launchpd rollback ${subdomain} --to <n>`)} to restore a version.`);
69
89
  console.log('');
70
90
 
71
91
  } catch (err) {
72
- error(`Failed to list versions: ${err.message}`);
92
+ errorWithSuggestions(`Failed to list versions: ${err.message}`, [
93
+ 'Check your internet connection',
94
+ 'Verify the subdomain exists',
95
+ 'Try running with --verbose for more details',
96
+ ], { verbose, cause: err });
73
97
  process.exit(1);
74
98
  }
75
99
  }
@@ -1,4 +1,8 @@
1
1
  import chalk from 'chalk';
2
+ import ora from 'ora';
3
+
4
+ // Store active spinner reference
5
+ let activeSpinner = null;
2
6
 
3
7
  /**
4
8
  * Log a success message
@@ -11,9 +15,16 @@ export function success(message) {
11
15
  /**
12
16
  * Log an error message
13
17
  * @param {string} message
18
+ * @param {object} options - Optional error details
19
+ * @param {boolean} options.verbose - Show verbose error details
20
+ * @param {Error} options.cause - Original error for verbose mode
14
21
  */
15
- export function error(message) {
22
+ export function error(message, options = {}) {
16
23
  console.error(chalk.red.bold('āœ—'), chalk.red(message));
24
+ if (options.verbose && options.cause) {
25
+ console.error(chalk.gray(' Stack trace:'));
26
+ console.error(chalk.gray(` ${options.cause.stack || options.cause.message}`));
27
+ }
17
28
  }
18
29
 
19
30
  /**
@@ -31,3 +42,115 @@ export function info(message) {
31
42
  export function warning(message) {
32
43
  console.log(chalk.yellow.bold('⚠'), chalk.yellow(message));
33
44
  }
45
+
46
+ /**
47
+ * Create and start a spinner
48
+ * @param {string} text - Initial spinner text
49
+ * @returns {object} - Spinner instance with helper methods
50
+ */
51
+ export function spinner(text) {
52
+ activeSpinner = ora({
53
+ text,
54
+ color: 'cyan',
55
+ spinner: 'dots',
56
+ }).start();
57
+
58
+ return {
59
+ /**
60
+ * Update spinner text
61
+ * @param {string} newText
62
+ */
63
+ update(newText) {
64
+ if (activeSpinner) {
65
+ activeSpinner.text = newText;
66
+ }
67
+ },
68
+
69
+ /**
70
+ * Mark spinner as successful and stop
71
+ * @param {string} text - Success message
72
+ */
73
+ succeed(text) {
74
+ if (activeSpinner) {
75
+ activeSpinner.succeed(chalk.green(text));
76
+ activeSpinner = null;
77
+ }
78
+ },
79
+
80
+ /**
81
+ * Mark spinner as failed and stop
82
+ * @param {string} text - Failure message
83
+ */
84
+ fail(text) {
85
+ if (activeSpinner) {
86
+ activeSpinner.fail(chalk.red(text));
87
+ activeSpinner = null;
88
+ }
89
+ },
90
+
91
+ /**
92
+ * Stop spinner with info message
93
+ * @param {string} text - Info message
94
+ */
95
+ info(text) {
96
+ if (activeSpinner) {
97
+ activeSpinner.info(chalk.blue(text));
98
+ activeSpinner = null;
99
+ }
100
+ },
101
+
102
+ /**
103
+ * Stop spinner with warning
104
+ * @param {string} text - Warning message
105
+ */
106
+ warn(text) {
107
+ if (activeSpinner) {
108
+ activeSpinner.warn(chalk.yellow(text));
109
+ activeSpinner = null;
110
+ }
111
+ },
112
+
113
+ /**
114
+ * Stop spinner without any symbol
115
+ */
116
+ stop() {
117
+ if (activeSpinner) {
118
+ activeSpinner.stop();
119
+ activeSpinner = null;
120
+ }
121
+ },
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Format bytes to human readable size (KB/MB/GB)
127
+ * @param {number} bytes - Size in bytes
128
+ * @param {number} decimals - Number of decimal places
129
+ * @returns {string} - Formatted size string
130
+ */
131
+ export function formatSize(bytes, decimals = 2) {
132
+ if (bytes === 0) return '0 Bytes';
133
+
134
+ const k = 1024;
135
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
136
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
137
+
138
+ return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
139
+ }
140
+
141
+ /**
142
+ * Log helpful error with suggestions
143
+ * @param {string} message - Error message
144
+ * @param {string[]} suggestions - Array of suggested actions
145
+ * @param {object} options - Error options
146
+ */
147
+ export function errorWithSuggestions(message, suggestions = [], options = {}) {
148
+ error(message, options);
149
+ if (suggestions.length > 0) {
150
+ console.log('');
151
+ console.log(chalk.yellow('šŸ’” Suggestions:'));
152
+ suggestions.forEach(suggestion => {
153
+ console.log(chalk.gray(` • ${suggestion}`));
154
+ });
155
+ }
156
+ }
@@ -2,7 +2,6 @@ import { readdir, readFile } from 'node:fs/promises';
2
2
  import { join, relative, posix, sep } from 'node:path';
3
3
  import mime from 'mime-types';
4
4
  import { config } from '../config.js';
5
- import { info } from './logger.js';
6
5
 
7
6
  const API_BASE_URL = `https://api.${config.domain}`;
8
7
 
@@ -91,8 +90,9 @@ async function completeUpload(subdomain, version, fileCount, totalBytes, folderN
91
90
  * @param {string} localPath - Local folder path
92
91
  * @param {string} subdomain - Subdomain to use as bucket prefix
93
92
  * @param {number} version - Version number for this deployment
93
+ * @param {function} onProgress - Progress callback (uploaded, total, fileName)
94
94
  */
95
- export async function uploadFolder(localPath, subdomain, version = 1) {
95
+ export async function uploadFolder(localPath, subdomain, version = 1, onProgress = null) {
96
96
  const files = await readdir(localPath, { recursive: true, withFileTypes: true });
97
97
 
98
98
  let uploaded = 0;
@@ -119,7 +119,11 @@ export async function uploadFolder(localPath, subdomain, version = 1) {
119
119
  await uploadFile(body, subdomain, version, posixPath, contentType);
120
120
 
121
121
  uploaded++;
122
- info(` Uploaded (${uploaded}/${total}): ${posixPath}`);
122
+
123
+ // Call progress callback if provided
124
+ if (onProgress) {
125
+ onProgress(uploaded, total, posixPath);
126
+ }
123
127
  }
124
128
 
125
129
  return { uploaded, subdomain, totalBytes };