@aweray/hsk-cli 0.2.2 → 0.4.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 CHANGED
@@ -10,8 +10,9 @@
10
10
 
11
11
  - **多平台自动适配** — 自动检测操作系统和架构,下载对应原生客户端二进制
12
12
  - **内网穿透** — 一键将本地服务暴露到公网,支持前台/后台两种保活模式
13
- - **文件托管** — 上传本地文件或构建产物,获取公网下载链接
14
- - **构建部署** — `deploy` 命令一键完成 build → pack → upload
13
+ - **文件托管** — 上传本地文件或目录(自动打包),获取公网下载链接
14
+ - **构建部署** — `deploy` 命令一键完成 build → upload,原生二进制自动打包目录
15
+ - **资源复用** — 隧道支持 `--reuse` 检测,有效则复用,失效则重建
15
16
  - **AI 友好** — 支持 `--json` 结构化输出,便于 AI Agent 解析和自动化
16
17
  - **版本同步** — npm 包版本与原生二进制版本一致,自动匹配升级
17
18
 
@@ -61,10 +62,14 @@ hsk-cli tunnel --ip 127.0.0.1 --port 9000 --detach
61
62
 
62
63
  后台模式启动后 CLI 立即退出,隧道持续运行。使用 `tunnel list` / `tunnel stop` 管理。
63
64
 
64
- ### 4. 文件托管
65
+ ### 4. 文件托管(上传文件或目录)
65
66
 
66
67
  ```bash
68
+ # 上传单文件
67
69
  hsk-cli host ./document.pdf
70
+
71
+ # 上传目录(原生二进制自动打包为 zip)
72
+ hsk-cli host ./dist/
68
73
  ```
69
74
 
70
75
  ### 5. 构建并部署项目
@@ -73,7 +78,7 @@ hsk-cli host ./document.pdf
73
78
  hsk-cli deploy
74
79
  ```
75
80
 
76
- 自动执行 `npm run build` → 打包 `dist/` → 上传到文件托管。
81
+ 自动执行 `npm run build` → 上传构建产物到文件托管。原生二进制自动打包目录。
77
82
 
78
83
  ---
79
84
 
@@ -104,6 +109,7 @@ hsk-cli tunnel --ip <IP> --port <PORT> [options]
104
109
  | `--arch <arch>` | 否 | 强制指定客户端架构: `win32`, `win64`, `macos-x64`, `macos-arm64`, `linux-x64`, `linux-arm64` |
105
110
  | `--force-download` | 否 | 强制重新下载客户端二进制 |
106
111
  | `--detach` | 否 | 后台模式,CLI 立即退出,隧道持续运行 |
112
+ | `--reuse` | 否 | 复用已有隧道,不存在则创建 |
107
113
  | `--json` | 否 | 以 JSON 格式输出 |
108
114
 
109
115
  #### `tunnel list` — 查看后台隧道
@@ -126,21 +132,21 @@ hsk-cli tunnel stop --all
126
132
 
127
133
  ### File Hosting
128
134
 
129
- #### `host` — 上传文件
135
+ #### `host` — 文件托管
130
136
 
131
137
  ```bash
132
- hsk-cli host <filePath> [options]
138
+ hsk-cli host <fileOrDirPath> [options]
133
139
  ```
134
140
 
141
+ 支持上传单文件或目录。传入目录时,原生二进制自动打包为 zip 后上传。
142
+
135
143
  | Option | Required | Description |
136
144
  |--------|----------|-------------|
137
- | `<filePath>` | 是 | 本地文件路径 |
145
+ | `<fileOrDirPath>` | 是 | 本地文件或目录路径 |
138
146
  | `--resource-id <id>` | 否 | 资源 ID(传入则更新已有资源) |
139
147
  | `--open` | 否 | 上传完成后自动打开浏览器进行认领 |
140
148
  | `--json` | 否 | 以 JSON 格式输出 |
141
149
 
142
- > **注意**: `host` 仅支持单文件。上传目录(如 build/dist)请使用 `deploy` 命令。
143
-
144
150
  ### Deploy
145
151
 
146
152
  #### `deploy` — 构建并部署项目
@@ -149,20 +155,18 @@ hsk-cli host <filePath> [options]
149
155
  hsk-cli deploy [options]
150
156
  ```
