launchpd 1.0.2 → 1.0.5

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,51 +1,87 @@
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 } from '../utils/api.js';
11
- import { getProjectConfig, findProjectRoot, updateProjectConfig } from '../utils/projectConfig.js';
12
- import { success, errorWithSuggestions, info, warning, spinner, formatSize } 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';
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 QRCode from 'qrcode'
19
48
 
20
49
  /**
21
50
  * Calculate total size of a folder
22
51
  */
23
- async function calculateFolderSize(folderPath) {
24
- const files = await readdir(folderPath, { recursive: true, withFileTypes: true });
25
- let totalSize = 0;
26
-
27
- for (const file of files) {
28
- const parentDir = file.parentPath || file.path;
29
- const relativePath = relative(folderPath, join(parentDir, file.name));
30
- const pathParts = relativePath.split(sep);
31
-
32
- // Skip ignored directories/files in the path
33
- if (pathParts.some(part => isIgnored(part, file.isDirectory()))) {
34
- continue;
35
- }
52
+ async function calculateFolderSize (folderPath) {
53
+ const files = await readdir(folderPath, {
54
+ recursive: true,
55
+ withFileTypes: true
56
+ })
57
+ let totalSize = 0
58
+
59
+ for (const file of files) {
60
+ const parentDir = file.parentPath || file.path
61
+ const relativePath = relative(folderPath, join(parentDir, file.name))
62
+ const pathParts = relativePath.split(sep)
63
+
64
+ // Skip ignored directories/files in the path
65
+ if (
66
+ pathParts.some((part) => {
67
+ return isIgnored(part, file.isDirectory())
68
+ })
69
+ ) {
70
+ continue
71
+ }
36
72
 
37
- if (file.isFile()) {
38
- const fullPath = join(parentDir, file.name);
39
- try {
40
- const stats = statSync(fullPath);
41
- totalSize += stats.size;
42
- } catch {
43
- // File may have been deleted
44
- }
45
- }
73
+ if (file.isFile()) {
74
+ const fullPath = join(parentDir, file.name)
75
+ try {
76
+ const stats = statSync(fullPath)
77
+ totalSize += stats.size
78
+ } catch {
79
+ // File may have been deleted
80
+ }
46
81
  }
82
+ }
47
83
 
48
- return totalSize;
84
+ return totalSize
49
85
  }
50
86
 
51
87
  /**
@@ -56,297 +92,483 @@ async function calculateFolderSize(folderPath) {
56
92
  * @param {string} options.expires - Expiration time (e.g., "30m", "2h", "1d")
57
93
  * @param {boolean} options.verbose - Show verbose error details
58
94
  */
59
- export async function deploy(folder, options) {
60
- const folderPath = resolve(folder);
61
- const verbose = options.verbose || false;
62
-
63
- // Parse expiration if provided
64
- let expiresAt = null;
65
- if (options.expires) {
66
- try {
67
- expiresAt = calculateExpiresAt(options.expires);
68
- } catch (err) {
69
- errorWithSuggestions(err.message, [
70
- 'Use format like: 30m, 2h, 1d, 7d',
71
- 'Minimum expiration is 30 minutes',
72
- 'Examples: --expires 1h, --expires 2d',
73
- ], { verbose, cause: err });
74
- process.exit(1);
75
- }
76
- }
95
+ export async function deploy (folder, options) {
96
+ const folderPath = resolve(folder)
97
+ const verbose = options.verbose || false
77
98
 
78
- // Validate deployment message is provided
79
- if (!options.message) {
80
- errorWithSuggestions('Deployment message is required.', [
81
- 'Use -m or --message to provide a description',
82
- 'Example: launchpd deploy . -m "Fix layout"',
83
- 'Example: launchpd deploy . -m "Initial deployment"'
84
- ], { verbose });
85
- process.exit(1);
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)
86
115
  }
87
-
88
- // Validate folder exists
89
- if (!existsSync(folderPath)) {
90
- errorWithSuggestions(`Folder not found: ${folderPath}`, [
91
- 'Check the path is correct',
92
- 'Use an absolute path or path relative to current directory',
93
- `Current directory: ${process.cwd()}`,
94
- ], { verbose });
95
- process.exit(1);
116
+ }
117
+
118
+ // Validate deployment message is provided
119
+ if (!options.message) {
120
+ errorWithSuggestions(
121
+ 'Deployment message is required.',
122
+ [
123
+ 'Use -m or --message to provide a description',
124
+ 'Example: launchpd deploy . -m "Fix layout"',
125
+ 'Example: launchpd deploy . -m "Initial deployment"'
126
+ ],
127
+ { verbose }
128
+ )
129
+ process.exit(1)
130
+ }
131
+
132
+ // Validate folder exists
133
+ if (!existsSync(folderPath)) {
134
+ errorWithSuggestions(
135
+ `Folder not found: ${folderPath}`,
136
+ [
137
+ 'Check the path is correct',
138
+ 'Use an absolute path or path relative to current directory',
139
+ `Current directory: ${process.cwd()}`
140
+ ],
141
+ { verbose }
142
+ )
143
+ process.exit(1)
144
+ }
145
+
146
+ // Check folder is not empty
147
+ const scanSpinner = spinner('Scanning folder...')
148
+ const files = await readdir(folderPath, {
149
+ recursive: true,
150
+ withFileTypes: true
151
+ })
152
+
153
+ // Filter out ignored files for the count
154
+ const activeFiles = files.filter((file) => {
155
+ if (!file.isFile()) return false
156
+ const parentDir = file.parentPath || file.path
157
+ const relativePath = relative(folderPath, join(parentDir, file.name))
158
+ const pathParts = relativePath.split(sep)
159
+ return !pathParts.some((part) => isIgnored(part, file.isDirectory()))
160
+ })
161
+
162
+ const fileCount = activeFiles.length
163
+
164
+ if (fileCount === 0) {
165
+ scanSpinner.fail('Folder is empty or only contains ignored files')
166
+ errorWithSuggestions(
167
+ 'Nothing to deploy.',
168
+ [
169
+ 'Add some files to your folder',
170
+ 'Make sure your files are not in ignored directories (like node_modules)',
171
+ 'Make sure index.html exists for static sites'
172
+ ],
173
+ { verbose }
174
+ )
175
+ process.exit(1)
176
+ }
177
+ scanSpinner.succeed(
178
+ `Found ${fileCount} file(s) (ignored system files skipped)`
179
+ )
180
+
181
+ // Static-Only Validation
182
+ const validationSpinner = spinner('Validating files...')
183
+ const validation = await validateStaticOnly(folderPath)
184
+ if (!validation.success) {
185
+ if (options.force) {
186
+ validationSpinner.warn(
187
+ 'Static-only validation failed, but proceeding due to --force'
188
+ )
189
+ warning('Non-static files detected.')
190
+ warning(chalk.bold.red('IMPORTANT: Launchpd only hosts STATIC files.'))
191
+ warning(
192
+ 'Backend code (Node.js, PHP, etc.) will NOT be executed on the server.'
193
+ )
194
+ } else {
195
+ validationSpinner.fail('Deployment blocked: Non-static files detected')
196
+ errorWithSuggestions(
197
+ 'Your project contains files that are not allowed.',
198
+ [
199
+ 'Launchpd only supports static files (HTML, CSS, JS, images, etc.)',
200
+ '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
+ : '',
205
+ 'If you use a framework (React, Vue, etc.), deploy the "dist" or "build" folder instead.'
206
+ ],
207
+ { verbose }
208
+ )
209
+ process.exit(1)
210
+ }
211
+ } else {
212
+ validationSpinner.succeed('Project validated (Static files only)')
213
+ }
214
+
215
+ // Generate or use provided subdomain
216
+ // Anonymous users cannot use custom subdomains
217
+ const creds = await getCredentials()
218
+ if (options.name && !creds?.email) {
219
+ warning('Custom subdomains require registration!')
220
+ info('Anonymous deployments use random subdomains.')
221
+ info('Run "launchpd register" to use --name option.')
222
+ log('')
223
+ }
224
+
225
+ // Detect project config if no name provided
226
+ let subdomain =
227
+ options.name && creds?.email ? options.name.toLowerCase() : null
228
+ let configSubdomain = null
229
+
230
+ const projectRoot = findProjectRoot(folderPath)
231
+ const config = await getProjectConfig(projectRoot)
232
+ if (config?.subdomain) {
233
+ configSubdomain = config.subdomain
234
+ }
235
+
236
+ if (!subdomain) {
237
+ if (configSubdomain) {
238
+ subdomain = configSubdomain
239
+ info(`Using project subdomain: ${chalk.bold(subdomain)}`)
240
+ } else {
241
+ subdomain = generateSubdomain()
242
+ }
243
+ } 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)}`
246
+ )
247
+
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'
96
255
  }
97
256
 
98
- // Check folder is not empty
99
- const scanSpinner = spinner('Scanning folder...');
100
- const files = await readdir(folderPath, { recursive: true, withFileTypes: true });
101
-
102
- // Filter out ignored files for the count
103
- const activeFiles = files.filter(file => {
104
- if (!file.isFile()) return false;
105
- const parentDir = file.parentPath || file.path;
106
- const relativePath = relative(folderPath, join(parentDir, file.name));
107
- const pathParts = relativePath.split(sep);
108
- return !pathParts.some(part => isIgnored(part, file.isDirectory()));
109
- });
110
-
111
- const fileCount = activeFiles.length;
112
-
113
- if (fileCount === 0) {
114
- scanSpinner.fail('Folder is empty or only contains ignored files');
115
- errorWithSuggestions('Nothing to deploy.', [
116
- 'Add some files to your folder',
117
- 'Make sure your files are not in ignored directories (like node_modules)',
118
- 'Make sure index.html exists for static sites',
119
- ], { verbose });
120
- process.exit(1);
257
+ if (shouldUpdate) {
258
+ await updateProjectConfig({ subdomain }, projectRoot)
259
+ success(`Project configuration updated to: ${subdomain}`)
121
260
  }
122
- scanSpinner.succeed(`Found ${fileCount} file(s) (ignored system files skipped)`);
123
-
124
- // Static-Only Validation
125
- const validationSpinner = spinner('Validating files...');
126
- const validation = await validateStaticOnly(folderPath);
127
- if (!validation.success) {
128
- if (options.force) {
129
- validationSpinner.warn('Static-only validation failed, but proceeding due to --force');
130
- warning('Non-static files detected.');
131
- warning(chalk.bold.red('IMPORTANT: Launchpd only hosts STATIC files.'));
132
- warning('Backend code (Node.js, PHP, etc.) will NOT be executed on the server.');
133
- } else {
134
- validationSpinner.fail('Deployment blocked: Non-static files detected');
135
- errorWithSuggestions('Your project contains files that are not allowed.', [
136
- 'Launchpd only supports static files (HTML, CSS, JS, images, etc.)',
137
- 'Remove framework files, backend code, and build metadata:',
138
- ...validation.violations.map(v => ` - ${v}`).slice(0, 10),
139
- validation.violations.length > 10 ? ` - ...and ${validation.violations.length - 10} more` : '',
140
- 'If you use a framework (React, Vue, etc.), deploy the "dist" or "build" folder instead.',
141
- ], { verbose });
142
- process.exit(1);
143
- }
261
+ }
262
+
263
+ const url = `https://${subdomain}.launchpd.cloud`
264
+
265
+ // Check subdomain availability and ownership (ALWAYS run this)
266
+ const checkSpinner = spinner('Checking subdomain availability...')
267
+ try {
268
+ const isAvailable = await checkSubdomainAvailable(subdomain)
269
+
270
+ if (!isAvailable) {
271
+ // Check if the current user owns it
272
+ const result = await listSubdomains()
273
+ const owned = result?.subdomains?.some((s) => s.subdomain === subdomain)
274
+
275
+ if (owned) {
276
+ checkSpinner.succeed(
277
+ `Deploying new version to your subdomain: "${subdomain}"`
278
+ )
279
+ } else {
280
+ checkSpinner.fail(
281
+ `Subdomain "${subdomain}" is already taken by another user`
282
+ )
283
+ warning(
284
+ 'You do not own this subdomain. Please choose a different name.'
285
+ )
286
+ process.exit(1)
287
+ }
144
288
  } else {
145
- validationSpinner.succeed('Project validated (Static files only)');
289
+ // If strictly new, it's available
290
+ checkSpinner.succeed(`Subdomain "${subdomain}" is available`)
146
291
  }
147
-
148
- // Generate or use provided subdomain
149
- // Anonymous users cannot use custom subdomains
150
- const creds = await getCredentials();
151
- if (options.name && !creds?.email) {
152
- warning('Custom subdomains require registration!');
153
- info('Anonymous deployments use random subdomains.');
154
- info('Run "launchpd register" to use --name option.');
155
- console.log('');
292
+ } catch {
293
+ checkSpinner.warn(
294
+ 'Could not verify subdomain availability (skipping check)'
295
+ )
296
+ }
297
+
298
+ // Auto-init: If using --name and no config exists, prompt to save it
299
+ if (options.name && !configSubdomain) {
300
+ const confirm = await prompt(
301
+ `\nRun "launchpd init" to link '${folderPath}' to '${subdomain}'? (Y/N): `
302
+ )
303
+ if (
304
+ confirm.toLowerCase() === 'y' ||
305
+ confirm.toLowerCase() === 'yes' ||
306
+ confirm === ''
307
+ ) {
308
+ await initProjectConfig(subdomain, folderPath)
309
+ success('Project initialized! Future deploys here can skip --name.')
310
+ }
311
+ }
312
+
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
319
+ const quotaSpinner = spinner('Checking quota...')
320
+ const isUpdate = configSubdomain && subdomain === configSubdomain
321
+
322
+ const quotaCheck = await checkQuota(subdomain, estimatedBytes, { isUpdate })
323
+
324
+ if (!quotaCheck.allowed) {
325
+ if (options.force) {
326
+ quotaSpinner.warn(
327
+ 'Deployment blocked due to quota limits, but proceeding due to --force'
328
+ )
329
+ warning(
330
+ 'Uploading anyway... (server might still reject if physical limit is hit)'
331
+ )
332
+ } else {
333
+ quotaSpinner.fail('Deployment blocked due to quota limits')
334
+ info('Try running "launchpd quota" to check your storage.')
335
+ info('Use --force to try anyway (if you think this is a mistake)')
336
+ process.exit(1)
337
+ }
338
+ } else {
339
+ quotaSpinner.succeed('Quota check passed')
340
+ }
341
+
342
+ // Display any warnings
343
+ displayQuotaWarnings(quotaCheck.warnings)
344
+
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)')
350
+ }
351
+
352
+ info(`Deploying ${fileCount} file(s) from ${folderPath}`)
353
+ info(`Target: ${url}`)
354
+
355
+ // Perform actual upload
356
+ 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)
362
+ }
363
+ versionSpinner.succeed(`Deploying as version ${version}`)
364
+
365
+ // Upload all files via API proxy
366
+ const folderName = basename(folderPath)
367
+ const uploadSpinner = spinner(`Uploading files... 0/${fileCount}`)
368
+
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
+ }
378
+ )
379
+
380
+ uploadSpinner.succeed(
381
+ `Uploaded ${fileCount} files (${formatBytes(totalBytes)})`
382
+ )
383
+
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
394
+ )
395
+ finalizeSpinner.succeed('Deployment finalized')
396
+
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
+ })
407
+
408
+ success(`Deployed successfully! (v${version})`)
409
+ log(`\n${url}`)
410
+
411
+ if (options.open) {
412
+ const platform = process.platform
413
+ let command = 'xdg-open'
414
+ let args = [url]
415
+
416
+ if (platform === 'darwin') {
417
+ command = 'open'
418
+ } else if (platform === 'win32') {
419
+ command = 'cmd'
420
+ args = ['/c', 'start', '', url]
421
+ }
422
+
423
+ execFile(command, args)
156
424
  }
