ship2-cli 0.1.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/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # ship2
2
+
3
+ **Deploy static sites in 30 seconds. Get a live URL instantly.**
4
+
5
+ ```bash
6
+ npx ship2 deploy ./dist
7
+ ```
8
+
9
+ ```
10
+ ✓ Deployed!
11
+
12
+ URL: https://my-app.ship2.app
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ```bash
18
+ # Deploy a directory
19
+ ship2 deploy ./dist
20
+
21
+ # Deploy a single HTML file
22
+ ship2 deploy index.html
23
+
24
+ # Deploy current directory
25
+ ship2 deploy
26
+ ```
27
+
28
+ ## Private Links
29
+
30
+ Share work-in-progress without publishing publicly:
31
+
32
+ ```bash
33
+ # Create a private link (expires in 24h)
34
+ ship2 deploy ./dist --private
35
+
36
+ # Set custom expiration
37
+ ship2 deploy ./dist --private --ttl 48h
38
+ ```
39
+
40
+ ```
41
+ ✓ Private link created!
42
+
43
+ URL: https://ship2.app/p/a1b2c3d4...
44
+ Expires: in 24 hours
45
+
46
+ ⚠ This link will expire. Copy it now!
47
+ ```
48
+
49
+ ## Commands
50
+
51
+ | Command | Description |
52
+ |---------|-------------|
53
+ | `ship2 deploy [path]` | Deploy files to ship2.app |
54
+ | `ship2 deploy --private` | Create temporary private link |
55
+ | `ship2 ls` | List your deployed projects |
56
+ | `ship2 whoami` | Show current user |
57
+ | `ship2 login` | Login to ship2.app |
58
+ | `ship2 logout` | Logout |
59
+
60
+ ## Deploy Options
61
+
62
+ ```bash
63
+ ship2 deploy [path] [options]
64
+
65
+ Options:
66
+ -n, --name <name> Project name (subdomain)
67
+ --private Create a private deployment
68
+ --ttl <time> Link expiration (private only): 24h, 2d, 1w
69
+ --message <text> Deployment note
70
+ --json Output JSON
71
+ ```
72
+
73
+ ## Limits
74
+
75
+ | | Free | Pro |
76
+ |---|---|---|
77
+ | File size | 5 MB | 5 MB |
78
+ | Total size | 10 MB | 50 MB |
79
+ | Projects | 3 | 30 |
80
+ | Private links | Unlimited | Unlimited |
81
+
82
+ ## Examples
83
+
84
+ ### Deploy a Vite project
85
+
86
+ ```bash
87
+ npm run build
88
+ ship2 deploy ./dist
89
+ ```
90
+
91
+ ### Deploy a Next.js static export
92
+
93
+ ```bash
94
+ npm run build
95
+ ship2 deploy ./out
96
+ ```
97
+
98
+ ### Share a prototype with a client
99
+
100
+ ```bash
101
+ ship2 deploy ./prototype --private --ttl 48h
102
+ ```
103
+
104
+ ### CI/CD Integration
105
+
106
+ ```bash
107
+ # GitHub Actions
108
+ - name: Deploy to ship2
109
+ run: |
110
+ npm run build
111
+ npx ship2 deploy ./dist --json
112
+ env:
113
+ SHIP2_TOKEN: ${{ secrets.SHIP2_TOKEN }}
114
+ ```
115
+
116
+ ## Links
117
+
118
+ - Website: https://ship2.app
119
+ - Issues: https://github.com/anthropics/ship2/issues
package/bin/ship2.js ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { program } from 'commander'
4
+ import { readFileSync } from 'fs'
5
+ import { fileURLToPath } from 'url'
6
+ import { dirname, join } from 'path'
7
+
8
+ import login from '../commands/login.js'
9
+ import deploy from '../commands/deploy.js'
10
+ import whoami from '../commands/whoami.js'
11
+ import ls from '../commands/ls.js'
12
+ import logout from '../commands/logout.js'
13
+
14
+ const __filename = fileURLToPath(import.meta.url)
15
+ const __dirname = dirname(__filename)
16
+ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'))
17
+
18
+ program
19
+ .name('ship2')
20
+ .description('Deploy static sites to ship2.app in seconds')
21
+ .version(pkg.version)
22
+
23
+ program
24
+ .command('login')
25
+ .description('Login to ship2.app')
26
+ .option('--token <token>', 'Use API token directly')
27
+ .option('--no-browser', 'Do not open browser automatically')
28
+ .action(login)
29
+
30
+ program
31
+ .command('deploy [path]')
32
+ .description('Deploy a file or directory to ship2.app')
33
+ .option('-n, --name <name>', 'Project name (also used as subdomain)')
34
+ .option('--private', 'Create a private deployment')
35
+ .option('--ttl <hours>', 'Link expiration time in hours (private only)')
36
+ .option('--message <text>', 'Deployment message/note')
37
+ .option('--json', 'Output in JSON format')
38
+ .action(deploy)
39
+
40
+ program
41
+ .command('whoami')
42
+ .description('Show current logged in user')
43
+ .action(whoami)
44
+
45
+ program
46
+ .command('ls')
47
+ .alias('list')
48
+ .description('List your deployed projects')
49
+ .option('--json', 'Output in JSON format')
50
+ .action(ls)
51
+
52
+ program
53
+ .command('logout')
54
+ .description('Logout from ship2.app')
55
+ .action(logout)
56
+
57
+ program.parse()
@@ -0,0 +1,295 @@
1
+ /**
2
+ * ship2 deploy - Deploy command
3
+ */
4
+
5
+ import { readFileSync, existsSync } from 'fs'
6
+ import { resolve, basename, extname } from 'path'
7
+ import chalk from 'chalk'
8
+ import ora from 'ora'
9
+ import { isLoggedIn, getToken, getApiBase } from '../lib/config.js'
10
+ import {
11
+ detectPathType,
12
+ collectFiles,
13
+ validateDirectory,
14
+ validateReferences,
15
+ inferProjectName,
16
+ sanitizeProjectName,
17
+ formatSize,
18
+ detectBuildOutput,
19
+ ValidationError
20
+ } from '../lib/files.js'
21
+ import * as output from '../lib/output.js'
22
+
23
+ /**
24
+ * Parse TTL string to hours
25
+ * Supports: "24h", "24", "1d", "1w"
26
+ */
27
+ function parseTTL(ttl) {
28
+ if (!ttl) return 24
29
+
30
+ const match = ttl.match(/^(\d+)(h|d|w)?$/i)
31
+ if (!match) return 24
32
+
33
+ const value = parseInt(match[1])
34
+ const unit = (match[2] || 'h').toLowerCase()
35
+
36
+ switch (unit) {
37
+ case 'h': return Math.min(value, 168) // max 1 week
38
+ case 'd': return Math.min(value * 24, 168)
39
+ case 'w': return Math.min(value * 168, 168)
40
+ default: return Math.min(value, 168)
41
+ }
42
+ }
43
+
44
+ export default async function deploy(inputPath, options) {
45
+ // Default to current directory
46
+ const targetPath = resolve(inputPath || '.')
47
+
48
+ // Check login status
49
+ if (!isLoggedIn()) {
50
+ output.error('Not logged in. Run: ship2 login')
51
+ process.exit(1)
52
+ }
53
+
54
+ const isJson = options.json
55
+ const isPrivate = options.private
56
+
57
+ // Detect path type
58
+ const pathInfo = detectPathType(targetPath)
59
+
60
+ if (pathInfo.type === 'not_found') {
61
+ output.deployError({
62
+ code: 'NOT_FOUND',
63
+ message: `Path not found: ${targetPath}`,
64
+ suggestion: 'Check if the path exists and try again.'
65
+ }, isJson)
66
+ process.exit(1)
67
+ }
68
+
69
+ if (pathInfo.type === 'invalid_file') {
70
+ output.deployError({
71
+ code: 'INVALID_FILE',
72
+ message: `Unsupported file type: ${pathInfo.ext}`,
73
+ suggestion: 'Only .html files or directories with index.html are supported.'
74
+ }, isJson)
75
+ process.exit(1)
76
+ }
77
+
78
+ // Infer project name
79
+ let projectName = options.name || inferProjectName(targetPath)
80
+ projectName = sanitizeProjectName(projectName)
81
+
82
+ if (!projectName) {
83
+ projectName = 'my-site'
84
+ }
85
+
86
+ if (!isJson) {
87
+ console.log()
88
+ if (isPrivate) {
89
+ output.info(`Creating private link: ${chalk.cyan(projectName)}`)
90
+ } else {
91
+ output.info(`Deploying: ${chalk.cyan(projectName)}`)
92
+ }
93
+ }
94
+
95
+ let files = []
96
+ let totalSize = 0
97
+
98
+ // Handle single file
99
+ if (pathInfo.type === 'single_file') {
100
+ if (!isJson) {
101
+ output.info('Mode: Single file')
102
+ }
103
+
104
+ const content = readFileSync(targetPath, 'utf8')
105
+ const size = Buffer.byteLength(content, 'utf8')
106
+
107
+ // Check file size (5MB limit)
108
+ if (size > 5 * 1024 * 1024) {
109
+ output.deployError({
110
+ code: 'FILE_TOO_LARGE',
111
+ message: `File too large: ${formatSize(size)} (limit: 5MB)`,
112
+ suggestion: 'Compress the file or split into multiple files.'
113
+ }, isJson)
114
+ process.exit(1)
115
+ }
116
+
117
+ files = [{
118
+ path: 'index.html',
119
+ content
120
+ }]
121
+ totalSize = size
122
+ }
123
+
124
+ // Handle directory
125
+ if (pathInfo.type === 'directory') {
126
+ if (!isJson) {
127
+ output.info('Mode: Directory')
128
+ }
129
+
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 {
142
+ 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'
146
+ }, isJson)
147
+ }
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
+
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
+ }
182
+ }
183
+ process.exit(1)
184
+ }
185
+
186
+ files = result.files
187
+ totalSize = result.totalSize
188
+
189
+ if (spinner) {
190
+ spinner.succeed(`Scanned: ${files.length} files, ${formatSize(totalSize)}`)
191
+ }
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
+ }
200
+ }
201
+
202
+ // Start deployment
203
+ const deploySpinner = isJson ? null : ora(isPrivate ? 'Creating private link...' : 'Deploying...').start()
204
+
205
+ try {
206
+ const apiBase = getApiBase()
207
+ const token = getToken()
208
+
209
+ // Private deployment
210
+ if (isPrivate) {
211
+ const ttlHours = parseTTL(options.ttl)
212
+
213
+ const response = await fetch(`${apiBase}/api/private`, {
214
+ method: 'POST',
215
+ headers: {
216
+ 'Content-Type': 'application/json',
217
+ 'Authorization': `Bearer ${token}`
218
+ },
219
+ body: JSON.stringify({
220
+ files: files.map(f => ({ path: f.path, content: f.content })),
221
+ name: projectName,
222
+ ttl: ttlHours
223
+ })
224
+ })
225
+
226
+ const data = await response.json()
227
+
228
+ if (!response.ok) {
229
+ if (deploySpinner) deploySpinner.fail('Failed')
230
+ output.deployError({
231
+ code: data.error || 'PRIVATE_DEPLOY_FAILED',
232
+ message: data.message || data.error || 'Failed to create private link'
233
+ }, isJson)
234
+ process.exit(1)
235
+ }
236
+
237
+ if (deploySpinner) deploySpinner.succeed('Private link created')
238
+
239
+ output.privateResult({
240
+ ...data,
241
+ projectName,
242
+ totalSize
243
+ }, isJson)
244
+
245
+ return
246
+ }
247
+
248
+ // 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 || ''
263
+ }
264
+ })
265
+ })
266
+
267
+ const data = await response.json()
268
+
269
+ if (!response.ok) {
270
+ if (deploySpinner) deploySpinner.fail('Deploy failed')
271
+ output.deployError({
272
+ code: 'DEPLOY_FAILED',
273
+ message: data.error || 'Deployment failed'
274
+ }, isJson)
275
+ process.exit(1)
276
+ }
277
+
278
+ if (deploySpinner) deploySpinner.succeed('Deployed')
279
+
280
+ output.deployResult({
281
+ ...data,
282
+ projectName,
283
+ totalSize
284
+ }, isJson)
285
+
286
+ } catch (err) {
287
+ if (deploySpinner) deploySpinner.fail('Failed')
288
+ output.deployError({
289
+ code: 'NETWORK_ERROR',
290
+ message: err.message,
291
+ suggestion: 'Check your network connection and try again.'
292
+ }, isJson)
293
+ process.exit(1)
294
+ }
295
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * ship2 login - 登录命令
3
+ *
4
+ * 使用 Device Code 流程:
5
+ * 1. CLI 获取 device_code 和 user_code
6
+ * 2. 显示 user_code 和验证 URL
7
+ * 3. 用户在浏览器中完成授权
8
+ * 4. CLI 轮询获取 token
9
+ */
10
+
11
+ import chalk from 'chalk'
12
+ import ora from 'ora'
13
+ import open from 'open'
14
+ import { setToken, setEmail, setUserId, getApiBase } from '../lib/config.js'
15
+ import { validateToken } from '../lib/api.js'
16
+ import * as output from '../lib/output.js'
17
+
18
+ // 轮询间隔 (秒)
19
+ const POLL_INTERVAL = 5
20
+
21
+ export default async function login(options) {
22
+ const apiBase = getApiBase()
23
+
24
+ // 如果提供了 token,直接验证并保存
25
+ if (options.token) {
26
+ const spinner = ora('Validating token...').start()
27
+
28
+ try {
29
+ const user = await validateToken(options.token)
30
+
31
+ if (!user) {
32
+ spinner.fail('Invalid token')
33
+ process.exit(1)
34
+ }
35
+
36
+ setToken(options.token)
37
+ setEmail(user.email || '')
38
+ setUserId(user.id || '')
39
+
40
+ spinner.succeed('Login successful')
41
+ output.userInfo(user)
42
+ } catch (err) {
43
+ spinner.fail(`Login failed: ${err.message}`)
44
+ process.exit(1)
45
+ }
46
+
47
+ return
48
+ }
49
+
50
+ // Device Code 流程
51
+ console.log()
52
+ console.log(chalk.bold('Login to ship2.app'))
53
+ console.log()
54
+
55
+ const spinner = ora('Requesting device code...').start()
56
+
57
+ try {
58
+ // 1. 获取 device code
59
+ const deviceRes = await fetch(`${apiBase}/api/auth/device`, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' }
62
+ })
63
+
64
+ if (!deviceRes.ok) {
65
+ const data = await deviceRes.json()
66
+ spinner.fail(data.error || 'Failed to get device code')
67
+ process.exit(1)
68
+ }
69
+
70
+ const deviceData = await deviceRes.json()
71
+ const { device_code, user_code, verification_uri, expires_in } = deviceData
72
+
73
+ spinner.stop()
74
+
75
+ // 2. 显示验证码和 URL
76
+ console.log()
77
+ console.log(chalk.bold(' Your verification code:'))
78
+ console.log()
79
+ console.log(chalk.bold.cyan(` ${user_code}`))
80
+ console.log()
81
+ console.log(` Open ${chalk.underline(verification_uri)} in your browser`)
82
+ console.log(` and enter the code above to authorize.`)
83
+ console.log()
84
+ console.log(chalk.dim(` Code expires in ${Math.floor(expires_in / 60)} minutes`))
85
+ console.log()
86
+
87
+ // 尝试自动打开浏览器
88
+ if (!options.noBrowser) {
89
+ try {
90
+ await open(`${verification_uri}?code=${user_code}`)
91
+ console.log(chalk.dim(' Browser opened automatically.'))
92
+ console.log()
93
+ } catch {
94
+ // ignore, user can open manually
95
+ }
96
+ }
97
+
98
+ // 3. 轮询等待授权
99
+ const pollSpinner = ora('Waiting for authorization...').start()
100
+ const startTime = Date.now()
101
+ const timeout = expires_in * 1000
102
+
103
+ while (Date.now() - startTime < timeout) {
104
+ await sleep(POLL_INTERVAL * 1000)
105
+
106
+ try {
107
+ const tokenRes = await fetch(`${apiBase}/api/auth/device/token`, {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body: JSON.stringify({ device_code })
111
+ })
112
+
113
+ const tokenData = await tokenRes.json()
114
+
115
+ if (tokenRes.ok && tokenData.access_token) {
116
+ // 授权成功
117
+ setToken(tokenData.access_token)
118
+ setEmail(tokenData.user?.email || '')
119
+ setUserId(tokenData.user?.id || '')
120
+
121
+ pollSpinner.succeed('Login successful!')
122
+ console.log()
123
+ output.userInfo(tokenData.user)
124
+ return
125
+ }
126
+
127
+ // 检查错误类型
128
+ if (tokenData.error === 'authorization_pending') {
129
+ // 继续轮询
130
+ continue
131
+ }
132
+
133
+ if (tokenData.error === 'expired_token') {
134
+ pollSpinner.fail('Verification code expired')
135
+ process.exit(1)
136
+ }
137
+
138
+ if (tokenData.error === 'access_denied') {
139
+ pollSpinner.fail('Authorization denied')
140
+ process.exit(1)
141
+ }
142
+
143
+ // 其他错误
144
+ pollSpinner.fail(tokenData.error || 'Authorization failed')
145
+ process.exit(1)
146
+
147
+ } catch (err) {
148
+ // 网络错误,继续重试
149
+ continue
150
+ }
151
+ }
152
+
153
+ pollSpinner.fail('Authorization timed out')
154
+ process.exit(1)
155
+
156
+ } catch (err) {
157
+ spinner.fail(`Login failed: ${err.message}`)
158
+ process.exit(1)
159
+ }
160
+ }
161
+
162
+ function sleep(ms) {
163
+ return new Promise(resolve => setTimeout(resolve, ms))
164
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * ship2 logout - 登出
3
+ */
4
+
5
+ import { clearAuth, isLoggedIn } from '../lib/config.js'
6
+ import * as output from '../lib/output.js'
7
+
8
+ export default async function logout() {
9
+ if (!isLoggedIn()) {
10
+ output.info('当前未登录')
11
+ return
12
+ }
13
+
14
+ clearAuth()
15
+ output.success('已登出')
16
+ }
package/commands/ls.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * ship2 ls - 列出已部署的项目
3
+ */
4
+
5
+ import ora from 'ora'
6
+ import { isLoggedIn, getToken, getApiBase } from '../lib/config.js'
7
+ import * as output from '../lib/output.js'
8
+
9
+ export default async function ls(options) {
10
+ if (!isLoggedIn()) {
11
+ output.error('请先登录: ship2 login')
12
+ process.exit(1)
13
+ }
14
+
15
+ const isJson = options.json
16
+ const spinner = isJson ? null : ora('获取项目列表...').start()
17
+
18
+ try {
19
+ const apiBase = getApiBase()
20
+ const token = getToken()
21
+
22
+ const response = await fetch(`${apiBase}/api/user/projects`, {
23
+ headers: {
24
+ 'Authorization': `Bearer ${token}`
25
+ }
26
+ })
27
+
28
+ const data = await response.json()
29
+
30
+ if (!response.ok) {
31
+ if (spinner) spinner.fail('获取失败')
32
+ output.error(data.error || '获取项目列表失败')
33
+ process.exit(1)
34
+ }
35
+
36
+ if (spinner) spinner.succeed('获取成功')
37
+
38
+ output.projectList(data.projects || [], isJson)
39
+
40
+ } catch (err) {
41
+ if (spinner) spinner.fail('获取失败')
42
+ output.error(err.message)
43
+ process.exit(1)
44
+ }
45
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * ship2 whoami - 显示当前登录用户
3
+ */
4
+
5
+ import { isLoggedIn, getEmail, getUserId, getConfigPath } from '../lib/config.js'
6
+ import * as output from '../lib/output.js'
7
+
8
+ export default async function whoami() {
9
+ if (!isLoggedIn()) {
10
+ output.error('未登录')
11
+ console.log()
12
+ console.log('请运行: ship2 login')
13
+ process.exit(1)
14
+ }
15
+
16
+ const email = getEmail()
17
+ const userId = getUserId()
18
+
19
+ output.userInfo({
20
+ email: email || '(未知)',
21
+ id: userId
22
+ })
23
+
24
+ console.log('配置文件:', getConfigPath())
25
+ console.log()
26
+ }
package/lib/api.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Ship2 API 客户端
3
+ */
4
+
5
+ import { getToken, getApiBase } from './config.js'
6
+
7
+ class ApiError extends Error {
8
+ constructor(message, status, code) {
9
+ super(message)
10
+ this.name = 'ApiError'
11
+ this.status = status
12
+ this.code = code
13
+ }
14
+ }
15
+
16
+ async function request(endpoint, options = {}) {
17
+ const apiBase = getApiBase()
18
+ const token = getToken()
19
+
20
+ const url = `${apiBase}${endpoint}`
21
+ const headers = {
22
+ 'Content-Type': 'application/json',
23
+ ...options.headers
24
+ }
25
+
26
+ if (token) {
27
+ headers['Authorization'] = `Bearer ${token}`
28
+ }
29
+
30
+ const response = await fetch(url, {
31
+ ...options,
32
+ headers
33
+ })
34
+
35
+ const data = await response.json().catch(() => ({}))
36
+
37
+ if (!response.ok) {
38
+ throw new ApiError(
39
+ data.error || `Request failed with status ${response.status}`,
40
+ response.status,
41
+ data.code
42
+ )
43
+ }
44
+
45
+ return data
46
+ }
47
+
48
+ /**
49
+ * 登录
50
+ */
51
+ export async function login(email, password) {
52
+ return request('/api/auth/login', {
53
+ method: 'POST',
54
+ body: JSON.stringify({ email, password })
55
+ })
56
+ }
57
+
58
+ /**
59
+ * 获取当前用户信息
60
+ */
61
+ export async function getCurrentUser() {
62
+ return request('/api/user/me')
63
+ }
64
+
65
+ /**
66
+ * 部署项目
67
+ */
68
+ export async function deploy(files, options = {}) {
69
+ const { projectName, subdomain, meta = {} } = options
70
+
71
+ return request('/api/publish-multi', {
72
+ method: 'POST',
73
+ body: JSON.stringify({
74
+ files,
75
+ projectName,
76
+ subdomain,
77
+ meta
78
+ })
79
+ })
80
+ }
81
+
82
+ /**
83
+ * 获取用户的项目列表
84
+ */
85
+ export async function listProjects() {
86
+ return request('/api/user/projects')
87
+ }
88
+
89
+ /**
90
+ * 验证 token 是否有效
91
+ */
92
+ export async function validateToken(token) {
93
+ const apiBase = getApiBase()
94
+ const response = await fetch(`${apiBase}/api/user/me`, {
95
+ headers: {
96
+ 'Authorization': `Bearer ${token}`
97
+ }
98
+ })
99
+
100
+ if (!response.ok) {
101
+ return null
102
+ }
103
+
104
+ return response.json()
105
+ }
106
+
107
+ export { ApiError }
package/lib/config.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 配置管理
3
+ * 存储路径: ~/.config/ship2/config.json
4
+ */
5
+
6
+ import Conf from 'conf'
7
+
8
+ const config = new Conf({
9
+ projectName: 'ship2',
10
+ schema: {
11
+ token: {
12
+ type: 'string',
13
+ default: ''
14
+ },
15
+ email: {
16
+ type: 'string',
17
+ default: ''
18
+ },
19
+ userId: {
20
+ type: 'string',
21
+ default: ''
22
+ },
23
+ apiBase: {
24
+ type: 'string',
25
+ default: 'https://ship2.app'
26
+ }
27
+ }
28
+ })
29
+
30
+ export function getToken() {
31
+ return config.get('token')
32
+ }
33
+
34
+ export function setToken(token) {
35
+ config.set('token', token)
36
+ }
37
+
38
+ export function getEmail() {
39
+ return config.get('email')
40
+ }
41
+
42
+ export function setEmail(email) {
43
+ config.set('email', email)
44
+ }
45
+
46
+ export function getUserId() {
47
+ return config.get('userId')
48
+ }
49
+
50
+ export function setUserId(userId) {
51
+ config.set('userId', userId)
52
+ }
53
+
54
+ export function getApiBase() {
55
+ return config.get('apiBase')
56
+ }
57
+
58
+ export function setApiBase(apiBase) {
59
+ config.set('apiBase', apiBase)
60
+ }
61
+
62
+ export function clearAuth() {
63
+ config.delete('token')
64
+ config.delete('email')
65
+ config.delete('userId')
66
+ }
67
+
68
+ export function isLoggedIn() {
69
+ return !!config.get('token')
70
+ }
71
+
72
+ export function getConfigPath() {
73
+ return config.path
74
+ }
75
+
76
+ export default config
package/lib/files.js ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * File handling utilities
3
+ */
4
+
5
+ import { readFileSync, statSync, existsSync, readdirSync } from 'fs'
6
+ import { join, basename, extname, relative } from 'path'
7
+ import { glob } from 'glob'
8
+ import mime from 'mime-types'
9
+
10
+ // File size limits
11
+ const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB per file
12
+ const MAX_TOTAL_SIZE = 10 * 1024 * 1024 // 10MB total (Free tier)
13
+
14
+ // Common build output directories
15
+ const BUILD_DIRS = ['dist', 'build', 'out', 'public', '.next/static', '.output/public']
16
+
17
+ // Ignore patterns
18
+ const IGNORE_PATTERNS = [
19
+ 'node_modules/**',
20
+ '.git/**',
21
+ '.DS_Store',
22
+ 'Thumbs.db',
23
+ '*.log',
24
+ '.env*',
25
+ '.idea/**',
26
+ '.vscode/**'
27
+ ]
28
+
29
+ /**
30
+ * Validation error types
31
+ */
32
+ export const ValidationError = {
33
+ MISSING_INDEX: 'MISSING_INDEX',
34
+ MISSING_ASSET: 'MISSING_ASSET',
35
+ FILE_TOO_LARGE: 'FILE_TOO_LARGE',
36
+ TOTAL_TOO_LARGE: 'TOTAL_TOO_LARGE',
37
+ INVALID_PATH: 'INVALID_PATH',
38
+ NOT_FOUND: 'NOT_FOUND'
39
+ }
40
+
41
+ /**
42
+ * Detect path type
43
+ */
44
+ export function detectPathType(inputPath) {
45
+ if (!existsSync(inputPath)) {
46
+ return { type: 'not_found', path: inputPath }
47
+ }
48
+
49
+ const stat = statSync(inputPath)
50
+
51
+ if (stat.isFile()) {
52
+ const ext = extname(inputPath).toLowerCase()
53
+ if (ext === '.html' || ext === '.htm') {
54
+ return { type: 'single_file', path: inputPath }
55
+ }
56
+ return { type: 'invalid_file', path: inputPath, ext }
57
+ }
58
+
59
+ if (stat.isDirectory()) {
60
+ return { type: 'directory', path: inputPath }
61
+ }
62
+
63
+ return { type: 'unknown', path: inputPath }
64
+ }
65
+
66
+ /**
67
+ * Detect build output directory
68
+ * Returns the path if found, null otherwise
69
+ */
70
+ export function detectBuildOutput(dirPath) {
71
+ for (const buildDir of BUILD_DIRS) {
72
+ const fullPath = join(dirPath, buildDir)
73
+ if (existsSync(fullPath) && existsSync(join(fullPath, 'index.html'))) {
74
+ return `./${buildDir}`
75
+ }
76
+ }
77
+ return null
78
+ }
79
+
80
+ /**
81
+ * Collect files from directory
82
+ */
83
+ export async function collectFiles(dirPath) {
84
+ const files = await glob('**/*', {
85
+ cwd: dirPath,
86
+ nodir: true,
87
+ ignore: IGNORE_PATTERNS,
88
+ dot: false
89
+ })
90
+
91
+ const result = []
92
+ let totalSize = 0
93
+ const errors = []
94
+
95
+ for (const file of files) {
96
+ const fullPath = join(dirPath, file)
97
+ const stat = statSync(fullPath)
98
+ const size = stat.size
99
+
100
+ // Check single file size
101
+ if (size > MAX_FILE_SIZE) {
102
+ errors.push({
103
+ type: ValidationError.FILE_TOO_LARGE,
104
+ file,
105
+ size,
106
+ maxSize: MAX_FILE_SIZE
107
+ })
108
+ continue
109
+ }
110
+
111
+ totalSize += size
112
+
113
+ // Read file content
114
+ const content = readFileSync(fullPath, 'utf8')
115
+
116
+ result.push({
117
+ path: file,
118
+ content,
119
+ size
120
+ })
121
+ }
122
+
123
+ // Check total size
124
+ if (totalSize > MAX_TOTAL_SIZE) {
125
+ errors.push({
126
+ type: ValidationError.TOTAL_TOO_LARGE,
127
+ totalSize,
128
+ maxSize: MAX_TOTAL_SIZE
129
+ })
130
+ }
131
+
132
+ return { files: result, totalSize, errors }
133
+ }
134
+
135
+ /**
136
+ * Validate directory has index.html
137
+ */
138
+ export function validateDirectory(dirPath) {
139
+ const indexPath = join(dirPath, 'index.html')
140
+ if (!existsSync(indexPath)) {
141
+ return {
142
+ valid: false,
143
+ error: {
144
+ type: ValidationError.MISSING_INDEX,
145
+ code: 'MISSING_INDEX',
146
+ message: 'No index.html found.',
147
+ suggestion: 'Make sure index.html exists in the directory.'
148
+ }
149
+ }
150
+ }
151
+ return { valid: true }
152
+ }
153
+
154
+ /**
155
+ * Extract local references from HTML
156
+ */
157
+ export function extractLocalReferences(htmlContent) {
158
+ const references = []
159
+
160
+ // Match src="./xxx" or href="./xxx" or relative paths
161
+ const patterns = [
162
+ /src=["']\.?\/([^"']+)["']/gi,
163
+ /href=["']\.?\/([^"']+)["']/gi,
164
+ /url\(["']?\.?\/([^"')]+)["']?\)/gi
165
+ ]
166
+
167
+ for (const pattern of patterns) {
168
+ let match
169
+ while ((match = pattern.exec(htmlContent)) !== null) {
170
+ const ref = match[1]
171
+ // Exclude external links and anchors
172
+ if (!ref.startsWith('http') && !ref.startsWith('#') && !ref.startsWith('//')) {
173
+ references.push(ref)
174
+ }
175
+ }
176
+ }
177
+
178
+ return [...new Set(references)]
179
+ }
180
+
181
+ /**
182
+ * Validate referenced files exist
183
+ */
184
+ export function validateReferences(dirPath, htmlContent) {
185
+ const references = extractLocalReferences(htmlContent)
186
+ const missing = []
187
+
188
+ for (const ref of references) {
189
+ const fullPath = join(dirPath, ref)
190
+ if (!existsSync(fullPath)) {
191
+ missing.push(ref)
192
+ }
193
+ }
194
+
195
+ if (missing.length > 0) {
196
+ // Limit to 20 files
197
+ const displayMissing = missing.slice(0, 20)
198
+ const hasMore = missing.length > 20
199
+
200
+ return {
201
+ valid: false,
202
+ error: {
203
+ type: ValidationError.MISSING_ASSET,
204
+ code: 'MISSING_ASSET',
205
+ message: `Missing ${missing.length} referenced file${missing.length > 1 ? 's' : ''}.`,
206
+ missing: displayMissing,
207
+ hasMore,
208
+ totalMissing: missing.length,
209
+ suggestion: 'Add the missing files or fix the references in your HTML.'
210
+ }
211
+ }
212
+ }
213
+
214
+ return { valid: true, references }
215
+ }
216
+
217
+ /**
218
+ * Infer project name from path
219
+ */
220
+ export function inferProjectName(inputPath) {
221
+ const pathType = detectPathType(inputPath)
222
+
223
+ if (pathType.type === 'single_file') {
224
+ // Single file: use filename without extension
225
+ return basename(inputPath, extname(inputPath))
226
+ }
227
+
228
+ if (pathType.type === 'directory') {
229
+ // Directory: use directory name
230
+ return basename(inputPath)
231
+ }
232
+
233
+ return 'my-site'
234
+ }
235
+
236
+ /**
237
+ * Sanitize project name (slug)
238
+ */
239
+ export function sanitizeProjectName(name) {
240
+ return name
241
+ .toLowerCase()
242
+ .replace(/[^a-z0-9-]/g, '-')
243
+ .replace(/-+/g, '-')
244
+ .replace(/^-|-$/g, '')
245
+ .slice(0, 32)
246
+ }
247
+
248
+ /**
249
+ * Format file size
250
+ */
251
+ export function formatSize(bytes) {
252
+ if (bytes < 1024) return `${bytes} B`
253
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
254
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
255
+ }
package/lib/output.js ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Output formatting utilities
3
+ */
4
+
5
+ import chalk from 'chalk'
6
+
7
+ /**
8
+ * Success output
9
+ */
10
+ export function success(message) {
11
+ console.log(chalk.green('✓'), message)
12
+ }
13
+
14
+ /**
15
+ * Error output
16
+ */
17
+ export function error(message) {
18
+ console.error(chalk.red('✗'), message)
19
+ }
20
+
21
+ /**
22
+ * Warning output
23
+ */
24
+ export function warn(message) {
25
+ console.warn(chalk.yellow('⚠'), message)
26
+ }
27
+
28
+ /**
29
+ * Info output
30
+ */
31
+ export function info(message) {
32
+ console.log(chalk.blue('ℹ'), message)
33
+ }
34
+
35
+ /**
36
+ * Plain output
37
+ */
38
+ export function log(message) {
39
+ console.log(message)
40
+ }
41
+
42
+ /**
43
+ * Output deploy result
44
+ */
45
+ export function deployResult(result, isJson = false) {
46
+ if (isJson) {
47
+ console.log(JSON.stringify({
48
+ success: true,
49
+ url: result.domainUrl,
50
+ vercelUrl: result.vercelUrl,
51
+ project: result.projectName,
52
+ files: result.fileCount,
53
+ size: result.totalSize
54
+ }, null, 2))
55
+ return
56
+ }
57
+
58
+ console.log()
59
+ success('Deployed!')
60
+ console.log()
61
+ console.log(chalk.bold(' URL:'), chalk.cyan(result.domainUrl))
62
+ if (result.vercelUrl && result.vercelUrl !== result.domainUrl) {
63
+ console.log(chalk.dim(' Vercel:'), chalk.dim(result.vercelUrl))
64
+ }
65
+ console.log(chalk.dim(' Files:'), result.fileCount || result.files?.length)
66
+ console.log()
67
+ }
68
+
69
+ /**
70
+ * Output private link result
71
+ */
72
+ export function privateResult(result, isJson = false) {
73
+ if (isJson) {
74
+ console.log(JSON.stringify({
75
+ success: true,
76
+ url: result.url,
77
+ token: result.token,
78
+ expiresAt: result.expiresAt,
79
+ ttlHours: result.ttlHours,
80
+ files: result.fileCount,
81
+ size: result.totalSize
82
+ }, null, 2))
83
+ return
84
+ }
85
+
86
+ console.log()
87
+ success('Private link created!')
88
+ console.log()
89
+ console.log(chalk.bold(' URL:'), chalk.cyan(result.url))
90
+ console.log(chalk.dim(' Expires:'), formatExpiry(result.expiresAt))
91
+ console.log(chalk.dim(' Files:'), result.fileCount)
92
+ console.log()
93
+ console.log(chalk.yellow(' ⚠ This link will expire. Copy it now!'))
94
+ console.log()
95
+ }
96
+
97
+ /**
98
+ * Format expiry time
99
+ */
100
+ function formatExpiry(expiresAt) {
101
+ const expiry = new Date(expiresAt)
102
+ const now = new Date()
103
+ const diffMs = expiry - now
104
+ const diffHours = Math.round(diffMs / (1000 * 60 * 60))
105
+
106
+ if (diffHours < 24) {
107
+ return `in ${diffHours} hour${diffHours !== 1 ? 's' : ''}`
108
+ }
109
+
110
+ const diffDays = Math.round(diffHours / 24)
111
+ return `in ${diffDays} day${diffDays !== 1 ? 's' : ''}`
112
+ }
113
+
114
+ /**
115
+ * Output deploy error (productized)
116
+ */
117
+ export function deployError(err, isJson = false) {
118
+ if (isJson) {
119
+ console.log(JSON.stringify({
120
+ success: false,
121
+ error: {
122
+ code: err.code || 'DEPLOY_FAILED',
123
+ message: err.message,
124
+ missing: err.missing,
125
+ suggestion: err.suggestion
126
+ }
127
+ }, null, 2))
128
+ return
129
+ }
130
+
131
+ console.log()
132
+ console.log(chalk.red.bold(`✗ ${err.code || 'ERROR'}`), chalk.red(err.message))
133
+
134
+ // Show missing files (max 20)
135
+ if (err.missing && err.missing.length > 0) {
136
+ console.log()
137
+ console.log(chalk.dim(' Missing files:'))
138
+ for (const file of err.missing) {
139
+ console.log(chalk.dim(` - ${file}`))
140
+ }
141
+ if (err.hasMore) {
142
+ console.log(chalk.dim(` ... and ${err.totalMissing - 20} more`))
143
+ }
144
+ }
145
+
146
+ // Show suggestion
147
+ if (err.suggestion) {
148
+ console.log()
149
+ console.log(chalk.cyan(' →'), err.suggestion)
150
+ }
151
+
152
+ console.log()
153
+ }
154
+
155
+ /**
156
+ * Output project list
157
+ */
158
+ export function projectList(projects, isJson = false) {
159
+ if (isJson) {
160
+ console.log(JSON.stringify(projects, null, 2))
161
+ return
162
+ }
163
+
164
+ if (!projects || projects.length === 0) {
165
+ info('No projects deployed yet.')
166
+ console.log()
167
+ console.log(' Deploy your first project:')
168
+ console.log(chalk.cyan(' ship2 deploy ./my-site'))
169
+ console.log()
170
+ return
171
+ }
172
+
173
+ console.log()
174
+ console.log(chalk.bold(`Projects (${projects.length})`))
175
+ console.log()
176
+
177
+ for (const project of projects) {
178
+ console.log(chalk.cyan(` ${project.name}`))
179
+ console.log(chalk.dim(` ${project.domain_url}`))
180
+ if (project.updated_at) {
181
+ const date = new Date(project.updated_at).toLocaleDateString()
182
+ console.log(chalk.dim(` Updated: ${date}`))
183
+ }
184
+ console.log()
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Output user info
190
+ */
191
+ export function userInfo(user) {
192
+ console.log()
193
+ console.log(chalk.bold('Logged in as'))
194
+ console.log()
195
+ console.log(' Email:', chalk.cyan(user.email))
196
+ if (user.id) {
197
+ console.log(chalk.dim(` ID: ${user.id}`))
198
+ }
199
+ console.log()
200
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "ship2-cli",
3
+ "version": "0.1.0",
4
+ "description": "Deploy static sites to ship2.app in seconds",
5
+ "main": "lib/index.js",
6
+ "bin": {
7
+ "ship2": "./bin/ship2.js"
8
+ },
9
+ "type": "module",
10
+ "scripts": {
11
+ "dev": "node bin/ship2.js",
12
+ "build": "echo 'No build step needed'",
13
+ "test": "node --test"
14
+ },
15
+ "keywords": [
16
+ "deploy",
17
+ "static-site",
18
+ "hosting",
19
+ "cli"
20
+ ],
21
+ "author": "ship2",
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "chalk": "^5.3.0",
25
+ "commander": "^12.1.0",
26
+ "conf": "^13.0.1",
27
+ "glob": "^11.0.0",
28
+ "mime-types": "^2.1.35",
29
+ "node-fetch": "^3.3.2",
30
+ "open": "^10.1.0",
31
+ "ora": "^8.1.0"
32
+ },
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "files": [
37
+ "bin",
38
+ "lib",
39
+ "commands"
40
+ ],
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/lyonwe/ship2.git",
44
+ "directory": "cli"
45
+ },
46
+ "homepage": "https://ship2.app"
47
+ }