launchpd 1.0.3 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,426 +1,785 @@
1
- import { existsSync, statSync } from 'node:fs';
2
- import { exec } from 'node:child_process';
3
- import chalk from 'chalk';
4
- import { readdir } from 'node:fs/promises';
5
- import { resolve, basename, join, relative, sep } from 'node:path';
6
- import { generateSubdomain } from '../utils/id.js';
7
- import { uploadFolder, finalizeUpload } from '../utils/upload.js';
8
- import { getNextVersion } from '../utils/metadata.js';
9
- import { saveLocalDeployment } from '../utils/localConfig.js';
10
- import { getNextVersionFromAPI, checkSubdomainAvailable, listSubdomains, MaintenanceError, NetworkError, AuthError } from '../utils/api.js';
11
- import { getProjectConfig, findProjectRoot, updateProjectConfig, initProjectConfig } from '../utils/projectConfig.js';
12
- import { success, errorWithSuggestions, info, warning, spinner, formatSize, log, raw } from '../utils/logger.js';
13
- import { calculateExpiresAt, formatTimeRemaining } from '../utils/expiration.js';
14
- import { checkQuota, displayQuotaWarnings } from '../utils/quota.js';
15
- import { getCredentials } from '../utils/credentials.js';
16
- import { validateStaticOnly } from '../utils/validator.js';
17
- import { isIgnored } from '../utils/ignore.js';
18
- import { prompt } from '../utils/prompt.js';
19
- import { handleCommonError } from '../utils/errors.js';
20
- import QRCode from 'qrcode';
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { execFile } from 'node:child_process'
3
+ import chalk from 'chalk'
4
+ import { readdir } from 'node:fs/promises'
5
+ import { resolve, basename, join, relative, sep } from 'node:path'
6
+ import { generateSubdomain } from '../utils/id.js'
7
+ import { uploadFolder, finalizeUpload } from '../utils/upload.js'
8
+ import { getNextVersion } from '../utils/metadata.js'
9
+ import { saveLocalDeployment } from '../utils/localConfig.js'
10
+ import {
11
+ getNextVersionFromAPI,
12
+ checkSubdomainAvailable,
13
+ listSubdomains,
14
+ MaintenanceError,
15
+ NetworkError,
16
+ AuthError
17
+ } from '../utils/api.js'
18
+ import {
19
+ getProjectConfig,
20
+ findProjectRoot,
21
+ updateProjectConfig,
22
+ initProjectConfig
23
+ } from '../utils/projectConfig.js'
24
+ import {
25
+ success,
26
+ errorWithSuggestions,
27
+ info,
28
+ warning,
29
+ spinner,
30
+ log,
31
+ raw
32
+ } from '../utils/logger.js'
33
+ import {
34
+ calculateExpiresAt,
35
+ formatTimeRemaining
36
+ } from '../utils/expiration.js'
37
+ import {
38
+ checkQuota,
39
+ displayQuotaWarnings,
40
+ formatBytes
41
+ } from '../utils/quota.js'
42
+ import { getCredentials } from '../utils/credentials.js'
43
+ import { validateStaticOnly } from '../utils/validator.js'
44
+ import { isIgnored } from '../utils/ignore.js'
45
+ import { prompt } from '../utils/prompt.js'
46
+ import { handleCommonError } from '../utils/errors.js'
47
+ import {
48
+ isRemoteUrl,
49
+ parseRemoteUrl,
50
+ fetchRemoteSource,
51
+ cleanupTempDir
52
+ } from '../utils/remoteSource.js'
53
+ import QRCode from 'qrcode'
54
+
55
+ // ============================================================================
56
+ // Helper Functions (extracted to reduce cyclomatic complexity)
57
+ // ============================================================================
21
58
 
22
59
  /**
23
- * Calculate total size of a folder
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
24
64
  */