157
425
 
158
- // Detect project config if no name provided
159
- let subdomain = (options.name && creds?.email) ? options.name.toLowerCase() : null;
160
- let configSubdomain = null;
161
-
162
- const projectRoot = findProjectRoot(folderPath);
163
- const config = await getProjectConfig(projectRoot);
164
- if (config?.subdomain) {
165
- configSubdomain = config.subdomain;
426
+ if (expiresAt) {
427
+ warning(`Expires: ${formatTimeRemaining(expiresAt)}`)
166
428
  }
167
429
 
168
- if (!subdomain) {
169
- if (configSubdomain) {
170
- subdomain = configSubdomain;
171
- info(`Using project subdomain: ${chalk.bold(subdomain)}`);
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('')
443
+
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}`)
172
466
  } else {
173
- subdomain = generateSubdomain();
174
- }
175
- } else if (configSubdomain && subdomain !== configSubdomain) {
176
- warning(`Mismatch: This project is linked to ${chalk.bold(configSubdomain)} but you are deploying to ${chalk.bold(subdomain)}`);
177
-
178
- let shouldUpdate = options.yes;
179
- if (!shouldUpdate) {
180
- const confirm = await prompt(`Would you like to update this project's default subdomain to "${subdomain}"? (Y/N): `);
181
- shouldUpdate = (confirm.toLowerCase() === 'y' || confirm.toLowerCase() === 'yes');
182
- }
183
-
184
- if (shouldUpdate) {
185
- await updateProjectConfig({ subdomain }, projectRoot);
186
- success(`Project configuration updated to: ${subdomain}`);
467
+ log(`\nScan this QR code to view your site on mobile:\n${qr}`)
187
468
  }
469
+ } catch (err) {
470
+ warning('Could not generate QR code.')
471
+ if (verbose) raw(err, 'error')
472
+ }
188
473
  }
