codex-slot 0.1.25 → 0.1.26

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.
@@ -4,6 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getRunningPid = getRunningPid;
7
+ exports.buildLaunchAgentPlist = buildLaunchAgentPlist;
8
+ exports.resolveServiceManagerKind = resolveServiceManagerKind;
9
+ exports.buildSystemdUserUnit = buildSystemdUserUnit;
7
10
  exports.startManagedService = startManagedService;
8
11
  exports.stopManagedService = stopManagedService;
9
12
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -20,6 +23,7 @@ const cli_helpers_1 = require("../cli-helpers");
20
23
  const config_1 = require("../config");
21
24
  const STARTUP_POLL_INTERVAL_MS = 100;
22
25
  const STARTUP_TIMEOUT_MS = 5000;
26
+ const LAUNCH_AGENT_LABEL_PREFIX = "com.openxiaobu.cslot";
23
27
  /**
24
28
  * 休眠指定毫秒数,供启动轮询流程复用。
25
29
  *
@@ -75,6 +79,361 @@ function resolveServeEntrypoint() {
75
79
  args: [`${serveBasePath}.js`]
76
80
  };
77
81
  }
82
+ /**
83
+ * 基于当前 HOME 目录生成稳定的 launchd label,避免测试隔离 HOME 与真实 HOME 互相冲突。
84
+ *
85
+ * @returns 当前 HOME 对应的 launchd label。
86
+ * @throws 无显式抛出。
87
+ */
88
+ function getLaunchAgentLabel() {
89
+ const home = (0, config_1.getUserHomeDir)();
90
+ let hash = 2166136261;
91
+ for (const character of home) {
92
+ hash ^= character.charCodeAt(0);
93
+ hash = Math.imul(hash, 16777619);
94
+ }
95
+ return `${LAUNCH_AGENT_LABEL_PREFIX}.${(hash >>> 0).toString(16)}`;
96
+ }
97
+ /**
98
+ * 返回当前 HOME 对应的 LaunchAgents plist 路径,并确保父目录存在。
99
+ *
100
+ * @returns launchd plist 绝对路径。
101
+ * @throws 当目录创建失败时抛出文件系统错误。
102
+ */
103
+ function getLaunchAgentPlistPath() {
104
+ const launchAgentsDir = node_path_1.default.join((0, config_1.getUserHomeDir)(), "Library", "LaunchAgents");
105
+ node_fs_1.default.mkdirSync(launchAgentsDir, { recursive: true });
106
+ return node_path_1.default.join(launchAgentsDir, `${getLaunchAgentLabel()}.plist`);
107
+ }
108
+ /**
109
+ * 对 XML 文本做最小转义,避免命令参数与日志路径写入 plist 时破坏结构。
110
+ *
111
+ * @param value 待写入 plist 的原始文本。
112
+ * @returns 完成 XML 转义后的文本。
113
+ * @throws 无显式抛出。
114
+ */
115
+ function escapeXml(value) {
116
+ return value
117
+ .replaceAll("&", "&")
118
+ .replaceAll("<", "&lt;")
119
+ .replaceAll(">", "&gt;")
120
+ .replaceAll("\"", "&quot;")
121
+ .replaceAll("'", "&apos;");
122
+ }
123
+ /**
124
+ * 生成 launchd plist 内容,使 cslot 服务具备开机自启与异常退出自动拉起能力。
125
+ *
126
+ * @param command 启动服务使用的绝对命令路径。
127
+ * @param args 命令参数列表。
128
+ * @param logPath 标准输出与错误输出写入的日志路径。
129
+ * @returns 可直接写入磁盘的 plist 文本。
130
+ * @throws 无显式抛出。
131
+ */
132
+ function buildLaunchAgentPlist(command, args, logPath) {
133
+ const programArguments = [command, ...args].map((item) => ` <string>${escapeXml(item)}</string>`).join("\n");
134
+ const home = escapeXml((0, config_1.getUserHomeDir)());
135
+ const label = escapeXml(getLaunchAgentLabel());
136
+ const escapedLogPath = escapeXml(logPath);
137
+ return [
138
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
139
+ "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">",
140
+ "<plist version=\"1.0\">",
141
+ "<dict>",
142
+ " <key>Label</key>",
143
+ ` <string>${label}</string>`,
144
+ " <key>ProgramArguments</key>",
145
+ " <array>",
146
+ programArguments,
147
+ " </array>",
148
+ " <key>RunAtLoad</key>",
149
+ " <true/>",
150
+ " <key>KeepAlive</key>",
151
+ " <true/>",
152
+ " <key>EnvironmentVariables</key>",
153
+ " <dict>",
154
+ " <key>HOME</key>",
155
+ ` <string>${home}</string>`,
156
+ " </dict>",
157
+ " <key>StandardOutPath</key>",
158
+ ` <string>${escapedLogPath}</string>`,
159
+ " <key>StandardErrorPath</key>",
160
+ ` <string>${escapedLogPath}</string>`,
161
+ "</dict>",
162
+ "</plist>",
163
+ ""
164
+ ].join("\n");
165
+ }
166
+ /**
167
+ * 判断当前环境是否应启用 launchd 托管。
168
+ *
169
+ * @returns macOS 且未显式禁用 launchd 时返回 `true`,否则返回 `false`。
170
+ * @throws 无显式抛出。
171
+ */
172
+ function shouldUseLaunchd() {
173
+ return process.platform === "darwin" && process.env.CSLOT_DISABLE_LAUNCHD !== "1";
174
+ }
175
+ /**
176
+ * 判断当前 Linux 环境是否应使用 systemd user service 托管。
177
+ *
178
+ * @returns Linux 且未显式禁用 systemd 托管时返回 `true`,否则返回 `false`。
179
+ * @throws 无显式抛出。
180
+ */
181
+ function shouldUseSystemdUser() {
182
+ return process.platform === "linux" && process.env.CSLOT_DISABLE_SYSTEMD !== "1";
183
+ }
184
+ /**
185
+ * 解析当前平台应使用的后台服务托管方式。
186
+ *
187
+ * @returns 当前平台对应的服务管理器类型。
188
+ * @throws 当 Linux 期望使用 systemd 但本机缺少 `systemctl` 时抛出异常。
189
+ */
190
+ function resolveServiceManagerKind() {
191
+ if (shouldUseLaunchd()) {
192
+ return "launchd";
193
+ }
194
+ if (shouldUseSystemdUser()) {
195
+ try {
196
+ (0, node_child_process_1.execFileSync)("systemctl", ["--user", "--version"], {
197
+ stdio: ["ignore", "ignore", "ignore"]
198
+ });
199
+ }
200
+ catch {
201
+ throw new Error("当前 Linux 环境缺少 systemd --user,无法提供自动拉起与开机自启。请安装 systemd user service,或显式设置 CSLOT_DISABLE_SYSTEMD=1 回退到 detached 模式。");
202
+ }
203
+ return "systemd-user";
204
+ }
205
+ return "detached";
206
+ }
207
+ /**
208
+ * 返回当前用户的 launchd domain,供 `launchctl bootstrap/bootout` 复用。
209
+ *
210
+ * @returns 类似 `gui/501` 的 domain 字符串。
211
+ * @throws 当当前进程无法获取 uid 时抛出异常。
212
+ */
213
+ function getLaunchctlDomain() {
214
+ if (typeof process.getuid !== "function") {
215
+ throw new Error("当前平台不支持 launchctl user domain");
216
+ }
217
+ return `gui/${process.getuid()}`;
218
+ }
219
+ /**
220
+ * 返回 systemd user service 的 unit 名称,按 HOME 做稳定区分,避免测试与真实环境互串。
221
+ *
222
+ * @returns 当前 HOME 对应的 systemd unit 文件名。
223
+ * @throws 无显式抛出。
224
+ */
225
+ function getSystemdUserUnitName() {
226
+ return `${getLaunchAgentLabel().replaceAll(".", "-")}.service`;
227
+ }
228
+ /**
229
+ * 返回 systemd user service unit 文件路径,并确保目录存在。
230
+ *
231
+ * @returns systemd user unit 绝对路径。
232
+ * @throws 当目录创建失败时抛出文件系统错误。
233
+ */
234
+ function getSystemdUserUnitPath() {
235
+ const unitDir = node_path_1.default.join((0, config_1.getUserHomeDir)(), ".config", "systemd", "user");
236
+ node_fs_1.default.mkdirSync(unitDir, { recursive: true });
237
+ return node_path_1.default.join(unitDir, getSystemdUserUnitName());
238
+ }
239
+ /**
240
+ * 对 systemd Environment 值做最小转义,避免空格或双引号破坏 unit 文件语义。
241
+ *
242
+ * @param value Environment 原始值。
243
+ * @returns 已转义的值文本。
244
+ * @throws 无显式抛出。
245
+ */
246
+ function escapeSystemdEnvironmentValue(value) {
247
+ return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
248
+ }
249
+ /**
250
+ * 对 systemd ExecStart 参数做 shell 风格转义,确保路径和参数可被 systemd 正确拆分。
251
+ *
252
+ * @param value ExecStart 单个参数文本。
253
+ * @returns 安全可写入 unit 的参数文本。
254
+ * @throws 无显式抛出。
255
+ */
256
+ function quoteSystemdExecArgument(value) {
257
+ return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
258
+ }
259
+ /**
260
+ * 生成 systemd --user service unit,使 cslot 在 Linux 下具备用户级开机自启与异常退出自动重启能力。
261
+ *
262
+ * @param command 启动服务使用的绝对命令路径。
263
+ * @param args 命令参数列表。
264
+ * @param logPath 服务日志路径。
265
+ * @returns 可直接写入 systemd user unit 的文本。
266
+ * @throws 无显式抛出。
267
+ */
268
+ function buildSystemdUserUnit(command, args, logPath) {
269
+ const execStart = [command, ...args].map(quoteSystemdExecArgument).join(" ");
270
+ const home = escapeSystemdEnvironmentValue((0, config_1.getUserHomeDir)());
271
+ const safeLogPath = logPath.replaceAll("\\", "\\\\").replaceAll("%", "%%");
272
+ return [
273
+ "[Unit]",
274
+ "Description=cslot managed local proxy",
275
+ "After=network-online.target",
276
+ "Wants=network-online.target",
277
+ "",
278
+ "[Service]",
279
+ "Type=simple",
280
+ `Environment=HOME="${home}"`,
281
+ `ExecStart=${execStart}`,
282
+ "Restart=always",
283
+ "RestartSec=1",
284
+ `StandardOutput=append:${safeLogPath}`,
285
+ `StandardError=append:${safeLogPath}`,
286
+ "",
287
+ "[Install]",
288
+ "WantedBy=default.target",
289
+ ""
290
+ ].join("\n");
291
+ }
292
+ /**
293
+ * 执行一次 systemctl --user 命令,并在允许的退出码范围内按幂等成功处理。
294
+ *
295
+ * @param args systemctl 参数列表。
296
+ * @param allowedStatuses 允许按成功处理的退出码集合。
297
+ * @returns 标准输出文本。
298
+ * @throws 当命令失败且退出码不在允许列表中时抛出异常。
299
+ */
300
+ function runSystemctlUser(args, allowedStatuses = []) {
301
+ try {
302
+ return (0, node_child_process_1.execFileSync)("systemctl", ["--user", ...args], {
303
+ encoding: "utf8",
304
+ stdio: ["ignore", "pipe", "pipe"]
305
+ });
306
+ }
307
+ catch (error) {
308
+ const status = typeof error === "object" && error && "status" in error ? Number(error.status) : null;
309
+ if (status !== null && allowedStatuses.includes(status)) {
310
+ return "";
311
+ }
312
+ const stderr = typeof error === "object" && error && "stderr" in error && typeof error.stderr === "string"
313
+ ? error.stderr.trim()
314
+ : "";
315
+ const message = stderr || (error instanceof Error ? error.message : String(error));
316
+ throw new Error(`systemctl --user ${args.join(" ")} 失败: ${message}`);
317
+ }
318
+ }
319
+ /**
320
+ * 启动或重载 systemd --user 托管的 cslot 服务。
321
+ *
322
+ * @param port 最终启动端口。
323
+ * @param logPath 服务日志路径。
324
+ * @returns 当前 systemd 托管实例的 PID。
325
+ * @throws 当 unit 写入、systemctl 调用或服务就绪检查失败时抛出异常。
326
+ */
327
+ async function startManagedServiceWithSystemdUser(port, logPath) {
328
+ const serveEntrypoint = resolveServeEntrypoint();
329
+ const unitPath = getSystemdUserUnitPath();
330
+ const unitName = getSystemdUserUnitName();
331
+ const unit = buildSystemdUserUnit(serveEntrypoint.command, [...serveEntrypoint.args, "--port", String(port)], logPath);
332
+ node_fs_1.default.writeFileSync(unitPath, unit, "utf8");
333
+ runSystemctlUser(["daemon-reload"]);
334
+ runSystemctlUser(["enable", "--now", unitName]);
335
+ return await waitForManagedServicePid();
336
+ }
337
+ /**
338
+ * 停止并卸载 systemd --user 托管的 cslot 服务,同时清理本地 unit 工件。
339
+ *
340
+ * @returns 停止前记录到的 PID;若当时没有可见运行 PID 则返回 `null`。
341
+ * @throws 当 systemctl 卸载或清理失败时抛出异常。
342
+ */
343
+ function stopManagedServiceWithSystemdUser() {
344
+ const pid = getRunningPid();
345
+ const unitName = getSystemdUserUnitName();
346
+ const unitPath = getSystemdUserUnitPath();
347
+ runSystemctlUser(["disable", "--now", unitName], [1, 5]);
348
+ node_fs_1.default.rmSync(unitPath, { force: true });
349
+ runSystemctlUser(["daemon-reload"]);
350
+ runSystemctlUser(["reset-failed", unitName], [1, 5]);
351
+ node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
352
+ return pid;
353
+ }
354
+ /**
355
+ * 执行一次 launchctl 命令,并在允许的失败码内按幂等处理。
356
+ *
357
+ * @param args launchctl 参数列表。
358
+ * @param allowedStatuses 允许视为成功的退出码集合。
359
+ * @returns 标准输出文本。
360
+ * @throws 当命令执行失败且退出码不在允许集合中时抛出异常。
361
+ */
362
+ function runLaunchctl(args, allowedStatuses = []) {
363
+ try {
364
+ return (0, node_child_process_1.execFileSync)("launchctl", args, {
365
+ encoding: "utf8",
366
+ stdio: ["ignore", "pipe", "pipe"]
367
+ });
368
+ }
369
+ catch (error) {
370
+ const status = typeof error === "object" && error && "status" in error ? Number(error.status) : null;
371
+ if (status !== null && allowedStatuses.includes(status)) {
372
+ return "";
373
+ }
374
+ const stderr = typeof error === "object" && error && "stderr" in error && typeof error.stderr === "string"
375
+ ? error.stderr.trim()
376
+ : "";
377
+ const message = stderr || (error instanceof Error ? error.message : String(error));
378
+ throw new Error(`launchctl ${args.join(" ")} 失败: ${message}`);
379
+ }
380
+ }
381
+ /**
382
+ * 等待服务进程把自己的 PID 写入本地状态文件,兼容 launchd 拉起的非子进程模型。
383
+ *
384
+ * @param timeoutMs 等待超时时间,单位毫秒。
385
+ * @returns Promise,成功时返回当前运行中的 PID。
386
+ * @throws 当超时后仍未拿到有效 PID 时抛出异常。
387
+ */
388
+ async function waitForManagedServicePid(timeoutMs = STARTUP_TIMEOUT_MS) {
389
+ const startedAt = Date.now();
390
+ while (Date.now() - startedAt < timeoutMs) {
391
+ const pid = getRunningPid();
392
+ if (pid) {
393
+ return pid;
394
+ }
395
+ await sleep(STARTUP_POLL_INTERVAL_MS);
396
+ }
397
+ throw new Error(`后台服务启动超时,未在 ${timeoutMs}ms 内写入有效 PID`);
398
+ }
399
+ /**
400
+ * 启动或重载 launchd 托管的 cslot 服务。
401
+ *
402
+ * @param port 最终启动端口。
403
+ * @param logPath 服务日志路径。
404
+ * @returns 当前 launchd 托管实例的 PID。
405
+ * @throws 当 plist 写入、launchctl 调用或服务就绪检查失败时抛出异常。
406
+ */
407
+ async function startManagedServiceWithLaunchd(port, logPath) {
408
+ const serveEntrypoint = resolveServeEntrypoint();
409
+ const plistPath = getLaunchAgentPlistPath();
410
+ const domain = getLaunchctlDomain();
411
+ const label = getLaunchAgentLabel();
412
+ const plist = buildLaunchAgentPlist(serveEntrypoint.command, [...serveEntrypoint.args, "--port", String(port)], logPath);
413
+ node_fs_1.default.writeFileSync(plistPath, plist, "utf8");
414
+ // 先卸载旧 job,再用最新配置重载,避免端口或命令参数更新后仍复用旧定义。
415
+ runLaunchctl(["bootout", domain, plistPath], [3, 5, 36, 64, 113]);
416
+ runLaunchctl(["bootstrap", domain, plistPath]);
417
+ runLaunchctl(["enable", `${domain}/${label}`], [3, 5, 64, 113]);
418
+ runLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
419
+ const pid = await waitForManagedServicePid();
420
+ return pid;
421
+ }
422
+ /**
423
+ * 停止并卸载 launchd 托管的 cslot 服务,同时清理本地 plist 工件。
424
+ *
425
+ * @returns 停止前记录到的 PID;若当时没有可见运行 PID 则返回 `null`。
426
+ * @throws 当 launchctl 卸载失败时抛出异常。
427
+ */
428
+ function stopManagedServiceWithLaunchd() {
429
+ const pid = getRunningPid();
430
+ const plistPath = getLaunchAgentPlistPath();
431
+ const domain = getLaunchctlDomain();
432
+ runLaunchctl(["bootout", domain, plistPath], [3, 5, 36, 64, 113]);
433
+ node_fs_1.default.rmSync(plistPath, { force: true });
434
+ node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
435
+ return pid;
436
+ }
78
437
  /**
79
438
  * 检查指定地址与端口当前是否可绑定,用于启动前规避端口冲突。
80
439
  *
@@ -165,6 +524,18 @@ function rollbackFailedStart(pid, previousConfig) {
165
524
  }
166
525
  }
167
526
  node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
527
+ try {
528
+ const manager = resolveServiceManagerKind();
529
+ if (manager === "launchd") {
530
+ stopManagedServiceWithLaunchd();
531
+ }
532
+ else if (manager === "systemd-user") {
533
+ stopManagedServiceWithSystemdUser();
534
+ }
535
+ }
536
+ catch {
537
+ // 回滚阶段以尽力清理为主,不覆盖原始启动异常。
538
+ }
168
539
  (0, config_1.saveConfig)(previousConfig);
169
540
  (0, codex_config_1.deactivateManagedCodexConfig)();
170
541
  (0, codex_auth_1.deactivateManagedCodexAuth)();
@@ -255,6 +626,7 @@ async function startManagedService(portOverride) {
255
626
  const config = (0, config_1.loadConfig)();
256
627
  const previousConfig = structuredClone(config);
257
628
  const { port, autoSwitched } = await resolveStartPort(config.server.host, portOverride);
629
+ const manager = resolveServiceManagerKind();
258
630
  const runningPid = getRunningPid();
259
631
  if (runningPid) {
260
632
  return {
@@ -262,7 +634,8 @@ async function startManagedService(portOverride) {
262
634
  pid: runningPid,
263
635
  port: config.server.port,
264
636
  logPath: (0, config_1.getServiceLogPath)(),
265
- autoSwitched: false
637
+ autoSwitched: false,
638
+ manager
266
639
  };
267
640
  }
268
641
  if (config.server.port !== port) {
@@ -272,21 +645,40 @@ async function startManagedService(portOverride) {
272
645
  (0, codex_config_1.applyManagedCodexConfig)(undefined, { config });
273
646
  applyManagedAuthIfPossible();
274
647
  const logPath = (0, config_1.getServiceLogPath)();
275
- const logFd = node_fs_1.default.openSync(logPath, "a");
276
- const serveEntrypoint = resolveServeEntrypoint();
277
- const child = (0, node_child_process_1.spawn)(serveEntrypoint.command, [...serveEntrypoint.args, "--port", String(port)], {
278
- detached: true,
279
- stdio: ["ignore", logFd, logFd]
280
- });
281
- const childPid = child.pid ?? null;
282
- child.unref();
283
- if (!childPid) {
648
+ let childPid = null;
649
+ if (manager === "launchd") {
650
+ try {
651
+ childPid = await startManagedServiceWithLaunchd(port, logPath);
652
+ }
653
+ catch (error) {
654
+ rollbackFailedStart(null, previousConfig);
655
+ throw error;
656
+ }
657
+ }
658
+ else if (manager === "systemd-user") {
659
+ try {
660
+ childPid = await startManagedServiceWithSystemdUser(port, logPath);
661
+ }
662
+ catch (error) {
663
+ rollbackFailedStart(null, previousConfig);
664
+ throw error;
665
+ }
666
+ }
667
+ else {
668
+ const logFd = node_fs_1.default.openSync(logPath, "a");
669
+ const serveEntrypoint = resolveServeEntrypoint();
670
+ const child = (0, node_child_process_1.spawn)(serveEntrypoint.command, [...serveEntrypoint.args, "--port", String(port)], {
671
+ detached: true,
672
+ stdio: ["ignore", logFd, logFd]
673
+ });
674
+ childPid = child.pid ?? null;
675
+ child.unref();
284
676
  node_fs_1.default.closeSync(logFd);
285
- rollbackFailedStart(null, previousConfig);
286
- throw new Error("后台服务启动失败,未获取到有效子进程 PID");
677
+ if (!childPid) {
678
+ rollbackFailedStart(null, previousConfig);
679
+ throw new Error("后台服务启动失败,未获取到有效子进程 PID");
680
+ }
287
681
  }
288
- node_fs_1.default.writeFileSync((0, config_1.getPidPath)(), `${childPid}\n`, "utf8");
289
- node_fs_1.default.closeSync(logFd);
290
682
  try {
291
683
  await waitForManagedServiceReady(config.server.host, port, childPid);
292
684
  }
@@ -299,7 +691,8 @@ async function startManagedService(portOverride) {
299
691
  pid: childPid,
300
692
  port,
301
693
  logPath,
302
- autoSwitched
694
+ autoSwitched,
695
+ manager
303
696
  };
304
697
  }
305
698
  /**
@@ -309,14 +702,25 @@ async function startManagedService(portOverride) {
309
702
  * @throws 当进程终止失败时透传底层异常。
310
703
  */
