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.
- package/README.md +23 -22
- package/bin/cli.js +6 -4
- package/package.json +2 -2
- package/src/commands/auth.js +36 -15
- package/src/commands/deploy.js +482 -271
- package/src/utils/api.js +28 -1
- package/src/utils/credentials.js +8 -1
- package/src/utils/errors.js +7 -0
- package/src/utils/quota.js +21 -17
- package/src/utils/remoteSource.js +680 -0
- package/src/utils/upload.js +44 -15
package/src/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
const verbose = options.verbose || false
|
|
111
|
+
function parseExpiration (expiresOption, verbose) {
|
|
112
|
+
if (!expiresOption) return null
|
|
98
113
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
202
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
271
|
+
await handleSubdomainMismatch(
|
|
272
|
+
subdomain,
|
|
273
|
+
configSubdomain,
|
|
274
|
+
options,
|
|
275
|
+
projectRoot
|
|
246
276
|
)
|
|
277
|
+
}
|
|
247
278
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
320
|
+
if (shouldUpdate) {
|
|
321
|
+
await updateProjectConfig({ subdomain }, projectRoot)
|
|
322
|
+
success(`Project configuration updated to: ${subdomain}`)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
264
325
|
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
353
|
-
|
|
445
|
+
uploadSpinner.succeed(
|
|
446
|
+
`Uploaded ${fileCount} files (${formatBytes(totalBytes)})`
|
|
447
|
+
)
|
|
354
448
|
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
554
|
+
} catch (err) {
|
|
555
|
+
warning('Could not generate QR code.')
|
|
556
|
+
if (verbose) raw(err, 'error')
|
|
557
|
+
}
|
|
558
|
+
}
|
|
364
559
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
608
|
+
process.exit(1)
|
|
609
|
+
}
|
|
396
610
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
500
|
-
|
|
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
|
-
|
|
699
|
+
`Remote fetch failed: ${err.message}`,
|
|
503
700
|
[
|
|
504
|
-
'Check
|
|
505
|
-
'
|
|
506
|
-
'
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
}
|