189
-
190
- const url = `https://${subdomain}.launchpd.cloud`;
191
-
192
- // Check if custom subdomain is taken (only if explicitly provided or new)
193
- if (options.name || !subdomain) {
194
- const checkSpinner = spinner('Checking subdomain availability...');
195
- try {
196
- const isAvailable = await checkSubdomainAvailable(subdomain);
197
-
198
- if (!isAvailable) {
199
- // Check if the current user owns it
200
- const result = await listSubdomains();
201
- const owned = result?.subdomains?.some(s => s.subdomain === subdomain);
202
-
203
- if (owned) {
204
- checkSpinner.succeed(`Deploying new version to your subdomain: "${subdomain}"`);
205
- } else {
206
- checkSpinner.fail(`Subdomain "${subdomain}" is already taken`);
207
- warning('Choose a different subdomain name with --name or deployment without it.');
208
- process.exit(1);
209
- }
210
- } else {
211
- checkSpinner.succeed(`Subdomain "${subdomain}" is available`);
212
- }
213
- } catch {
214
- checkSpinner.warn('Could not verify subdomain availability');
215
- }
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)
216
484
  }
217
485
 
218
- // Calculate estimated upload size
219
- const sizeSpinner = spinner('Calculating folder size...');
220
- const estimatedBytes = await calculateFolderSize(folderPath);
221
- sizeSpinner.succeed(`Size: ${formatSize(estimatedBytes)}`);
222
-
223
- // Check quota before deploying
224
- const quotaSpinner = spinner('Checking quota...');
225
- const isUpdate = (configSubdomain && subdomain === configSubdomain);
226
-
227
- const quotaCheck = await checkQuota(subdomain, estimatedBytes, { isUpdate });
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
+ }
228
498
 
