@botcord/daemon 0.2.81 → 0.2.83

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.
@@ -37,3 +37,4 @@ export declare function writeCurrentPid(opts?: {
37
37
  currentPid?: number;
38
38
  }): void;
39
39
  export declare function removePidFile(pidPath?: string): void;
40
+ export declare function isBotCordDaemonStartCommand(command: string): boolean;
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
3
4
  import { PID_PATH } from "./config.js";
4
5
  const noopLogger = {
5
6
  info() {
@@ -137,7 +138,17 @@ export function ensureNoOtherDaemonFromPidFile(opts = {}) {
137
138
  return null;
138
139
  }
139
140
  export function writeCurrentPid(opts = {}) {
140
- writeFileSync(opts.pidPath ?? PID_PATH, String(opts.currentPid ?? process.pid), { mode: 0o600 });
141
+ const pidPath = opts.pidPath ?? PID_PATH;
142
+ // Cloud-mode startup writes the PID file before `saveConfig` runs, so
143
+ // the daemon dir may not exist yet. mkdir its parent (0700) so the
144
+ // first write doesn't crash with ENOENT.
145
+ try {
146
+ mkdirSync(path.dirname(pidPath), { recursive: true, mode: 0o700 });
147
+ }
148
+ catch {
149
+ // best-effort — writeFileSync below will surface the real error
150
+ }
151
+ writeFileSync(pidPath, String(opts.currentPid ?? process.pid), { mode: 0o600 });
141
152
  }
142
153
  export function removePidFile(pidPath = PID_PATH) {
143
154
  try {
@@ -147,12 +158,30 @@ export function removePidFile(pidPath = PID_PATH) {
147
158
  // ignore
148
159
  }
149
160
  }
150
- function isBotCordDaemonStartCommand(command) {
161
+ export function isBotCordDaemonStartCommand(command) {
151
162
  if (!/\bstart\b/.test(command))
152
163
  return false;
153
- return (command.includes("botcord-daemon") ||
154
- /(?:^|\s)\S*botcord\S*\/daemon\/dist\/index\.js(?:\s|$)/.test(command) ||
155
- /(?:^|\s)\S*packages\/daemon\/dist\/index\.js(?:\s|$)/.test(command));
164
+ // Only treat a row as "the daemon" when argv[0] is the real entry point:
165
+ // either the node interpreter (running `dist/index.js`) or the resolved
166
+ // `botcord-daemon` bin shim. Reject shell wrappers (`sh`, `bash`, `npm`,
167
+ // `npx`, `timeout`, ...) — their argv mentions `botcord-daemon` only as a
168
+ // literal arg, not as the executable being run. Killing a wrapper takes
169
+ // out the actual daemon it started, which is exactly the bug we want
170
+ // to avoid in cloud sandboxes.
171
+ const exe = (command.trim().split(/\s+/, 1)[0] ?? "").split("/").pop() ?? "";
172
+ const isNode = /^node(\d.*)?$/.test(exe);
173
+ const isDaemonBin = exe === "botcord-daemon";
174
+ if (!isNode && !isDaemonBin)
175
+ return false;
176
+ return (
177
+ // node-resolved bin shim, e.g. .../node_modules/.bin/botcord-daemon
178
+ /\/\.?bin\/botcord-daemon(?:\s|$)/.test(command) ||
179
+ // direct bin invocation, e.g. /usr/local/bin/botcord-daemon or argv[0]=botcord-daemon
180
+ /(?:^|\s)\S*botcord-daemon(?:\s|$)/.test(command) ||
181
+ // node running the published daemon entry script
182
+ /\bbotcord\S*\/daemon\/dist\/index\.js(?:\s|$)/.test(command) ||
183
+ // node running the in-repo daemon entry script (dev / monorepo)
184
+ /\bpackages\/daemon\/dist\/index\.js(?:\s|$)/.test(command));
156
185
  }
157
186
  function delay(ms) {
158
187
  return new Promise((resolve) => setTimeout(resolve, ms));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.81",
3
+ "version": "0.2.83",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
6
  import {
7
7
  ensureNoOtherDaemonFromPidFile,
8
+ isBotCordDaemonStartCommand,
8
9
  parseDaemonProcesses,
9
10
  readPid,
10
11
  removePidFile,
@@ -44,6 +45,17 @@ describe("daemon singleton pid helpers", () => {
44
45
  expect(readFileSync(pidPath, "utf8")).toBe("12345");
45
46
  });
46
47
 
48
+ it("creates the parent directory if missing (cloud-mode first boot)", () => {
49
+ // Cloud-mode start writes the PID file before `saveConfig` runs, so
50
+ // ~/.botcord/daemon/ may not exist yet. Without the mkdir the daemon
51
+ // crashes immediately with ENOENT.
52
+ const pidPath = path.join(tmpDir, "fresh-cloud-home", "daemon", "daemon.pid");
53
+
54
+ writeCurrentPid({ pidPath, currentPid: 7890 });
55
+
56
+ expect(readPid(pidPath)).toBe(7890);
57
+ });
58
+
47
59
  it("does not report the current process as another daemon", () => {
48
60
  const pidPath = path.join(tmpDir, "daemon.pid");
49
61
  writeCurrentPid({ pidPath, currentPid: process.pid });
@@ -90,6 +102,38 @@ describe("daemon singleton pid helpers", () => {
90
102
  expect(out.map((p) => p.pid)).toEqual([111, 222]);
91
103
  });
92
104
 
105
+ it("does not match shell wrappers whose argv mentions botcord-daemon as a literal", () => {
106
+ // These are the wrapper command lines we observed in cloud sandboxes;
107
+ // they must NOT be classified as the daemon, otherwise the singleton
108
+ // check kills the wrapper and takes the actual daemon down with it.
109
+ const wrappers = [
110
+ "npm exec --yes --package @botcord/daemon@latest -- botcord-daemon start --foreground",
111
+ "npx --yes --package @botcord/daemon@latest -- botcord-daemon start --foreground",
112
+ "sh -c botcord-daemon start --foreground",
113
+ "/bin/bash -l -c export npm_config_cache=/tmp/c; npm exec --yes --package @botcord/daemon@latest -- botcord-daemon start --foreground",
114
+ "timeout 30 npm exec --yes --package @botcord/daemon@latest -- botcord-daemon start --foreground",
115
+ ];
116
+ for (const cmd of wrappers) {
117
+ expect(isBotCordDaemonStartCommand(cmd), `wrongly matched wrapper: ${cmd}`).toBe(false);
118
+ }
119
+ });
120
+
121
+ it("matches the actual daemon entry processes", () => {
122
+ const matches = [
123
+ // node running the published daemon (npx / npm exec resolution under _npx)
124
+ "node /tmp/botcord-npm-cache/_npx/abc123/node_modules/@botcord/daemon/dist/index.js start --foreground",
125
+ // node running the resolved bin shim
126
+ "node /Users/me/.botcord/daemon/node_modules/.bin/botcord-daemon start --foreground",
127
+ // direct invocation of the bin
128
+ "/usr/local/bin/botcord-daemon start --foreground",
129
+ // monorepo dev: node running packages/daemon/dist/index.js
130
+ "node /home/dev/botcord/packages/daemon/dist/index.js start --foreground",
131
+ ];
132
+ for (const cmd of matches) {
133
+ expect(isBotCordDaemonStartCommand(cmd), `failed to match daemon: ${cmd}`).toBe(true);
134
+ }
135
+ });
136
+
93
137
  it("terminates extra daemon processes discovered outside the pid file", async () => {
94
138
  const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
95
139
  stdio: "ignore",
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
3
4
  import { PID_PATH } from "./config.js";
4
5
 
5
6
  export interface SingletonLogger {
@@ -182,7 +183,16 @@ export function writeCurrentPid(
182
183
  currentPid?: number;
183
184
  } = {},
184
185
  ): void {
185
- writeFileSync(opts.pidPath ?? PID_PATH, String(opts.currentPid ?? process.pid), { mode: 0o600 });
186
+ const pidPath = opts.pidPath ?? PID_PATH;
187
+ // Cloud-mode startup writes the PID file before `saveConfig` runs, so
188
+ // the daemon dir may not exist yet. mkdir its parent (0700) so the
189
+ // first write doesn't crash with ENOENT.
190
+ try {
191
+ mkdirSync(path.dirname(pidPath), { recursive: true, mode: 0o700 });
192
+ } catch {
193
+ // best-effort — writeFileSync below will surface the real error
194
+ }
195
+ writeFileSync(pidPath, String(opts.currentPid ?? process.pid), { mode: 0o600 });
186
196
  }
187
197
 
188
198
  export function removePidFile(pidPath = PID_PATH): void {
@@ -193,12 +203,28 @@ export function removePidFile(pidPath = PID_PATH): void {
193
203
  }
194
204
  }
195
205
 
196
- function isBotCordDaemonStartCommand(command: string): boolean {
206
+ export function isBotCordDaemonStartCommand(command: string): boolean {
197
207
  if (!/\bstart\b/.test(command)) return false;
208
+ // Only treat a row as "the daemon" when argv[0] is the real entry point:
209
+ // either the node interpreter (running `dist/index.js`) or the resolved
210
+ // `botcord-daemon` bin shim. Reject shell wrappers (`sh`, `bash`, `npm`,
211
+ // `npx`, `timeout`, ...) — their argv mentions `botcord-daemon` only as a
212
+ // literal arg, not as the executable being run. Killing a wrapper takes
213
+ // out the actual daemon it started, which is exactly the bug we want
214
+ // to avoid in cloud sandboxes.
215
+ const exe = (command.trim().split(/\s+/, 1)[0] ?? "").split("/").pop() ?? "";
216
+ const isNode = /^node(\d.*)?$/.test(exe);
217
+ const isDaemonBin = exe === "botcord-daemon";
218
+ if (!isNode && !isDaemonBin) return false;
198
219
  return (
199
- command.includes("botcord-daemon") ||
200
- /(?:^|\s)\S*botcord\S*\/daemon\/dist\/index\.js(?:\s|$)/.test(command) ||
201
- /(?:^|\s)\S*packages\/daemon\/dist\/index\.js(?:\s|$)/.test(command)
220
+ // node-resolved bin shim, e.g. .../node_modules/.bin/botcord-daemon
221
+ /\/\.?bin\/botcord-daemon(?:\s|$)/.test(command) ||
222
+ // direct bin invocation, e.g. /usr/local/bin/botcord-daemon or argv[0]=botcord-daemon
223
+ /(?:^|\s)\S*botcord-daemon(?:\s|$)/.test(command) ||
224
+ // node running the published daemon entry script
225
+ /\bbotcord\S*\/daemon\/dist\/index\.js(?:\s|$)/.test(command) ||
226
+ // node running the in-repo daemon entry script (dev / monorepo)
227
+ /\bpackages\/daemon\/dist\/index\.js(?:\s|$)/.test(command)
202
228
  );
203
229
  }
204
230