ship2-cli 0.1.0 → 0.2.0

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,108 @@ 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
+ return await response.json()
76
+ } catch (err) {
77
+ return { error: err.message }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Interactive subdomain selection
83
+ */
84
+ async function selectSubdomain(suggestedName, token, apiBase, isJson) {
85
+ // First check if suggested name is available
86
+ const check = await checkSubdomain(suggestedName, token, apiBase)
87
+
88
+ if (check.available) {
89
+ if (check.owned) {
90
+ // User owns this subdomain, will update
91
+ if (!isJson) {
92
+ console.log(chalk.dim(` Domain: ${chalk.cyan(suggestedName + '.ship2.app')} (will update existing)`))
93
+ }
94
+ return suggestedName
95
+ } else {
96
+ // Available, ask if user wants to use it
97
+ if (!isJson) {
98
+ console.log(chalk.dim(` Suggested domain: ${chalk.cyan(suggestedName + '.ship2.app')}`))
99
+ }
100
+ const useDefault = await prompt(` Use this domain? [Y/n]: `)
101
+ if (!useDefault || useDefault.toLowerCase() === 'y' || useDefault.toLowerCase() === 'yes') {
102
+ return suggestedName
103
+ }
104
+ }
105
+ } else {
106
+ // Not available, inform user
107
+ if (!isJson) {
108
+ console.log(chalk.yellow(` Domain "${suggestedName}.ship2.app" is not available.`))
109
+ }
110
+ }
111
+
112
+ // Let user enter custom subdomain
113
+ while (true) {
114
+ const customName = await prompt(` Enter subdomain (3-63 chars, lowercase, hyphens ok): `)
115
+
116
+ if (!customName) {
117
+ console.log(chalk.red(' Subdomain is required.'))
118
+ continue
119
+ }
120
+
121
+ const sanitized = sanitizeProjectName(customName)
122
+ if (sanitized.length < 3) {
123
+ console.log(chalk.red(' Subdomain must be at least 3 characters.'))
124
+ continue
125
+ }
126
+
127
+ // Check availability
128
+ const spinner = ora('Checking availability...').start()
129
+ const checkResult = await checkSubdomain(sanitized, token, apiBase)
130
+ spinner.stop()
131
+
132
+ if (checkResult.error) {
133
+ console.log(chalk.red(` Error: ${checkResult.error}`))
134
+ continue
135
+ }
136
+
137
+ if (checkResult.available) {
138
+ if (checkResult.owned) {
139
+ console.log(chalk.dim(` Will update existing project at ${chalk.cyan(sanitized + '.ship2.app')}`))
140
+ } else {
141
+ console.log(chalk.green(` Domain ${chalk.cyan(sanitized + '.ship2.app')} is available!`))
142
+ }
143
+ return sanitized
144
+ } else {
145
+ console.log(chalk.yellow(` Domain "${sanitized}.ship2.app" is already taken. Try another name.`))
146
+ }
147
+ }
148
+ }
149
+
44
150
  export default async function deploy(inputPath, options) {
45
151
  // Default to current directory
46
152
  const targetPath = resolve(inputPath || '.')
@@ -57,6 +163,24 @@ export default async function deploy(inputPath, options) {
57
163
  // Detect path type
58
164
  const pathInfo = detectPathType(targetPath)
59
165
 
166
+ // Detect project type (auto-detect or from --type flag)
167
+ let projectType = options.type || 'auto'
168
+ if (projectType === 'auto' && pathInfo.type === 'directory') {
169
+ if (detectNextjsProject(targetPath)) {
170
+ projectType = 'nextjs'
171
+ if (!isJson) {
172
+ const hasApi = hasApiRoutes(targetPath)
173
+ if (hasApi) {
174
+ console.log(chalk.cyan(' Detected: Next.js project with API routes'))
175
+ } else {
176
+ console.log(chalk.cyan(' Detected: Next.js project'))
177
+ }
178
+ }
179
+ } else {
180
+ projectType = 'static'
181
+ }
182
+ }
183
+
60
184
  if (pathInfo.type === 'not_found') {
61
185
  output.deployError({
62
186
  code: 'NOT_FOUND',
@@ -75,21 +199,76 @@ export default async function deploy(inputPath, options) {
75
199
  process.exit(1)
76
200
  }
77
201
 
78
- // Infer project name
79
- let projectName = options.name || inferProjectName(targetPath)
80
- projectName = sanitizeProjectName(projectName)
202
+ // Infer project name from directory/file
203
+ let suggestedName = inferProjectName(targetPath)
204
+ suggestedName = sanitizeProjectName(suggestedName)
81
205
 
82
- if (!projectName) {
83
- projectName = 'my-site'
206
+ if (!suggestedName || suggestedName.length < 3) {
207
+ suggestedName = 'my-site'
84
208
  }
85
209
 
86
- if (!isJson) {
210
+ const apiBase = getApiBase()
211
+ const token = getToken()
212
+
213
+ // Determine subdomain
214
+ let projectName
215
+
216
+ if (options.name) {
217
+ // User specified name via --name flag
218
+ const sanitized = sanitizeProjectName(options.name)
219
+ if (sanitized.length < 3) {
220
+ output.deployError({
221
+ code: 'INVALID_NAME',
222
+ message: 'Project name must be at least 3 characters.',
223
+ suggestion: 'Use a longer name with lowercase letters, numbers, and hyphens.'
224
+ }, isJson)
225
+ process.exit(1)
226
+ }
227
+
228
+ // Check if available
229
+ const check = await checkSubdomain(sanitized, token, apiBase)
230
+ if (!check.available && !check.owned) {
231
+ output.deployError({
232
+ code: 'NAME_TAKEN',
233
+ message: `Domain "${sanitized}.ship2.app" is already taken.`,
234
+ suggestion: 'Try a different name with --name flag.'
235
+ }, isJson)
236
+ process.exit(1)
237
+ }
238
+
239
+ projectName = sanitized
240
+ if (!isJson) {
241
+ if (check.owned) {
242
+ console.log()
243
+ output.info(`Updating: ${chalk.cyan(projectName + '.ship2.app')}`)
244
+ } else {
245
+ console.log()
246
+ output.info(`Deploying to: ${chalk.cyan(projectName + '.ship2.app')}`)
247
+ }
248
+ }
249
+ } else if (!isJson) {
250
+ // Interactive mode: ask user to confirm/change subdomain
87
251
  console.log()
88
252
  if (isPrivate) {
89
- output.info(`Creating private link: ${chalk.cyan(projectName)}`)
253
+ output.info(`Creating private link`)
254
+ projectName = suggestedName
90
255
  } else {
91
- output.info(`Deploying: ${chalk.cyan(projectName)}`)
256
+ output.info(`Setting up deployment`)
257
+ projectName = await selectSubdomain(suggestedName, token, apiBase, isJson)
92
258
  }
259
+ console.log()
260
+ } else {
261
+ // JSON mode without --name: use suggested name, check availability
262
+ const check = await checkSubdomain(suggestedName, token, apiBase)
263
+ if (!check.available && !check.owned) {
264
+ output.deployError({
265
+ code: 'NAME_TAKEN',
266
+ message: `Domain "${suggestedName}.ship2.app" is already taken.`,
267
+ suggestion: 'Specify a custom name with --name flag.'
268
+ }, isJson)
269
+ process.exit(1)
270
+ }
271
+ projectName = suggestedName
93
272
  }
94
273
 
95
274
  let files = []
@@ -123,79 +302,129 @@ export default async function deploy(inputPath, options) {
123
302
 
124
303
  // Handle directory
125
304
  if (pathInfo.type === 'directory') {
126
- if (!isJson) {
127
- output.info('Mode: Directory')
128
- }
305
+ // Next.js project deployment
306
+ if (projectType === 'nextjs') {
307
+ if (!isJson) {
308
+ output.info('Mode: Next.js Full-Stack')
309
+ }
129
310
 
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 {
311
+ // Collect Next.js project files
312
+ const spinner = isJson ? null : ora('Scanning Next.js project...').start()
313
+
314
+ try {
315
+ const result = await collectNextjsFiles(targetPath)
316
+
317
+ if (result.errors.length > 0) {
318
+ if (spinner) spinner.fail('Validation failed')
319
+
320
+ for (const error of result.errors) {
321
+ if (error.type === ValidationError.FILE_TOO_LARGE) {
322
+ output.deployError({
323
+ code: 'FILE_TOO_LARGE',
324
+ message: `File too large: ${error.file} (${formatSize(error.size)}, limit: ${formatSize(error.maxSize)})`,
325
+ suggestion: 'Compress or remove the file.'
326
+ }, isJson)
327
+ } else if (error.type === ValidationError.TOTAL_TOO_LARGE) {
328
+ output.deployError({
329
+ code: 'TOTAL_TOO_LARGE',
330
+ message: `Total size too large: ${formatSize(error.totalSize)} (limit: ${formatSize(error.maxSize)})`,
331
+ suggestion: 'Remove unnecessary files or compress assets.'
332
+ }, isJson)
333
+ }
334
+ }
335
+ process.exit(1)
336
+ }
337
+
338
+ files = result.files
339
+ totalSize = result.totalSize
340
+
341
+ if (spinner) {
342
+ spinner.succeed(`Scanned: ${files.length} files, ${formatSize(totalSize)}`)
343
+ }
344
+ } catch (err) {
345
+ if (spinner) spinner.fail('Scan failed')
142
346
  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'
347
+ code: 'SCAN_FAILED',
348
+ message: err.message
146
349
  }, isJson)
350
+ process.exit(1)
351
+ }
352
+ } else {
353
+ // Static project deployment (original logic)
354
+ if (!isJson) {
355
+ output.info('Mode: Static')
147
356
  }
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
357
 
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
- }
358
+ // Validate directory has index.html
359
+ const dirValidation = validateDirectory(targetPath)
360
+ if (!dirValidation.valid) {
361
+ // Check for build output directories
362
+ const buildDir = detectBuildOutput(targetPath)
363
+ if (buildDir) {
364
+ output.deployError({
365
+ code: 'MISSING_INDEX',
366
+ message: 'No index.html found in current directory.',
367
+ suggestion: `Detected build output. Try: ship2 deploy ${buildDir}`
368
+ }, isJson)
369
+ } else {
370
+ output.deployError({
371
+ code: 'MISSING_INDEX',
372
+ message: 'No index.html found.',
373
+ suggestion: 'Run your build command first, then deploy the output directory.\nExample: npm run build && ship2 deploy ./dist'
374
+ }, isJson)
182
375
  }
183
376
  process.exit(1)
184
377
  }
185
378
 
186
- files = result.files
187
- totalSize = result.totalSize
379
+ // Validate references
380
+ const indexContent = readFileSync(resolve(targetPath, 'index.html'), 'utf8')
381
+ const refValidation = validateReferences(targetPath, indexContent)
382
+ if (!refValidation.valid) {
383
+ output.deployError(refValidation.error, isJson)
384
+ process.exit(1)
385
+ }
386
+
387
+ // Collect files
388
+ const spinner = isJson ? null : ora('Scanning files...').start()
389
+
390
+ try {
391
+ const result = await collectFiles(targetPath)
392
+
393
+ if (result.errors.length > 0) {
394
+ if (spinner) spinner.fail('Validation failed')
395
+
396
+ for (const error of result.errors) {
397
+ if (error.type === ValidationError.FILE_TOO_LARGE) {
398
+ output.deployError({
399
+ code: 'FILE_TOO_LARGE',
400
+ message: `File too large: ${error.file} (${formatSize(error.size)}, limit: ${formatSize(error.maxSize)})`,
401
+ suggestion: 'Compress or remove the file.'
402
+ }, isJson)
403
+ } else if (error.type === ValidationError.TOTAL_TOO_LARGE) {
404
+ output.deployError({
405
+ code: 'TOTAL_TOO_LARGE',
406
+ message: `Total size too large: ${formatSize(error.totalSize)} (limit: ${formatSize(error.maxSize)})`,
407
+ suggestion: 'Remove unnecessary files or compress assets.'
408
+ }, isJson)
409
+ }
410
+ }
411
+ process.exit(1)
412
+ }
188
413
 
189
- if (spinner) {
190
- spinner.succeed(`Scanned: ${files.length} files, ${formatSize(totalSize)}`)
414
+ files = result.files
415
+ totalSize = result.totalSize
416
+
417
+ if (spinner) {
418
+ spinner.succeed(`Scanned: ${files.length} files, ${formatSize(totalSize)}`)
419
+ }
420
+ } catch (err) {
421
+ if (spinner) spinner.fail('Scan failed')
422
+ output.deployError({
423
+ code: 'SCAN_FAILED',
424
+ message: err.message
425
+ }, isJson)
426
+ process.exit(1)
191
427
  }
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
428
  }
200
429
  }
201
430
 
@@ -203,9 +432,6 @@ export default async function deploy(inputPath, options) {
203
432
  const deploySpinner = isJson ? null : ora(isPrivate ? 'Creating private link...' : 'Deploying...').start()
204
433
 
205
434
  try {
206
- const apiBase = getApiBase()
207
- const token = getToken()
208
-
209
435
  // Private deployment
210
436
  if (isPrivate) {
211
437
  const ttlHours = parseTTL(options.ttl)
@@ -246,23 +472,66 @@ export default async function deploy(inputPath, options) {
246
472
  }
247
473
 
248
474
  // 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 || ''
475
+ let response
476
+ let apiEndpoint
477
+
478
+ if (projectType === 'nextjs') {
479
+ // Next.js full-stack deployment
480
+ apiEndpoint = `${apiBase}/api/deploy-nextjs`
481
+
482
+ // Parse environment variables from --env flags
483
+ const envVars = {}
484
+ if (options.env) {
485
+ const envList = Array.isArray(options.env) ? options.env : [options.env]
486
+ for (const envStr of envList) {
487
+ const eqIndex = envStr.indexOf('=')
488
+ if (eqIndex > 0) {
489
+ const key = envStr.slice(0, eqIndex)
490
+ const value = envStr.slice(eqIndex + 1)
491
+ envVars[key] = value
492
+ }
263
493
  }
494
+ }
495
+
496
+ response = await fetch(apiEndpoint, {
497
+ method: 'POST',
498
+ headers: {
499
+ 'Content-Type': 'application/json',
500
+ 'Authorization': `Bearer ${token}`
501
+ },
502
+ body: JSON.stringify({
503
+ files: files.map(f => ({ path: f.path, content: f.content, encoding: f.encoding })),
504
+ projectName,
505
+ subdomain: projectName,
506
+ env: envVars,
507
+ meta: {
508
+ title: projectName,
509
+ platform: 'CLI',
510
+ message: options.message || ''
511
+ }
512
+ })
264
513
  })
265
- })
514
+ } else {
515
+ // Static deployment (original)
516
+ apiEndpoint = `${apiBase}/api/publish-multi`
517
+ response = await fetch(apiEndpoint, {
518
+ method: 'POST',
519
+ headers: {
520
+ 'Content-Type': 'application/json',
521
+ 'Authorization': `Bearer ${token}`
522
+ },
523
+ body: JSON.stringify({
524
+ files: files.map(f => ({ path: f.path, content: f.content })),
525
+ projectName,
526
+ subdomain: projectName,
527
+ meta: {
528
+ title: projectName,
529
+ platform: 'CLI',
530
+ message: options.message || ''
531
+ }
532
+ })
533
+ })
534
+ }
266
535
 
267
536
  const data = await response.json()
268
537
 
@@ -275,12 +544,19 @@ export default async function deploy(inputPath, options) {
275
544
  process.exit(1)
276
545
  }
277
546
 
278
- if (deploySpinner) deploySpinner.succeed('Deployed')
547
+ if (deploySpinner) {
548
+ if (projectType === 'nextjs') {
549
+ deploySpinner.succeed('Deployed (Vercel is building...)')
550
+ } else {
551
+ deploySpinner.succeed('Deployed')
552
+ }
553
+ }
279
554
 
280
555
  output.deployResult({
281
556
  ...data,
282
557
  projectName,
283
- totalSize
558
+ totalSize,
559
+ projectType
284
560
  }, isJson)
285
561
 
286
562
  } 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.0",
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"