229
- if (!quotaCheck.allowed) {
230
- if (options.force) {
231
- quotaSpinner.warn('Deployment blocked due to quota limits, but proceeding due to --force');
232
- warning('Uploading anyway... (server might still reject if physical limit is hit)');
233
- } else {
234
- quotaSpinner.fail('Deployment blocked due to quota limits');
235
- info('Try running "launchpd quota" to check your storage.');
236
- info('Use --force to try anyway (if you think this is a mistake)');
237
- process.exit(1);
238
- }
239
- } else {
240
- quotaSpinner.succeed('Quota check passed');
499
+ // Handle network errors
500
+ if (err instanceof NetworkError || err.isNetworkError) {
501
+ errorWithSuggestions(
502
+ 'Unable to connect to LaunchPd',
503
+ [
504
+ 'Check your internet connection',
505
+ 'The API server may be temporarily unavailable',
506
+ 'Check https://status.launchpd.cloud for service status'
507
+ ],
508
+ { verbose, cause: err }
509
+ )
510
+ process.exit(1)
241
511
  }
242
512
 
243
- // Display any warnings
244
- displayQuotaWarnings(quotaCheck.warnings);
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)
524
+ }
245
525
 
246
- // Show current user status (creds already fetched above)
247
- if (creds?.email) {
248
- info(`Deploying as: ${creds.email}`);
526
+ const suggestions = []
527
+
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
+ )
249
561
  } else {
250
- info('Deploying as: anonymous (run "launchpd login" for more quota)');
562
+ suggestions.push(
563
+ 'Try running with --verbose for more details',
564
+ 'Check https://status.launchpd.cloud for service status'
565
+ )
251
566
  }
