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.
- package/LICENSE +21 -21
- package/README.md +60 -39
- package/bin/cli.js +147 -111
- package/bin/setup.js +42 -40
- package/package.json +6 -4
- package/src/commands/auth.js +503 -293
- package/src/commands/deploy.js +533 -311
- package/src/commands/index.js +14 -8
- 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 -14
- package/src/utils/api.js +211 -111
- package/src/utils/credentials.js +81 -85
- package/src/utils/endpoint.js +58 -0
- package/src/utils/errors.js +127 -0
- 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 -10
- package/src/utils/localConfig.js +39 -43
- package/src/utils/logger.js +130 -102
- package/src/utils/machineId.js +15 -18
- package/src/utils/metadata.js +113 -87
- package/src/utils/projectConfig.js +48 -44
- package/src/utils/prompt.js +87 -68
- package/src/utils/quota.js +257 -203
- package/src/utils/upload.js +175 -133
- package/src/utils/validator.js +116 -68
package/src/commands/deploy.js
CHANGED
|
@@ -1,51 +1,87 @@
|
|
|
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 {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
289
|
+
// If strictly new, it's available
|
|
290
|
+
checkSpinner.succeed(`Subdomain "${subdomain}" is available`)
|
|
146
291
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
//
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
}
|