25
- async function calculateFolderSize(folderPath) {
26
- const files = await readdir(folderPath, { recursive: true, withFileTypes: true });
27
- let totalSize = 0;
28
-
29
- for (const file of files) {
30
- const parentDir = file.parentPath || file.path;
31
- const relativePath = relative(folderPath, join(parentDir, file.name));
32
- const pathParts = relativePath.split(sep);
33
-
34
- // Skip ignored directories/files in the path
35
- if (pathParts.some(part => isIgnored(part, file.isDirectory()))) {
36
- continue;
37
- }
38
-
39
- if (file.isFile()) {
40
- const fullPath = join(parentDir, file.name);
41
- try {
42
- const stats = statSync(fullPath);
43
- totalSize += stats.size;
44
- } catch {
45
- // File may have been deleted
46
- }
47
- }
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
+
75
+ /**
76
+ * Calculate total size of a folder (excluding ignored files)
77
+ */
78
+ async function calculateFolderSize (folderPath) {
79
+ const files = await readdir(folderPath, {
80
+ recursive: true,
81
+ withFileTypes: true
82
+ })
83
+ let totalSize = 0
84
+
85
+ for (const file of files) {
86
+ const parentDir = file.parentPath || file.path
87
+ const relativePath = relative(folderPath, join(parentDir, file.name))
88
+ const pathParts = relativePath.split(sep)
89
+
90
+ if (pathParts.some((part) => isIgnored(part, file.isDirectory()))) {
91
+ continue
92
+ }
93
+
94
+ if (file.isFile()) {
95
+ const fullPath = join(parentDir, file.name)
96
+ try {
97
+ const stats = statSync(fullPath)
98
+ totalSize += stats.size
99
+ } catch {
100
+ // File may have been deleted
101
+ }
48
102
  }
103
+ }
49
104
 
50
- return totalSize;
105
+ return totalSize
51
106
  }
52
107
 
53
108
  /**
54
- * Deploy a local folder to StaticLaunch
55
- * @param {string} folder - Path to folder to deploy
56
- * @param {object} options - Command options
57
- * @param {string} options.name - Custom subdomain
58
- * @param {string} options.expires - Expiration time (e.g., "30m", "2h", "1d")
59
- * @param {boolean} options.verbose - Show verbose error details
109
+ * Parse and validate expiration option
110
+ */
111
+ function parseExpiration (expiresOption, verbose) {
112
+ if (!expiresOption) return null
113
+
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
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Validate required options
133
+ */
134
+ function validateOptions (options, folderPath, verbose) {
135
+ if (!options.message) {
136
+ errorWithSuggestions(
137
+ 'Deployment message is required.',
138
+ [
139
+ 'Use -m or --message to provide a description',
140
+ 'Example: launchpd deploy . -m "Fix layout"',
141
+ 'Example: launchpd deploy . -m "Initial deployment"'
142
+ ],
143
+ { verbose }
144
+ )
145
+ process.exit(1)
146
+ }
147
+
148
+ if (!existsSync(folderPath)) {
149
+ errorWithSuggestions(
150
+ `Folder not found: ${folderPath}`,
151
+ [
152
+ 'Check the path is correct',
153
+ 'Use an absolute path or path relative to current directory',
154
+ `Current directory: ${process.cwd()}`
155
+ ],
156
+ { verbose }
157
+ )
158
+ process.exit(1)
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Scan folder and return active file count
164
+ */
165
+ async function scanFolder (folderPath, verbose) {
166
+ const scanSpinner = spinner('Scanning folder...')
167
+ const files = await readdir(folderPath, {
168
+ recursive: true,
169
+ withFileTypes: true
170
+ })
171
+
172
+ const activeFiles = files.filter((file) => {
173
+ if (!file.isFile()) return false
174
+ const parentDir = file.parentPath || file.path
175
+ const relativePath = relative(folderPath, join(parentDir, file.name))
176
+ const pathParts = relativePath.split(sep)
177
+ return !pathParts.some((part) => isIgnored(part, file.isDirectory()))
178
+ })
179
+
180
+ const fileCount = activeFiles.length
181
+
182
+ if (fileCount === 0) {
183
+ scanSpinner.fail('Folder is empty or only contains ignored files')
184
+ errorWithSuggestions(
185
+ 'Nothing to deploy.',
186
+ [
187
+ 'Add some files to your folder',
188
+ 'Make sure your files are not in ignored directories (like node_modules)',
189
+ 'Make sure index.html exists for static sites'
190
+ ],
191
+ { verbose }
192
+ )
193
+ process.exit(1)
194
+ }
195
+
196
+ scanSpinner.succeed(
197
+ `Found ${fileCount} file(s) (ignored system files skipped)`
198
+ )
199
+ return fileCount
200
+ }
201
+
202
+ /**
203
+ * Validate static-only files
60
204
  */
61
- export async function deploy(folder, options) {
62
- const folderPath = resolve(folder);
63
- const verbose = options.verbose || false;
64
-
65
- // Parse expiration if provided
66
- let expiresAt = null;
67
- if (options.expires) {
68
- try {
69
- expiresAt = calculateExpiresAt(options.expires);
70
- } catch (err) {
71
- errorWithSuggestions(err.message, [
72
- 'Use format like: 30m, 2h, 1d, 7d',
73
- 'Minimum expiration is 30 minutes',
74
- 'Examples: --expires 1h, --expires 2d',
75
- ], { verbose, cause: err });
76
- process.exit(1);
77
- }
205
+ async function validateStaticFiles (folderPath, options, verbose) {
206
+ const validationSpinner = spinner('Validating files...')
207
+ const validation = await validateStaticOnly(folderPath)
208
+
209
+ if (!validation.success) {
210
+ if (options.force) {
211
+ validationSpinner.warn(
212
+ 'Static-only validation failed, but proceeding due to --force'
213
+ )
214
+ warning('Non-static files detected.')
215
+ warning(chalk.bold.red('IMPORTANT: Launchpd only hosts STATIC files.'))
216
+ warning(
217
+ 'Backend code (Node.js, PHP, etc.) will NOT be executed on the server.'
218
+ )
219
+ } else {
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
+ : ''
228
+ errorWithSuggestions(
229
+ 'Your project contains files that are not allowed.',
230
+ [
231
+ 'Launchpd only supports static files (HTML, CSS, JS, images, etc.)',
232
+ 'Remove framework files, backend code, and build metadata:',
233
+ ...violationList,
234
+ moreCount,
235
+ 'If you use a framework (React, Vue, etc.), deploy the "dist" or "build" folder instead.'
236
+ ],
237
+ { verbose }
238
+ )
239
+ process.exit(1)
78
240
  }
241
+ } else {
242
+ validationSpinner.succeed('Project validated (Static files only)')
243
+ }
244
+ }
79
245
 
80
- // Validate deployment message is provided
81
- if (!options.message) {
82
- errorWithSuggestions('Deployment message is required.', [
83
- 'Use -m or --message to provide a description',
84
- 'Example: launchpd deploy . -m "Fix layout"',
85
- 'Example: launchpd deploy . -m "Initial deployment"'
86
- ], { verbose });
87
- process.exit(1);
246
+ /**
247
+ * Resolve subdomain from options/config
248
+ */
249
+ async function resolveSubdomain (options, folderPath, creds, verbose) {
250
+ if (options.name && !creds?.email) {
251
+ warning('Custom subdomains require registration!')
252
+ info('Anonymous deployments use random subdomains.')
253
+ info('Run "launchpd register" to use --name option.')
254
+ log('')
255
+ }
256
+
257
+ let subdomain =
258
+ options.name && creds?.email ? options.name.toLowerCase() : null
259
+ const projectRoot = findProjectRoot(folderPath)
260
+ const config = await getProjectConfig(projectRoot)
261
+ const configSubdomain = config?.subdomain || null
262
+
263
+ if (!subdomain) {
264
+ if (configSubdomain) {
265
+ subdomain = configSubdomain
266
+ info(`Using project subdomain: ${chalk.bold(subdomain)}`)
267
+ } else {
268
+ subdomain = generateSubdomain()
88
269
  }
270
+ } else if (configSubdomain && subdomain !== configSubdomain) {
271
+ await handleSubdomainMismatch(
272
+ subdomain,
273
+ configSubdomain,
274
+ options,
275
+ projectRoot
276
+ )
277
+ }
278
+
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
+ }
294
+
295
+ return { subdomain, configSubdomain, projectRoot }
296
+ }
89
297
 
90
- // Validate folder exists
91
- if (!existsSync(folderPath)) {
92
- errorWithSuggestions(`Folder not found: ${folderPath}`, [
93
- 'Check the path is correct',
94
- 'Use an absolute path or path relative to current directory',
95
- `Current directory: ${process.cwd()}`,
96
- ], { verbose });
97
- process.exit(1);
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'
318
+ }
319
+
320
+ if (shouldUpdate) {
321
+ await updateProjectConfig({ subdomain }, projectRoot)
322
+ success(`Project configuration updated to: ${subdomain}`)
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Check subdomain availability
328
+ */
329
+ async function checkSubdomainOwnership (subdomain) {
330
+ const checkSpinner = spinner('Checking subdomain availability...')
331
+ try {
332
+ const isAvailable = await checkSubdomainAvailable(subdomain)
333
+
334
+ if (!isAvailable) {
335
+ const result = await listSubdomains()
336
+ const owned = result?.subdomains?.some((s) => s.subdomain === subdomain)
337
+
338
+ if (owned) {
339
+ checkSpinner.succeed(
340
+ `Deploying new version to your subdomain: "${subdomain}"`
341
+ )
342
+ } else {
343
+ checkSpinner.fail(
344
+ `Subdomain "${subdomain}" is already taken by another user`
345
+ )
346
+ warning(
347
+ 'You do not own this subdomain. Please choose a different name.'
348
+ )
349
+ process.exit(1)
350
+ }
351
+ } else {
352
+ checkSpinner.succeed(`Subdomain "${subdomain}" is available`)
98
353
  }
354
+ } catch {
355
+ checkSpinner.warn(
356
+ 'Could not verify subdomain availability (skipping check)'
357
+ )
358
+ }
359
+ }
99
360
 
100
- // Check folder is not empty
101
- const scanSpinner = spinner('Scanning folder...');
102
- const files = await readdir(folderPath, { recursive: true, withFileTypes: true });
103
-
104
- // Filter out ignored files for the count
105
- const activeFiles = files.filter(file => {
106
- if (!file.isFile()) return false;
107
- const parentDir = file.parentPath || file.path;
108
- const relativePath = relative(folderPath, join(parentDir, file.name));
109
- const pathParts = relativePath.split(sep);
110
- return !pathParts.some(part => isIgnored(part, file.isDirectory()));
111
- });
112
-
113
- const fileCount = activeFiles.length;
114
-
115
- if (fileCount === 0) {
116
- scanSpinner.fail('Folder is empty or only contains ignored files');
117
- errorWithSuggestions('Nothing to deploy.', [
118
- 'Add some files to your folder',
119
- 'Make sure your files are not in ignored directories (like node_modules)',
120
- 'Make sure index.html exists for static sites',
121
- ], { verbose });
122
- process.exit(1);
361
+ /**
362
+ * Prompt for auto-init if needed
363
+ */
364
+ async function promptAutoInit (options, configSubdomain, subdomain, folderPath) {
365
+ if (options.name && !configSubdomain) {
366
+ const confirm = await prompt(
367
+ `\nRun "launchpd init" to link '${folderPath}' to '${subdomain}'? (Y/N): `
368
+ )
369
+ if (
370
+ confirm.toLowerCase() === 'y' ||
371
+ confirm.toLowerCase() === 'yes' ||
372
+ confirm === ''
373
+ ) {
374
+ await initProjectConfig(subdomain, folderPath)
375
+ success('Project initialized! Future deploys here can skip --name.')
123
376
  }
124
- scanSpinner.succeed(`Found ${fileCount} file(s) (ignored system files skipped)`);
125
-
126
- // Static-Only Validation
127
- const validationSpinner = spinner('Validating files...');
128
- const validation = await validateStaticOnly(folderPath);
129
- if (!validation.success) {
130
- if (options.force) {
131
- validationSpinner.warn('Static-only validation failed, but proceeding due to --force');
132
- warning('Non-static files detected.');
133
- warning(chalk.bold.red('IMPORTANT: Launchpd only hosts STATIC files.'));
134
- warning('Backend code (Node.js, PHP, etc.) will NOT be executed on the server.');
135
- } else {
136
- validationSpinner.fail('Deployment blocked: Non-static files detected');
137
- errorWithSuggestions('Your project contains files that are not allowed.', [
138
- 'Launchpd only supports static files (HTML, CSS, JS, images, etc.)',
139
- 'Remove framework files, backend code, and build metadata:',
140
- ...validation.violations.map(v => ` - ${v}`).slice(0, 10),
141
- validation.violations.length > 10 ? ` - ...and ${validation.violations.length - 10} more` : '',
142
- 'If you use a framework (React, Vue, etc.), deploy the "dist" or "build" folder instead.',
143
- ], { verbose });
144
- process.exit(1);
145
- }
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Check quota and return result
382
+ */
383
+ async function checkDeploymentQuota (
384
+ subdomain,
385
+ estimatedBytes,
386
+ configSubdomain,
387
+ options
388
+ ) {
389
+ const quotaSpinner = spinner('Checking quota...')
390
+ const isUpdate = configSubdomain && subdomain === configSubdomain
391
+ const quotaCheck = await checkQuota(subdomain, estimatedBytes, { isUpdate })
392
+
393
+ if (!quotaCheck.allowed) {
394
+ if (options.force) {
395
+ quotaSpinner.warn(
396
+ 'Deployment blocked due to quota limits, but proceeding due to --force'
397
+ )
398
+ warning(
399
+ 'Uploading anyway... (server might still reject if physical limit is hit)'
400
+ )
146
401
  } else {
147
- validationSpinner.succeed('Project validated (Static files only)');
402
+ quotaSpinner.fail('Deployment blocked due to quota limits')
403
+ info('Try running "launchpd quota" to check your storage.')
404
+ info('Use --force to try anyway (if you think this is a mistake)')
405
+ process.exit(1)
148
406
  }
407
+ } else {
408
+ quotaSpinner.succeed('Quota check passed')
409
+ }
410
+
411
+ displayQuotaWarnings(quotaCheck.warnings)
412
+ }
149
413
 
150
- // Generate or use provided subdomain
151
- // Anonymous users cannot use custom subdomains
152
- const creds = await getCredentials();
153
- if (options.name && !creds?.email) {
154
- warning('Custom subdomains require registration!');
155
- info('Anonymous deployments use random subdomains.');
156
- info('Run "launchpd register" to use --name option.');
157
- log('');
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)
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
+ )
158
442
  }
443
+ )
444
+
445
+ uploadSpinner.succeed(
446
+ `Uploaded ${fileCount} files (${formatBytes(totalBytes)})`
447
+ )
448
+
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
+ }
159
473
 
160
- // Detect project config if no name provided
161
- let subdomain = (options.name && creds?.email) ? options.name.toLowerCase() : null;
162
- let configSubdomain = null;
474
+ /**
475
+ * Show post-deployment info
476
+ */
477
+ async function showPostDeploymentInfo (url, options, expiresAt, creds, verbose) {
478
+ if (options.open) {
479
+ openUrlInBrowser(url)
480
+ }
163
481
 
164
- const projectRoot = findProjectRoot(folderPath);
165
- const config = await getProjectConfig(projectRoot);
166
- if (config?.subdomain) {
167
- configSubdomain = config.subdomain;
168
- }
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
+ }
169
496
 
170
- if (!subdomain) {
171
- if (configSubdomain) {
172
- subdomain = configSubdomain;
173
- info(`Using project subdomain: ${chalk.bold(subdomain)}`);
174
- } else {
175
- subdomain = generateSubdomain();
176
- }
177
- } else if (configSubdomain && subdomain !== configSubdomain) {
178
- warning(`Mismatch: This project is linked to ${chalk.bold(configSubdomain)} but you are deploying to ${chalk.bold(subdomain)}`);
179
-
180
- let shouldUpdate = options.yes;
181
- if (!shouldUpdate) {
182
- const confirm = await prompt(`Would you like to update this project's default subdomain to "${subdomain}"? (Y/N): `);
183
- shouldUpdate = (confirm.toLowerCase() === 'y' || confirm.toLowerCase() === 'yes');
184
- }
185
-
186
- if (shouldUpdate) {
187
- await updateProjectConfig({ subdomain }, projectRoot);
188
- success(`Project configuration updated to: ${subdomain}`);
189
- }
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) {
535
+ try {
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}`)
190
553
  }
554
+ } catch (err) {
555
+ warning('Could not generate QR code.')
556
+ if (verbose) raw(err, 'error')
557
+ }
558
+ }
191
559
 
192
- const url = `https://${subdomain}.launchpd.cloud`;
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
+ }
573
+
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 }
582
+ )
583
+ process.exit(1)
584
+ }
585
+
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 }
595
+ )
596
+ process.exit(1)
597
+ }
598
+
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 }
607
+ )
608
+ process.exit(1)
609
+ }
610
+
611
+ const suggestions = getErrorSuggestions(err)
612
+ errorWithSuggestions(`Upload failed: ${err.message}`, suggestions, {
613
+ verbose,
614
+ cause: err
615
+ })
616
+ process.exit(1)
617
+ }
193
618
 
