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 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')
@@ -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 projectName = options.name || inferProjectName(targetPath)
80
- projectName = sanitizeProjectName(projectName)
223
+ // Infer project name from directory/file
224
+ let suggestedName = inferProjectName(targetPath)
225
+ suggestedName = sanitizeProjectName(suggestedName)
81
226
 
82
- if (!projectName) {
83
- projectName = 'my-site'
227
+ if (!suggestedName || suggestedName.length < 3) {
228
+ suggestedName = 'my-site'
84
229
  }
85
230
 
86
- if (!isJson) {
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: ${chalk.cyan(projectName)}`)
285
+ output.info(`Creating private link`)
286
+ projectName = suggestedName
90
287
  } else {
91
- output.info(`Deploying: ${chalk.cyan(projectName)}`)
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
- if (!isJson) {
127
- output.info('Mode: Directory')
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
- // Validate directory has index.html
131
- const dirValidation = validateDirectory(targetPath)
132
- if (!dirValidation.valid) {
133
- // Check for build output directories
134
- const buildDir = detectBuildOutput(targetPath)
135
- if (buildDir) {
136
- output.deployError({
137
- code: 'MISSING_INDEX',
138
- message: 'No index.html found in current directory.',
139
- suggestion: `Detected build output. Try: ship2 deploy ${buildDir}`
140
- }, isJson)
141
- } else {
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: 'MISSING_INDEX',
144
- message: 'No index.html found.',
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
- // Collect files
160
- const spinner = isJson ? null : ora('Scanning files...').start()
161
-
162
- try {
163
- const result = await collectFiles(targetPath)
164
-
165
- if (result.errors.length > 0) {
166
- if (spinner) spinner.fail('Validation failed')
167
-
168
- for (const error of result.errors) {
169
- if (error.type === ValidationError.FILE_TOO_LARGE) {
170
- output.deployError({
171
- code: 'FILE_TOO_LARGE',
172
- message: `File too large: ${error.file} (${formatSize(error.size)}, limit: ${formatSize(error.maxSize)})`,
173
- suggestion: 'Compress or remove the file.'
174
- }, isJson)
175
- } else if (error.type === ValidationError.TOTAL_TOO_LARGE) {
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
- files = result.files
187
- totalSize = result.totalSize
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
- if (spinner) {
190
- spinner.succeed(`Scanned: ${files.length} files, ${formatSize(totalSize)}`)
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
- const response = await fetch(`${apiBase}/api/publish-multi`, {
250
- method: 'POST',
251
- headers: {
252
- 'Content-Type': 'application/json',
253
- 'Authorization': `Bearer ${token}`
254
- },
255
- body: JSON.stringify({
256
- files: files.map(f => ({ path: f.path, content: f.content })),
257
- projectName,
258
- subdomain: projectName,
259
- meta: {
260
- title: projectName,
261
- platform: 'CLI',
262
- message: options.message || ''
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) deploySpinner.succeed('Deployed')
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
- // Ignore patterns
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 content = readFileSync(fullPath, 'utf8')
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.0",
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"