@aweray/hsk-cli 0.2.2
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 +356 -0
- package/bin/hsk-cli.js +439 -0
- package/index.js +11 -0
- package/lib/build.js +27 -0
- package/lib/config.js +63 -0
- package/lib/download.js +87 -0
- package/lib/fileHosting.js +257 -0
- package/lib/format.js +116 -0
- package/lib/pack.js +68 -0
- package/lib/pidManager.js +142 -0
- package/lib/platform.js +64 -0
- package/lib/resourceChecker.js +59 -0
- package/lib/resourceStore.js +112 -0
- package/lib/tunnel.js +192 -0
- package/lib/version.js +3 -0
- package/package.json +42 -0
- package/versions.json +18 -0
package/bin/hsk-cli.js
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const tunnel = require('../lib/tunnel');
|
|
6
|
+
const fileHosting = require('../lib/fileHosting');
|
|
7
|
+
const download = require('../lib/download');
|
|
8
|
+
const platform = require('../lib/platform');
|
|
9
|
+
const format = require('../lib/format');
|
|
10
|
+
const pidManager = require('../lib/pidManager');
|
|
11
|
+
const version = require('../lib/version');
|
|
12
|
+
const config = require('../lib/config');
|
|
13
|
+
const build = require('../lib/build');
|
|
14
|
+
const pack = require('../lib/pack');
|
|
15
|
+
const resourceStore = require('../lib/resourceStore');
|
|
16
|
+
const resourceChecker = require('../lib/resourceChecker');
|
|
17
|
+
|
|
18
|
+
// === 全局选项 ===
|
|
19
|
+
program
|
|
20
|
+
.name('hsk-cli')
|
|
21
|
+
.description('HSK CLI - 内网穿透 & 文件托管,支持 Windows/macOS/Linux 多平台')
|
|
22
|
+
.version(version)
|
|
23
|
+
.option('--format <format>', '输出格式: json | pretty | table | ndjson | csv (默认: pretty)', 'pretty')
|
|
24
|
+
.option('--dry-run', '预览模式,不执行实际操作');
|
|
25
|
+
|
|
26
|
+
// 解析全局选项(在子命令之前)
|
|
27
|
+
const globalOpts = program.opts();
|
|
28
|
+
|
|
29
|
+
function getFormat(options) {
|
|
30
|
+
// --json 是 --format json 的快捷方式
|
|
31
|
+
if (options.json) return 'json';
|
|
32
|
+
return options.format || globalOpts.format || 'pretty';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isDryRun(options) {
|
|
36
|
+
return options.dryRun || globalOpts.dryRun || false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function handleOutput(data, formatType) {
|
|
40
|
+
if (formatType === 'pretty') {
|
|
41
|
+
console.log(format.outputFormat(data, 'pretty'));
|
|
42
|
+
} else {
|
|
43
|
+
console.log(format.outputFormat(data, formatType));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function handleError(err, formatType) {
|
|
48
|
+
const data = { success: false, error: err.message };
|
|
49
|
+
if (formatType === 'pretty') {
|
|
50
|
+
console.error(chalk.red('❌ 失败:') + err.message);
|
|
51
|
+
} else {
|
|
52
|
+
console.log(format.outputFormat(data, formatType));
|
|
53
|
+
}
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// === tunnel 命令 ===
|
|
58
|
+
const tunnelCmd = program
|
|
59
|
+
.command('tunnel')
|
|
60
|
+
.description('启动内网穿透隧道')
|
|
61
|
+
.requiredOption('--ip <ip>', '本地服务 IP 地址,如 127.0.0.1')
|
|
62
|
+
.requiredOption('--port <port>', '本地服务端口,如 9000')
|
|
63
|
+
.option('--arch <arch>', '强制指定架构 (win32, win64, macos-x64, macos-arm64, linux-x64, linux-arm64)')
|
|
64
|
+
.option('--force-download', '强制重新下载客户端二进制文件')
|
|
65
|
+
.option('--detach', '后台模式,启动后 CLI 立即退出,隧道在后台运行')
|
|
66
|
+
.option('--reuse', '复用已有隧道,不存在则创建')
|
|
67
|
+
.option('--json', '以 JSON 格式输出(等同于 --format json)')
|
|
68
|
+
.action(async (options) => {
|
|
69
|
+
const fmt = getFormat(options);
|
|
70
|
+
if (isDryRun(options)) {
|
|
71
|
+
const info = options.arch ? platform.fromString(options.arch) : platform.detect();
|
|
72
|
+
const binaryInfo = download.getBinaryInfo(info.platformKey);
|
|
73
|
+
const mode = options.detach ? '后台' : '前台';
|
|
74
|
+
console.log(format.dryRunInfo(
|
|
75
|
+
`${binaryInfo.filename} -ip ${options.ip} -port ${options.port}${options.detach ? ' (后台模式)' : ''}`,
|
|
76
|
+
{
|
|
77
|
+
目标IP: options.ip,
|
|
78
|
+
目标端口: options.port,
|
|
79
|
+
架构: `${info.platform} ${info.arch}`,
|
|
80
|
+
二进制文件: binaryInfo.filename,
|
|
81
|
+
版本: `v${binaryInfo.version}`,
|
|
82
|
+
运行模式: mode,
|
|
83
|
+
预期输出: '公网访问地址 (https://...)',
|
|
84
|
+
}
|
|
85
|
+
));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
if (options.forceDownload) {
|
|
90
|
+
const info = options.arch ? platform.fromString(options.arch) : platform.detect();
|
|
91
|
+
await download.ensureBinary(info.platformKey, true);
|
|
92
|
+
}
|
|
93
|
+
const result = await tunnel.start({
|
|
94
|
+
ip: options.ip,
|
|
95
|
+
port: options.port,
|
|
96
|
+
arch: options.arch,
|
|
97
|
+
detached: options.detach,
|
|
98
|
+
reuse: options.reuse
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!options.detach && fmt === 'pretty') {
|
|
102
|
+
// 前台保活模式:打印成功后不退出,保持子进程运行
|
|
103
|
+
console.log(format.formatPretty(result));
|
|
104
|
+
console.log(chalk.gray('📡 隧道正在运行... (PID: ' + result.pid + ')'));
|
|
105
|
+
console.log(chalk.gray(' 按 Ctrl+C 停止隧道\n'));
|
|
106
|
+
// 保持进程运行,不再 handleOutput(已经打印了)
|
|
107
|
+
} else {
|
|
108
|
+
handleOutput(result, fmt);
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
handleError(err, fmt);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// === tunnel list 命令 ===
|
|
116
|
+
program
|
|
117
|
+
.command('tunnel list')
|
|
118
|
+
.description('查看后台运行的内网穿透隧道')
|
|
119
|
+
.action(() => {
|
|
120
|
+
const records = pidManager.listPids();
|
|
121
|
+
const active = records.filter(r => r.isRunning);
|
|
122
|
+
const dead = records.filter(r => !r.isRunning);
|
|
123
|
+
|
|
124
|
+
if (active.length === 0 && dead.length === 0) {
|
|
125
|
+
console.log(chalk.gray('当前没有后台运行的隧道。'));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (active.length > 0) {
|
|
130
|
+
console.log(chalk.green('🟢 运行中的隧道:'));
|
|
131
|
+
active.forEach(r => {
|
|
132
|
+
console.log(` PID: ${r.pid} | ${r.meta.ip}:${r.meta.port} | 启动: ${r.createdAt}`);
|
|
133
|
+
console.log(` 日志: ${r.logFile}`);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (dead.length > 0) {
|
|
138
|
+
console.log(chalk.yellow('⚪ 已停止的隧道(残留记录):'));
|
|
139
|
+
dead.forEach(r => {
|
|
140
|
+
console.log(` PID: ${r.pid} | ${r.meta.ip}:${r.meta.port}`);
|
|
141
|
+
});
|
|
142
|
+
const cleaned = pidManager.cleanupDeadPids();
|
|
143
|
+
console.log(chalk.gray(` 已清理 ${cleaned} 条残留记录。`));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// === tunnel stop 命令 ===
|
|
148
|
+
program
|
|
149
|
+
.command('tunnel stop')
|
|
150
|
+
.description('停止内网穿透隧道')
|
|
151
|
+
.option('--pid <pid>', '指定要停止的 PID')
|
|
152
|
+
.option('--all', '停止所有后台隧道')
|
|
153
|
+
.action((options) => {
|
|
154
|
+
if (options.all) {
|
|
155
|
+
const count = pidManager.stopAll();
|
|
156
|
+
console.log(chalk.green(`✅ 已停止 ${count} 个后台隧道。`));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (options.pid) {
|
|
160
|
+
const pid = Number(options.pid);
|
|
161
|
+
if (isNaN(pid)) {
|
|
162
|
+
console.error(chalk.red('❌ 无效的 PID'));
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
const ok = pidManager.stopPid(pid);
|
|
166
|
+
if (ok) {
|
|
167
|
+
console.log(chalk.green(`✅ 已停止隧道 (PID: ${pid})`));
|
|
168
|
+
} else {
|
|
169
|
+
console.log(chalk.yellow(`⚠️ PID ${pid} 未找到或已停止`));
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
console.error(chalk.red('❌ 请指定 --pid <PID> 或 --all'));
|
|
174
|
+
process.exit(1);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// === host 命令 ===
|
|
178
|
+
const hostCmd = program
|
|
179
|
+
.command('host <filePath>')
|
|
180
|
+
.description('上传文件到公网进行托管')
|
|
181
|
+
.option('--json', '以 JSON 格式输出(等同于 --format json)')
|
|
182
|
+
.option('--open', '上传完成后自动打开浏览器进行认领')
|
|
183
|
+
.option('--resource-id <id>', '资源 ID(传入则更新已有资源,否则创建新资源)')
|
|
184
|
+
.action(async (filePath, options) => {
|
|
185
|
+
const fmt = getFormat(options);
|
|
186
|
+
if (isDryRun(options)) {
|
|
187
|
+
const fs = require('fs');
|
|
188
|
+
const path = require('path');
|
|
189
|
+
const stats = fs.existsSync(filePath) ? fs.statSync(filePath) : null;
|
|
190
|
+
const uploadBase = fileHosting.getUploadBaseUrl();
|
|
191
|
+
console.log(format.dryRunInfo(
|
|
192
|
+
`POST ${fileHosting.TICKET_API}`,
|
|
193
|
+
{
|
|
194
|
+
文件路径: filePath,
|
|
195
|
+
文件大小: stats ? `${(stats.size / 1024 / 1024).toFixed(2)} MB` : '文件不存在',
|
|
196
|
+
资源ID: options.resourceId || '(新建)',
|
|
197
|
+
预期操作: '计算 SHA-256 → 获取 ticket → 上传/更新文件',
|
|
198
|
+
上传服务: options.resourceId ? `PUT ${uploadBase}/resource/update` : `POST ${uploadBase}/upload`,
|
|
199
|
+
预期输出: 'publicUrl + resourceId',
|
|
200
|
+
}
|
|
201
|
+
));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const result = await fileHosting.host(filePath, { resourceId: options.resourceId });
|
|
206
|
+
handleOutput(result, fmt);
|
|
207
|
+
if (options.open && result.publicUrl) {
|
|
208
|
+
console.log(chalk.gray('🌐 正在打开浏览器...'));
|
|
209
|
+
await fileHosting.openClaimUrl(result.publicUrl);
|
|
210
|
+
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
handleError(err, fmt);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// === download 命令 ===
|
|
217
|
+
program
|
|
218
|
+
.command('download')
|
|
219
|
+
.description('预下载对应平台的客户端二进制文件')
|
|
220
|
+
.option('--arch <arch>', '指定架构,默认自动检测')
|
|
221
|
+
.action(async (options) => {
|
|
222
|
+
if (isDryRun(options)) {
|
|
223
|
+
const info = options.arch ? platform.fromString(options.arch) : platform.detect();
|
|
224
|
+
const binaryInfo = download.getBinaryInfo(info.platformKey);
|
|
225
|
+
console.log(format.dryRunInfo(
|
|
226
|
+
`下载 ${binaryInfo.filename}`,
|
|
227
|
+
{ 架构: `${info.platform} ${info.arch}`, 版本: binaryInfo.version, 保存路径: `~/.hsk/bin/${binaryInfo.filename}` }
|
|
228
|
+
));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const binPath = await download.ensureBinary(options.arch, true);
|
|
233
|
+
console.log(chalk.green('✅ 客户端已就绪:') + binPath);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
console.error(chalk.red('❌ 下载失败:') + err.message);
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// === update 命令 ===
|
|
241
|
+
program
|
|
242
|
+
.command('update')
|
|
243
|
+
.description('检查并更新客户端二进制文件到最新版本')
|
|
244
|
+
.option('--arch <arch>', '指定架构,默认自动检测')
|
|
245
|
+
.option('--force', '强制重新下载,即使本地已有')
|
|
246
|
+
.action(async (options) => {
|
|
247
|
+
const fs = require('fs');
|
|
248
|
+
const info = options.arch ? platform.fromString(options.arch) : platform.detect();
|
|
249
|
+
const binaryInfo = download.getBinaryInfo(info.platformKey);
|
|
250
|
+
|
|
251
|
+
if (isDryRun(options)) {
|
|
252
|
+
const binPath = download.getBinaryPath(binaryInfo.filename);
|
|
253
|
+
const hasLocal = fs.existsSync(binPath);
|
|
254
|
+
console.log(format.dryRunInfo(
|
|
255
|
+
hasLocal ? `重新下载 ${binaryInfo.filename}` : `下载 ${binaryInfo.filename}`,
|
|
256
|
+
{
|
|
257
|
+
架构: `${info.platform} ${info.arch}`,
|
|
258
|
+
版本: `v${binaryInfo.version}`,
|
|
259
|
+
本地状态: hasLocal ? '已存在' : '首次下载',
|
|
260
|
+
保存路径: `~/.hsk/bin/${binaryInfo.filename}`,
|
|
261
|
+
下载源: binaryInfo.downloadUrl
|
|
262
|
+
}
|
|
263
|
+
));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const binPath = download.getBinaryPath(binaryInfo.filename);
|
|
269
|
+
const hasLocal = fs.existsSync(binPath);
|
|
270
|
+
|
|
271
|
+
if (hasLocal && !options.force) {
|
|
272
|
+
console.log(chalk.green(`✅ 本地已有 v${binaryInfo.version}: ${binaryInfo.filename}`));
|
|
273
|
+
console.log(chalk.gray(` 路径: ${binPath}`));
|
|
274
|
+
console.log(chalk.gray(` 使用 --force 强制重新下载`));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (options.force) {
|
|
279
|
+
console.log(chalk.cyan('🔄 强制重新下载...'));
|
|
280
|
+
} else {
|
|
281
|
+
console.log(chalk.cyan('⬇️ 首次下载...'));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const downloadedPath = await download.ensureBinary(options.arch, true);
|
|
285
|
+
console.log(chalk.green(`✅ 更新完成: ${downloadedPath}`));
|
|
286
|
+
console.log(chalk.gray(` 版本: v${binaryInfo.version}`));
|
|
287
|
+
} catch (err) {
|
|
288
|
+
console.error(chalk.red('❌ 更新失败:') + err.message);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// === deploy 命令 ===
|
|
294
|
+
program
|
|
295
|
+
.command('deploy')
|
|
296
|
+
.description('构建项目并上传构建产物到文件托管')
|
|
297
|
+
.option('--build-cmd <cmd>', '构建命令,如 "npm run build"')
|
|
298
|
+
.option('--build-dir <dir>', '构建输出目录,如 "dist"')
|
|
299
|
+
.option('--pack-output <file>', '打包输出文件名,如 "dist.zip"', 'dist.zip')
|
|
300
|
+
.option('--no-build', '跳过构建,直接打包上传')
|
|
301
|
+
.option('--no-clean', '上传后保留打包文件')
|
|
302
|
+
.option('--resource-id <id>', '资源 ID(更新已有资源)')
|
|
303
|
+
.option('--open', '上传完成后自动打开浏览器进行认领')
|
|
304
|
+
.option('--json', '以 JSON 格式输出(等同于 --format json)')
|
|
305
|
+
.action(async (options) => {
|
|
306
|
+
const fmt = getFormat(options);
|
|
307
|
+
const fs = require('fs');
|
|
308
|
+
|
|
309
|
+
// 读取配置(命令行 > 项目配置 > 全局配置 > 默认配置)
|
|
310
|
+
const cfg = config.getDeployConfig();
|
|
311
|
+
const buildCmd = options.buildCmd || cfg.buildCmd;
|
|
312
|
+
const buildDir = options.buildDir || cfg.buildDir;
|
|
313
|
+
const packOutput = options.packOutput || cfg.packOutput;
|
|
314
|
+
const cleanAfter = options.clean !== false && cfg.cleanAfterUpload;
|
|
315
|
+
|
|
316
|
+
if (isDryRun(options)) {
|
|
317
|
+
const info = options.arch ? platform.fromString(options.arch) : platform.detect();
|
|
318
|
+
console.log(format.dryRunInfo(
|
|
319
|
+
`deploy: ${buildCmd} → pack ${buildDir} → upload ${packOutput}`,
|
|
320
|
+
{
|
|
321
|
+
构建命令: buildCmd,
|
|
322
|
+
构建目录: buildDir,
|
|
323
|
+
打包文件: packOutput,
|
|
324
|
+
上传后清理: cleanAfter,
|
|
325
|
+
资源ID: options.resourceId || '(新建)'
|
|
326
|
+
}
|
|
327
|
+
));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
// 1. 构建
|
|
333
|
+
if (options.build !== false) {
|
|
334
|
+
console.log(chalk.cyan('🔨 开始构建...'));
|
|
335
|
+
console.log(chalk.gray(` 命令: ${buildCmd}`));
|
|
336
|
+
await build.execBuild(buildCmd);
|
|
337
|
+
console.log(chalk.green('✅ 构建完成'));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 2. 检查构建目录
|
|
341
|
+
if (!fs.existsSync(buildDir)) {
|
|
342
|
+
throw new Error(`构建目录不存在: ${buildDir}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 3. 打包
|
|
346
|
+
console.log(chalk.cyan(`📦 打包 ${buildDir}...`));
|
|
347
|
+
const packResult = await pack.packDir(buildDir, packOutput);
|
|
348
|
+
console.log(chalk.green(`✅ 打包完成: ${packResult.path}`));
|
|
349
|
+
console.log(chalk.gray(` 大小: ${(packResult.size / 1024 / 1024).toFixed(2)} MB`));
|
|
350
|
+
|
|
351
|
+
// 4. 上传
|
|
352
|
+
console.log(chalk.cyan('☁️ 上传中...'));
|
|
353
|
+
const result = await fileHosting.host(packResult.path, { resourceId: options.resourceId });
|
|
354
|
+
|
|
355
|
+
// 5. 清理
|
|
356
|
+
if (cleanAfter) {
|
|
357
|
+
pack.cleanupPack(packResult.path);
|
|
358
|
+
console.log(chalk.gray('🧹 已清理打包文件'));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
handleOutput(result, fmt);
|
|
362
|
+
|
|
363
|
+
if (options.open && result.publicUrl) {
|
|
364
|
+
console.log(chalk.gray('🌐 正在打开浏览器...'));
|
|
365
|
+
await fileHosting.openClaimUrl(result.publicUrl);
|
|
366
|
+
}
|
|
367
|
+
} catch (err) {
|
|
368
|
+
handleError(err, fmt);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// === status 命令 ===
|
|
373
|
+
program
|
|
374
|
+
.command('status')
|
|
375
|
+
.description('检查已发布资源的状态(仅内网穿透)')
|
|
376
|
+
.option('--json', '以 JSON 格式输出(等同于 --format json)')
|
|
377
|
+
.action(async (options) => {
|
|
378
|
+
const fmt = getFormat(options);
|
|
379
|
+
const all = resourceStore.getAllResources();
|
|
380
|
+
const tunnels = all.filter((r) => r.type === 'tunnel');
|
|
381
|
+
|
|
382
|
+
const results = [];
|
|
383
|
+
for (const r of tunnels) {
|
|
384
|
+
const check = await resourceChecker.checkResource(r);
|
|
385
|
+
results.push({ ...r, valid: check.valid, reason: check.reason, detail: check.detail });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (fmt === 'json') {
|
|
389
|
+
console.log(JSON.stringify({ resources: results }, null, 2));
|
|
390
|
+
} else {
|
|
391
|
+
if (results.length === 0) {
|
|
392
|
+
console.log(chalk.gray('暂无隧道资源记录'));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
console.log(chalk.cyan('📋 隧道状态:\n'));
|
|
396
|
+
for (const r of results) {
|
|
397
|
+
const statusIcon = r.valid ? chalk.green('✅') : chalk.red('❌');
|
|
398
|
+
console.log(`${statusIcon} ${r.localIp}:${r.localPort}`);
|
|
399
|
+
console.log(` URL: ${r.publicUrl || 'N/A'}`);
|
|
400
|
+
console.log(` 状态: ${r.status} | ${r.valid ? '有效' : '已失效'}`);
|
|
401
|
+
if (!r.valid) {
|
|
402
|
+
console.log(` 原因: ${r.detail || r.reason}`);
|
|
403
|
+
}
|
|
404
|
+
console.log();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// === platform 命令 ===
|
|
410
|
+
program
|
|
411
|
+
.command('platform')
|
|
412
|
+
.description('显示当前检测到的平台信息')
|
|
413
|
+
.action(() => {
|
|
414
|
+
const info = platform.detect();
|
|
415
|
+
console.log(JSON.stringify(info, null, 2));
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// === Shortcut 层命令(+ 前缀) ===
|
|
419
|
+
// Commander 不直接支持 + 前缀,但我们可以解析原始参数
|
|
420
|
+
// 如果用户输入 +tunnel 或 +host,转换为对应的子命令
|
|
421
|
+
const rawArgs = process.argv.slice(2);
|
|
422
|
+
if (rawArgs.length > 0 && rawArgs[0].startsWith('+')) {
|
|
423
|
+
const shortcut = rawArgs[0].slice(1); // 去掉 + 前缀
|
|
424
|
+
if (shortcut === 'tunnel') {
|
|
425
|
+
rawArgs[0] = 'tunnel';
|
|
426
|
+
} else if (shortcut === 'host') {
|
|
427
|
+
rawArgs[0] = 'host';
|
|
428
|
+
} else if (shortcut === 'deploy') {
|
|
429
|
+
rawArgs[0] = 'deploy';
|
|
430
|
+
} else {
|
|
431
|
+
console.error(chalk.red(`❌ 未知 Shortcut: +${shortcut}`));
|
|
432
|
+
console.log(chalk.gray('可用 Shortcuts: +tunnel, +host, +deploy'));
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
// 重新设置 process.argv 让 Commander 解析
|
|
436
|
+
process.argv = [process.argv[0], process.argv[1], ...rawArgs];
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
program.parse(process.argv);
|
package/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const tunnel = require('./lib/tunnel');
|
|
2
|
+
const fileHosting = require('./lib/fileHosting');
|
|
3
|
+
const download = require('./lib/download');
|
|
4
|
+
const platform = require('./lib/platform');
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
tunnel,
|
|
8
|
+
fileHosting,
|
|
9
|
+
download,
|
|
10
|
+
platform
|
|
11
|
+
};
|
package/lib/build.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 执行构建命令
|
|
5
|
+
* @param {string} buildCmd - 构建命令,如 "npm run build"
|
|
6
|
+
* @param {string} cwd - 工作目录
|
|
7
|
+
*/
|
|
8
|
+
function execBuild(buildCmd, cwd = process.cwd()) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const [cmd, ...args] = buildCmd.split(' ');
|
|
11
|
+
const proc = spawn(cmd, args, { cwd, stdio: 'inherit', shell: true });
|
|
12
|
+
|
|
13
|
+
proc.on('close', (code) => {
|
|
14
|
+
if (code === 0) {
|
|
15
|
+
resolve();
|
|
16
|
+
} else {
|
|
17
|
+
reject(new Error(`Build 失败,退出码: ${code}`));
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
proc.on('error', (err) => {
|
|
22
|
+
reject(new Error(`Build 执行失败: ${err.message}`));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { execBuild };
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONFIG = {
|
|
6
|
+
deploy: {
|
|
7
|
+
buildCmd: 'npm run build',
|
|
8
|
+
buildDir: 'dist',
|
|
9
|
+
packFormat: 'zip',
|
|
10
|
+
packOutput: 'dist.zip',
|
|
11
|
+
cleanAfterUpload: true
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 从当前目录向上查找 package.json
|
|
17
|
+
*/
|
|
18
|
+
function findProjectConfig() {
|
|
19
|
+
let cwd = process.cwd();
|
|
20
|
+
while (cwd !== path.dirname(cwd)) {
|
|
21
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
22
|
+
if (fs.existsSync(pkgPath)) {
|
|
23
|
+
try {
|
|
24
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
25
|
+
return pkg.hsk || null;
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
cwd = path.dirname(cwd);
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 读取全局配置 ~/.hsk/config.json
|
|
37
|
+
*/
|
|
38
|
+
function findGlobalConfig() {
|
|
39
|
+
const globalPath = path.join(os.homedir(), '.hsk', 'config.json');
|
|
40
|
+
if (fs.existsSync(globalPath)) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(fs.readFileSync(globalPath, 'utf8'));
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 获取 deploy 配置,优先级:命令行 > 项目配置 > 全局配置 > 默认配置
|
|
52
|
+
*/
|
|
53
|
+
function getDeployConfig() {
|
|
54
|
+
const project = findProjectConfig();
|
|
55
|
+
const global = findGlobalConfig();
|
|
56
|
+
return {
|
|
57
|
+
...DEFAULT_CONFIG.deploy,
|
|
58
|
+
...(global?.deploy || {}),
|
|
59
|
+
...(project?.deploy || {})
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { getDeployConfig, DEFAULT_CONFIG, findProjectConfig, findGlobalConfig };
|
package/lib/download.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const fetch = require('node-fetch');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
const platform = require('./platform');
|
|
7
|
+
|
|
8
|
+
const BIN_DIR = path.join(os.homedir(), '.hsk', 'bin');
|
|
9
|
+
const VERSIONS_JSON = require('../versions.json');
|
|
10
|
+
|
|
11
|
+
function ensureBinDir() {
|
|
12
|
+
if (!fs.existsSync(BIN_DIR)) {
|
|
13
|
+
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
return BIN_DIR;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getBinaryPath(filename) {
|
|
19
|
+
return path.join(ensureBinDir(), filename);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isBinaryExists(filename) {
|
|
23
|
+
return fs.existsSync(getBinaryPath(filename));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 根据平台信息获取对应的版本配置
|
|
27
|
+
function getBinaryInfo(platformKey) {
|
|
28
|
+
const binaryConfig = VERSIONS_JSON.binaries[platformKey];
|
|
29
|
+
if (!binaryConfig) {
|
|
30
|
+
throw new Error(`该平台暂无可用二进制: ${platformKey}。可用平台: ${Object.keys(VERSIONS_JSON.binaries).join(', ')}`);
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
version: VERSIONS_JSON.version,
|
|
34
|
+
filename: binaryConfig.filename,
|
|
35
|
+
downloadUrl: `${VERSIONS_JSON.downloadBaseUrl}/${binaryConfig.filename}`
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function downloadBinary(binaryInfo, showProgress = true) {
|
|
40
|
+
const { filename, downloadUrl, version } = binaryInfo;
|
|
41
|
+
const binPath = getBinaryPath(filename);
|
|
42
|
+
|
|
43
|
+
if (showProgress) {
|
|
44
|
+
console.log(chalk.gray(`⬇️ 下载 ${filename} (v${version})...`));
|
|
45
|
+
console.log(chalk.gray(` 来源: ${downloadUrl}`));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const response = await fetch(downloadUrl);
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new Error(`下载失败: HTTP ${response.status} - ${response.statusText}\nURL: ${downloadUrl}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const buffer = await response.buffer();
|
|
54
|
+
fs.writeFileSync(binPath, buffer);
|
|
55
|
+
|
|
56
|
+
if (process.platform !== 'win32') {
|
|
57
|
+
fs.chmodSync(binPath, 0o755);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (showProgress) {
|
|
61
|
+
console.log(chalk.green(`✅ 下载完成: ${binPath}`));
|
|
62
|
+
}
|
|
63
|
+
return binPath;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 获取当前平台需要的二进制,自动下载/更新
|
|
67
|
+
async function ensureBinary(archOverride, forceDownload = false) {
|
|
68
|
+
const info = archOverride ? platform.fromString(archOverride) : platform.detect();
|
|
69
|
+
const binaryInfo = getBinaryInfo(info.platformKey);
|
|
70
|
+
const binPath = getBinaryPath(binaryInfo.filename);
|
|
71
|
+
|
|
72
|
+
// 检查是否已有正确版本(通过文件名中的版本号判断)
|
|
73
|
+
if (!forceDownload && isBinaryExists(binaryInfo.filename)) {
|
|
74
|
+
console.log(chalk.gray(`📦 使用本地缓存: ${binaryInfo.filename}`));
|
|
75
|
+
return binPath;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return downloadBinary(binaryInfo, true);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 获取当前平台版本信息
|
|
82
|
+
function getCurrentVersionInfo() {
|
|
83
|
+
const info = platform.detect();
|
|
84
|
+
return getBinaryInfo(info.platformKey);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { ensureBinary, downloadBinary, getBinaryPath, getBinaryInfo, getCurrentVersionInfo, BIN_DIR };
|