194
- // Check subdomain availability and ownership (ALWAYS run this)
195
- const checkSpinner = spinner('Checking subdomain availability...');
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
+ }
631
+
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
+ }
638
+
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
+ }
645
+
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
+ }
652
+
653
+ return [
654
+ 'Try running with --verbose for more details',
655
+ 'Check https://status.launchpd.cloud for service status'
656
+ ]
657
+ }
658
+
659
+ // ============================================================================
660
+ // Main Deploy Function
661
+ // ============================================================================
662
+
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
677
+
678
+ // Detect remote URL vs local folder
679
+ if (isRemoteUrl(source)) {
680
+ const fetchSpinner = spinner('Fetching remote source...')
196
681
  try {
197
- const isAvailable = await checkSubdomainAvailable(subdomain);
198
-
199
- if (!isAvailable) {
200
- // Check if the current user owns it
201
- const result = await listSubdomains();
202
- const owned = result?.subdomains?.some(s => s.subdomain === subdomain);
203
-
204
- if (owned) {
205
- checkSpinner.succeed(`Deploying new version to your subdomain: "${subdomain}"`);
206
- } else {
207
- checkSpinner.fail(`Subdomain "${subdomain}" is already taken by another user`);
208
- warning('You do not own this subdomain. Please choose a different name.');
209
- process.exit(1);
210
- }
211
- } else {
212
- // If strictly new, it's available
213
- checkSpinner.succeed(`Subdomain "${subdomain}" is available`);
214
- }
215
- } catch {
216
- checkSpinner.warn('Could not verify subdomain availability (skipping check)');
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')
698
+ errorWithSuggestions(
699
+ `Remote fetch failed: ${err.message}`,
700
+ [
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'
705
+ ],
706
+ { verbose, cause: err }
707
+ )
708
+ process.exit(1)
709
+ return // Unreachable in production, satisfies test mocks
217
710
  }
218
-
219
- // Auto-init: If using --name and no config exists, prompt to save it
220
- if (options.name && !configSubdomain) {
221
- const confirm = await prompt(`\nRun "launchpd init" to link '${folderPath}' to '${subdomain}'? (Y/N): `);
222
- if (confirm.toLowerCase() === 'y' || confirm.toLowerCase() === 'yes' || confirm === '') {
223
- await initProjectConfig(subdomain, folderPath);
224
- success(`Project initialized! Future deploys here can skip --name.`);
225
- }
711
+ } else {
712
+ folderPath = resolve(source)
713
+ }
714
+
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)
226
740
  }
