launchpd 1.0.5 → 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.
@@ -44,10 +44,36 @@ import { validateStaticOnly } from '../utils/validator.js'
44
44
  import { isIgnored } from '../utils/ignore.js'
45
45
  import { prompt } from '../utils/prompt.js'
46
46
  import { handleCommonError } from '../utils/errors.js'
47
+ import {
48
+ isRemoteUrl,
49
+ parseRemoteUrl,
50
+ fetchRemoteSource,
51
+ cleanupTempDir
52
+ } from '../utils/remoteSource.js'
47
53
  import QRCode from 'qrcode'
48
54
 
55
+ // ============================================================================
56
+ // Helper Functions (extracted to reduce cyclomatic complexity)
57
+ // ============================================================================
58
+
59
+ /**
60
+ * Validate subdomain contains only safe DNS characters
61
+ * @param {string} subdomain - The subdomain to validate
62
+ * @returns {string} The validated subdomain
63
+ * @throws {Error} If subdomain contains invalid characters
64
+ */
65
+ function validateSubdomain (subdomain) {
66
+ const safePattern = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/
67
+ if (!safePattern.test(subdomain)) {
68
+ throw new Error(
69
+ `Invalid subdomain "${subdomain}": must contain only lowercase letters, numbers, and hyphens`
70
+ )
71
+ }
72
+ return subdomain
73
+ }
74
+
49
75
  /**
50
- * Calculate total size of a folder
76
+ * Calculate total size of a folder (excluding ignored files)
51
77
  */
