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 +119 -0
- package/bin/ship2.js +57 -0
- package/commands/deploy.js +295 -0
- package/commands/login.js +164 -0
- package/commands/logout.js +16 -0
- package/commands/ls.js +45 -0
- package/commands/whoami.js +26 -0
- package/lib/api.js +107 -0
- package/lib/config.js +76 -0
- package/lib/files.js +255 -0
- package/lib/output.js +200 -0
- package/package.json +47 -0
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
|
+
}
|