252
567
 
253
- info(`Deploying ${fileCount} file(s) from ${folderPath}`);
254
- info(`Target: ${url}`);
255
-
256
- // Perform actual upload
257
- try {
258
- // Get next version number for this subdomain (try API first, fallback to local)
259
- const versionSpinner = spinner('Fetching version info...');
260
- let version = await getNextVersionFromAPI(subdomain);
261
- if (version === null) {
262
- version = await getNextVersion(subdomain);
263
- }
264
- versionSpinner.succeed(`Deploying as version ${version}`);
265
-
266
- // Upload all files via API proxy
267
- const folderName = basename(folderPath);
268
- const uploadSpinner = spinner(`Uploading files... 0/${fileCount}`);
269
-
270
- const { totalBytes } = await uploadFolder(folderPath, subdomain, version, (uploaded, total, fileName) => {
271
- uploadSpinner.update(`Uploading files... ${uploaded}/${total} (${fileName})`);
272
- });
273
-
274
- uploadSpinner.succeed(`Uploaded ${fileCount} files (${formatSize(totalBytes)})`);
275
-
276
- // Finalize upload: set active version and record metadata
277
- const finalizeSpinner = spinner('Finalizing deployment...');
278
- await finalizeUpload(
279
- subdomain,
280
- version,
281
- fileCount,
282
- totalBytes,
283
- folderName,
284
- expiresAt?.toISOString() || null,
285
- options.message
286
- );
287
- finalizeSpinner.succeed('Deployment finalized');
288
-
289
- // Save locally for quick access
290
- await saveLocalDeployment({
291
- subdomain,
292
- folderName,
293
- fileCount,
294
- totalBytes,
295
- version,
296
- timestamp: new Date().toISOString(),
297
- expiresAt: expiresAt?.toISOString() || null,
298
- });
299
-
300
- success(`Deployed successfully! (v${version})`);
301
- console.log(`\n${url}`);
302
-
303
- if (options.open) {
304
- const platform = process.platform;
305
- let cmd;
306
- if (platform === 'darwin') cmd = `open "${url}"`;
307
- else if (platform === 'win32') cmd = `start "" "${url}"`;
308
- else cmd = `xdg-open "${url}"`;
309
-
310
- exec(cmd);
311
- }
312
-
313
- if (expiresAt) {
314
- warning(`Expires: ${formatTimeRemaining(expiresAt)}`);
315
- }
316
-
317
- // Show anonymous limit warnings
318
- if (!creds?.email) {
319
- console.log('');
320
- warning('Anonymous deployment limits:');
321
- console.log(' • 3 active sites per IP');
322
- console.log(' • 50MB total storage');
323
- console.log(' • 7-day site expiration');
324
- console.log('');
325
- info('Run "launchpd register" to unlock unlimited sites and permanent storage!');
326
- }
327
- console.log('');
328
- } catch (err) {
329
- const suggestions = [];
330
-
331
- // Provide context-specific suggestions
332
- if (err.message.includes('fetch failed') || err.message.includes('ENOTFOUND')) {
333
- suggestions.push('Check your internet connection');
334
- suggestions.push('The API server may be temporarily unavailable');
335
- } else if (err.message.includes('401') || err.message.includes('Unauthorized')) {
336
- suggestions.push('Run "launchpd login" to authenticate');
337
- suggestions.push('Your API key may have expired');
338
- } else if (err.message.includes('413') || err.message.includes('too large')) {
339
- suggestions.push('Try deploying fewer or smaller files');
340
- suggestions.push('Check your storage quota with "launchpd quota"');
341
- } else if (err.message.includes('429') || err.message.includes('rate limit')) {
342
- suggestions.push('Wait a few minutes and try again');
343
- suggestions.push('You may be deploying too frequently');
344
- } else {
345
- suggestions.push('Try running with --verbose for more details');
346
- suggestions.push('Check https://status.launchpd.cloud for service status');
347
- }
348
-
349
- errorWithSuggestions(`Upload failed: ${err.message}`, suggestions, { verbose, cause: err });
350
- process.exit(1);
351
- }
568
+ errorWithSuggestions(`Upload failed: ${err.message}`, suggestions, {
569
+ verbose,
570
+ cause: err
571
+ })
572
+ process.exit(1)
573
+ }
352
574
  }