ship2-cli 0.1.0 → 0.2.1
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/bin/ship2.js +3 -1
- package/commands/deploy.js +409 -90
- package/lib/files.js +149 -3
- package/package.json +2 -2
package/bin/ship2.js
CHANGED
|
@@ -17,7 +17,7 @@ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'))
|
|
|
17
17
|
|
|
18
18
|
program
|
|
19
19
|
.name('ship2')
|
|
20
|
-
.description('Deploy static sites to ship2.app in seconds')
|
|
20
|
+
.description('Deploy static sites and Next.js apps to ship2.app in seconds')
|
|
21
21
|
.version(pkg.version)
|
|
22
22
|
|
|
23
23
|
program
|
|
@@ -31,6 +31,8 @@ program
|
|
|
31
31
|
.command('deploy [path]')
|
|
32
32
|
.description('Deploy a file or directory to ship2.app')
|
|
33
33
|
.option('-n, --name <name>', 'Project name (also used as subdomain)')
|
|
34
|
+
.option('-t, --type <type>', 'Project type: static, nextjs, or auto (default: auto)')
|
|
35
|
+
.option('-e, --env <KEY=VALUE>', 'Environment variable (can be used multiple times)', (val, prev) => prev ? [...prev, val] : [val])
|
|
34
36
|
.option('--private', 'Create a private deployment')
|
|
35
37
|
.option('--ttl <hours>', 'Link expiration time in hours (private only)')
|
|
36
38
|
.option('--message <text>', 'Deployment message/note')
|
package/commands/deploy.js
CHANGED
|
@@ -4,18 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
import { readFileSync, existsSync } from 'fs'
|
|
6
6
|
import { resolve, basename, extname } from 'path'
|
|
7
|
+
import { createInterface } from 'readline'
|
|
7
8
|
import chalk from 'chalk'
|
|
8
9
|
import ora from 'ora'
|
|
9
10
|
import { isLoggedIn, getToken, getApiBase } from '../lib/config.js'
|
|
10
11
|
import {
|
|
11
12
|
detectPathType,
|
|
12
13
|
collectFiles,
|
|
14
|
+
collectNextjsFiles,
|
|
13
15
|
validateDirectory,
|
|
14
16
|
validateReferences,
|
|
15
17
|
inferProjectName,
|
|
16
18
|
sanitizeProjectName,
|
|
17
19
|
formatSize,
|
|
18
20
|
detectBuildOutput,
|
|
21
|
+
detectNextjsProject,
|
|
22
|
+
hasApiRoutes,
|
|
19
23
|
ValidationError
|
|
20
24
|
} from '../lib/files.js'
|
|
21
25
|
import * as output from '../lib/output.js'
|
|
@@ -41,6 +45,129 @@ function parseTTL(ttl) {
|
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Prompt user for input
|
|
50
|
+
*/
|
|
51
|
+
function prompt(question) {
|
|
52
|
+
const rl = createInterface({
|
|
53
|
+
input: process.stdin,
|
|
54
|
+
output: process.stdout
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
rl.question(question, (answer) => {
|
|
59
|
+
rl.close()
|
|
60
|
+
resolve(answer.trim())
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if subdomain is available
|
|
67
|
+
*/
|
|
68
|
+
async function checkSubdomain(subdomain, token, apiBase) {
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch(`${apiBase}/api/check-subdomain?subdomain=${encodeURIComponent(subdomain)}`, {
|
|
71
|
+
headers: {
|
|
72
|
+
'Authorization': `Bearer ${token}`
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
const data = await response.json()
|
|
76
|
+
|
|
77
|
+
// Handle unauthorized - token expired
|
|
78
|
+
if (response.status === 401 || data.error === 'unauthorized') {
|
|
79
|
+
return { error: 'unauthorized', authError: true }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return data
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return { error: err.message }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Interactive subdomain selection
|
|
90
|
+
*/
|
|
91
|
+
async function selectSubdomain(suggestedName, token, apiBase, isJson) {
|
|
92
|
+
// First check if suggested name is available
|
|
93
|
+
const check = await checkSubdomain(suggestedName, token, apiBase)
|
|
94
|
+
|
|
95
|
+
// Handle auth error
|
|
96
|
+
if (check.authError) {
|
|
97
|
+
console.log(chalk.red('\n Session expired. Please login again.'))
|
|
98
|
+
console.log(chalk.dim(' Run: ship2 login\n'))
|
|
99
|
+
process.exit(1)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (check.available) {
|
|
103
|
+
if (check.owned) {
|
|
104
|
+
// User owns this subdomain, will update
|
|
105
|
+
if (!isJson) {
|
|
106
|
+
console.log(chalk.dim(` Domain: ${chalk.cyan(suggestedName + '.ship2.app')} (will update existing)`))
|
|
107
|
+
}
|
|
108
|
+
return suggestedName
|
|
109
|
+
} else {
|
|
110
|
+
// Available, ask if user wants to use it
|
|
111
|
+
if (!isJson) {
|
|
112
|
+
console.log(chalk.dim(` Suggested domain: ${chalk.cyan(suggestedName + '.ship2.app')}`))
|
|
113
|
+
}
|
|
114
|
+
const useDefault = await prompt(` Use this domain? [Y/n]: `)
|
|
115
|
+
if (!useDefault || useDefault.toLowerCase() === 'y' || useDefault.toLowerCase() === 'yes') {
|
|
116
|
+
return suggestedName
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
// Not available, inform user
|
|
121
|
+
if (!isJson) {
|
|
122
|
+
console.log(chalk.yellow(` Domain "${suggestedName}.ship2.app" is not available.`))
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Let user enter custom subdomain
|
|
127
|
+
while (true) {
|
|
128
|
+
const customName = await prompt(` Enter subdomain (3-63 chars, lowercase, hyphens ok): `)
|
|
129
|
+
|
|
130
|
+
if (!customName) {
|
|
131
|
+
console.log(chalk.red(' Subdomain is required.'))
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const sanitized = sanitizeProjectName(customName)
|
|
136
|
+
if (sanitized.length < 3) {
|
|
137
|
+
console.log(chalk.red(' Subdomain must be at least 3 characters.'))
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check availability
|
|
142
|
+
const spinner = ora('Checking availability...').start()
|
|
143
|
+
const checkResult = await checkSubdomain(sanitized, token, apiBase)
|
|
144
|
+
spinner.stop()
|
|
145
|
+
|
|
146
|
+
// Handle auth error
|
|
147
|
+
if (checkResult.authError) {
|
|
148
|
+
console.log(chalk.red('\n Session expired. Please login again.'))
|
|
149
|
+
console.log(chalk.dim(' Run: ship2 login\n'))
|
|
150
|
+
process.exit(1)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (checkResult.error) {
|
|
154
|
+
console.log(chalk.red(` Error: ${checkResult.error}`))
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (checkResult.available) {
|
|
159
|
+
if (checkResult.owned) {
|
|
160
|
+
console.log(chalk.dim(` Will update existing project at ${chalk.cyan(sanitized + '.ship2.app')}`))
|
|
161
|
+
} else {
|
|
162
|
+
console.log(chalk.green(` Domain ${chalk.cyan(sanitized + '.ship2.app')} is available!`))
|
|
163
|
+
}
|
|
164
|
+
return sanitized
|
|
165
|
+
} else {
|
|
166
|
+
console.log(chalk.yellow(` Domain "${sanitized}.ship2.app" is already taken. Try another name.`))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
44
171
|
export default async function deploy(inputPath, options) {
|
|
45
172
|
// Default to current directory
|
|
46
173
|
const targetPath = resolve(inputPath || '.')
|
|
@@ -57,6 +184,24 @@ export default async function deploy(inputPath, options) {
|
|
|
57
184
|
// Detect path type
|
|
58
185
|
const pathInfo = detectPathType(targetPath)
|
|
59
186
|
|
|
187
|
+
// Detect project type (auto-detect or from --type flag)
|
|
188
|
+
let projectType = options.type || 'auto'
|
|
189
|
+
if (projectType === 'auto' && pathInfo.type === 'directory') {
|
|
190
|
+
if (detectNextjsProject(targetPath)) {
|
|
191
|
+
projectType = 'nextjs'
|
|
192
|
+
if (!isJson) {
|
|
193
|
+
const hasApi = hasApiRoutes(targetPath)
|
|
194
|
+
if (hasApi) {
|
|
195
|
+
console.log(chalk.cyan(' Detected: Next.js project with API routes'))
|
|
196
|
+
} else {
|
|
197
|
+
console.log(chalk.cyan(' Detected: Next.js project'))
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
projectType = 'static'
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
60
205
|
if (pathInfo.type === 'not_found') {
|
|
61
206
|
output.deployError({
|
|
62
207
|
code: 'NOT_FOUND',
|
|
@@ -75,21 +220,98 @@ export default async function deploy(inputPath, options) {
|
|
|
75
220
|
process.exit(1)
|
|
76
221
|
}
|
|
77
222
|
|
|
78
|
-
// Infer project name
|
|
79
|
-
let
|
|
80
|
-
|
|
223
|
+
// Infer project name from directory/file
|
|
224
|
+
let suggestedName = inferProjectName(targetPath)
|
|
225
|
+
suggestedName = sanitizeProjectName(suggestedName)
|
|
81
226
|
|
|
82
|
-
if (!
|
|
83
|
-
|
|
227
|
+
if (!suggestedName || suggestedName.length < 3) {
|
|
228
|
+
suggestedName = 'my-site'
|
|
84
229
|
}
|
|
85
230
|
|
|
86
|
-
|
|
231
|
+
const apiBase = getApiBase()
|
|
232
|
+
const token = getToken()
|
|
233
|
+
|
|
234
|
+
// Determine subdomain
|
|
235
|
+
let projectName
|
|
236
|
+
|
|
237
|
+
if (options.name) {
|
|
238
|
+
// User specified name via --name flag
|
|
239
|
+
const sanitized = sanitizeProjectName(options.name)
|
|
240
|
+
if (sanitized.length < 3) {
|
|
241
|
+
output.deployError({
|
|
242
|
+
code: 'INVALID_NAME',
|
|
243
|
+
message: 'Project name must be at least 3 characters.',
|
|
244
|
+
suggestion: 'Use a longer name with lowercase letters, numbers, and hyphens.'
|
|
245
|
+
}, isJson)
|
|
246
|
+
process.exit(1)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Check if available
|
|
250
|
+
const check = await checkSubdomain(sanitized, token, apiBase)
|
|
251
|
+
|
|
252
|
+
// Handle auth error first
|
|
253
|
+
if (check.authError) {
|
|
254
|
+
output.deployError({
|
|
255
|
+
code: 'UNAUTHORIZED',
|
|
256
|
+
message: 'Session expired. Please login again.',
|
|
257
|
+
suggestion: 'Run: ship2 login'
|
|
258
|
+
}, isJson)
|
|
259
|
+
process.exit(1)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!check.available && !check.owned) {
|
|
263
|
+
output.deployError({
|
|
264
|
+
code: 'NAME_TAKEN',
|
|
265
|
+
message: `Domain "${sanitized}.ship2.app" is already taken.`,
|
|
266
|
+
suggestion: 'Try a different name with --name flag.'
|
|
267
|
+
}, isJson)
|
|
268
|
+
process.exit(1)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
projectName = sanitized
|
|
272
|
+
if (!isJson) {
|
|
273
|
+
if (check.owned) {
|
|
274
|
+
console.log()
|
|
275
|
+
output.info(`Updating: ${chalk.cyan(projectName + '.ship2.app')}`)
|
|
276
|
+
} else {
|
|
277
|
+
console.log()
|
|
278
|
+
output.info(`Deploying to: ${chalk.cyan(projectName + '.ship2.app')}`)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} else if (!isJson) {
|
|
282
|
+
// Interactive mode: ask user to confirm/change subdomain
|
|
87
283
|
console.log()
|
|
88
284
|
if (isPrivate) {
|
|
89
|
-
output.info(`Creating private link
|
|
285
|
+
output.info(`Creating private link`)
|
|
286
|
+
projectName = suggestedName
|
|
90
287
|
} else {
|
|
91
|
-
output.info(`
|
|
288
|
+
output.info(`Setting up deployment`)
|
|
289
|
+
projectName = await selectSubdomain(suggestedName, token, apiBase, isJson)
|
|
290
|
+
}
|
|
291
|
+
console.log()
|
|
292
|
+
} else {
|
|
293
|
+
// JSON mode without --name: use suggested name, check availability
|
|
294
|
+
const check = await checkSubdomain(suggestedName, token, apiBase)
|
|
295
|
+
|
|
296
|
+
// Handle auth error first
|
|
297
|
+
if (check.authError) {
|
|
298
|
+
output.deployError({
|
|
299
|
+
code: 'UNAUTHORIZED',
|
|
300
|
+
message: 'Session expired. Please login again.',
|
|
301
|
+
suggestion: 'Run: ship2 login'
|
|
302
|
+
}, isJson)
|
|
303
|
+
process.exit(1)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!check.available && !check.owned) {
|
|
307
|
+
output.deployError({
|
|
308
|
+
code: 'NAME_TAKEN',
|
|
309
|
+
message: `Domain "${suggestedName}.ship2.app" is already taken.`,
|
|
310
|
+
suggestion: 'Specify a custom name with --name flag.'
|
|
311
|
+
}, isJson)
|
|
312
|
+
process.exit(1)
|
|
92
313
|
}
|
|
314
|
+
projectName = suggestedName
|
|
93
315
|
}
|
|
94
316
|
|
|
95
317
|
let files = []
|
|
@@ -123,79 +345,129 @@ export default async function deploy(inputPath, options) {
|
|
|
123
345
|
|
|
124
346
|
// Handle directory
|
|
125
347
|
if (pathInfo.type === 'directory') {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
348
|
+
// Next.js project deployment
|
|
349
|
+
if (projectType === 'nextjs') {
|
|
350
|
+
if (!isJson) {
|
|
351
|
+
output.info('Mode: Next.js Full-Stack')
|
|
352
|
+
}
|
|
129
353
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
354
|
+
// Collect Next.js project files
|
|
355
|
+
const spinner = isJson ? null : ora('Scanning Next.js project...').start()
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const result = await collectNextjsFiles(targetPath)
|
|
359
|
+
|
|
360
|
+
if (result.errors.length > 0) {
|
|
361
|
+
if (spinner) spinner.fail('Validation failed')
|
|
362
|
+
|
|
363
|
+
for (const error of result.errors) {
|
|
364
|
+
if (error.type === ValidationError.FILE_TOO_LARGE) {
|
|
365
|
+
output.deployError({
|
|
366
|
+
code: 'FILE_TOO_LARGE',
|
|
367
|
+
message: `File too large: ${error.file} (${formatSize(error.size)}, limit: ${formatSize(error.maxSize)})`,
|
|
368
|
+
suggestion: 'Compress or remove the file.'
|
|
369
|
+
}, isJson)
|
|
370
|
+
} else if (error.type === ValidationError.TOTAL_TOO_LARGE) {
|
|
371
|
+
output.deployError({
|
|
372
|
+
code: 'TOTAL_TOO_LARGE',
|
|
373
|
+
message: `Total size too large: ${formatSize(error.totalSize)} (limit: ${formatSize(error.maxSize)})`,
|
|
374
|
+
suggestion: 'Remove unnecessary files or compress assets.'
|
|
375
|
+
}, isJson)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
process.exit(1)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
files = result.files
|
|
382
|
+
totalSize = result.totalSize
|
|
383
|
+
|
|
384
|
+
if (spinner) {
|
|
385
|
+
spinner.succeed(`Scanned: ${files.length} files, ${formatSize(totalSize)}`)
|
|
386
|
+
}
|
|
387
|
+
} catch (err) {
|
|
388
|
+
if (spinner) spinner.fail('Scan failed')
|
|
142
389
|
output.deployError({
|
|
143
|
-
code: '
|
|
144
|
-
message:
|
|
145
|
-
suggestion: 'Run your build command first, then deploy the output directory.\nExample: npm run build && ship2 deploy ./dist'
|
|
390
|
+
code: 'SCAN_FAILED',
|
|
391
|
+
message: err.message
|
|
146
392
|
}, isJson)
|
|
393
|
+
process.exit(1)
|
|
394
|
+
}
|
|
395
|
+
} else {
|
|
396
|
+
// Static project deployment (original logic)
|
|
397
|
+
if (!isJson) {
|
|
398
|
+
output.info('Mode: Static')
|
|
147
399
|
}
|
|
148
|
-
process.exit(1)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Validate references
|
|
152
|
-
const indexContent = readFileSync(resolve(targetPath, 'index.html'), 'utf8')
|
|
153
|
-
const refValidation = validateReferences(targetPath, indexContent)
|
|
154
|
-
if (!refValidation.valid) {
|
|
155
|
-
output.deployError(refValidation.error, isJson)
|
|
156
|
-
process.exit(1)
|
|
157
|
-
}
|
|
158
400
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
output.deployError({
|
|
177
|
-
code: 'TOTAL_TOO_LARGE',
|
|
178
|
-
message: `Total size too large: ${formatSize(error.totalSize)} (limit: ${formatSize(error.maxSize)})`,
|
|
179
|
-
suggestion: 'Remove unnecessary files or compress assets.'
|
|
180
|
-
}, isJson)
|
|
181
|
-
}
|
|
401
|
+
// Validate directory has index.html
|
|
402
|
+
const dirValidation = validateDirectory(targetPath)
|
|
403
|
+
if (!dirValidation.valid) {
|
|
404
|
+
// Check for build output directories
|
|
405
|
+
const buildDir = detectBuildOutput(targetPath)
|
|
406
|
+
if (buildDir) {
|
|
407
|
+
output.deployError({
|
|
408
|
+
code: 'MISSING_INDEX',
|
|
409
|
+
message: 'No index.html found in current directory.',
|
|
410
|
+
suggestion: `Detected build output. Try: ship2 deploy ${buildDir}`
|
|
411
|
+
}, isJson)
|
|
412
|
+
} else {
|
|
413
|
+
output.deployError({
|
|
414
|
+
code: 'MISSING_INDEX',
|
|
415
|
+
message: 'No index.html found.',
|
|
416
|
+
suggestion: 'Run your build command first, then deploy the output directory.\nExample: npm run build && ship2 deploy ./dist'
|
|
417
|
+
}, isJson)
|
|
182
418
|
}
|
|
183
419
|
process.exit(1)
|
|
184
420
|
}
|
|
185
421
|
|
|
186
|
-
|
|
187
|
-
|
|
422
|
+
// Validate references
|
|
423
|
+
const indexContent = readFileSync(resolve(targetPath, 'index.html'), 'utf8')
|
|
424
|
+
const refValidation = validateReferences(targetPath, indexContent)
|
|
425
|
+
if (!refValidation.valid) {
|
|
426
|
+
output.deployError(refValidation.error, isJson)
|
|
427
|
+
process.exit(1)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Collect files
|
|
431
|
+
const spinner = isJson ? null : ora('Scanning files...').start()
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
const result = await collectFiles(targetPath)
|
|
435
|
+
|
|
436
|
+
if (result.errors.length > 0) {
|
|
437
|
+
if (spinner) spinner.fail('Validation failed')
|
|
438
|
+
|
|
439
|
+
for (const error of result.errors) {
|
|
440
|
+
if (error.type === ValidationError.FILE_TOO_LARGE) {
|
|
441
|
+
output.deployError({
|
|
442
|
+
code: 'FILE_TOO_LARGE',
|
|
443
|
+
message: `File too large: ${error.file} (${formatSize(error.size)}, limit: ${formatSize(error.maxSize)})`,
|
|
444
|
+
suggestion: 'Compress or remove the file.'
|
|
445
|
+
}, isJson)
|
|
446
|
+
} else if (error.type === ValidationError.TOTAL_TOO_LARGE) {
|
|
447
|
+
output.deployError({
|
|
448
|
+
code: 'TOTAL_TOO_LARGE',
|
|
449
|
+
message: `Total size too large: ${formatSize(error.totalSize)} (limit: ${formatSize(error.maxSize)})`,
|
|
450
|
+
suggestion: 'Remove unnecessary files or compress assets.'
|
|
451
|
+
}, isJson)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
process.exit(1)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
files = result.files
|
|
458
|
+
totalSize = result.totalSize
|
|
188
459
|
|
|
189
|
-
|
|
190
|
-
|
|
460
|
+
if (spinner) {
|
|
461
|
+
spinner.succeed(`Scanned: ${files.length} files, ${formatSize(totalSize)}`)
|
|
462
|
+
}
|
|
463
|
+
} catch (err) {
|
|
464
|
+
if (spinner) spinner.fail('Scan failed')
|
|
465
|
+
output.deployError({
|
|
466
|
+
code: 'SCAN_FAILED',
|
|
467
|
+
message: err.message
|
|
468
|
+
}, isJson)
|
|
469
|
+
process.exit(1)
|
|
191
470
|
}
|
|
192
|
-
} catch (err) {
|
|
193
|
-
if (spinner) spinner.fail('Scan failed')
|
|
194
|
-
output.deployError({
|
|
195
|
-
code: 'SCAN_FAILED',
|
|
196
|
-
message: err.message
|
|
197
|
-
}, isJson)
|
|
198
|
-
process.exit(1)
|
|
199
471
|
}
|
|
200
472
|
}
|
|
201
473
|
|
|
@@ -203,9 +475,6 @@ export default async function deploy(inputPath, options) {
|
|
|
203
475
|
const deploySpinner = isJson ? null : ora(isPrivate ? 'Creating private link...' : 'Deploying...').start()
|
|
204
476
|
|
|
205
477
|
try {
|
|
206
|
-
const apiBase = getApiBase()
|
|
207
|
-
const token = getToken()
|
|
208
|
-
|
|
209
478
|
// Private deployment
|
|
210
479
|
if (isPrivate) {
|
|
211
480
|
const ttlHours = parseTTL(options.ttl)
|
|
@@ -246,23 +515,66 @@ export default async function deploy(inputPath, options) {
|
|
|
246
515
|
}
|
|
247
516
|
|
|
248
517
|
// Public deployment
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
518
|
+
let response
|
|
519
|
+
let apiEndpoint
|
|
520
|
+
|
|
521
|
+
if (projectType === 'nextjs') {
|
|
522
|
+
// Next.js full-stack deployment
|
|
523
|
+
apiEndpoint = `${apiBase}/api/deploy-nextjs`
|
|
524
|
+
|
|
525
|
+
// Parse environment variables from --env flags
|
|
526
|
+
const envVars = {}
|
|
527
|
+
if (options.env) {
|
|
528
|
+
const envList = Array.isArray(options.env) ? options.env : [options.env]
|
|
529
|
+
for (const envStr of envList) {
|
|
530
|
+
const eqIndex = envStr.indexOf('=')
|
|
531
|
+
if (eqIndex > 0) {
|
|
532
|
+
const key = envStr.slice(0, eqIndex)
|
|
533
|
+
const value = envStr.slice(eqIndex + 1)
|
|
534
|
+
envVars[key] = value
|
|
535
|
+
}
|
|
263
536
|
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
response = await fetch(apiEndpoint, {
|
|
540
|
+
method: 'POST',
|
|
541
|
+
headers: {
|
|
542
|
+
'Content-Type': 'application/json',
|
|
543
|
+
'Authorization': `Bearer ${token}`
|
|
544
|
+
},
|
|
545
|
+
body: JSON.stringify({
|
|
546
|
+
files: files.map(f => ({ path: f.path, content: f.content, encoding: f.encoding })),
|
|
547
|
+
projectName,
|
|
548
|
+
subdomain: projectName,
|
|
549
|
+
env: envVars,
|
|
550
|
+
meta: {
|
|
551
|
+
title: projectName,
|
|
552
|
+
platform: 'CLI',
|
|
553
|
+
message: options.message || ''
|
|
554
|
+
}
|
|
555
|
+
})
|
|
264
556
|
})
|
|
265
|
-
}
|
|
557
|
+
} else {
|
|
558
|
+
// Static deployment (original)
|
|
559
|
+
apiEndpoint = `${apiBase}/api/publish-multi`
|
|
560
|
+
response = await fetch(apiEndpoint, {
|
|
561
|
+
method: 'POST',
|
|
562
|
+
headers: {
|
|
563
|
+
'Content-Type': 'application/json',
|
|
564
|
+
'Authorization': `Bearer ${token}`
|
|
565
|
+
},
|
|
566
|
+
body: JSON.stringify({
|
|
567
|
+
files: files.map(f => ({ path: f.path, content: f.content })),
|
|
568
|
+
projectName,
|
|
569
|
+
subdomain: projectName,
|
|
570
|
+
meta: {
|
|
571
|
+
title: projectName,
|
|
572
|
+
platform: 'CLI',
|
|
573
|
+
message: options.message || ''
|
|
574
|
+
}
|
|
575
|
+
})
|
|
576
|
+
})
|
|
577
|
+
}
|
|
266
578
|
|
|
267
579
|
const data = await response.json()
|
|
268
580
|
|
|
@@ -275,12 +587,19 @@ export default async function deploy(inputPath, options) {
|
|
|
275
587
|
process.exit(1)
|
|
276
588
|
}
|
|
277
589
|
|
|
278
|
-
if (deploySpinner)
|
|
590
|
+
if (deploySpinner) {
|
|
591
|
+
if (projectType === 'nextjs') {
|
|
592
|
+
deploySpinner.succeed('Deployed (Vercel is building...)')
|
|
593
|
+
} else {
|
|
594
|
+
deploySpinner.succeed('Deployed')
|
|
595
|
+
}
|
|
596
|
+
}
|
|
279
597
|
|
|
280
598
|
output.deployResult({
|
|
281
599
|
...data,
|
|
282
600
|
projectName,
|
|
283
|
-
totalSize
|
|
601
|
+
totalSize,
|
|
602
|
+
projectType
|
|
284
603
|
}, isJson)
|
|
285
604
|
|
|
286
605
|
} catch (err) {
|
package/lib/files.js
CHANGED
|
@@ -10,11 +10,26 @@ import mime from 'mime-types'
|
|
|
10
10
|
// File size limits
|
|
11
11
|
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB per file
|
|
12
12
|
const MAX_TOTAL_SIZE = 10 * 1024 * 1024 // 10MB total (Free tier)
|
|
13
|
+
const MAX_TOTAL_SIZE_NEXTJS = 50 * 1024 * 1024 // 50MB total for Next.js projects
|
|
13
14
|
|
|
14
15
|
// Common build output directories
|
|
15
16
|
const BUILD_DIRS = ['dist', 'build', 'out', 'public', '.next/static', '.output/public']
|
|
16
17
|
|
|
17
|
-
//
|
|
18
|
+
// Binary file extensions (use base64 encoding)
|
|
19
|
+
const BINARY_EXTENSIONS = new Set([
|
|
20
|
+
// Images
|
|
21
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.svg', '.avif',
|
|
22
|
+
// Fonts
|
|
23
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
|
24
|
+
// Audio/Video
|
|
25
|
+
'.mp3', '.mp4', '.webm', '.ogg', '.wav', '.m4a',
|
|
26
|
+
// Archives
|
|
27
|
+
'.zip', '.gz', '.tar', '.rar',
|
|
28
|
+
// Other binary
|
|
29
|
+
'.pdf', '.wasm', '.bin'
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
// Ignore patterns for static projects
|
|
18
33
|
const IGNORE_PATTERNS = [
|
|
19
34
|
'node_modules/**',
|
|
20
35
|
'.git/**',
|
|
@@ -26,6 +41,27 @@ const IGNORE_PATTERNS = [
|
|
|
26
41
|
'.vscode/**'
|
|
27
42
|
]
|
|
28
43
|
|
|
44
|
+
// Ignore patterns for Next.js projects
|
|
45
|
+
const NEXTJS_IGNORE_PATTERNS = [
|
|
46
|
+
'node_modules/**',
|
|
47
|
+
'.next/**',
|
|
48
|
+
'.git/**',
|
|
49
|
+
'.DS_Store',
|
|
50
|
+
'Thumbs.db',
|
|
51
|
+
'*.log',
|
|
52
|
+
'.env',
|
|
53
|
+
'.env.local',
|
|
54
|
+
'.env.*.local',
|
|
55
|
+
'.vercel/**',
|
|
56
|
+
'coverage/**',
|
|
57
|
+
'.nyc_output/**',
|
|
58
|
+
'.idea/**',
|
|
59
|
+
'.vscode/**',
|
|
60
|
+
'out/**',
|
|
61
|
+
'dist/**',
|
|
62
|
+
'build/**'
|
|
63
|
+
]
|
|
64
|
+
|
|
29
65
|
/**
|
|
30
66
|
* Validation error types
|
|
31
67
|
*/
|
|
@@ -110,12 +146,24 @@ export async function collectFiles(dirPath) {
|
|
|
110
146
|
|
|
111
147
|
totalSize += size
|
|
112
148
|
|
|
113
|
-
// Read file content
|
|
114
|
-
const
|
|
149
|
+
// Read file content - use base64 for binary files
|
|
150
|
+
const ext = extname(file).toLowerCase()
|
|
151
|
+
const isBinary = BINARY_EXTENSIONS.has(ext)
|
|
152
|
+
|
|
153
|
+
let content
|
|
154
|
+
let encoding = 'utf8'
|
|
155
|
+
|
|
156
|
+
if (isBinary) {
|
|
157
|
+
content = readFileSync(fullPath).toString('base64')
|
|
158
|
+
encoding = 'base64'
|
|
159
|
+
} else {
|
|
160
|
+
content = readFileSync(fullPath, 'utf8')
|
|
161
|
+
}
|
|
115
162
|
|
|
116
163
|
result.push({
|
|
117
164
|
path: file,
|
|
118
165
|
content,
|
|
166
|
+
encoding,
|
|
119
167
|
size
|
|
120
168
|
})
|
|
121
169
|
}
|
|
@@ -253,3 +301,101 @@ export function formatSize(bytes) {
|
|
|
253
301
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
254
302
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
|
255
303
|
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Detect if directory is a Next.js project
|
|
307
|
+
*/
|
|
308
|
+
export function detectNextjsProject(dirPath) {
|
|
309
|
+
const pkgPath = join(dirPath, 'package.json')
|
|
310
|
+
if (!existsSync(pkgPath)) return false
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
314
|
+
return !!(pkg.dependencies?.next || pkg.devDependencies?.next)
|
|
315
|
+
} catch {
|
|
316
|
+
return false
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Check if Next.js project has API routes
|
|
322
|
+
*/
|
|
323
|
+
export function hasApiRoutes(dirPath) {
|
|
324
|
+
// App Router API routes
|
|
325
|
+
const appApiDir = join(dirPath, 'app', 'api')
|
|
326
|
+
// Pages Router API routes
|
|
327
|
+
const pagesApiDir = join(dirPath, 'pages', 'api')
|
|
328
|
+
// Also check src directory
|
|
329
|
+
const srcAppApiDir = join(dirPath, 'src', 'app', 'api')
|
|
330
|
+
const srcPagesApiDir = join(dirPath, 'src', 'pages', 'api')
|
|
331
|
+
|
|
332
|
+
return existsSync(appApiDir) || existsSync(pagesApiDir) ||
|
|
333
|
+
existsSync(srcAppApiDir) || existsSync(srcPagesApiDir)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Collect files from a Next.js project
|
|
338
|
+
*/
|
|
339
|
+
export async function collectNextjsFiles(dirPath) {
|
|
340
|
+
const files = await glob('**/*', {
|
|
341
|
+
cwd: dirPath,
|
|
342
|
+
nodir: true,
|
|
343
|
+
ignore: NEXTJS_IGNORE_PATTERNS,
|
|
344
|
+
dot: false
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
const result = []
|
|
348
|
+
let totalSize = 0
|
|
349
|
+
const errors = []
|
|
350
|
+
|
|
351
|
+
for (const file of files) {
|
|
352
|
+
const fullPath = join(dirPath, file)
|
|
353
|
+
const stat = statSync(fullPath)
|
|
354
|
+
const size = stat.size
|
|
355
|
+
|
|
356
|
+
// Check single file size
|
|
357
|
+
if (size > MAX_FILE_SIZE) {
|
|
358
|
+
errors.push({
|
|
359
|
+
type: ValidationError.FILE_TOO_LARGE,
|
|
360
|
+
file,
|
|
361
|
+
size,
|
|
362
|
+
maxSize: MAX_FILE_SIZE
|
|
363
|
+
})
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
totalSize += size
|
|
368
|
+
|
|
369
|
+
// Read file content - use base64 for binary files
|
|
370
|
+
const ext = extname(file).toLowerCase()
|
|
371
|
+
const isBinary = BINARY_EXTENSIONS.has(ext)
|
|
372
|
+
|
|
373
|
+
let content
|
|
374
|
+
let encoding = 'utf8'
|
|
375
|
+
|
|
376
|
+
if (isBinary) {
|
|
377
|
+
content = readFileSync(fullPath).toString('base64')
|
|
378
|
+
encoding = 'base64'
|
|
379
|
+
} else {
|
|
380
|
+
content = readFileSync(fullPath, 'utf8')
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
result.push({
|
|
384
|
+
path: file,
|
|
385
|
+
content,
|
|
386
|
+
encoding,
|
|
387
|
+
size
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Check total size (use larger limit for Next.js)
|
|
392
|
+
if (totalSize > MAX_TOTAL_SIZE_NEXTJS) {
|
|
393
|
+
errors.push({
|
|
394
|
+
type: ValidationError.TOTAL_TOO_LARGE,
|
|
395
|
+
totalSize,
|
|
396
|
+
maxSize: MAX_TOTAL_SIZE_NEXTJS
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return { files: result, totalSize, errors }
|
|
401
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ship2-cli",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Deploy static sites to ship2.app in seconds",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Deploy static sites and Next.js apps to ship2.app in seconds",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"ship2": "./bin/ship2.js"
|