311
704
  function stopManagedService() {
705
+ const manager = resolveServiceManagerKind();
706
+ const hasLaunchAgent = manager === "launchd" && node_fs_1.default.existsSync(getLaunchAgentPlistPath());
707
+ const hasSystemdUnit = manager === "systemd-user" && node_fs_1.default.existsSync(getSystemdUserUnitPath());
312
708
  const pid = getRunningPid();
313
- if (!pid) {
709
+ if (!pid && !hasLaunchAgent && !hasSystemdUnit) {
314
710
  (0, codex_config_1.deactivateManagedCodexConfig)();
315
711
  (0, codex_auth_1.deactivateManagedCodexAuth)();
316
712
  return { stoppedPid: null };
317
713
  }
318
- process.kill(pid, "SIGTERM");
319
- node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
714
+ if (hasLaunchAgent) {
715
+ stopManagedServiceWithLaunchd();
716
+ }
717
+ else if (hasSystemdUnit) {
718
+ stopManagedServiceWithSystemdUser();
719
+ }
720
+ else if (pid) {
721
+ process.kill(pid, "SIGTERM");
722
+ node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
723
+ }
320
724
  (0, codex_config_1.deactivateManagedCodexConfig)();
321
725
  (0, codex_auth_1.deactivateManagedCodexAuth)();
322
726
  return { stoppedPid: pid };
package/dist/serve.js CHANGED
@@ -1,9 +1,60 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
3
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ const node_fs_1 = __importDefault(require("node:fs"));
4
8
  const config_1 = require("./config");
5
9
  const server_1 = require("./server");
6
10
  const text_1 = require("./text");
11
+ /**
12
+ * 将当前服务进程 PID 持久化到本地状态文件,供 `cslot stop` 与健康检查流程复用。
13
+ *
14
+ * @returns 无返回值。
15
+ * @throws 当 PID 文件写入失败时抛出文件系统错误。
16
+ */
17
+ function writeCurrentPid() {
18
+ node_fs_1.default.writeFileSync((0, config_1.getPidPath)(), `${process.pid}\n`, "utf8");
19
+ }
20
+ /**
21
+ * 按幂等方式清理当前服务进程留下的 PID 文件,避免异常退出后残留脏状态。
22
+ *
23
+ * @returns 无返回值。
24
+ * @throws 无显式抛出。
25
+ */
26
+ function cleanupPidFile() {
27
+ try {
28
+ const pidPath = (0, config_1.getPidPath)();
29
+ if (!node_fs_1.default.existsSync(pidPath)) {
30
+ return;
31
+ }
32
+ const raw = node_fs_1.default.readFileSync(pidPath, "utf8").trim();
33
+ if (Number(raw) === process.pid) {
34
+ node_fs_1.default.rmSync(pidPath, { force: true });
35
+ }
36
+ }
37
+ catch {
38
+ // 退出清理阶段以幂等为主,不阻塞真实退出流程。
39
+ }
40
+ }
41
+ /**
42
+ * 注册服务进程退出时的 PID 清理逻辑,兼容正常停止与 launchd 重启场景。
43
+ *
44
+ * @returns 无返回值。
45
+ * @throws 无显式抛出。
46
+ */
47
+ function registerPidCleanupHandlers() {
48
+ process.once("exit", cleanupPidFile);
49
+ process.once("SIGINT", () => {
50
+ cleanupPidFile();
51
+ process.exit(0);
52
+ });
53
+ process.once("SIGTERM", () => {
54
+ cleanupPidFile();
55
+ process.exit(0);
56
+ });
57
+ }
7
58
  /**
8
59
  * 后台服务进程入口。
9
60
  *
@@ -16,9 +67,12 @@ async function main() {
16
67
  const port = portArgIndex >= 0 && process.argv[portArgIndex + 1]
17
68
  ? Number(process.argv[portArgIndex + 1])
18
69
  : config.server.port;
70
+ writeCurrentPid();
71
+ registerPidCleanupHandlers();
19
72
  await (0, server_1.startServer)(port);
20
73
  }
21
74
  void main().catch((error) => {
75
+ cleanupPidFile();
22
76
  const message = error instanceof Error ? error.message : String(error);
23
77
  console.error((0, text_1.bi)(`cslot service 启动失败: ${message}`, `cslot service failed to start: ${message}`));
24
78
  process.exit(1);
package/dist/server.js CHANGED
@@ -26,6 +26,35 @@ async function readRawRequestBody(stream) {
26
26
  }
27
27
  return Buffer.concat(chunks);
28
28
  }
29
+ /**
30
+ * 将上游响应体安全地透传给已 hijack 的本地响应。
31
+ *
32
+ * 业务含义:
33
+ * 1. 一旦开始写出上游 header,就不能再把异常抛回 Fastify 默认错误处理器。
34
+ * 2. 上游流中途断开或客户端提前关闭时,只终止当前连接,避免整个 cslot 进程崩溃。
35
+ *
36
+ * @param reply 当前请求对应的 Fastify reply。
37
+ * @param result 已成功拿到响应头的上游代理结果。
38
+ * @returns Promise,流复制结束或被安全终止后返回。
39
+ * @throws 无显式抛出;异常会被当前方法内部吞掉并转成连接销毁。
40
+ */
41
+ async function streamProxyResponse(reply, result) {
42
+ reply.hijack();
43
+ reply.raw.writeHead(result.statusCode, result.headers);
44
+ try {
45
+ for await (const chunk of result.body) {
46
+ reply.raw.write(chunk);
47
+ }
48
+ reply.raw.end();
49
+ }
50
+ catch (error) {
51
+ console.error("cslot proxy stream aborted", error);
52
+ // 响应已开始输出,此时只能销毁当前连接,不能再交给 Fastify 二次写 header。
53
+ if (!reply.raw.destroyed) {
54
+ reply.raw.destroy?.(error instanceof Error ? error : new Error(String(error)));
55
+ }
56
+ }
57
+ }
29
58
  /**
30
59
  * 启动一个极轻量本地服务,供后续接入代理或脚本化查询使用。
31
60
  *
@@ -68,12 +97,7 @@ async function startServer(port) {
68
97
  reply.send(result.payload);
69
98
  return;
70
99
  }
71
- reply.hijack();
72
- reply.raw.writeHead(result.statusCode, result.headers);
73
- for await (const chunk of result.body) {
74
- reply.raw.write(chunk);
75
- }
76
- reply.raw.end();
100
+ await streamProxyResponse(reply, result);
77
101
  };
78
102
  const backendProxyHandler = async (request, reply) => {
79
103
  const requestBody = request.body ? await readRawRequestBody(request.body) : undefined;
@@ -91,12 +115,7 @@ async function startServer(port) {
91
115
  reply.send(result.payload);
92
116
  return;
93
117
  }
94
- reply.hijack();
95
- reply.raw.writeHead(result.statusCode, result.headers);
96
- for await (const chunk of result.body) {
97
- reply.raw.write(chunk);
98
- }
99
- reply.raw.end();
118
+ await streamProxyResponse(reply, result);
100
119
  };
101
120
  await app.register(async (proxyApp) => {
102
121
  // 代理路由需要原样透传 body,不能在本地先做 JSON 解析与大小限制拦截。
@@ -5,6 +5,16 @@ exports.handleStop = handleStop;
5
5
  const service_lifecycle_service_1 = require("./app/service-lifecycle-service");
6
6
  const config_1 = require("./config");
7
7
  const text_1 = require("./text");
8
+ function describeServiceManager(manager) {
9
+ switch (manager) {
10
+ case "launchd":
11
+ return (0, text_1.bi)("launchd(开机自启 + 异常自动拉起)", "launchd (auto-start on boot + auto-restart on failure)");
12
+ case "systemd-user":
13
+ return (0, text_1.bi)("systemd --user(开机自启 + 异常自动拉起)", "systemd --user (auto-start on boot + auto-restart on failure)");
14
+ default:
15
+ return (0, text_1.bi)("detached 进程(仅本次后台运行,不保证自动拉起)", "detached process (background only, no guaranteed auto-restart)");
16
+ }
17
+ }
8
18
  /**
9
19
  * 后台启动 cslot 服务并写入 PID 文件。
10
20
  *
@@ -17,6 +27,7 @@ async function handleStart(portOverride) {
17
27
  const result = await (0, service_lifecycle_service_1.startManagedService)(portOverride);
18
28
  if (result.alreadyRunning) {
19
29
  console.log((0, text_1.bi)(`服务已在运行,PID=${result.pid}`, `Service is already running. PID=${result.pid}`));
30
+ console.log(`托管 / Manager: ${describeServiceManager(result.manager)}`);
20
31
  if (portOverride) {
21
32
  console.log((0, text_1.bi)(`已将新端口写入配置: ${result.port}`, `The new port has been saved to config: ${result.port}`));
22
33
  console.log((0, text_1.bi)("请先执行 cslot stop,再执行 cslot start 使新端口生效。", "Run cslot stop first, then cslot start to apply the new port."));
@@ -27,6 +38,7 @@ async function handleStart(portOverride) {
27
38
  console.log((0, text_1.bi)(`默认端口 4399 已被占用,已自动切换到 ${result.port}`, `Default port 4399 is busy. Automatically switched to ${result.port}`));
28
39
  }
29
40
  console.log((0, text_1.bi)(`服务已启动: http://${config.server.host}:${result.port}`, `Service started: http://${config.server.host}:${result.port}`));
41
+ console.log(`托管 / Manager: ${describeServiceManager(result.manager)}`);
30
42
  console.log(`PID: ${result.pid}`);
31
43
  console.log((0, text_1.bi)(`日志: ${result.logPath}`, `Log: ${result.logPath}`));
32
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-slot",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "本地 Codex 多账号切换与状态管理工具",
5
5
  "type": "commonjs",
6
6
  "main": "dist/cli.js",