227
741
 
228
- // Calculate estimated upload size
229
- const sizeSpinner = spinner('Calculating folder size...');
230
- const estimatedBytes = await calculateFolderSize(folderPath);
231
- sizeSpinner.succeed(`Size: ${formatSize(estimatedBytes)}`);
232
-
233
- // Check quota before deploying
234
- const quotaSpinner = spinner('Checking quota...');
235
- const isUpdate = (configSubdomain && subdomain === configSubdomain);
236
-
237
- const quotaCheck = await checkQuota(subdomain, estimatedBytes, { isUpdate });
238
-
239
- if (!quotaCheck.allowed) {
240
- if (options.force) {
241
- quotaSpinner.warn('Deployment blocked due to quota limits, but proceeding due to --force');
242
- warning('Uploading anyway... (server might still reject if physical limit is hit)');
243
- } else {
244
- quotaSpinner.fail('Deployment blocked due to quota limits');
245
- info('Try running "launchpd quota" to check your storage.');
246
- info('Use --force to try anyway (if you think this is a mistake)');
247
- process.exit(1);
248
- }
249
- } else {
250
- quotaSpinner.succeed('Quota check passed');
251
- }
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)}`)
252
746
 
253
- // Display any warnings
254
- displayQuotaWarnings(quotaCheck.warnings);
747
+ await checkDeploymentQuota(
748
+ subdomain,
749
+ estimatedBytes,
750
+ configSubdomain,
751
+ options
752
+ )
255
753
 
256
- // Show current user status (creds already fetched above)
754
+ // Show deployment info
257
755
  if (creds?.email) {
258
- info(`Deploying as: ${creds.email}`);
756
+ info(`Deploying as: ${creds.email}`)
259
757
  } else {
260
- info('Deploying as: anonymous (run "launchpd login" for more quota)');
758
+ info('Deploying as: anonymous (run "launchpd login" for more quota)')
261
759
  }
760
+ const sourceLabel = tempDir ? source : folderPath
761
+ info(`Deploying ${fileCount} file(s) from ${sourceLabel}`)
762
+ info(`Target: ${url}`)
262
763
 
263
- info(`Deploying ${fileCount} file(s) from ${folderPath}`);
264
- info(`Target: ${url}`);
265
-
266
- // Perform actual upload
764
+ // Perform upload
267
765
  try {
268
- // Get next version number for this subdomain (try API first, fallback to local)
269
- const versionSpinner = spinner('Fetching version info...');
270
- let version = await getNextVersionFromAPI(subdomain);
271
- if (version === null) {
272
- version = await getNextVersion(subdomain);
273
- }
274
- versionSpinner.succeed(`Deploying as version ${version}`);
275
-
276
- // Upload all files via API proxy
277
- const folderName = basename(folderPath);
278
- const uploadSpinner = spinner(`Uploading files... 0/${fileCount}`);
279
-
280
- const { totalBytes } = await uploadFolder(folderPath, subdomain, version, (uploaded, total, fileName) => {
281
- uploadSpinner.update(`Uploading files... ${uploaded}/${total} (${fileName})`);
282
- });
283
-
284
- uploadSpinner.succeed(`Uploaded ${fileCount} files (${formatSize(totalBytes)})`);
285
-
286
- // Finalize upload: set active version and record metadata
287
- const finalizeSpinner = spinner('Finalizing deployment...');
288
- await finalizeUpload(
289
- subdomain,
290
- version,
291
- fileCount,
292
- totalBytes,
293
- folderName,
294
- expiresAt?.toISOString() || null,
295
- options.message
296
- );
297
- finalizeSpinner.succeed('Deployment finalized');
298
-
299
- // Save locally for quick access
300
- await saveLocalDeployment({
301
- subdomain,
302
- folderName,
303
- fileCount,
304
- totalBytes,
305
- version,
306
- timestamp: new Date().toISOString(),
307
- expiresAt: expiresAt?.toISOString() || null,
308
- });
309
-
310
- success(`Deployed successfully! (v${version})`);
311
- log(`\n${url}`);
312
-
313
- if (options.open) {
314
- const platform = process.platform;
315
- let cmd;
316
- if (platform === 'darwin') cmd = `open "${url}"`;
317
- else if (platform === 'win32') cmd = `start "" "${url}"`;
318
- else cmd = `xdg-open "${url}"`;
319
-
320
- exec(cmd);
321
- }
322
-
323
-
324
-
325
- if (expiresAt) {
326
- warning(`Expires: ${formatTimeRemaining(expiresAt)}`);
327
- }
328
-
329
- // Show anonymous limit warnings
330
- if (!creds?.email) {
331
- log('');
332
- warning('Anonymous deployment limits:');
333
- log(' • 3 active sites per IP');
334
- log(' • 50MB total storage');
335
- log(' • 7-day site expiration');
336
- log('');
337
- info('Run "launchpd register" to unlock unlimited sites and permanent storage!');
338
- }
339
- log('');
340
-
341
- if (options.qr) {
342
- try {
343
- // Determine terminal width to avoid wrapping
344
- const terminalWidth = process.stdout.columns || 80;
345
-
346
- // version: 2-3 is typical for these URLs. L level is smallest.
347
- // margin: 2 is safe but compact.
348
- const qr = await QRCode.toString(url, {
349
- type: 'terminal',
350
- small: true,
351
- margin: 2,
352
- errorCorrectionLevel: 'L'
353
- });
354
-
355
- // Check if QR might wrap
356
- const firstLine = qr.split('\n')[0];
357
- if (firstLine.length > terminalWidth) {
358
- warning('\nTerminal is too narrow to display the QR code correctly.');
359
- info(`Please expand your terminal to at least ${firstLine.length} columns.`);
360
- info(`URL: ${url}`);
361
- } else {
362
- log(`\nScan this QR code to view your site on mobile:\n${qr}`);
363
- }
364
- } catch (err) {
365
- warning('Could not generate QR code.');
366
- if (verbose) raw(err, 'error');
367
- }
368
- }
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)
369
776
  } catch (err) {
370
- // Handle common errors with standardized messages
371
- if (handleCommonError(err, { error: (msg) => errorWithSuggestions(msg, [], { verbose }), info, warning })) {
372
- process.exit(1);
373
- }
374
-
375
- // Handle maintenance mode specifically
376
- if (err instanceof MaintenanceError || err.isMaintenanceError) {
377
- errorWithSuggestions('⚠️ LaunchPd is under maintenance', [
378
- 'Please try again in a few minutes',
379
- 'Check https://status.launchpd.cloud for updates',
380
- ], { verbose });
381
- process.exit(1);
382
- }
383
-
384
- // Handle network errors
385
- if (err instanceof NetworkError || err.isNetworkError) {
386
- errorWithSuggestions('Unable to connect to LaunchPd', [
387
- 'Check your internet connection',
388
- 'The API server may be temporarily unavailable',
389
- 'Check https://status.launchpd.cloud for service status',
390
- ], { verbose, cause: err });
391
- process.exit(1);
392
- }
393
-
394
- // Handle auth errors
395
- if (err instanceof AuthError || err.isAuthError) {
396
- errorWithSuggestions('Authentication failed', [
397
- 'Run "launchpd login" to authenticate',
398
- 'Your API key may have expired or been revoked',
399
- ], { verbose, cause: err });
400
- process.exit(1);
401
- }
402
-
403
- const suggestions = [];
404
-
405
- // Provide context-specific suggestions for other errors
406
- if (err.message.includes('fetch failed') || err.message.includes('ENOTFOUND')) {
407
- suggestions.push('Check your internet connection');
408
- suggestions.push('The API server may be temporarily unavailable');
409
- } else if (err.message.includes('401') || err.message.includes('Unauthorized')) {
410
- suggestions.push('Run "launchpd login" to authenticate');
411
- suggestions.push('Your API key may have expired');
412
- } else if (err.message.includes('413') || err.message.includes('too large')) {
413
- suggestions.push('Try deploying fewer or smaller files');
414
- suggestions.push('Check your storage quota with "launchpd quota"');
415
- } else if (err.message.includes('429') || err.message.includes('rate limit')) {
416
- suggestions.push('Wait a few minutes and try again');
417
- suggestions.push('You may be deploying too frequently');
418
- } else {
419
- suggestions.push('Try running with --verbose for more details');
420
- suggestions.push('Check https://status.launchpd.cloud for service status');
421
- }
422
-
423
- errorWithSuggestions(`Upload failed: ${err.message}`, suggestions, { verbose, cause: err });
424
- process.exit(1);
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)
425
783
  }
784
+ }
426
785
  }