151
157
 
152
- 一键执行:**构建** → **打包** → **上传**。
158
+ 一键执行:**构建** → **上传** 到文件托管。原生二进制自动处理目录打包。
153
159
 
154
160
  | Option | Description |
155
161
  |--------|-------------|
156
162
  | `--build-cmd <cmd>` | 构建命令(默认: `npm run build`) |
157
163
  | `--build-dir <dir>` | 构建输出目录(默认: `dist`) |
158
- | `--pack-output <file>` | 打包文件名(默认: `dist.zip`) |
159
- | `--no-build` | 跳过构建,直接打包上传现有目录 |
160
- | `--no-clean` | 上传后保留打包文件 |
164
+ | `--no-build` | 跳过构建,直接上传现有目录 |
161
165
  | `--resource-id <id>` | 更新已有资源 |
162
166
  | `--open` | 上传完成后自动打开浏览器 |
163
167
  | `--json` | 以 JSON 格式输出 |
164
168
 
165
- **配置优先级**: 命令行参数 > 项目 `package.json` 的 `hsk.deploy` > 全局 `~/.hsk/config.json` > 默认配置
169
+ **配置优先级**: 命令行参数 > 项目 `package.json` 的 `hsk.deploy` > 全局 `~/.hsk/config.json` 的 `deploy` > 默认配置
166
170
 
167
171
  **项目配置示例(`package.json`)**:
168
172
 
@@ -174,9 +178,7 @@ hsk-cli deploy [options]
174
178
  "hsk": {
175
179
  "deploy": {
176
180
  "buildCmd": "npm run build",
177
- "buildDir": "dist",
178
- "packOutput": "dist.zip",
179
- "cleanAfterUpload": true
181
+ "buildDir": "dist"
180
182
  }
181
183
  }
182
184
  }
@@ -184,6 +186,14 @@ hsk-cli deploy [options]
184
186
 
185
187
  ### System
186
188
 
189
+ #### `status` — 检查资源状态
190
+
191
+ ```bash
192
+ hsk-cli status [--json]
193
+ ```
194
+
195
+ 检查已发布的隧道资源状态(进程存活 + URL 可访问)。
196
+
187
197
  #### `download` — 预下载客户端
188
198
 
189
199
  ```bash
@@ -220,12 +230,19 @@ hsk-cli tunnel list
220
230
 
221
231
  # 停止全部后台隧道
222
232
  hsk-cli tunnel stop --all
233
+
234
+ # 复用已有隧道(有效则复用,失效则重建)
235
+ hsk-cli tunnel --ip 127.0.0.1 --port 9000 --reuse
223
236
  ```
224
237
 
225
- ### 上传文件并自动认领
238
+ ### 上传文件或目录并自动认领
226
239
 
227
240
  ```bash
241
+ # 上传单文件
228
242
  hsk-cli host ./report.pdf --open
243
+
244
+ # 上传目录(原生二进制自动打包为 zip)
245
+ hsk-cli host ./dist/ --open
229
246
  ```
230
247
 
231
248
  ### 跨平台预下载
@@ -240,6 +257,7 @@ hsk-cli download --arch linux-x64
240
257
  ```bash
241
258
  hsk-cli tunnel --ip 127.0.0.1 --port 9000 --json
242
259
  hsk-cli host ./document.pdf --json
260
+ hsk-cli status --json
243
261
  ```
244
262
 
245
263
  ### 构建并部署
@@ -251,7 +269,7 @@ hsk-cli deploy
251
269
  # 命令行覆盖配置
252
270
  hsk-cli deploy --build-cmd "yarn build" --build-dir "build"
253
271
 
