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.
- package/LICENSE +21 -21
- package/README.md +61 -39
- package/bin/cli.js +149 -124
- package/bin/setup.js +42 -40
- package/package.json +4 -4
- package/src/commands/auth.js +516 -522
- package/src/commands/deploy.js +745 -386
- package/src/commands/index.js +14 -7
- package/src/commands/init.js +95 -72
- package/src/commands/list.js +120 -122
- package/src/commands/rollback.js +139 -102
- package/src/commands/status.js +75 -51
- package/src/commands/versions.js +153 -113
- package/src/config.js +32 -31
- package/src/utils/api.js +220 -195
- package/src/utils/credentials.js +88 -85
- package/src/utils/endpoint.js +58 -0
- package/src/utils/errors.js +79 -69
- package/src/utils/expiration.js +49 -47
- package/src/utils/id.js +5 -5
- package/src/utils/ignore.js +35 -36
- package/src/utils/index.js +10 -11
- package/src/utils/localConfig.js +39 -43
- package/src/utils/logger.js +113 -106
- package/src/utils/machineId.js +15 -19
- package/src/utils/metadata.js +113 -87
- package/src/utils/projectConfig.js +48 -45
- package/src/utils/prompt.js +91 -82
- package/src/utils/quota.js +261 -225
- package/src/utils/remoteSource.js +680 -0
- package/src/utils/upload.js +197 -127
- package/src/utils/validator.js +116 -68
package/src/commands/deploy.js
CHANGED
|
@@ -1,426 +1,785 @@
|
|
|
1
|
-
import { existsSync, statSync } from 'node:fs'
|
|
2
|
-
import {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
105
|
+
return totalSize
|
|
51
106
|
}
|
|
52
107
|
|
|
53
108
|
/**
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
229
|
-
const sizeSpinner = spinner('Calculating folder size...')
|
|
230
|
-
const estimatedBytes = await calculateFolderSize(folderPath)
|
|
231
|
-
sizeSpinner.succeed(`Size: ${
|
|
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
|
-
|
|
254
|
-
|
|
747
|
+
await checkDeploymentQuota(
|
|
748
|
+
subdomain,
|
|
749
|
+
estimatedBytes,
|
|
750
|
+
configSubdomain,
|
|
751
|
+
options
|
|
752
|
+
)
|
|
255
753
|
|
|
256
|
-
// Show
|
|
754
|
+
// Show deployment info
|
|
257
755
|
if (creds?.email) {
|
|
258
|
-
|
|
756
|
+
info(`Deploying as: ${creds.email}`)
|
|
259
757
|
} else {
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
info(`Target: ${url}`);
|
|
265
|
-
|
|
266
|
-
// Perform actual upload
|
|
764
|
+
// Perform upload
|
|
267
765
|
try {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
}
|