52
78
  async function calculateFolderSize (folderPath) {
53
79
  const files = await readdir(folderPath, {
@@ -61,12 +87,7 @@ async function calculateFolderSize (folderPath) {
61
87
  const relativePath = relative(folderPath, join(parentDir, file.name))
62
88
  const pathParts = relativePath.split(sep)
63
89
 
64
- // Skip ignored directories/files in the path
65
- if (
66
- pathParts.some((part) => {
67
- return isIgnored(part, file.isDirectory())
68
- })
69
- ) {
90
+ if (pathParts.some((part) => isIgnored(part, file.isDirectory()))) {
70
91
  continue
71
92
  }
72
93
 
@@ -85,37 +106,32 @@ async function calculateFolderSize (folderPath) {
85
106
  }
86
107
 
87
108
  /**
88
- * Deploy a local folder to StaticLaunch
89
- * @param {string} folder - Path to folder to deploy
90
- * @param {object} options - Command options
91
- * @param {string} options.name - Custom subdomain
92
- * @param {string} options.expires - Expiration time (e.g., "30m", "2h", "1d")
93
- * @param {boolean} options.verbose - Show verbose error details
109
+ * Parse and validate expiration option
94
110
  */
95
- export async function deploy (folder, options) {
96
- const folderPath = resolve(folder)
97
- const verbose = options.verbose || false
111
+ function parseExpiration (expiresOption, verbose) {
112
+ if (!expiresOption) return null
98
113
 
99
- // Parse expiration if provided
100
- let expiresAt = null
101
- if (options.expires) {
102
- try {
103
- expiresAt = calculateExpiresAt(options.expires)
104
- } catch (err) {
105
- errorWithSuggestions(
106
- err.message,
107
- [
108
- 'Use format like: 30m, 2h, 1d, 7d',
109
- 'Minimum expiration is 30 minutes',
110
- 'Examples: --expires 1h, --expires 2d'
111
- ],
112
- { verbose, cause: err }
113
- )
114
- process.exit(1)
115
- }
114
+ try {
115
+ return calculateExpiresAt(expiresOption)
116
+ } catch (err) {
117
+ errorWithSuggestions(
118
+ err.message,
119
+ [
120
+ 'Use format like: 30m, 2h, 1d, 7d',
121
+ 'Minimum expiration is 30 minutes',
122
+ 'Examples: --expires 1h, --expires 2d'
123
+ ],
124
+ { verbose, cause: err }
125
+ )
126
+ process.exit(1)
127
+ return null // Unreachable, but satisfies static analysis
116
128
  }
129
+ }
117
130
 
118
- // Validate deployment message is provided
131
+ /**
132
+ * Validate required options
133
+ */
134
+ function validateOptions (options, folderPath, verbose) {
119
135
  if (!options.message) {
120
136
  errorWithSuggestions(
121
137
  'Deployment message is required.',
@@ -129,7 +145,6 @@ export async function deploy (folder, options) {
129
145
  process.exit(1)
130
146
  }
131
147
 
132
- // Validate folder exists
133
148
  if (!existsSync(folderPath)) {
134
149
  errorWithSuggestions(
135
150
  `Folder not found: ${folderPath}`,
@@ -142,15 +157,18 @@ export async function deploy (folder, options) {
142
157
  )
143
158
  process.exit(1)
144
159
  }
160
+ }
145
161
 
146
- // Check folder is not empty
162
+ /**
163
+ * Scan folder and return active file count
164
+ */
165
+ async function scanFolder (folderPath, verbose) {
147
166
  const scanSpinner = spinner('Scanning folder...')
148
167
  const files = await readdir(folderPath, {
149
168
  recursive: true,
150
169
  withFileTypes: true
151
170
  })
152
171
 
153
- // Filter out ignored files for the count
154
172
  const activeFiles = files.filter((file) => {
155
173
  if (!file.isFile()) return false
156
174
  const parentDir = file.parentPath || file.path
@@ -174,13 +192,20 @@ export async function deploy (folder, options) {
174
192
  )
175
193
  process.exit(1)
176
194
  }
195
+
177
196
  scanSpinner.succeed(
178
197
  `Found ${fileCount} file(s) (ignored system files skipped)`
179
198
  )
199
+ return fileCount
200
+ }
180
201
 
181
- // Static-Only Validation
202
+ /**
203
+ * Validate static-only files
204
+ */
205
+ async function validateStaticFiles (folderPath, options, verbose) {
182
206
  const validationSpinner = spinner('Validating files...')
183
207
  const validation = await validateStaticOnly(folderPath)
208
+
184
209
  if (!validation.success) {
185
210
  if (options.force) {
186
211
  validationSpinner.warn(
@@ -193,15 +218,20 @@ export async function deploy (folder, options) {
193
218
  )
194
219
  } else {
195
220
  validationSpinner.fail('Deployment blocked: Non-static files detected')
221
+ const violationList = validation.violations
222
+ .map((v) => ` - ${v}`)
223
+ .slice(0, 10)
224
+ const moreCount =
225
+ validation.violations.length > 10
226
+ ? ` - ...and ${validation.violations.length - 10} more`
227
+ : ''
196
228
  errorWithSuggestions(
197
229
  'Your project contains files that are not allowed.',
198
230
  [
199
231
  'Launchpd only supports static files (HTML, CSS, JS, images, etc.)',
200
232
  'Remove framework files, backend code, and build metadata:',
201
- ...validation.violations.map((v) => ` - ${v}`).slice(0, 10),
202
- validation.violations.length > 10
203
- ? ` - ...and ${validation.violations.length - 10} more`
204
- : '',
233
+ ...violationList,
234
+ moreCount,
205
235
  'If you use a framework (React, Vue, etc.), deploy the "dist" or "build" folder instead.'
206
236
  ],
207
237
  { verbose }
@@ -211,10 +241,12 @@ export async function deploy (folder, options) {
211
241
  } else {
212
242
  validationSpinner.succeed('Project validated (Static files only)')
213
243
  }
244
+ }
214
245
 
215
- // Generate or use provided subdomain
216
- // Anonymous users cannot use custom subdomains
217
- const creds = await getCredentials()
246
+ /**
247
+ * Resolve subdomain from options/config
248
+ */
249
+ async function resolveSubdomain (options, folderPath, creds, verbose) {
218
250
  if (options.name && !creds?.email) {
219
251
  warning('Custom subdomains require registration!')
220
252
  info('Anonymous deployments use random subdomains.')
@@ -222,16 +254,11 @@ export async function deploy (folder, options) {
222
254
  log('')
223
255
  }
224
256
 
225
- // Detect project config if no name provided
226
257
  let subdomain =
227
258
  options.name && creds?.email ? options.name.toLowerCase() : null
228
- let configSubdomain = null
229
-
230
259
  const projectRoot = findProjectRoot(folderPath)
231
260
  const config = await getProjectConfig(projectRoot)
232
- if (config?.subdomain) {
233
- configSubdomain = config.subdomain
234
- }
261
+ const configSubdomain = config?.subdomain || null
235
262
 
236
263
  if (!subdomain) {
237
264
  if (configSubdomain) {
@@ -241,34 +268,70 @@ export async function deploy (folder, options) {
241
268
  subdomain = generateSubdomain()
242
269
  }
243
270
  } else if (configSubdomain && subdomain !== configSubdomain) {
244
- warning(
245
- `Mismatch: This project is linked to ${chalk.bold(configSubdomain)} but you are deploying to ${chalk.bold(subdomain)}`
271
+ await handleSubdomainMismatch(
272
+ subdomain,
273
+ configSubdomain,
274
+ options,
275
+ projectRoot
246
276
  )
277
+ }
247
278
 
248
- let shouldUpdate = options.yes
249
- if (!shouldUpdate) {
250
- const confirm = await prompt(
251
- `Would you like to update this project's default subdomain to "${subdomain}"? (Y/N): `
252
- )
253
- shouldUpdate =
254
- confirm.toLowerCase() === 'y' || confirm.toLowerCase() === 'yes'
255
- }
279
+ // Validate subdomain
280
+ try {
281
+ subdomain = validateSubdomain(subdomain)
282
+ } catch (err) {
283
+ errorWithSuggestions(
284
+ err.message,
285
+ [
286
+ 'Subdomain must start and end with alphanumeric characters',
287
+ 'Only lowercase letters, numbers, and hyphens are allowed',
288
+ 'Example: my-site-123'
289
+ ],
290
+ { verbose }
291
+ )
292
+ process.exit(1)
293
+ }
256
294
 
257
- if (shouldUpdate) {
258
- await updateProjectConfig({ subdomain }, projectRoot)
259
- success(`Project configuration updated to: ${subdomain}`)
260
- }
295
+ return { subdomain, configSubdomain, projectRoot }
296
+ }
297
+
298
+ /**
299
+ * Handle subdomain mismatch between CLI arg and config
300
+ */
301
+ async function handleSubdomainMismatch (
302
+ subdomain,
303
+ configSubdomain,
304
+ options,
305
+ projectRoot
306
+ ) {
307
+ warning(
308
+ `Mismatch: This project is linked to ${chalk.bold(configSubdomain)} but you are deploying to ${chalk.bold(subdomain)}`
309
+ )
310
+
311
+ let shouldUpdate = options.yes
312
+ if (!shouldUpdate) {
313
+ const confirm = await prompt(
314
+ `Would you like to update this project's default subdomain to "${subdomain}"? (Y/N): `
315
+ )
316
+ shouldUpdate =
317
+ confirm.toLowerCase() === 'y' || confirm.toLowerCase() === 'yes'
261
318
  }
262
319
 
263
- const url = `https://${subdomain}.launchpd.cloud`
320
+ if (shouldUpdate) {
321
+ await updateProjectConfig({ subdomain }, projectRoot)
322
+ success(`Project configuration updated to: ${subdomain}`)
323
+ }
324
+ }
264
325
 
265
- // Check subdomain availability and ownership (ALWAYS run this)
326
+ /**
327
+ * Check subdomain availability
328
+ */
329
+ async function checkSubdomainOwnership (subdomain) {
266
330
  const checkSpinner = spinner('Checking subdomain availability...')
267
331
  try {
268
332
  const isAvailable = await checkSubdomainAvailable(subdomain)
269
333
 
270
334
  if (!isAvailable) {
271
- // Check if the current user owns it
272
335
  const result = await listSubdomains()
273
336
  const owned = result?.subdomains?.some((s) => s.subdomain === subdomain)
274
337
 
@@ -286,7 +349,6 @@ export async function deploy (folder, options) {
286
349
  process.exit(1)
287
350
  }
288
351
  } else {
289
- // If strictly new, it's available
290
352
  checkSpinner.succeed(`Subdomain "${subdomain}" is available`)
291
353
  }
292
354
  } catch {
@@ -294,8 +356,12 @@ export async function deploy (folder, options) {
294
356
  'Could not verify subdomain availability (skipping check)'
295
357
  )
296
358
  }
359
+ }
297
360
 
298
- // Auto-init: If using --name and no config exists, prompt to save it
361
+ /**
362
+ * Prompt for auto-init if needed
363
+ */
364
+ async function promptAutoInit (options, configSubdomain, subdomain, folderPath) {
299
365
  if (options.name && !configSubdomain) {
300
366
  const confirm = await prompt(
301
367
  `\nRun "launchpd init" to link '${folderPath}' to '${subdomain}'? (Y/N): `
@@ -309,16 +375,19 @@ export async function deploy (folder, options) {
309
375
  success('Project initialized! Future deploys here can skip --name.')
310
376
  }
311
377
  }
378
+ }
312
379
 
313
- // Calculate estimated upload size
314
- const sizeSpinner = spinner('Calculating folder size...')
315
- const estimatedBytes = await calculateFolderSize(folderPath)
316
- sizeSpinner.succeed(`Size: ${formatBytes(estimatedBytes)}`)
317
-
318
- // Check quota before deploying
380
+ /**
381
+ * Check quota and return result
382
+ */
383
+ async function checkDeploymentQuota (
384
+ subdomain,
385
+ estimatedBytes,
386
+ configSubdomain,
387
+ options
388
+ ) {
319
389
  const quotaSpinner = spinner('Checking quota...')
320
390
  const isUpdate = configSubdomain && subdomain === configSubdomain
321
-
322
391
  const quotaCheck = await checkQuota(subdomain, estimatedBytes, { isUpdate })
323
392
 
324
393
  if (!quotaCheck.allowed) {
@@ -339,236 +408,378 @@ export async function deploy (folder, options) {
339
408
  quotaSpinner.succeed('Quota check passed')
340
409
  }
341
410
 
342
- // Display any warnings
343
411
  displayQuotaWarnings(quotaCheck.warnings)
412
+ }
344
413
 
345
- // Show current user status (creds already fetched above)
346
- if (creds?.email) {
347
- info(`Deploying as: ${creds.email}`)
348
- } else {
349
- info('Deploying as: anonymous (run "launchpd login" for more quota)')
414
+ /**
415
+ * Perform the actual upload
416
+ */
417
+ async function performUpload (
418
+ folderPath,
419
+ subdomain,
420
+ fileCount,
421
+ expiresAt,
422
+ options
423
+ ) {
424
+ const versionSpinner = spinner('Fetching version info...')
425
+ let version = await getNextVersionFromAPI(subdomain)
426
+ if (version === null) {
427
+ version = await getNextVersion(subdomain)
350
428
  }
429
+ versionSpinner.succeed(`Deploying as version ${version}`)
430
+
431
+ const folderName = basename(folderPath)
432
+ const uploadSpinner = spinner(`Uploading files... 0/${fileCount}`)
433
+
434
+ const { totalBytes } = await uploadFolder(
435
+ folderPath,
436
+ subdomain,
437
+ version,
438
+ (uploaded, total, fileName) => {
439
+ uploadSpinner.update(
440
+ `Uploading files... ${uploaded}/${total} (${fileName})`
441
+ )
442
+ }
443
+ )
351
444
 
352
- info(`Deploying ${fileCount} file(s) from ${folderPath}`)
353
- info(`Target: ${url}`)
445
+ uploadSpinner.succeed(
446
+ `Uploaded ${fileCount} files (${formatBytes(totalBytes)})`
447
+ )
354
448
 
355
- // Perform actual upload
449
+ const finalizeSpinner = spinner('Finalizing deployment...')
450
+ await finalizeUpload(
451
+ subdomain,
452
+ version,
453
+ fileCount,
454
+ totalBytes,
455
+ folderName,
456
+ expiresAt?.toISOString() || null,
457
+ options.message
458
+ )
459
+ finalizeSpinner.succeed('Deployment finalized')
460
+
461
+ await saveLocalDeployment({
462
+ subdomain,
463
+ folderName,
464
+ fileCount,
465
+ totalBytes,
466
+ version,
467
+ timestamp: new Date().toISOString(),
468
+ expiresAt: expiresAt?.toISOString() || null
469
+ })
470
+
471
+ return { version, totalBytes }
472
+ }
473
+
474
+ /**
475
+ * Show post-deployment info
476
+ */
477
+ async function showPostDeploymentInfo (url, options, expiresAt, creds, verbose) {
478
+ if (options.open) {
479
+ openUrlInBrowser(url)
480
+ }
481
+
482
+ if (expiresAt) {
483
+ warning(`Expires: ${formatTimeRemaining(expiresAt)}`)
484
+ }
485
+
486
+ if (!creds?.email) {
487
+ showAnonymousWarnings()
488
+ }
489
+
490
+ log('')
491
+
492
+ if (options.qr) {
493
+ await showQRCode(url, verbose)
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Open URL in system browser
499
+ */
500
+ function openUrlInBrowser (url) {
501
+ const platform = process.platform
502
+ let command = 'xdg-open'
503
+ let args = [url]
504
+
505
+ if (platform === 'darwin') {
506
+ command = 'open'
507
+ } else if (platform === 'win32') {
508
+ // Use rundll32 to open the URL with the default browser without invoking a shell
509
+ command = 'rundll32'
510
+ args = ['url.dll,FileProtocolHandler', url]
511
+ }
512
+
513
+ execFile(command, args)
514
+ }
515
+
516
+ /**
517
+ * Show warnings for anonymous deployments
518
+ */
519
+ function showAnonymousWarnings () {
520
+ log('')
521
+ warning('Anonymous deployment limits:')
522
+ log(' • 3 active sites per IP')
523
+ log(' • 50MB total storage')
524
+ log(' • 7-day site expiration')
525
+ log('')
526
+ info(
527
+ 'Run "launchpd register" to unlock unlimited sites and permanent storage!'
528
+ )
529
+ }
530
+
531
+ /**
532
+ * Generate and display QR code
533
+ */
534
+ async function showQRCode (url, verbose) {
356
535
  try {
357
- // Get next version number for this subdomain (try API first, fallback to local)
358
- const versionSpinner = spinner('Fetching version info...')
359
- let version = await getNextVersionFromAPI(subdomain)
360
- if (version === null) {
361
- version = await getNextVersion(subdomain)
536
+ const terminalWidth = process.stdout.columns || 80
537
+ const qr = await QRCode.toString(url, {
538
+ type: 'terminal',
539
+ small: true,
540
+ margin: 2,
541
+ errorCorrectionLevel: 'L'
542
+ })
543
+
544
+ const firstLine = qr.split('\n')[0]
545
+ if (firstLine.length > terminalWidth) {
546
+ warning('\nTerminal is too narrow to display the QR code correctly.')
547
+ info(
548
+ `Please expand your terminal to at least ${firstLine.length} columns.`
549
+ )
550
+ info(`URL: ${url}`)
551
+ } else {
552
+ log(`\nScan this QR code to view your site on mobile:\n${qr}`)
362
553
  }
363
- versionSpinner.succeed(`Deploying as version ${version}`)
554
+ } catch (err) {
555
+ warning('Could not generate QR code.')
556
+ if (verbose) raw(err, 'error')
557
+ }
558
+ }
364
559
 
365
- // Upload all files via API proxy
366
- const folderName = basename(folderPath)
367
- const uploadSpinner = spinner(`Uploading files... 0/${fileCount}`)
560
+ /**
561
+ * Handle upload errors with appropriate messages
562
+ */
563
+ function handleUploadError (err, verbose) {
564
+ if (
565
+ handleCommonError(err, {
566
+ error: (msg) => errorWithSuggestions(msg, [], { verbose }),
567
+ info,
568
+ warning
569
+ })
570
+ ) {
571
+ process.exit(1)
572
+ }
368
573
 
369
- const { totalBytes } = await uploadFolder(
370
- folderPath,
371
- subdomain,
372
- version,
373
- (uploaded, total, fileName) => {
374
- uploadSpinner.update(
375
- `Uploading files... ${uploaded}/${total} (${fileName})`
376
- )
377
- }
574
+ if (err instanceof MaintenanceError || err.isMaintenanceError) {
575
+ errorWithSuggestions(
576
+ '⚠️ LaunchPd is under maintenance',
577
+ [
578
+ 'Please try again in a few minutes',
579
+ 'Check https://status.launchpd.cloud for updates'
580
+ ],
581
+ { verbose }
378
582
  )
583
+ process.exit(1)
584
+ }
379
585
 
380
- uploadSpinner.succeed(
381
- `Uploaded ${fileCount} files (${formatBytes(totalBytes)})`
586
+ if (err instanceof NetworkError || err.isNetworkError) {
587
+ errorWithSuggestions(
588
+ 'Unable to connect to LaunchPd',
589
+ [
590
+ 'Check your internet connection',
591
+ 'The API server may be temporarily unavailable',
592
+ 'Check https://status.launchpd.cloud for service status'
593
+ ],
594
+ { verbose, cause: err }
382
595
  )
596
+ process.exit(1)
597
+ }
383
598
 
384
- // Finalize upload: set active version and record metadata
385
- const finalizeSpinner = spinner('Finalizing deployment...')
386
- await finalizeUpload(
387
- subdomain,
388
- version,
389
- fileCount,
390
- totalBytes,
391
- folderName,
392
- expiresAt?.toISOString() || null,
393
- options.message
599
+ if (err instanceof AuthError || err.isAuthError) {
600
+ errorWithSuggestions(
601
+ 'Authentication failed',
602
+ [
603
+ 'Run "launchpd login" to authenticate',
604
+ 'Your API key may have expired or been revoked'
605
+ ],
606
+ { verbose, cause: err }
394
607
  )
395
- finalizeSpinner.succeed('Deployment finalized')
608
+ process.exit(1)
609
+ }
396
610
 
397
- // Save locally for quick access
398
- await saveLocalDeployment({
399
- subdomain,
400
- folderName,
401
- fileCount,
402
- totalBytes,
403
- version,
404
- timestamp: new Date().toISOString(),
405
- expiresAt: expiresAt?.toISOString() || null
406
- })
611
+ const suggestions = getErrorSuggestions(err)
612
+ errorWithSuggestions(`Upload failed: ${err.message}`, suggestions, {
613
+ verbose,
614
+ cause: err
615
+ })
616
+ process.exit(1)
617
+ }
407
618
 
408
- success(`Deployed successfully! (v${version})`)
409
- log(`\n${url}`)
619
+ /**
620
+ * Get context-specific suggestions for errors
621
+ */
622
+ function getErrorSuggestions (err) {
623
+ const message = err.message || ''
624
+
625
+ if (message.includes('fetch failed') || message.includes('ENOTFOUND')) {
626
+ return [
627
+ 'Check your internet connection',
628
+ 'The API server may be temporarily unavailable'
629
+ ]
630
+ }
410
631
 
411
- if (options.open) {
412
- const platform = process.platform
413
- let command = 'xdg-open'
414
- let args = [url]
632
+ if (message.includes('401') || message.includes('Unauthorized')) {
633
+ return [
634
+ 'Run "launchpd login" to authenticate',
635
+ 'Your API key may have expired'
636
+ ]
637
+ }
415
638
 
416
- if (platform === 'darwin') {
417
- command = 'open'
418
- } else if (platform === 'win32') {
419
- command = 'cmd'
420
- args = ['/c', 'start', '', url]
421
- }
639
+ if (message.includes('413') || message.includes('too large')) {
640
+ return [
641
+ 'Try deploying fewer or smaller files',
642
+ 'Check your storage quota with "launchpd quota"'
643
+ ]
644
+ }
422
645
 
423
- execFile(command, args)
424
- }
646
+ if (message.includes('429') || message.includes('rate limit')) {
647
+ return [
648
+ 'Wait a few minutes and try again',
649
+ 'You may be deploying too frequently'
650
+ ]
651
+ }
425
652
 
426
- if (expiresAt) {
427
- warning(`Expires: ${formatTimeRemaining(expiresAt)}`)
428
- }
653
+ return [
654
+ 'Try running with --verbose for more details',
655
+ 'Check https://status.launchpd.cloud for service status'
656
+ ]
657
+ }
429
658
 
430
- // Show anonymous limit warnings
431
- if (!creds?.email) {
432
- log('')
433
- warning('Anonymous deployment limits:')
434
- log(' • 3 active sites per IP')
435
- log(' • 50MB total storage')
436
- log(' • 7-day site expiration')
437
- log('')
438
- info(
439
- 'Run "launchpd register" to unlock unlimited sites and permanent storage!'
440
- )
441
- }
442
- log('')
659
+ // ============================================================================
660
+ // Main Deploy Function
661
+ // ============================================================================
443
662
 
444
- if (options.qr) {
445
- try {
446
- // Determine terminal width to avoid wrapping
447
- const terminalWidth = process.stdout.columns || 80
448
-
449
- // version: 2-3 is typical for these URLs. L level is smallest.
450
- // margin: 2 is safe but compact.
451
- const qr = await QRCode.toString(url, {
452
- type: 'terminal',
453
- small: true,
454
- margin: 2,
455
- errorCorrectionLevel: 'L'
456
- })
457
-
458
- // Check if QR might wrap
459
- const firstLine = qr.split('\n')[0]
460
- if (firstLine.length > terminalWidth) {
461
- warning('\nTerminal is too narrow to display the QR code correctly.')
462
- info(
463
- `Please expand your terminal to at least ${firstLine.length} columns.`
464
- )
465
- info(`URL: ${url}`)
466
- } else {
467
- log(`\nScan this QR code to view your site on mobile:\n${qr}`)
468
- }
469
- } catch (err) {
470
- warning('Could not generate QR code.')
471
- if (verbose) raw(err, 'error')
472
- }
473
- }
474
- } catch (err) {
475
- // Handle common errors with standardized messages
476
- if (
477
- handleCommonError(err, {
478
- error: (msg) => errorWithSuggestions(msg, [], { verbose }),
479
- info,
480
- warning
481
- })
482
- ) {
483
- process.exit(1)
484
- }
485
-
486
- // Handle maintenance mode specifically
487
- if (err instanceof MaintenanceError || err.isMaintenanceError) {
488
- errorWithSuggestions(
489
- '⚠️ LaunchPd is under maintenance',
490
- [
491
- 'Please try again in a few minutes',
492
- 'Check https://status.launchpd.cloud for updates'
493
- ],
494
- { verbose }
495
- )
496
- process.exit(1)
497
- }
663
+ /**
664
+ * Deploy a local folder or remote URL to StaticLaunch
665
+ * @param {string} source - Path to folder, GitHub repo URL, or Gist URL
666
+ * @param {object} options - Command options
667
+ * @param {string} options.name - Custom subdomain
668
+ * @param {string} options.expires - Expiration time (e.g., "30m", "2h", "1d")
669
+ * @param {boolean} options.verbose - Show verbose error details
670
+ * @param {string} options.branch - Git branch (for repo URLs)
671
+ * @param {string} options.dir - Subdirectory within repo to deploy
672
+ */
673
+ export async function deploy (source, options) {
674
+ const verbose = options.verbose || false
675
+ let folderPath = null
676
+ let tempDir = null
498
677
 
499
- // Handle network errors
500
- if (err instanceof NetworkError || err.isNetworkError) {
678
+ // Detect remote URL vs local folder
679
+ if (isRemoteUrl(source)) {
680
+ const fetchSpinner = spinner('Fetching remote source...')
681
+ try {
682
+ const parsed = parseRemoteUrl(source)
683
+ const sourceLabel =
684
+ parsed.type === 'gist'
685
+ ? `Gist (${parsed.gistId})`
686
+ : `${parsed.owner}/${parsed.repo}`
687
+ fetchSpinner.update(`Downloading from ${sourceLabel}...`)
688
+
689
+ const result = await fetchRemoteSource(parsed, {
690
+ branch: options.branch,
691
+ dir: options.dir
692
+ })
693
+ tempDir = result.tempDir
694
+ folderPath = result.folderPath
695
+ fetchSpinner.succeed(`Downloaded from ${parsed.type}: ${sourceLabel}`)
696
+ } catch (err) {
697
+ fetchSpinner.fail('Failed to fetch remote source')
501
698
  errorWithSuggestions(
502
- 'Unable to connect to LaunchPd',
699
+ `Remote fetch failed: ${err.message}`,
503
700
  [
504
- 'Check your internet connection',
505
- 'The API server may be temporarily unavailable',
506
- 'Check https://status.launchpd.cloud for service status'
701
+ 'Check that the URL is correct and the resource is public',
702
+ 'For repos, verify the branch exists with --branch',
703
+ 'For gists, make sure the gist ID is correct',
704
+ 'Check your internet connection'
507
705
  ],
508
706
  { verbose, cause: err }
509
707
  )
510
708
  process.exit(1)
709
+ return // Unreachable in production, satisfies test mocks
511
710
  }
711
+ } else {
712
+ folderPath = resolve(source)
713
+ }
512
714
 
513
- // Handle auth errors
514
- if (err instanceof AuthError || err.isAuthError) {
515
- errorWithSuggestions(
516
- 'Authentication failed',
517
- [
518
- 'Run "launchpd login" to authenticate',
519
- 'Your API key may have expired or been revoked'
520
- ],
521
- { verbose, cause: err }
522
- )
523
- process.exit(1)
715
+ try {
716
+ // Parse and validate
717
+ const expiresAt = parseExpiration(options.expires, verbose)
718
+ validateOptions(options, folderPath, verbose)
719
+
720
+ // Scan and validate folder
721
+ const fileCount = await scanFolder(folderPath, verbose)
722
+ await validateStaticFiles(folderPath, options, verbose)
723
+
724
+ // Resolve subdomain
725
+ const creds = await getCredentials()
726
+ const { subdomain, configSubdomain } = await resolveSubdomain(
727
+ options,
728
+ folderPath,
729
+ creds,
730
+ verbose
731
+ )
732
+ const url = `https://${subdomain}.launchpd.cloud`
733
+
734
+ // Check subdomain availability
735
+ await checkSubdomainOwnership(subdomain)
736
+
737
+ // Auto-init prompt (skip for remote URLs — no local project to init)
738
+ if (!tempDir) {
739
+ await promptAutoInit(options, configSubdomain, subdomain, folderPath)
524
740
  }
525
741
 
526
- const suggestions = []
742
+ // Calculate size and check quota
743
+ const sizeSpinner = spinner('Calculating folder size...')
744
+ const estimatedBytes = await calculateFolderSize(folderPath)
745
+ sizeSpinner.succeed(`Size: ${formatBytes(estimatedBytes)}`)
527
746
 
528
- // Provide context-specific suggestions for other errors
529
- if (
530
- err.message.includes('fetch failed') ||
531
- err.message.includes('ENOTFOUND')
532
- ) {
533
- suggestions.push(
534
- 'Check your internet connection',
535
- 'The API server may be temporarily unavailable'
536
- )
537
- } else if (
538
- err.message.includes('401') ||
539
- err.message.includes('Unauthorized')
540
- ) {
541
- suggestions.push(
542
- 'Run "launchpd login" to authenticate',
543
- 'Your API key may have expired'
544
- )
545
- } else if (
546
- err.message.includes('413') ||
547
- err.message.includes('too large')
548
- ) {
549
- suggestions.push(
550
- 'Try deploying fewer or smaller files',
551
- 'Check your storage quota with "launchpd quota"'
552
- )
553
- } else if (
554
- err.message.includes('429') ||
555
- err.message.includes('rate limit')
556
- ) {
557
- suggestions.push(
558
- 'Wait a few minutes and try again',
559
- 'You may be deploying too frequently'
560
- )
747
+ await checkDeploymentQuota(
748
+ subdomain,
749
+ estimatedBytes,
750
+ configSubdomain,
751
+ options
752
+ )
753
+
754
+ // Show deployment info
755
+ if (creds?.email) {
756
+ info(`Deploying as: ${creds.email}`)
561
757
  } else {
562
- suggestions.push(
563
- 'Try running with --verbose for more details',
564
- 'Check https://status.launchpd.cloud for service status'
565
- )
758
+ info('Deploying as: anonymous (run "launchpd login" for more quota)')
566
759
  }
760
+ const sourceLabel = tempDir ? source : folderPath
761
+ info(`Deploying ${fileCount} file(s) from ${sourceLabel}`)
762
+ info(`Target: ${url}`)
567
763
 
568
- errorWithSuggestions(`Upload failed: ${err.message}`, suggestions, {
569
- verbose,
570
- cause: err
571
- })
572
- process.exit(1)
764
+ // Perform upload
765
+ try {
766
+ const { version } = await performUpload(
767
+ folderPath,
768
+ subdomain,
769
+ fileCount,
770
+ expiresAt,
771
+ options
772
+ )
773
+ success(`Deployed successfully! (v${version})`)
774
+ log(`\n${url}`)
775
+ await showPostDeploymentInfo(url, options, expiresAt, creds, verbose)
776
+ } catch (err) {
777
+ handleUploadError(err, verbose)
778
+ }
779
+ } finally {
780
+ // Clean up temp directory if we fetched from a remote source
781
+ if (tempDir) {
782
+ await cleanupTempDir(tempDir)
783
+ }
573
784
  }
574
785
  }