254
- # 跳过构建,直接上传
272
+ # 跳过构建,直接上传现有目录
255
273
  hsk-cli deploy --no-build
256
274
 
257
275
  # 更新已有资源
@@ -269,9 +287,7 @@ hsk-cli deploy --resource-id abc123 --open
269
287
  "hsk": {
270
288
  "deploy": {
271
289
  "buildCmd": "npm run build",
272
- "buildDir": "dist",
273
- "packOutput": "dist.zip",
274
- "cleanAfterUpload": true
290
+ "buildDir": "dist"
275
291
  }
276
292
  }
277
293
  }
@@ -283,23 +299,17 @@ hsk-cli deploy --resource-id abc123 --open
283
299
  {
284
300
  "deploy": {
285
301
  "buildCmd": "npm run build",
286
- "buildDir": "dist",
287
- "packOutput": "dist.zip",
288
- "cleanAfterUpload": true
302
+ "buildDir": "dist"
289
303
  }
290
304
  }
291
305
  ```
292
306
 
293
307
  ### 环境变量
294
308
 
295
- | Variable | Description |
296
- |----------|-------------|
297
- | `HSK_DOWNLOAD_URL` | 客户端二进制下载基地址 |
298
- | `HSK_FILE_HOSTING_API` | Ticket API 完整 URL |
299
- | `HSK_FILE_HOSTING_TICKET_HOST` | Ticket 服务主机 |
300
- | `HSK_FILE_HOSTING_UPLOAD_HOST` | 上传服务主机 |
301
- | `HSK_FILE_HOSTING_UPLOAD_PORT` | 上传服务端口 |
302
- | `HSK_FILE_HOSTING_UPLOAD_SCHEME` | 上传服务协议 |
309
+ | Variable | Description | Default |
310
+ |----------|-------------|---------|
311
+ | `HSK_DOWNLOAD_URL` | 客户端二进制下载基地址 | 从 `versions.json` 读取 |
312
+ | `HSK_FILE_HOSTING_API` | Ticket API 完整 URL,覆盖原生二进制默认 host | 原生二进制内置默认值 |
303
313
 
304
314
  ---
305
315
 
@@ -325,9 +335,10 @@ hsk-cli deploy --resource-id abc123 --open
325
335
  └─────────────────┘
326
336
  ```
327
337
 
328
- - **npm 包**(启动器):提供命令行接口、平台检测、自动下载、版本管理
338
+ - **npm 包**(启动器):提供命令行接口、平台检测、自动下载、版本管理、资源状态持久化
329
339
  - **versions.json**:定义 npm 包版本与原生二进制版本的映射关系
330
340
  - **原生二进制**:按平台编译(Go/Rust/C++),实际执行网络穿透和文件传输
341
+ - **资源状态**:保存在 `{projectRoot}/.hsk/resources.json` 或 `~/.hsk/resources.json`
331
342
 
332
343
  ---
333
344
 
@@ -338,7 +349,9 @@ hsk-cli deploy --resource-id abc123 --open
338
349
  - The npm package version and native binary version are kept in sync. When you update the npm package, the corresponding binary will be downloaded automatically.
339
350
  - **前台模式(默认)**: CLI 启动客户端二进制作为子进程,实时输出日志,捕获到公网地址后持续运行。按 `Ctrl+C` 发送 `SIGTERM` 优雅停止。
340
351
  - **后台模式(`--detach`)**: CLI 启动子进程后使用 `detached: true` 解除父子绑定,立即退出。子进程在后台独立运行,日志写入 `~/.hsk/logs/`,PID 记录保存到 `~/.hsk/pids/`.
