baton-host 0.1.5 → 0.1.7

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.
Files changed (2) hide show
  1. package/bin/baton-host.js +780 -23
  2. package/package.json +5 -5
package/bin/baton-host.js CHANGED
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { spawn } = require("node:child_process");
3
+ const { spawn, spawnSync } = require("node:child_process");
4
+ const fs = require("node:fs");
5
+ const net = require("node:net");
6
+ const os = require("node:os");
4
7
  const path = require("node:path");
8
+ const { createInterface } = require("node:readline/promises");
5
9
 
6
10
  const mapping = {
7
11
  "darwin-arm64": "baton-host-darwin-arm64",
@@ -10,37 +14,790 @@ const mapping = {
10
14
  "linux-x64": "baton-host-linux-x64"
11
15
  };
12
16
 
13
- const key = `${process.platform}-${process.arch}`;
14
- const pkg = mapping[key];
17
+ const SERVICE_LABEL = "com.baton.host";
18
+ const INSTALL_ROOT = path.join(os.homedir(), ".baton-host");
19
+ const INSTALL_BIN_DIR = path.join(INSTALL_ROOT, "bin");
20
+ const INSTALL_LOG_DIR = path.join(INSTALL_ROOT, "logs");
21
+ const ENV_FILE_PATH = path.join(INSTALL_ROOT, ".env");
22
+ const BRIDGE_INFO_PATH = path.join(INSTALL_ROOT, "bridge-info.json");
23
+ const WRAPPER_PATH = path.join(INSTALL_ROOT, "run.sh");
24
+ const INSTALL_META_PATH = path.join(INSTALL_ROOT, "install.json");
15
25
 
16
- if (!pkg) {
17
- console.error(`❌ 当前平台暂不支持: ${key}`);
26
+ function fail(message) {
27
+ console.error(`❌ ${message}`);
18
28
  process.exit(1);
19
29
  }
20
30
 
21
- let binaryPath;
22
- try {
23
- const pkgJsonPath = require.resolve(`${pkg}/package.json`);
24
- binaryPath = path.join(path.dirname(pkgJsonPath), "bin", "baton-host");
25
- } catch (error) {
26
- console.error(`❌ 未找到平台二进制包 ${pkg},请重装 baton-host`);
27
- process.exit(1);
31
+ function getCurrentPlatformKey() {
32
+ return `${process.platform}-${process.arch}`;
33
+ }
34
+
35
+ function getMainPackageVersion() {
36
+ const packageJsonPath = path.join(__dirname, "..", "package.json");
37
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
38
+ return packageJson.version;
28
39
  }
29
40
 
30
- const bundledCloudflaredPath = path.join(path.dirname(binaryPath), process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
41
+ function resolveBundledAssets() {
42
+ const key = getCurrentPlatformKey();
43
+ const pkg = mapping[key];
31
44
 
32
- const child = spawn(binaryPath, process.argv.slice(2), {
33
- stdio: "inherit",
34
- env: {
35
- ...process.env,
36
- BATON_CLOUDFLARED_BIN: bundledCloudflaredPath
45
+ if (!pkg) {
46
+ fail(`当前平台暂不支持: ${key}`);
37
47
  }
38
- });
39
48
 
40
- child.on("exit", (code, signal) => {
41
- if (signal) {
42
- process.kill(process.pid, signal);
49
+ let pkgJsonPath;
50
+ try {
51
+ pkgJsonPath = require.resolve(`${pkg}/package.json`);
52
+ } catch (error) {
53
+ fail(`未找到平台二进制包 ${pkg},请重新执行 npx baton-host@latest service install`);
54
+ }
55
+
56
+ const binaryPath = path.join(path.dirname(pkgJsonPath), "bin", "baton-host");
57
+ const cloudflaredPath = path.join(
58
+ path.dirname(binaryPath),
59
+ process.platform === "win32" ? "cloudflared.exe" : "cloudflared"
60
+ );
61
+
62
+ if (!fs.existsSync(binaryPath)) {
63
+ fail(`缺少 Baton Host 二进制: ${binaryPath}`);
64
+ }
65
+
66
+ if (!fs.existsSync(cloudflaredPath)) {
67
+ fail(`缺少 cloudflared 二进制: ${cloudflaredPath}`);
68
+ }
69
+
70
+ return {
71
+ binaryPath,
72
+ cloudflaredPath,
73
+ version: getMainPackageVersion()
74
+ };
75
+ }
76
+
77
+ function ensureDir(dirPath) {
78
+ fs.mkdirSync(dirPath, { recursive: true });
79
+ }
80
+
81
+ function copyExecutable(sourcePath, targetPath) {
82
+ fs.copyFileSync(sourcePath, targetPath);
83
+ fs.chmodSync(targetPath, 0o755);
84
+ }
85
+
86
+ function readInstallMeta() {
87
+ if (!fs.existsSync(INSTALL_META_PATH)) {
88
+ return null;
89
+ }
90
+
91
+ try {
92
+ return JSON.parse(fs.readFileSync(INSTALL_META_PATH, "utf8"));
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function writeInstallMeta(version) {
99
+ const payload = {
100
+ version,
101
+ installedAt: new Date().toISOString(),
102
+ platform: process.platform,
103
+ arch: process.arch
104
+ };
105
+ fs.writeFileSync(INSTALL_META_PATH, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
106
+ }
107
+
108
+ function ensureDefaultEnvFile() {
109
+ if (fs.existsSync(ENV_FILE_PATH)) {
43
110
  return;
44
111
  }
45
- process.exit(code ?? 0);
112
+
113
+ const defaultContent = [
114
+ "# Baton Host 服务环境变量",
115
+ "BATON_BRIDGE_MODE=cloudflare",
116
+ "BRIDGE_LOG_PROFILE=core",
117
+ "PORT=9977",
118
+ ""
119
+ ].join("\n");
120
+
121
+ fs.writeFileSync(ENV_FILE_PATH, defaultContent, "utf8");
122
+ }
123
+
124
+ function readEnvFile() {
125
+ if (!fs.existsSync(ENV_FILE_PATH)) {
126
+ return {};
127
+ }
128
+
129
+ const result = {};
130
+ const content = fs.readFileSync(ENV_FILE_PATH, "utf8");
131
+ for (const rawLine of content.split(/\r?\n/)) {
132
+ const line = rawLine.trim();
133
+ if (!line || line.startsWith("#")) {
134
+ continue;
135
+ }
136
+ const separatorIndex = line.indexOf("=");
137
+ if (separatorIndex <= 0) {
138
+ continue;
139
+ }
140
+ const key = line.slice(0, separatorIndex).trim();
141
+ const value = line.slice(separatorIndex + 1).trim();
142
+ result[key] = value;
143
+ }
144
+ return result;
145
+ }
146
+
147
+ function writeEnvFile(envVars) {
148
+ const existingContent = fs.existsSync(ENV_FILE_PATH)
149
+ ? fs.readFileSync(ENV_FILE_PATH, "utf8")
150
+ : "# Baton Host 服务环境变量\n";
151
+ const lines = existingContent.split(/\r?\n/);
152
+ const keysToWrite = new Set(Object.keys(envVars));
153
+ const seenKeys = new Set();
154
+ const nextLines = lines.map((rawLine) => {
155
+ const trimmed = rawLine.trim();
156
+ if (!trimmed || trimmed.startsWith("#")) {
157
+ return rawLine;
158
+ }
159
+ const separatorIndex = rawLine.indexOf("=");
160
+ if (separatorIndex <= 0) {
161
+ return rawLine;
162
+ }
163
+ const key = rawLine.slice(0, separatorIndex).trim();
164
+ if (!keysToWrite.has(key)) {
165
+ return rawLine;
166
+ }
167
+ seenKeys.add(key);
168
+ return `${key}=${envVars[key]}`;
169
+ });
170
+
171
+ for (const key of keysToWrite) {
172
+ if (!seenKeys.has(key)) {
173
+ nextLines.push(`${key}=${envVars[key]}`);
174
+ }
175
+ }
176
+
177
+ const content = `${nextLines.filter((line, index, array) => !(index === array.length - 1 && line === "")).join("\n")}\n`;
178
+ fs.writeFileSync(ENV_FILE_PATH, content, "utf8");
179
+ }
180
+
181
+ function isInteractiveTerminal() {
182
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
183
+ }
184
+
185
+ function pickAvailablePort() {
186
+ return new Promise((resolve, reject) => {
187
+ const server = net.createServer();
188
+ server.unref();
189
+ server.on("error", reject);
190
+ server.listen(0, "127.0.0.1", () => {
191
+ const address = server.address();
192
+ if (!address || typeof address === "string") {
193
+ server.close(() => reject(new Error("随机端口分配失败")));
194
+ return;
195
+ }
196
+ resolve(address.port);
197
+ server.close();
198
+ });
199
+ });
200
+ }
201
+
202
+ function isValidPort(value) {
203
+ const port = Number(value);
204
+ return Number.isInteger(port) && port >= 1 && port <= 65535;
205
+ }
206
+
207
+ async function ensurePortAvailable(port) {
208
+ await new Promise((resolve, reject) => {
209
+ const server = net.createServer();
210
+ server.unref();
211
+ server.once("error", reject);
212
+ server.listen(port, "127.0.0.1", () => {
213
+ server.close((error) => {
214
+ if (error) {
215
+ reject(error);
216
+ return;
217
+ }
218
+ resolve();
219
+ });
220
+ });
221
+ });
222
+ return port;
223
+ }
224
+
225
+ async function promptBridgeModeForInstall() {
226
+ if (!isInteractiveTerminal()) {
227
+ return "cloudflare";
228
+ }
229
+
230
+ const rl = createInterface({
231
+ input: process.stdin,
232
+ output: process.stdout
233
+ });
234
+
235
+ try {
236
+ while (true) {
237
+ console.log("请选择连接方式:");
238
+ console.log("1) 局域网(同一 Wi‑Fi 下用)");
239
+ console.log("2) Cloudflare(推荐,直接回车也选这个)");
240
+ console.log("3) Tailscale");
241
+ const answer = (await rl.question("输入 1 / 2 / 3,直接回车默认 Cloudflare: ")).trim();
242
+ if (!answer || answer === "2") {
243
+ return "cloudflare";
244
+ }
245
+ if (answer === "1") {
246
+ return "lan";
247
+ }
248
+ if (answer === "3") {
249
+ return "tailscale";
250
+ }
251
+ console.log("⚠️ 请输入 1、2、3,或直接回车。\n");
252
+ }
253
+ } finally {
254
+ rl.close();
255
+ }
256
+ }
257
+
258
+ async function promptPortForInstall() {
259
+ const randomPort = await pickAvailablePort();
260
+ if (!isInteractiveTerminal()) {
261
+ return randomPort;
262
+ }
263
+
264
+ const rl = createInterface({
265
+ input: process.stdin,
266
+ output: process.stdout
267
+ });
268
+
269
+ try {
270
+ while (true) {
271
+ console.log("\n请选择端口:");
272
+ console.log(`直接回车:自动分配随机端口(推荐,当前候选 ${randomPort})`);
273
+ console.log("输入端口号:使用自定义端口");
274
+ const answer = (await rl.question("输入端口号,或直接回车使用随机端口: ")).trim();
275
+ if (!answer) {
276
+ return randomPort;
277
+ }
278
+ if (isValidPort(answer)) {
279
+ try {
280
+ return await ensurePortAvailable(Number(answer));
281
+ } catch {
282
+ console.log("⚠️ 这个端口当前不可用,请换一个。\n");
283
+ continue;
284
+ }
285
+ }
286
+ console.log("⚠️ 请输入 1-65535 的有效端口号,或直接回车。\n");
287
+ }
288
+ } finally {
289
+ rl.close();
290
+ }
291
+ }
292
+
293
+ async function printBridgeInfoFromCache() {
294
+ if (!fs.existsSync(BRIDGE_INFO_PATH)) {
295
+ throw new Error("未找到已保存的连接信息,请稍后重试");
296
+ }
297
+
298
+ let cached;
299
+ try {
300
+ cached = JSON.parse(fs.readFileSync(BRIDGE_INFO_PATH, "utf8"));
301
+ } catch {
302
+ throw new Error("已保存的连接信息损坏,请重新执行 service restart");
303
+ }
304
+
305
+ if (!cached?.bridgeUrl || !cached?.displayLabel) {
306
+ throw new Error("已保存的连接信息不完整,请稍后重试");
307
+ }
308
+
309
+ const payload = JSON.stringify({ bridgeUrl: cached.bridgeUrl });
310
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
311
+ const qrcode = require("qrcode-terminal");
312
+
313
+ console.log("\n✅ Baton Bridge 已启动");
314
+ console.log(`模式: ${cached.displayLabel}`);
315
+ console.log(`主连接地址: ${cached.bridgeUrl}`);
316
+ if (cached.lanBridgeUrl && cached.lanBridgeUrl !== cached.bridgeUrl) {
317
+ console.log(`局域网地址: ${cached.lanBridgeUrl}`);
318
+ }
319
+ if (cached.note) {
320
+ console.log(`说明: ${cached.note}`);
321
+ }
322
+ console.log("\n请使用 Baton App 扫描下方二维码连接:\n");
323
+
324
+ await new Promise((resolve) => {
325
+ qrcode.generate(payload, { small: true }, (qrText) => {
326
+ for (const line of qrText.split("\n")) {
327
+ console.log(line);
328
+ }
329
+ resolve();
330
+ });
331
+ });
332
+
333
+ console.log("\n在 Baton App → 设置 → Bridge 服务器 → 扫码连接");
334
+ console.log("现在可以关闭终端,服务会继续在后台运行。\n");
335
+ }
336
+
337
+ async function waitForBridgeInfo(timeoutMs = 15000) {
338
+ const start = Date.now();
339
+ while (Date.now() - start < timeoutMs) {
340
+ if (fs.existsSync(BRIDGE_INFO_PATH)) {
341
+ return;
342
+ }
343
+ await new Promise((resolve) => setTimeout(resolve, 250));
344
+ }
345
+ throw new Error("等待连接信息生成超时,请稍后执行 service restart 再试");
346
+ }
347
+
348
+ async function runBridgePrintCommand() {
349
+ await waitForBridgeInfo();
350
+ await printBridgeInfoFromCache();
351
+ }
352
+
353
+ function buildWrapperScript() {
354
+ return `#!/bin/sh
355
+ set -eu
356
+
357
+ BATON_HOME="$HOME/.baton-host"
358
+ ENV_FILE="$BATON_HOME/.env"
359
+
360
+ if [ -f "$ENV_FILE" ]; then
361
+ while IFS= read -r line || [ -n "$line" ]; do
362
+ case "$line" in
363
+ ''|'#'*) continue ;;
364
+ *=*)
365
+ key=\${line%%=*}
366
+ value=\${line#*=}
367
+ export "$key=$value"
368
+ ;;
369
+ esac
370
+ done < "$ENV_FILE"
371
+ fi
372
+
373
+ export BATON_CLOUDFLARED_BIN="$BATON_HOME/bin/cloudflared"
374
+ export PATH="$HOME/.local/bin:$HOME/Library/pnpm:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin\${PATH:+:$PATH}"
375
+
376
+ exec "$BATON_HOME/bin/baton-host" "$@"
377
+ `;
378
+ }
379
+
380
+ function writeWrapperScript() {
381
+ fs.writeFileSync(WRAPPER_PATH, buildWrapperScript(), { mode: 0o755 });
382
+ fs.chmodSync(WRAPPER_PATH, 0o755);
383
+ }
384
+
385
+ function escapeXml(value) {
386
+ return String(value)
387
+ .replaceAll("&", "&amp;")
388
+ .replaceAll("<", "&lt;")
389
+ .replaceAll(">", "&gt;")
390
+ .replaceAll('"', "&quot;")
391
+ .replaceAll("'", "&apos;");
392
+ }
393
+
394
+ function getServicePathEnv() {
395
+ return `${os.homedir()}/.local/bin:${os.homedir()}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`;
396
+ }
397
+
398
+ function getLinuxServiceName() {
399
+ return `${SERVICE_LABEL}.service`;
400
+ }
401
+
402
+ function getLinuxUnitPath() {
403
+ return path.join(os.homedir(), ".config", "systemd", "user", getLinuxServiceName());
404
+ }
405
+
406
+ function buildLinuxUnit() {
407
+ return `[Unit]
408
+ Description=Baton Host
409
+ After=network-online.target
410
+ Wants=network-online.target
411
+
412
+ [Service]
413
+ Type=simple
414
+ ExecStart=%h/.baton-host/run.sh
415
+ WorkingDirectory=%h/.baton-host
416
+ Restart=always
417
+ RestartSec=2
418
+ Environment=HOME=%h
419
+ Environment=PATH=%h/.local/bin:%h/Library/pnpm:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
420
+
421
+ [Install]
422
+ WantedBy=default.target
423
+ `;
424
+ }
425
+
426
+ function getMacPlistPath() {
427
+ return path.join(os.homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
428
+ }
429
+
430
+ function buildMacPlist() {
431
+ const stdoutPath = path.join(INSTALL_LOG_DIR, "stdout.log");
432
+ const stderrPath = path.join(INSTALL_LOG_DIR, "stderr.log");
433
+ const servicePathEnv = getServicePathEnv();
434
+
435
+ return `<?xml version="1.0" encoding="UTF-8"?>
436
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
437
+ <plist version="1.0">
438
+ <dict>
439
+ <key>Label</key>
440
+ <string>${escapeXml(SERVICE_LABEL)}</string>
441
+ <key>ProgramArguments</key>
442
+ <array>
443
+ <string>${escapeXml(WRAPPER_PATH)}</string>
444
+ </array>
445
+ <key>WorkingDirectory</key>
446
+ <string>${escapeXml(INSTALL_ROOT)}</string>
447
+ <key>RunAtLoad</key>
448
+ <true/>
449
+ <key>KeepAlive</key>
450
+ <true/>
451
+ <key>ProcessType</key>
452
+ <string>Background</string>
453
+ <key>EnvironmentVariables</key>
454
+ <dict>
455
+ <key>HOME</key>
456
+ <string>${escapeXml(os.homedir())}</string>
457
+ <key>PATH</key>
458
+ <string>${escapeXml(servicePathEnv)}</string>
459
+ </dict>
460
+ <key>StandardOutPath</key>
461
+ <string>${escapeXml(stdoutPath)}</string>
462
+ <key>StandardErrorPath</key>
463
+ <string>${escapeXml(stderrPath)}</string>
464
+ </dict>
465
+ </plist>
466
+ `;
467
+ }
468
+
469
+ function runCommand(command, args, options = {}) {
470
+ const result = spawnSync(command, args, {
471
+ encoding: "utf8",
472
+ stdio: ["ignore", "pipe", "pipe"]
473
+ });
474
+
475
+ if (result.error) {
476
+ if (options.allowFailure) {
477
+ return result;
478
+ }
479
+ throw result.error;
480
+ }
481
+
482
+ if (!options.allowFailure && result.status !== 0) {
483
+ const output = (result.stderr || result.stdout || "").trim();
484
+ throw new Error(output || `${command} ${args.join(" ")} 失败,退出码 ${result.status}`);
485
+ }
486
+
487
+ return result;
488
+ }
489
+
490
+ function installLinuxService() {
491
+ const unitPath = getLinuxUnitPath();
492
+ ensureDir(path.dirname(unitPath));
493
+ fs.writeFileSync(unitPath, buildLinuxUnit(), "utf8");
494
+
495
+ runCommand("systemctl", ["--user", "daemon-reload"]);
496
+ runCommand("systemctl", ["--user", "enable", getLinuxServiceName()]);
497
+
498
+ const restartResult = runCommand(
499
+ "systemctl",
500
+ ["--user", "restart", getLinuxServiceName()],
501
+ { allowFailure: true }
502
+ );
503
+
504
+ if (restartResult.status !== 0) {
505
+ runCommand("systemctl", ["--user", "start", getLinuxServiceName()]);
506
+ }
507
+
508
+ return unitPath;
509
+ }
510
+
511
+ function installMacService() {
512
+ const plistPath = getMacPlistPath();
513
+ const launchTarget = `gui/${process.getuid()}`;
514
+ ensureDir(path.dirname(plistPath));
515
+ fs.writeFileSync(plistPath, buildMacPlist(), "utf8");
516
+
517
+ runCommand("launchctl", ["bootout", launchTarget, plistPath], { allowFailure: true });
518
+ runCommand("launchctl", ["bootstrap", launchTarget, plistPath]);
519
+ runCommand("launchctl", ["enable", `${launchTarget}/${SERVICE_LABEL}`], { allowFailure: true });
520
+ runCommand("launchctl", ["kickstart", "-k", `${launchTarget}/${SERVICE_LABEL}`], {
521
+ allowFailure: true
522
+ });
523
+
524
+ return plistPath;
525
+ }
526
+
527
+ function removePathIfExists(targetPath) {
528
+ if (!fs.existsSync(targetPath)) {
529
+ return;
530
+ }
531
+ fs.rmSync(targetPath, { recursive: true, force: true });
532
+ }
533
+
534
+ function restartLinuxService() {
535
+ if (!fs.existsSync(getLinuxUnitPath())) {
536
+ fail("尚未安装 Baton Host systemd 服务,请先执行 npx baton-host@latest service install");
537
+ }
538
+ runCommand("systemctl", ["--user", "restart", getLinuxServiceName()]);
539
+ }
540
+
541
+ function restartMacService() {
542
+ const plistPath = getMacPlistPath();
543
+ if (!fs.existsSync(plistPath)) {
544
+ fail("尚未安装 Baton Host LaunchAgent,请先执行 npx baton-host@latest service install");
545
+ }
546
+ const launchTarget = `gui/${process.getuid()}`;
547
+ runCommand("launchctl", ["bootout", launchTarget, plistPath], { allowFailure: true });
548
+ runCommand("launchctl", ["bootstrap", launchTarget, plistPath]);
549
+ runCommand("launchctl", ["kickstart", "-k", `${launchTarget}/${SERVICE_LABEL}`], {
550
+ allowFailure: true
551
+ });
552
+ }
553
+
554
+ function uninstallLinuxService() {
555
+ runCommand("systemctl", ["--user", "disable", "--now", getLinuxServiceName()], {
556
+ allowFailure: true
557
+ });
558
+ removePathIfExists(getLinuxUnitPath());
559
+ runCommand("systemctl", ["--user", "daemon-reload"], { allowFailure: true });
560
+ }
561
+
562
+ function uninstallMacService() {
563
+ const plistPath = getMacPlistPath();
564
+ const launchTarget = `gui/${process.getuid()}`;
565
+ runCommand("launchctl", ["bootout", launchTarget, plistPath], { allowFailure: true });
566
+ removePathIfExists(plistPath);
567
+ }
568
+
569
+ function cleanupInstallArtifacts() {
570
+ removePathIfExists(path.join(INSTALL_BIN_DIR, "baton-host"));
571
+ removePathIfExists(path.join(INSTALL_BIN_DIR, "cloudflared"));
572
+ removePathIfExists(INSTALL_BIN_DIR);
573
+ removePathIfExists(WRAPPER_PATH);
574
+ removePathIfExists(INSTALL_META_PATH);
575
+ removePathIfExists(INSTALL_LOG_DIR);
576
+
577
+ try {
578
+ const remaining = fs.existsSync(INSTALL_ROOT) ? fs.readdirSync(INSTALL_ROOT) : [];
579
+ if (remaining.length === 0) {
580
+ fs.rmdirSync(INSTALL_ROOT);
581
+ }
582
+ } catch {}
583
+ }
584
+
585
+ function readLinuxStatus() {
586
+ const installed = fs.existsSync(getLinuxUnitPath());
587
+ const enabledResult = installed
588
+ ? runCommand("systemctl", ["--user", "is-enabled", getLinuxServiceName()], { allowFailure: true })
589
+ : { stdout: "", status: 1 };
590
+ const activeResult = installed
591
+ ? runCommand("systemctl", ["--user", "is-active", getLinuxServiceName()], { allowFailure: true })
592
+ : { stdout: "", status: 1 };
593
+
594
+ const enabledState = (enabledResult.stdout || "").trim();
595
+ const activeState = (activeResult.stdout || "").trim();
596
+
597
+ return {
598
+ installed,
599
+ loaded: installed && ["enabled", "enabled-runtime", "static", "indirect"].includes(enabledState),
600
+ running: activeState === "active",
601
+ detail: activeState || enabledState || "not installed",
602
+ serviceFile: getLinuxUnitPath()
603
+ };
604
+ }
605
+
606
+ function readMacStatus() {
607
+ const plistPath = getMacPlistPath();
608
+ const installed = fs.existsSync(plistPath);
609
+ const printResult = installed
610
+ ? runCommand("launchctl", ["print", `gui/${process.getuid()}/${SERVICE_LABEL}`], { allowFailure: true })
611
+ : { stdout: "", stderr: "", status: 1 };
612
+
613
+ const output = `${printResult.stdout || ""}\n${printResult.stderr || ""}`;
614
+ const pidMatch = output.match(/\bpid = (\d+)/);
615
+ const stateMatch = output.match(/\bstate = ([^\n]+)/);
616
+
617
+ return {
618
+ installed,
619
+ loaded: installed && printResult.status === 0,
620
+ running: installed && printResult.status === 0 && !!pidMatch && pidMatch[1] !== "0",
621
+ detail: stateMatch ? stateMatch[1].trim() : (printResult.status === 0 ? "loaded" : "not loaded"),
622
+ serviceFile: plistPath
623
+ };
624
+ }
625
+
626
+ function printServiceStatus() {
627
+ const meta = readInstallMeta();
628
+ const envVars = readEnvFile();
629
+ const platformStatus = process.platform === "linux" ? readLinuxStatus() : readMacStatus();
630
+
631
+ console.log("Baton Host Service Status");
632
+ console.log("");
633
+ console.log(`Installed: ${platformStatus.installed ? "yes" : "no"}`);
634
+ console.log(`Loaded: ${platformStatus.loaded ? "yes" : "no"}`);
635
+ console.log(`Running: ${platformStatus.running ? "yes" : "no"}`);
636
+ console.log(`State: ${platformStatus.detail}`);
637
+ if (meta?.version) {
638
+ console.log(`Version: ${meta.version}`);
639
+ }
640
+ console.log(`Mode: ${envVars.BATON_BRIDGE_MODE || "lan"}`);
641
+ console.log(`Port: ${envVars.PORT || "9966"}`);
642
+ console.log(`Log profile: ${envVars.BRIDGE_LOG_PROFILE || "core"}`);
643
+ console.log(`Binary: ${path.join(INSTALL_BIN_DIR, "baton-host")}`);
644
+ console.log(`Env file: ${ENV_FILE_PATH}`);
645
+ console.log(`Service file: ${platformStatus.serviceFile}`);
646
+ }
647
+
648
+ function ensureSupportedServicePlatform() {
649
+ if (!["linux", "darwin"].includes(process.platform)) {
650
+ fail(`当前平台不支持 service 子命令: ${process.platform}`);
651
+ }
652
+ }
653
+
654
+ async function handleServiceInstall(options = {}) {
655
+ ensureSupportedServicePlatform();
656
+
657
+ const { preserveConfig = false } = options;
658
+ const previousVersion = readInstallMeta()?.version || null;
659
+ const { binaryPath, cloudflaredPath, version } = resolveBundledAssets();
660
+
661
+ ensureDir(INSTALL_ROOT);
662
+ ensureDir(INSTALL_BIN_DIR);
663
+ ensureDir(INSTALL_LOG_DIR);
664
+ ensureDefaultEnvFile();
665
+
666
+ if (preserveConfig) {
667
+ const envVars = readEnvFile();
668
+ if (!envVars.BATON_BRIDGE_MODE || !envVars.PORT) {
669
+ fail("现有配置不完整,无法直接 upgrade。请先执行 npx baton-host@latest service install");
670
+ }
671
+ } else {
672
+ const mode = await promptBridgeModeForInstall();
673
+ const port = await promptPortForInstall();
674
+ writeEnvFile({
675
+ BATON_BRIDGE_MODE: mode,
676
+ PORT: String(port)
677
+ });
678
+ }
679
+
680
+ copyExecutable(binaryPath, path.join(INSTALL_BIN_DIR, "baton-host"));
681
+ copyExecutable(cloudflaredPath, path.join(INSTALL_BIN_DIR, "cloudflared"));
682
+ writeWrapperScript();
683
+ writeInstallMeta(version);
684
+
685
+ const serviceFile = process.platform === "linux" ? installLinuxService() : installMacService();
686
+ const action = preserveConfig
687
+ ? (previousVersion && previousVersion !== version ? "升级并重启" : "覆盖并重启")
688
+ : (previousVersion ? "覆盖并重启" : "安装并启动");
689
+
690
+ console.log(`✅ Baton Host 服务已${action}`);
691
+ console.log(`Version: ${version}`);
692
+ console.log(`Env file: ${ENV_FILE_PATH}`);
693
+ console.log(`Service file: ${serviceFile}`);
694
+ await runBridgePrintCommand();
695
+ }
696
+
697
+ async function handleServiceRestart() {
698
+ ensureSupportedServicePlatform();
699
+ if (process.platform === "linux") {
700
+ restartLinuxService();
701
+ } else {
702
+ restartMacService();
703
+ }
704
+ console.log("✅ Baton Host 服务已重启");
705
+ await runBridgePrintCommand();
706
+ }
707
+
708
+ function handleServiceUninstall() {
709
+ ensureSupportedServicePlatform();
710
+ if (process.platform === "linux") {
711
+ uninstallLinuxService();
712
+ } else {
713
+ uninstallMacService();
714
+ }
715
+ cleanupInstallArtifacts();
716
+ console.log("✅ Baton Host 服务已卸载");
717
+ console.log(`保留配置文件: ${ENV_FILE_PATH}`);
718
+ }
719
+
720
+ function printServiceUsage() {
721
+ console.log("Usage:");
722
+ console.log(" npx baton-host@latest service install");
723
+ console.log(" npx baton-host@latest service upgrade");
724
+ console.log(" npx baton-host@latest service restart");
725
+ console.log(" npx baton-host@latest service status");
726
+ console.log(" npx baton-host@latest service uninstall");
727
+ console.log(" npx baton-host@latest service help");
728
+ console.log("");
729
+ console.log("配置文件: ~/.baton-host/.env");
730
+ console.log(" BATON_BRIDGE_MODE=lan|tailscale|cloudflare");
731
+ console.log(" PORT=9966");
732
+ console.log("");
733
+ console.log("说明: install 会进入模式/端口向导;upgrade 复用现有配置并重启服务");
734
+ }
735
+
736
+ async function handleServiceCommand(args) {
737
+ const command = args[0] || "install";
738
+
739
+ if (command === "install") {
740
+ await handleServiceInstall();
741
+ return;
742
+ }
743
+
744
+ if (command === "upgrade") {
745
+ await handleServiceInstall({ preserveConfig: true });
746
+ return;
747
+ }
748
+
749
+ if (command === "restart") {
750
+ await handleServiceRestart();
751
+ return;
752
+ }
753
+
754
+ if (command === "status") {
755
+ printServiceStatus();
756
+ return;
757
+ }
758
+
759
+ if (command === "uninstall") {
760
+ handleServiceUninstall();
761
+ return;
762
+ }
763
+
764
+ if (command === "help" || command === "--help" || command === "-h") {
765
+ printServiceUsage();
766
+ return;
767
+ }
768
+
769
+ printServiceUsage();
770
+ process.exit(1);
771
+ }
772
+
773
+ function spawnHostBinary(args) {
774
+ const { binaryPath, cloudflaredPath } = resolveBundledAssets();
775
+ const child = spawn(binaryPath, args, {
776
+ stdio: "inherit",
777
+ env: {
778
+ ...process.env,
779
+ BATON_CLOUDFLARED_BIN: cloudflaredPath
780
+ }
781
+ });
782
+
783
+ child.on("exit", (code, signal) => {
784
+ if (signal) {
785
+ process.kill(process.pid, signal);
786
+ return;
787
+ }
788
+ process.exit(code ?? 0);
789
+ });
790
+ }
791
+
792
+ async function main() {
793
+ const args = process.argv.slice(2);
794
+ if (args[0] === "service") {
795
+ await handleServiceCommand(args.slice(1));
796
+ return;
797
+ }
798
+ spawnHostBinary(args);
799
+ }
800
+
801
+ void main().catch((error) => {
802
+ fail(error instanceof Error ? error.message : String(error));
46
803
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baton-host",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Baton Bridge Host CLI(二进制分发入口)",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -10,9 +10,9 @@
10
10
  "bin"
11
11
  ],
12
12
  "optionalDependencies": {
13
- "baton-host-darwin-arm64": "0.1.5",
14
- "baton-host-darwin-x64": "0.1.5",
15
- "baton-host-linux-arm64": "0.1.5",
16
- "baton-host-linux-x64": "0.1.5"
13
+ "baton-host-darwin-arm64": "0.1.7",
14
+ "baton-host-darwin-x64": "0.1.7",
15
+ "baton-host-linux-arm64": "0.1.7",
16
+ "baton-host-linux-x64": "0.1.7"
17
17
  }
18
18
  }