@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
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fetch = require('node-fetch');
|
|
5
|
+
const FormData = require('form-data');
|
|
6
|
+
const { spawn } = require('child_process');
|
|
7
|
+
const version = require('./version');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONFIG = {
|
|
10
|
+
ticketHost: 'onion-forward-api.oraybeta.com',
|
|
11
|
+
ticketPort: 443,
|
|
12
|
+
ticketScheme: 'https',
|
|
13
|
+
uploadHost: 'onion-fw-test2.oraybeta.com',
|
|
14
|
+
uploadPort: 8010,
|
|
15
|
+
uploadScheme: 'https',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const HEADERS_BASE = {
|
|
19
|
+
'User-Agent': `HSK-CLI/${version}`,
|
|
20
|
+
Accept: '*/*',
|
|
21
|
+
Connection: 'keep-alive',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function getTicketApiUrl() {
|
|
25
|
+
if (process.env.HSK_FILE_HOSTING_API) {
|
|
26
|
+
return process.env.HSK_FILE_HOSTING_API.replace(/\/+$/, '');
|
|
27
|
+
}
|
|
28
|
+
const host = process.env.HSK_FILE_HOSTING_TICKET_HOST || DEFAULT_CONFIG.ticketHost;
|
|
29
|
+
const port = process.env.HSK_FILE_HOSTING_TICKET_PORT || DEFAULT_CONFIG.ticketPort;
|
|
30
|
+
const scheme = process.env.HSK_FILE_HOSTING_TICKET_SCHEME || DEFAULT_CONFIG.ticketScheme;
|
|
31
|
+
return `${scheme}://${host}:${port}/public/file-hosting`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getUploadBaseUrl() {
|
|
35
|
+
const host = process.env.HSK_FILE_HOSTING_UPLOAD_HOST || DEFAULT_CONFIG.uploadHost;
|
|
36
|
+
const port = process.env.HSK_FILE_HOSTING_UPLOAD_PORT || DEFAULT_CONFIG.uploadPort;
|
|
37
|
+
const scheme = process.env.HSK_FILE_HOSTING_UPLOAD_SCHEME || DEFAULT_CONFIG.uploadScheme;
|
|
38
|
+
return `${scheme}://${host}:${port}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const TICKET_API = getTicketApiUrl();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 检查文件托管 API 配置
|
|
45
|
+
*/
|
|
46
|
+
function checkApiConfig() {
|
|
47
|
+
// 可通过环境变量覆盖 ticket / upload 服务地址,见 getTicketApiUrl / getUploadBaseUrl
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 计算文件 SHA-256 hash
|
|
52
|
+
*/
|
|
53
|
+
function sha256File(filePath) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const hash = crypto.createHash('sha256');
|
|
56
|
+
const stream = fs.createReadStream(filePath);
|
|
57
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
58
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
59
|
+
stream.on('error', reject);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 获取文件托管 ticket(创建不传 resourceId,更新时传 resourceId)
|
|
65
|
+
*/
|
|
66
|
+
async function getFileHostingTicket(fileHash, resourceId = null) {
|
|
67
|
+
const payload = { file_hash: fileHash };
|
|
68
|
+
if (resourceId) {
|
|
69
|
+
payload.resource_id = resourceId;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const res = await fetch(TICKET_API, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { ...HEADERS_BASE, 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify(payload),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
const text = await res.text();
|
|
80
|
+
throw new Error(`获取 ticket 失败: HTTP ${res.status} - ${text.slice(0, 500)}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const json = await res.json();
|
|
84
|
+
const data = json.data;
|
|
85
|
+
|
|
86
|
+
if (!data || !data.ticket) {
|
|
87
|
+
throw new Error(`获取 ticket 响应格式异常: ${JSON.stringify(json)}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return data;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 上传/更新资源文件
|
|
95
|
+
* @param {string} token - Bearer ticket
|
|
96
|
+
* @param {string} filePath - 本地文件路径
|
|
97
|
+
* @param {{ update?: boolean, uploadUrl?: string }} options
|
|
98
|
+
*/
|
|
99
|
+
async function uploadFile(token, filePath, options = {}) {
|
|
100
|
+
const { update = false, uploadUrl } = options;
|
|
101
|
+
const fileName = path.basename(filePath);
|
|
102
|
+
const form = new FormData();
|
|
103
|
+
form.append('file', fs.createReadStream(filePath), { filename: fileName });
|
|
104
|
+
|
|
105
|
+
const url = uploadUrl || `${getUploadBaseUrl()}${update ? '/resource/update' : '/upload'}`;
|
|
106
|
+
const method = update ? 'PUT' : 'POST';
|
|
107
|
+
|
|
108
|
+
const res = await fetch(url, {
|
|
109
|
+
method,
|
|
110
|
+
headers: {
|
|
111
|
+
...HEADERS_BASE,
|
|
112
|
+
Authorization: `Bearer ${token}`,
|
|
113
|
+
...form.getHeaders(),
|
|
114
|
+
},
|
|
115
|
+
body: form,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
const text = await res.text();
|
|
120
|
+
throw new Error(`文件${update ? '更新' : '上传'}失败: HTTP ${res.status} - ${text.slice(0, 500)}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return res.json();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolveResourceId(ticketData, uploadResult, resourceIdInput) {
|
|
127
|
+
let resourceId = ticketData.resource_id || resourceIdInput || null;
|
|
128
|
+
const resultData = uploadResult && uploadResult.data;
|
|
129
|
+
|
|
130
|
+
if (resultData && typeof resultData === 'object' && resultData.resource_id) {
|
|
131
|
+
resourceId = resultData.resource_id;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!resourceId && ticketData.public_url) {
|
|
135
|
+
const parts = ticketData.public_url.replace(/\/+$/, '').split('/');
|
|
136
|
+
if (parts.length) {
|
|
137
|
+
resourceId = parts[parts.length - 1];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return resourceId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 完整文件托管流程(创建或更新)
|
|
146
|
+
* @param {string} filePath
|
|
147
|
+
* @param {{ resourceId?: string }} options
|
|
148
|
+
*/
|
|
149
|
+
async function host(filePath, options = {}) {
|
|
150
|
+
checkApiConfig();
|
|
151
|
+
|
|
152
|
+
if (!fs.existsSync(filePath)) {
|
|
153
|
+
throw new Error(`文件不存在: ${filePath}`);
|
|
154
|
+
}
|
|
155
|
+
if (fs.statSync(filePath).isDirectory()) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`路径是目录而非文件: ${filePath}\n` +
|
|
158
|
+
`请使用 \`hsk-cli deploy\` 命令上传目录,或手动打包后上传。`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (!fs.statSync(filePath).isFile()) {
|
|
162
|
+
throw new Error(`路径不是文件: ${filePath}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const fileName = path.basename(filePath);
|
|
166
|
+
const resourceIdInput = options.resourceId || null;
|
|
167
|
+
const isUpdate = Boolean(resourceIdInput);
|
|
168
|
+
|
|
169
|
+
if (process.env.HSK_FILE_HOSTING_DIRECT) {
|
|
170
|
+
const form = new FormData();
|
|
171
|
+
form.append('file', fs.createReadStream(filePath), { filename: fileName });
|
|
172
|
+
|
|
173
|
+
const res = await fetch(TICKET_API, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { ...form.getHeaders() },
|
|
176
|
+
body: form,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
throw new Error(`文件上传失败: HTTP ${res.status} - ${res.statusText}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const json = await res.json();
|
|
184
|
+
const baseUrl = TICKET_API.replace(/\/public\/file-hosting$/, '');
|
|
185
|
+
const publicUrl = `${baseUrl}/${fileName}`;
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
success: true,
|
|
189
|
+
mode: 'direct',
|
|
190
|
+
publicUrl,
|
|
191
|
+
fileName,
|
|
192
|
+
filePath,
|
|
193
|
+
serverResponse: json,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const fileHash = await sha256File(filePath);
|
|
198
|
+
const ticket = await getFileHostingTicket(fileHash, resourceIdInput);
|
|
199
|
+
|
|
200
|
+
const useLegacyUpload = Boolean(ticket.server_address);
|
|
201
|
+
let uploadResult;
|
|
202
|
+
let uploadError;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
if (useLegacyUpload) {
|
|
206
|
+
uploadResult = await uploadFile(ticket.ticket, filePath, {
|
|
207
|
+
uploadUrl: ticket.server_address,
|
|
208
|
+
update: isUpdate,
|
|
209
|
+
});
|
|
210
|
+
} else {
|
|
211
|
+
uploadResult = await uploadFile(ticket.ticket, filePath, { update: isUpdate });
|
|
212
|
+
}
|
|
213
|
+
} catch (err) {
|
|
214
|
+
uploadError = err.message;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const resourceId = resolveResourceId(ticket, uploadResult, resourceIdInput);
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
success: true,
|
|
221
|
+
mode: isUpdate ? 'update' : 'create',
|
|
222
|
+
publicUrl: ticket.public_url,
|
|
223
|
+
verifyCode: ticket.verify_code,
|
|
224
|
+
resourceId,
|
|
225
|
+
uploadHost: useLegacyUpload ? ticket.server_address : getUploadBaseUrl(),
|
|
226
|
+
fileName,
|
|
227
|
+
fileHash,
|
|
228
|
+
filePath,
|
|
229
|
+
uploadResponse: uploadResult,
|
|
230
|
+
error: uploadError,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 打开浏览器认领页面
|
|
236
|
+
*/
|
|
237
|
+
function openClaimUrl(url) {
|
|
238
|
+
return new Promise((resolve) => {
|
|
239
|
+
const platform = process.platform;
|
|
240
|
+
const command = platform === 'win32' ? 'start' : platform === 'darwin' ? 'open' : 'xdg-open';
|
|
241
|
+
const proc = spawn(command, [url], { stdio: 'ignore', detached: true, shell: platform === 'win32' });
|
|
242
|
+
proc.on('error', () => { /* ignore */ });
|
|
243
|
+
proc.unref();
|
|
244
|
+
resolve();
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
TICKET_API,
|
|
250
|
+
getTicketApiUrl,
|
|
251
|
+
getUploadBaseUrl,
|
|
252
|
+
sha256File,
|
|
253
|
+
getFileHostingTicket,
|
|
254
|
+
uploadFile,
|
|
255
|
+
host,
|
|
256
|
+
openClaimUrl,
|
|
257
|
+
};
|
package/lib/format.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 格式化输出工具
|
|
5
|
+
* 支持 json / pretty / table / ndjson 格式
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function outputFormat(data, format) {
|
|
9
|
+
switch (format) {
|
|
10
|
+
case 'json':
|
|
11
|
+
return JSON.stringify(data, null, 2);
|
|
12
|
+
|
|
13
|
+
case 'ndjson':
|
|
14
|
+
return JSON.stringify(data);
|
|
15
|
+
|
|
16
|
+
case 'csv':
|
|
17
|
+
// 简单 CSV 实现(仅支持平铺对象)
|
|
18
|
+
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
|
|
19
|
+
const keys = Object.keys(data);
|
|
20
|
+
const header = keys.join(',');
|
|
21
|
+
const values = keys.map(k => {
|
|
22
|
+
const v = data[k];
|
|
23
|
+
if (typeof v === 'string') return `"${v.replace(/"/g, '""')}"`;
|
|
24
|
+
return String(v);
|
|
25
|
+
}).join(',');
|
|
26
|
+
return `${header}\n${values}`;
|
|
27
|
+
}
|
|
28
|
+
return JSON.stringify(data);
|
|
29
|
+
|
|
30
|
+
case 'table':
|
|
31
|
+
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
|
|
32
|
+
const rows = Object.entries(data).map(([k, v]) => {
|
|
33
|
+
const val = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
34
|
+
return `${chalk.gray(k.padEnd(16))} │ ${val}`;
|
|
35
|
+
});
|
|
36
|
+
return rows.join('\n');
|
|
37
|
+
}
|
|
38
|
+
return JSON.stringify(data, null, 2);
|
|
39
|
+
|
|
40
|
+
case 'pretty':
|
|
41
|
+
default:
|
|
42
|
+
return formatPretty(data);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatPretty(data) {
|
|
47
|
+
// 根据数据类型返回人类友好的格式化文本
|
|
48
|
+
if (data.success === true && data.publicUrl) {
|
|
49
|
+
if (data.fileName) {
|
|
50
|
+
// 文件托管结果
|
|
51
|
+
return [
|
|
52
|
+
'\n' + chalk.green('✅ 文件托管已创建!') + '\n',
|
|
53
|
+
chalk.gray('📁 文件:') + data.fileName,
|
|
54
|
+
chalk.gray('🔍 Hash:') + data.fileHash,
|
|
55
|
+
chalk.cyan('🔗 公网访问地址:') + chalk.underline.bold(data.publicUrl),
|
|
56
|
+
data.error ? chalk.red('⚠️ 上传异常:') + data.error : '',
|
|
57
|
+
'\n' + chalk.yellow('📋 下一步:'),
|
|
58
|
+
' 1. 在浏览器中打开上方公网地址',
|
|
59
|
+
' 2. 点击页面「确认认领」按钮完成绑定',
|
|
60
|
+
' 3. 认领后文件将绑定到你的账户\n',
|
|
61
|
+
].filter(Boolean).join('\n');
|
|
62
|
+
} else {
|
|
63
|
+
// 内网穿透结果
|
|
64
|
+
const lines = [
|
|
65
|
+
'\n' + chalk.green('✅ 内网穿透已建立!') + '\n',
|
|
66
|
+
chalk.cyan('🔗 公网访问地址:') + chalk.underline.bold(data.publicUrl),
|
|
67
|
+
chalk.gray('📌 本地目标:') + `http://${data.ip}:${data.port}`,
|
|
68
|
+
];
|
|
69
|
+
if (data.pid) {
|
|
70
|
+
lines.push(chalk.gray('🔧 进程 PID:') + data.pid);
|
|
71
|
+
}
|
|
72
|
+
if (data.detached) {
|
|
73
|
+
lines.push(chalk.gray('📂 日志文件:') + data.logFile);
|
|
74
|
+
}
|
|
75
|
+
lines.push(
|
|
76
|
+
'\n' + chalk.yellow('📋 下一步:'),
|
|
77
|
+
' 1. 在浏览器中打开上方公网地址',
|
|
78
|
+
' 2. 按页面提示完成激活认领',
|
|
79
|
+
' 3. 激活后隧道将绑定到你的账户'
|
|
80
|
+
);
|
|
81
|
+
if (!data.detached) {
|
|
82
|
+
lines.push(
|
|
83
|
+
'\n' + chalk.gray('⏹️ 按 Ctrl+C 停止隧道')
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return lines.join('\n');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 后台模式无 URL(还未捕获到)
|
|
91
|
+
if (data.success === true && data.detached) {
|
|
92
|
+
return [
|
|
93
|
+
'\n' + chalk.green('✅ 后台隧道已启动!') + '\n',
|
|
94
|
+
chalk.gray('📌 本地目标:') + `http://${data.ip}:${data.port}`,
|
|
95
|
+
chalk.gray('🔧 进程 PID:') + data.pid,
|
|
96
|
+
chalk.gray('📂 日志文件:') + data.logFile,
|
|
97
|
+
'\n' + chalk.yellow('📋 提示:'),
|
|
98
|
+
' 使用 `hsk-cli tunnel list` 查看后台隧道',
|
|
99
|
+
' 使用 `hsk-cli tunnel stop --pid ' + data.pid + '` 停止该隧道\n',
|
|
100
|
+
].join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (data.success === false) {
|
|
104
|
+
return chalk.red('❌ 失败:') + (data.error || '未知错误');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 默认回退到 json
|
|
108
|
+
return JSON.stringify(data, null, 2);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function dryRunInfo(command, details) {
|
|
112
|
+
return chalk.yellow('[DRY-RUN] ') + chalk.gray('将执行: ') + chalk.cyan(command) + '\n' +
|
|
113
|
+
Object.entries(details).map(([k, v]) => ` ${chalk.gray(k)}: ${v}`).join('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { outputFormat, formatPretty, dryRunInfo };
|
package/lib/pack.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 将目录打包为 zip
|
|
7
|
+
* 跨平台实现:
|
|
8
|
+
* - Windows: PowerShell Compress-Archive
|
|
9
|
+
* - macOS/Linux: zip 命令
|
|
10
|
+
*/
|
|
11
|
+
function packDir(sourceDir, outputFile) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const absSource = path.resolve(sourceDir);
|
|
14
|
+
const absOutput = path.resolve(outputFile);
|
|
15
|
+
|
|
16
|
+
if (!fs.existsSync(absSource)) {
|
|
17
|
+
return reject(new Error(`打包目录不存在: ${absSource}`));
|
|
18
|
+
}
|
|
19
|
+
if (!fs.statSync(absSource).isDirectory()) {
|
|
20
|
+
return reject(new Error(`路径不是目录: ${absSource}`));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let proc;
|
|
24
|
+
if (process.platform === 'win32') {
|
|
25
|
+
// Windows: PowerShell Compress-Archive
|
|
26
|
+
// 需要把目录内容单独列出,避免 zip 包含多余的父目录
|
|
27
|
+
const items = fs.readdirSync(absSource).map(item => path.join(absSource, item));
|
|
28
|
+
if (items.length === 0) {
|
|
29
|
+
return reject(new Error(`目录为空,无法打包: ${absSource}`));
|
|
30
|
+
}
|
|
31
|
+
const itemsStr = items.map(i => `"${i}"`).join(',');
|
|
32
|
+
proc = spawn('powershell', [
|
|
33
|
+
'-Command',
|
|
34
|
+
`Compress-Archive -Path ${itemsStr} -DestinationPath "${absOutput}" -Force`
|
|
35
|
+
], { stdio: 'inherit' });
|
|
36
|
+
} else {
|
|
37
|
+
// macOS/Linux: zip -r output.zip .
|
|
38
|
+
proc = spawn('zip', ['-r', absOutput, '.'], {
|
|
39
|
+
cwd: absSource,
|
|
40
|
+
stdio: 'inherit'
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
proc.on('close', (code) => {
|
|
45
|
+
if (code === 0) {
|
|
46
|
+
const stats = fs.statSync(absOutput);
|
|
47
|
+
resolve({ path: absOutput, size: stats.size });
|
|
48
|
+
} else {
|
|
49
|
+
reject(new Error(`打包失败,退出码: ${code}`));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
proc.on('error', (err) => {
|
|
54
|
+
reject(new Error(`打包执行失败: ${err.message}`));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 删除打包产物
|
|
61
|
+
*/
|
|
62
|
+
function cleanupPack(packPath) {
|
|
63
|
+
if (fs.existsSync(packPath)) {
|
|
64
|
+
fs.unlinkSync(packPath);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { packDir, cleanupPack };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const PID_DIR = path.join(os.homedir(), '.hsk', 'pids');
|
|
6
|
+
const LOG_DIR = path.join(os.homedir(), '.hsk', 'logs');
|
|
7
|
+
|
|
8
|
+
function ensureDir(dir) {
|
|
9
|
+
if (!fs.existsSync(dir)) {
|
|
10
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getPidFilePath(type, meta) {
|
|
15
|
+
ensureDir(PID_DIR);
|
|
16
|
+
const key = `${type}-${meta.ip || '0'}-${meta.port || '0'}.json`;
|
|
17
|
+
return path.join(PID_DIR, key);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getLogFilePath(type, meta) {
|
|
21
|
+
ensureDir(LOG_DIR);
|
|
22
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
23
|
+
const key = `${type}-${meta.ip || '0'}-${meta.port || '0'}-${timestamp}.log`;
|
|
24
|
+
return path.join(LOG_DIR, key);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function savePid(type, meta, pid) {
|
|
28
|
+
const pidFile = getPidFilePath(type, meta);
|
|
29
|
+
const logFile = getLogFilePath(type, meta);
|
|
30
|
+
const record = {
|
|
31
|
+
pid,
|
|
32
|
+
type,
|
|
33
|
+
meta,
|
|
34
|
+
createdAt: new Date().toISOString(),
|
|
35
|
+
logFile
|
|
36
|
+
};
|
|
37
|
+
fs.writeFileSync(pidFile, JSON.stringify(record, null, 2));
|
|
38
|
+
return record;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function listPids() {
|
|
42
|
+
ensureDir(PID_DIR);
|
|
43
|
+
const files = fs.readdirSync(PID_DIR);
|
|
44
|
+
const records = [];
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
if (!file.endsWith('.json')) continue;
|
|
47
|
+
const filePath = path.join(PID_DIR, file);
|
|
48
|
+
try {
|
|
49
|
+
const record = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
50
|
+
// 检查进程是否还在运行
|
|
51
|
+
const isRunning = isPidRunning(record.pid);
|
|
52
|
+
records.push({ ...record, isRunning, pidFile: filePath });
|
|
53
|
+
} catch (e) {
|
|
54
|
+
// 忽略损坏的 PID 文件
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return records;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isPidRunning(pid) {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
return true;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function removePidRecord(pid) {
|
|
70
|
+
const records = listPids();
|
|
71
|
+
for (const record of records) {
|
|
72
|
+
if (record.pid === pid) {
|
|
73
|
+
try {
|
|
74
|
+
fs.unlinkSync(record.pidFile);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// 忽略删除失败
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function stopPid(pid) {
|
|
85
|
+
try {
|
|
86
|
+
process.kill(pid, 'SIGTERM');
|
|
87
|
+
// 等待一段时间后强制终止
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
try {
|
|
90
|
+
process.kill(pid, 'SIGKILL');
|
|
91
|
+
} catch (e) {
|
|
92
|
+
// 可能已经退出
|
|
93
|
+
}
|
|
94
|
+
}, 3000);
|
|
95
|
+
removePidRecord(pid);
|
|
96
|
+
return true;
|
|
97
|
+
} catch (e) {
|
|
98
|
+
removePidRecord(pid);
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function stopAll() {
|
|
104
|
+
const records = listPids();
|
|
105
|
+
let count = 0;
|
|
106
|
+
for (const record of records) {
|
|
107
|
+
if (record.isRunning) {
|
|
108
|
+
stopPid(record.pid);
|
|
109
|
+
count++;
|
|
110
|
+
} else {
|
|
111
|
+
removePidRecord(record.pid);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return count;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function cleanupDeadPids() {
|
|
118
|
+
const records = listPids();
|
|
119
|
+
let count = 0;
|
|
120
|
+
for (const record of records) {
|
|
121
|
+
if (!record.isRunning) {
|
|
122
|
+
try {
|
|
123
|
+
fs.unlinkSync(record.pidFile);
|
|
124
|
+
count++;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
// 忽略
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return count;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
savePid,
|
|
135
|
+
listPids,
|
|
136
|
+
isPidRunning,
|
|
137
|
+
stopPid,
|
|
138
|
+
stopAll,
|
|
139
|
+
removePidRecord,
|
|
140
|
+
cleanupDeadPids,
|
|
141
|
+
getLogFilePath
|
|
142
|
+
};
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const os = require('os');
|
|
2
|
+
|
|
3
|
+
// 将 Node.js 平台标识符映射为二进制命名中的标识符
|
|
4
|
+
const PLATFORM_MAP = {
|
|
5
|
+
'win32': 'windows',
|
|
6
|
+
'darwin': 'darwin',
|
|
7
|
+
'linux': 'linux'
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const ARCH_MAP = {
|
|
11
|
+
'x64': 'amd64',
|
|
12
|
+
'arm64': 'arm64'
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function detect() {
|
|
16
|
+
const nodePlatform = os.platform();
|
|
17
|
+
const nodeArch = os.arch();
|
|
18
|
+
|
|
19
|
+
const platform = PLATFORM_MAP[nodePlatform];
|
|
20
|
+
const arch = ARCH_MAP[nodeArch];
|
|
21
|
+
|
|
22
|
+
if (!platform || !arch) {
|
|
23
|
+
throw new Error(`不支持的平台: ${nodePlatform}-${nodeArch}。仅支持 Windows/macOS/Linux x64 和 arm64。`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const isWindows = platform === 'windows';
|
|
27
|
+
const platformKey = `${platform}-${arch}`;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
platform, // 'windows' | 'darwin' | 'linux'
|
|
31
|
+
arch, // 'amd64' | 'arm64'
|
|
32
|
+
nodePlatform, // 'win32' | 'darwin' | 'linux'
|
|
33
|
+
nodeArch, // 'x64' | 'arm64'
|
|
34
|
+
isWindows,
|
|
35
|
+
platformKey // 'windows-amd64' | 'darwin-arm64' 等
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fromString(archOverride) {
|
|
40
|
+
const map = {
|
|
41
|
+
'win32': { platform: 'windows', arch: 'amd64', nodePlatform: 'win32', nodeArch: 'x64' },
|
|
42
|
+
'win64': { platform: 'windows', arch: 'amd64', nodePlatform: 'win32', nodeArch: 'x64' },
|
|
43
|
+
'windows': { platform: 'windows', arch: 'amd64', nodePlatform: 'win32', nodeArch: 'x64' },
|
|
44
|
+
'macos': { platform: 'darwin', arch: 'amd64', nodePlatform: 'darwin', nodeArch: 'x64' },
|
|
45
|
+
'macos-x64': { platform: 'darwin', arch: 'amd64', nodePlatform: 'darwin', nodeArch: 'x64' },
|
|
46
|
+
'macos-arm64': { platform: 'darwin', arch: 'arm64', nodePlatform: 'darwin', nodeArch: 'arm64' },
|
|
47
|
+
'linux': { platform: 'linux', arch: 'amd64', nodePlatform: 'linux', nodeArch: 'x64' },
|
|
48
|
+
'linux-x64': { platform: 'linux', arch: 'amd64', nodePlatform: 'linux', nodeArch: 'x64' },
|
|
49
|
+
'linux-arm64': { platform: 'linux', arch: 'arm64', nodePlatform: 'linux', nodeArch: 'arm64' }
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const info = map[archOverride];
|
|
53
|
+
if (!info) {
|
|
54
|
+
throw new Error(`未知的架构: ${archOverride}。可用: ${Object.keys(map).join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
...info,
|
|
59
|
+
isWindows: info.platform === 'windows',
|
|
60
|
+
platformKey: `${info.platform}-${info.arch}`
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { detect, fromString };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const fetch = require('node-fetch');
|
|
3
|
+
|
|
4
|
+
function isProcessAlive(pid) {
|
|
5
|
+
if (!pid) return false;
|
|
6
|
+
try {
|
|
7
|
+
if (process.platform === 'win32') {
|
|
8
|
+
const result = require('child_process').spawnSync('tasklist', ['/FI', `PID eq ${pid}`], { encoding: 'utf8' });
|
|
9
|
+
return result.stdout.includes(String(pid));
|
|
10
|
+
} else {
|
|
11
|
+
process.kill(pid, 0);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
} catch (e) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function checkHttpUrl(url, timeout = 5000) {
|
|
20
|
+
try {
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
23
|
+
const res = await fetch(url, {
|
|
24
|
+
method: 'HEAD',
|
|
25
|
+
signal: controller.signal,
|
|
26
|
+
redirect: 'follow',
|
|
27
|
+
});
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
return res.ok;
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function checkResource(resource) {
|
|
36
|
+
const { type, pid, publicUrl } = resource;
|
|
37
|
+
|
|
38
|
+
if (type === 'tunnel') {
|
|
39
|
+
const alive = isProcessAlive(pid);
|
|
40
|
+
if (!alive) {
|
|
41
|
+
return { valid: false, reason: 'process_not_found', detail: `PID ${pid} 已终止` };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (publicUrl) {
|
|
46
|
+
const reachable = await checkHttpUrl(publicUrl);
|
|
47
|
+
if (!reachable) {
|
|
48
|
+
return { valid: false, reason: 'http_unreachable', detail: `URL 不可访问: ${publicUrl}` };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { valid: true, reason: 'ok' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
isProcessAlive,
|
|
57
|
+
checkHttpUrl,
|
|
58
|
+
checkResource,
|
|
59
|
+
};
|