341
- - **Deploy**: `hsk-cli deploy` 执行 `build` → `pack` → `upload` 三步。打包使用系统原生 `zip`(macOS/Linux)或 PowerShell `Compress-Archive`(Windows),无需额外依赖.
352
+ - **Deploy**: `hsk-cli deploy` 执行 `build` → `upload` 两步。原生二进制自动打包目录,无需额外依赖.
353
+ - **File Hosting**: `hsk-cli host` 支持单文件和目录上传。传入目录时,原生二进制自动打包为 zip.
354
+ - **资源复用**: `hsk-cli tunnel --reuse` 先检测已有隧道(进程存活 + URL 可访问),有效则复用,失效则重新创建.
342
355
  - Enable `--dry-run` to preview operations without executing them.
343
356
 
344
357
  ---
@@ -349,7 +362,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release history.
349
362
 
350
363
  ## Contributing
351
364
 
352
- We welcome contributions! Please see [CONTRIBUTING.md](c) for guidelines.
365
+ We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
353
366
 
354
367
  ## License
355
368
 
package/bin/hsk-cli.js CHANGED
@@ -181,28 +181,32 @@ const hostCmd = program
181
181
  .option('--json', '以 JSON 格式输出(等同于 --format json)')
182
182
  .option('--open', '上传完成后自动打开浏览器进行认领')
183
183
  .option('--resource-id <id>', '资源 ID(传入则更新已有资源,否则创建新资源)')
184
+ .option('--reuse', '复用已有资源,不存在则创建新资源')
184
185
  .action(async (filePath, options) => {
185
186
  const fmt = getFormat(options);
186
187
  if (isDryRun(options)) {
187
188
  const fs = require('fs');
188
189
  const path = require('path');
189
190
  const stats = fs.existsSync(filePath) ? fs.statSync(filePath) : null;
190
- const uploadBase = fileHosting.getUploadBaseUrl();
191
+ const apiUrl = process.env.HSK_FILE_HOSTING_API;
192
+ const cmdParts = ['hsk-cli file-hosting'];
193
+ if (apiUrl) cmdParts.push(`-api "${apiUrl}"`);
194
+ if (options.resourceId) cmdParts.push(`-resource-id ${options.resourceId}`);
195
+ cmdParts.push(filePath);
191
196
  console.log(format.dryRunInfo(
192
- `POST ${fileHosting.TICKET_API}`,
197
+ cmdParts.join(' '),
193
198
  {
194
199
  文件路径: filePath,
195
200
  文件大小: stats ? `${(stats.size / 1024 / 1024).toFixed(2)} MB` : '文件不存在',
196
201
  资源ID: options.resourceId || '(新建)',
197
- 预期操作: '计算 SHA-256 → 获取 ticket → 上传/更新文件',
198
- 上传服务: options.resourceId ? `PUT ${uploadBase}/resource/update` : `POST ${uploadBase}/upload`,
199
- 预期输出: 'publicUrl + resourceId',
202
+ 预期操作: '原生二进制自动处理:计算 hash → 获取 ticket → 上传/更新',
203
+ 预期输出: '[AI_RESULT] public_url + resource_id',
200
204
  }
201
205
  ));
202
206
  return;
203
207
  }
204
208
  try {
205
- const result = await fileHosting.host(filePath, { resourceId: options.resourceId });
209
+ const result = await fileHosting.host(filePath, { resourceId: options.resourceId, reuse: options.reuse });
206
210
  handleOutput(result, fmt);
207
211
  if (options.open && result.publicUrl) {
208
212
  console.log(chalk.gray('🌐 正在打开浏览器...'));
@@ -298,8 +302,8 @@ program
298
302
  .option('--build-dir <dir>', '构建输出目录,如 "dist"')
299
303
  .option('--pack-output <file>', '打包输出文件名,如 "dist.zip"', 'dist.zip')
300
304
  .option('--no-build', '跳过构建,直接打包上传')
301
- .option('--no-clean', '上传后保留打包文件')
302
305
  .option('--resource-id <id>', '资源 ID(更新已有资源)')
306
+ .option('--reuse', '复用已有资源,不存在则创建新资源')
303
307
  .option('--open', '上传完成后自动打开浏览器进行认领')
304
308
  .option('--json', '以 JSON 格式输出(等同于 --format json)')
305
309
  .action(async (options) => {
@@ -310,19 +314,24 @@ program
310
314
  const cfg = config.getDeployConfig();
311
315
  const buildCmd = options.buildCmd || cfg.buildCmd;
312
316
  const buildDir = options.buildDir || cfg.buildDir;
313
- const packOutput = options.packOutput || cfg.packOutput;
314
- const cleanAfter = options.clean !== false && cfg.cleanAfterUpload;
315
317
 
316
318
  if (isDryRun(options)) {
317
319
  const info = options.arch ? platform.fromString(options.arch) : platform.detect();
320
+ const apiUrl = process.env.HSK_FILE_HOSTING_API;
321
+ const cmdParts = ['hsk-cli file-hosting'];
322
+ if (apiUrl) cmdParts.push(`-api "${apiUrl}"`);
323
+ if (options.resourceId) cmdParts.push(`-resource-id ${options.resourceId}`);
324
+ if (options.reuse) cmdParts.push('--reuse');
325
+ cmdParts.push(buildDir);
318
326
  console.log(format.dryRunInfo(
319
- `deploy: ${buildCmd} → pack ${buildDir} → upload ${packOutput}`,
327
+ `${buildCmd} → ${cmdParts.join(' ')}`,
320
328
  {
321
329
  构建命令: buildCmd,
322
330
  构建目录: buildDir,
323
- 打包文件: packOutput,
324
- 上传后清理: cleanAfter,
325
- 资源ID: options.resourceId || '(新建)'
331
+ 资源ID: options.resourceId || '(新建)',
332
+ 复用检测: options.reuse ? '启用' : '禁用',
333
+ 预期操作: '构建 原生二进制自动打包目录 → 上传',
334
+ 预期输出: '[AI_RESULT] public_url + resource_id',
326
335
  }
327
336
  ));
328
337
  return;
@@ -342,21 +351,9 @@ program
342
351
  throw new Error(`构建目录不存在: ${buildDir}`);
343
352
  }
344
353
 
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. 上传
354
+ // 3. 上传(原生二进制自动打包目录)
352
355
  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
- }
356
+ const result = await fileHosting.host(buildDir, { resourceId: options.resourceId, reuse: options.reuse });
360
357
 
361
358
  handleOutput(result, fmt);
362
359
 
@@ -1,257 +1,165 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const crypto = require('crypto');
4
- const fetch = require('node-fetch');
5
- const FormData = require('form-data');
6
3
  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();
4
+ const download = require('./download');
5
+ const resourceStore = require('./resourceStore');
6
+ const resourceChecker = require('./resourceChecker');
42
7
 
43
8
  /**
44
- * 检查文件托管 API 配置
9
+ * 解析原生二进制输出,提取 [AI_RESULT] 键值对
45
10
  */
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
- });
11
+ function parseAiResult(stdout) {
12
+ const result = {};
13
+ const lines = stdout.split('\n');
14
+ for (const line of lines) {
15
+ const match = line.match(/^\[AI_RESULT\]\s+(\w+):\s*(.+)$/);
16
+ if (match) {
17
+ const key = match[1];
18
+ const value = match[2].trim();
19
+ try {
20
+ result[key] = JSON.parse(value);
21
+ } catch (e) {
22
+ result[key] = value;
23
+ }
24
+ }
25
+ }
26
+ return result;
61
27
  }
62
28
 
63
29
  /**
64
- * 获取文件托管 ticket(创建不传 resourceId,更新时传 resourceId)
30
+ * 打开浏览器认领页面
65
31
  */
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),
32
+ function openClaimUrl(url) {
33
+ return new Promise((resolve) => {
34
+ const platform = process.platform;
35
+ const command = platform === 'win32' ? 'start' : platform === 'darwin' ? 'open' : 'xdg-open';
36
+ const proc = spawn(command, [url], { stdio: 'ignore', detached: true, shell: platform === 'win32' });
37
+ proc.on('error', () => { /* ignore */ });
38
+ proc.unref();
39
+ resolve();
76
40
  });
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
41
  }
92
42
 
93
43
  /**
94
- * 上传/更新资源文件
95
- * @param {string} token - Bearer ticket
96
- * @param {string} filePath - 本地文件路径
97
- * @param {{ update?: boolean, uploadUrl?: string }} options
44
+ * 文件托管(创建或更新)
45
+ * 调用原生二进制:./hsk-cli file-hosting [options] <file_or_dir>
98
46
  */
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;
47
+ async function host(filePath, options = {}) {
48
+ const resolvedPath = path.resolve(filePath);
49
+ if (!fs.existsSync(resolvedPath)) {
50
+ throw new Error(`文件不存在: ${resolvedPath}`);
132
51
  }
133
52
 
134
- if (!resourceId && ticketData.public_url) {
135
- const parts = ticketData.public_url.replace(/\/+$/, '').split('/');
136
- if (parts.length) {
137
- resourceId = parts[parts.length - 1];
53
+ // === reuse: 检测已有资源是否有效 ===
54
+ if (options.reuse) {
55
+ let existing = null;
56
+ if (options.resourceId) {
57
+ existing = resourceStore.findResource((r) => r.type === 'host' && r.resourceId === options.resourceId);
58
+ } else {
59
+ existing = resourceStore.findResource((r) => r.type === 'host' && r.filePath === resolvedPath);
60
+ }
61
+ if (existing) {
62
+ const check = await resourceChecker.checkResource(existing);
63
+ if (check.valid) {
64
+ return {
65
+ success: true,
66
+ mode: 'reused',
67
+ publicUrl: existing.publicUrl,
68
+ resourceId: existing.resourceId,
69
+ filePath: resolvedPath,
70
+ fileName: path.basename(resolvedPath),
71
+ reused: true,
72
+ };
73
+ }
74
+ // 失效:保存旧 resourceId 用于更新
75
+ if (!options.resourceId) {
76
+ options.resourceId = existing.resourceId;
77
+ }
138
78
  }
139
79
  }
140
80
 
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();
81
+ // 确保原生二进制存在
82
+ const binPath = await download.ensureBinary();
151
83
 
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
- );
84
+ // 构造参数
85
+ const args = ['file-hosting'];
86
+ const apiUrl = process.env.HSK_FILE_HOSTING_API;
87
+ if (apiUrl) {
88
+ args.push('-api', apiUrl);
160
89
  }
161
- if (!fs.statSync(filePath).isFile()) {
162
- throw new Error(`路径不是文件: ${filePath}`);
90
+ if (options.resourceId) {
91
+ args.push('-resource-id', options.resourceId);
163
92
  }
93
+ args.push(resolvedPath);
164
94
 
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,
95
+ return new Promise((resolve, reject) => {
96
+ const proc = spawn(binPath, args, {
97
+ stdio: ['ignore', 'pipe', 'pipe'],
98
+ windowsHide: true,
177
99
  });
178
100
 
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}`;
101
+ let stdout = '';
102
+ let stderr = '';
186
103
 
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);
104
+ proc.stdout.on('data', (data) => {
105
+ stdout += data.toString();
106
+ process.stdout.write(data); // 实时转发到终端
107
+ });
199
108
 
200
- const useLegacyUpload = Boolean(ticket.server_address);
201
- let uploadResult;
202
- let uploadError;
109
+ proc.stderr.on('data', (data) => {
110
+ stderr += data.toString();
111
+ process.stderr.write(data);
112
+ });
203
113
 
204
- try {
205
- if (useLegacyUpload) {
206
- uploadResult = await uploadFile(ticket.ticket, filePath, {
207
- uploadUrl: ticket.server_address,
208
- update: isUpdate,
114
+ proc.on('close', (code) => {
115
+ if (code !== 0) {
116
+ reject(new Error(`文件托管失败: ${stderr || stdout}`));
117
+ return;
118
+ }
119
+
120
+ const result = parseAiResult(stdout);
121
+
122
+ if (!result.public_url || !result.resource_id) {
123
+ reject(new Error(`无法从输出中解析结果:\n${stdout}`));
124
+ return;
125
+ }
126
+
127
+ const isUpdate = Boolean(options.resourceId);
128
+ const resourceId = String(result.resource_id);
129
+ const publicUrl = result.public_url;
130
+
131
+ // 保存资源状态
132
+ resourceStore.saveResource({
133
+ id: `host-${resourceId}`,
134
+ type: 'host',
135
+ filePath: resolvedPath,
136
+ publicUrl,
137
+ resourceId,
138
+ fileName: path.basename(resolvedPath),
139
+ status: 'active',
140
+ createdAt: new Date().toISOString(),
141
+ lastCheckedAt: new Date().toISOString(),
209
142
  });
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
143
 
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
- }
144
+ resolve({
145
+ success: true,
146
+ mode: isUpdate ? 'update' : 'create',
147
+ publicUrl,
148
+ verifyCode: result.verify_code || null,
149
+ resourceId,
150
+ createResult: result.create_result || null,
151
+ stdout: stdout.trim(),
152
+ });
153
+ });
233
154
 
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();
155
+ proc.on('error', (err) => {
156
+ reject(new Error(`无法启动文件托管: ${err.message}`));
157
+ });
245
158
  });
246
159
  }
247
160
 
248
161
  module.exports = {
249
- TICKET_API,
250
- getTicketApiUrl,
251
- getUploadBaseUrl,
252
- sha256File,
253
- getFileHostingTicket,
254
- uploadFile,
255
162
  host,
256
163
  openClaimUrl,
164
+ parseAiResult,
257
165
  };
package/lib/tunnel.js CHANGED
@@ -41,7 +41,7 @@ async function start(options) {
41
41
  }
42
42
 
43
43
  const binPath = await download.ensureBinary(arch);
44
- const args = ['-ip', ip, '-port', String(portNum)];
44
+ const args = ['tunnel', '-ip', ip, '-port', String(portNum)];
45
45
 
46
46
  if (detached) {
47
47
  const logFile = pidManager.getLogFilePath('tunnel', { ip, port: portNum });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aweray/hsk-cli",
3
- "version": "0.2.2",
3
+ "version": "0.4.2",
4
4
  "description": "HSK CLI - 内网穿透 & 文件托管,支持 Windows/macOS/Linux 多平台",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -28,8 +28,7 @@
28
28
  "dependencies": {
29
29
  "commander": "^11.0.0",
30
30
  "chalk": "^4.1.2",
31
- "node-fetch": "^2.7.0",
32
- "form-data": "^4.0.0"
31
+ "node-fetch": "^2.7.0"
33
32
  },
34
33
  "homepage": "https://hsk.oray.com",
35
34
  "files": [
package/versions.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
- "version": "0.2.2",
2
+ "version": "0.4.2",
3
3
  "downloadBaseUrl": "https://dw.oray.com/onion/cli",
4
4
  "binaries": {
5
5
  "windows-amd64": {
6
- "filename": "hsk-cli-windows-amd64-v0.2.2.exe"
6
+ "filename": "hsk-cli-windows-amd64-v0.4.2.exe"
7
7
  },
8
8
  "darwin-amd64": {
9
- "filename": "hsk-cli-darwin-amd64-v0.2.2"
9
+ "filename": "hsk-cli-darwin-amd64-v0.4.2"
10
10
  },
11
11
  "darwin-arm64": {
12
- "filename": "hsk-cli-darwin-arm64-v0.2.2"
12
+ "filename": "hsk-cli-darwin-arm64-v0.4.2"
13
13
  },
14
14
  "linux-amd64": {
15
- "filename": "hsk-cli-linux-amd64-v0.2.2"
15
+ "filename": "hsk-cli-linux-amd64-v0.4.2"
16
16
  }
17
17
  }
18
18
  }