@gemslibe/rbo 0.2.0 → 0.4.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemslibe/rbo",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "./dist/rbo.js",
6
6
  "bin": {
@@ -10,6 +10,9 @@
10
10
  "files": [
11
11
  "dist/rbo.js",
12
12
  "dist/rbo-mcp-stdio.js",
13
+ "config/controller.json",
14
+ "config/agent.json",
15
+ "scripts/stop-running-rbo.mjs",
13
16
  "LICENSE",
14
17
  "README.md"
15
18
  ],
@@ -19,7 +22,9 @@
19
22
  },
20
23
  "scripts": {
21
24
  "build": "node esbuild.config.mjs",
22
- "typecheck": "tsc --noEmit"
25
+ "typecheck": "tsc --noEmit",
26
+ "preinstall": "node ./scripts/stop-running-rbo.mjs",
27
+ "preuninstall": "node ./scripts/stop-running-rbo.mjs"
23
28
  },
24
29
  "dependencies": {
25
30
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -30,7 +35,7 @@
30
35
  "zod": "^3.24.2"
31
36
  },
32
37
  "optionalDependencies": {
33
- "@gemslibe/rbo-windows-executor-win32-x64": "0.2.0"
38
+ "@gemslibe/rbo-windows-executor-win32-x64": "0.4.0"
34
39
  },
35
40
  "devDependencies": {
36
41
  "@rbo/agent": "workspace:*",
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * preinstall / preuninstall hook for @gemslibe/rbo.
4
+ *
5
+ * Stops running Controller/Agent processes so Windows can replace locked
6
+ * better-sqlite3 native binaries during global `npm install -g` / uninstall.
7
+ *
8
+ * Runs only when npm_config_global=true (skips monorepo/local installs).
9
+ * Escape: RBO_SKIP_INSTALL_STOP=1
10
+ * Policy: warn and continue if a stop fails (do not fail the install).
11
+ * OS services (sc/systemd/launchd) are out of scope.
12
+ */
13
+
14
+ import { execFileSync } from 'node:child_process';
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { setTimeout as delay } from 'node:timers/promises';
19
+
20
+ /**
21
+ * @param {NodeJS.ProcessEnv} [env]
22
+ */
23
+ export function shouldSkipInstallStop(env = process.env) {
24
+ if (env.RBO_SKIP_INSTALL_STOP === '1') {
25
+ return true;
26
+ }
27
+ // Only act on global installs. Workspace `pnpm install` / local dependency installs
28
+ // must not kill an operator's running Controller/Agent.
29
+ return env.npm_config_global !== 'true';
30
+ }
31
+
32
+ /**
33
+ * @param {string | undefined | null} commandLine
34
+ * @returns {'controller' | 'agent' | null}
35
+ */
36
+ export function matchRboDaemonRole(commandLine) {
37
+ if (!commandLine) {
38
+ return null;
39
+ }
40
+ // Match the bundled CLI entrypoint, not rbo-mcp-stdio.js.
41
+ if (!/(?:^|[\\/\s"'])rbo\.js(?:["']|\s|$)/i.test(commandLine)) {
42
+ return null;
43
+ }
44
+ if (/\bcontroller\s+start\b/i.test(commandLine)) {
45
+ return 'controller';
46
+ }
47
+ if (/\bagent\s+start\b/i.test(commandLine)) {
48
+ return 'agent';
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * @param {{ env?: NodeJS.ProcessEnv; home?: string }} [options]
55
+ * @returns {{ role: 'controller' | 'agent'; path: string }[]}
56
+ */
57
+ export function resolveDaemonPidFiles(options = {}) {
58
+ const env = options.env ?? process.env;
59
+ const home = options.home ?? homedir();
60
+ const dataDir = env.RBO_DATA_DIR?.trim() || join(home, '.rbo');
61
+ const agentStateDir = env.RBO_AGENT_STATE_DIR?.trim() || join(dataDir, 'agent');
62
+ return [
63
+ { role: 'controller', path: join(dataDir, 'run', 'controller.pid') },
64
+ { role: 'agent', path: join(agentStateDir, 'run', 'agent.pid') },
65
+ ];
66
+ }
67
+
68
+ /**
69
+ * @param {string} pidFile
70
+ * @param {(pid: number) => boolean} isAlive
71
+ * @returns {number | null}
72
+ */
73
+ export function readLivePidFromFile(pidFile, isAlive) {
74
+ if (!existsSync(pidFile)) {
75
+ return null;
76
+ }
77
+ let raw;
78
+ try {
79
+ raw = readFileSync(pidFile, 'utf8').trim();
80
+ } catch {
81
+ return null;
82
+ }
83
+ const pid = Number.parseInt(raw, 10);
84
+ if (!Number.isInteger(pid) || pid <= 0) {
85
+ return null;
86
+ }
87
+ return isAlive(pid) ? pid : null;
88
+ }
89
+
90
+ /**
91
+ * @param {number} pid
92
+ * @returns {boolean}
93
+ */
94
+ export function isProcessAlive(pid) {
95
+ if (!Number.isInteger(pid) || pid <= 0) {
96
+ return false;
97
+ }
98
+ try {
99
+ process.kill(pid, 0);
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * @returns {Promise<{ pid: number; commandLine: string }[]>}
108
+ */
109
+ export async function listNodeProcesses() {
110
+ if (process.platform === 'win32') {
111
+ return listNodeProcessesWindows();
112
+ }
113
+ return listNodeProcessesUnix();
114
+ }
115
+
116
+ function listNodeProcessesWindows() {
117
+ const script =
118
+ 'Get-CimInstance Win32_Process -Filter "Name=\'node.exe\'" | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress';
119
+ let out;
120
+ try {
121
+ out = execFileSync('powershell.exe', ['-NoProfile', '-Command', script], {
122
+ encoding: 'utf8',
123
+ windowsHide: true,
124
+ maxBuffer: 10 * 1024 * 1024,
125
+ });
126
+ } catch {
127
+ return [];
128
+ }
129
+ const trimmed = out.trim();
130
+ if (!trimmed) {
131
+ return [];
132
+ }
133
+ let parsed;
134
+ try {
135
+ parsed = JSON.parse(trimmed);
136
+ } catch {
137
+ return [];
138
+ }
139
+ const rows = Array.isArray(parsed) ? parsed : [parsed];
140
+ return rows
141
+ .map((row) => ({
142
+ pid: Number(row.ProcessId),
143
+ commandLine: typeof row.CommandLine === 'string' ? row.CommandLine : '',
144
+ }))
145
+ .filter((row) => Number.isInteger(row.pid) && row.pid > 0);
146
+ }
147
+
148
+ function listNodeProcessesUnix() {
149
+ let out;
150
+ try {
151
+ out = execFileSync('ps', ['-ax', '-o', 'pid=', '-o', 'args='], {
152
+ encoding: 'utf8',
153
+ maxBuffer: 10 * 1024 * 1024,
154
+ });
155
+ } catch {
156
+ return [];
157
+ }
158
+ const rows = [];
159
+ for (const line of out.split('\n')) {
160
+ const trimmed = line.trim();
161
+ if (!trimmed) {
162
+ continue;
163
+ }
164
+ const match = /^(\d+)\s+(.*)$/.exec(trimmed);
165
+ if (!match) {
166
+ continue;
167
+ }
168
+ const pid = Number.parseInt(match[1], 10);
169
+ const commandLine = match[2] ?? '';
170
+ if (!Number.isInteger(pid) || pid <= 0) {
171
+ continue;
172
+ }
173
+ if (!/\bnode(\.exe)?\b/i.test(commandLine) && !commandLine.includes('rbo.js')) {
174
+ continue;
175
+ }
176
+ rows.push({ pid, commandLine });
177
+ }
178
+ return rows;
179
+ }
180
+
181
+ /**
182
+ * Soft then hard stop.
183
+ * @param {number} pid
184
+ * @param {{ platform?: NodeJS.Platform; isAlive?: (pid: number) => boolean; sleepMs?: (ms: number) => Promise<void> }} [options]
185
+ */
186
+ export async function stopPid(pid, options = {}) {
187
+ const platform = options.platform ?? process.platform;
188
+ const alive = options.isAlive ?? isProcessAlive;
189
+ const sleepMs = options.sleepMs ?? ((ms) => delay(ms));
190
+
191
+ if (platform === 'win32') {
192
+ try {
193
+ execFileSync('taskkill', ['/PID', String(pid)], {
194
+ encoding: 'utf8',
195
+ windowsHide: true,
196
+ stdio: ['ignore', 'pipe', 'pipe'],
197
+ });
198
+ } catch {
199
+ // process may already be gone
200
+ }
201
+ await sleepMs(300);
202
+ if (!alive(pid)) {
203
+ return;
204
+ }
205
+ execFileSync('taskkill', ['/F', '/PID', String(pid)], {
206
+ encoding: 'utf8',
207
+ windowsHide: true,
208
+ stdio: ['ignore', 'pipe', 'pipe'],
209
+ });
210
+ await sleepMs(100);
211
+ return;
212
+ }
213
+
214
+ try {
215
+ process.kill(pid, 'SIGTERM');
216
+ } catch {
217
+ return;
218
+ }
219
+ await sleepMs(500);
220
+ if (!alive(pid)) {
221
+ return;
222
+ }
223
+ try {
224
+ process.kill(pid, 'SIGKILL');
225
+ } catch {
226
+ // gone
227
+ }
228
+ }
229
+
230
+ /**
231
+ * @param {{
232
+ * env?: NodeJS.ProcessEnv;
233
+ * home?: string;
234
+ * listProcesses?: () => Promise<{ pid: number; commandLine: string }[]>;
235
+ * isAlive?: (pid: number) => boolean;
236
+ * stopPid?: (pid: number) => Promise<void>;
237
+ * log?: (msg: string) => void;
238
+ * warn?: (msg: string) => void;
239
+ * extraPids?: number[];
240
+ * }} [options]
241
+ */
242
+ export async function stopRunningRbo(options = {}) {
243
+ const env = options.env ?? process.env;
244
+ if (shouldSkipInstallStop(env)) {
245
+ return;
246
+ }
247
+
248
+ const home = options.home ?? homedir();
249
+ const listProcesses = options.listProcesses ?? listNodeProcesses;
250
+ const alive = options.isAlive ?? isProcessAlive;
251
+ const doStop = options.stopPid ?? ((pid) => stopPid(pid, { isAlive: alive }));
252
+ const log = options.log ?? ((msg) => console.error(`[rbo] ${msg}`));
253
+ const warn = options.warn ?? ((msg) => console.error(`[rbo] ${msg}`));
254
+
255
+ /** @type {Map<number, string>} */
256
+ const targets = new Map();
257
+
258
+ for (const { role, path } of resolveDaemonPidFiles({ env, home })) {
259
+ const pid = readLivePidFromFile(path, alive);
260
+ if (pid !== null) {
261
+ targets.set(pid, role);
262
+ }
263
+ }
264
+
265
+ for (const proc of await listProcesses()) {
266
+ if (proc.pid === process.pid) {
267
+ continue;
268
+ }
269
+ const role = matchRboDaemonRole(proc.commandLine);
270
+ if (role) {
271
+ targets.set(proc.pid, role);
272
+ }
273
+ }
274
+
275
+ for (const pid of options.extraPids ?? []) {
276
+ if (!targets.has(pid)) {
277
+ targets.set(pid, 'unknown');
278
+ }
279
+ }
280
+
281
+ for (const [pid, role] of targets) {
282
+ try {
283
+ log(`stopping ${role} process pid=${pid} for package install/uninstall`);
284
+ await doStop(pid);
285
+ } catch (error) {
286
+ warn(
287
+ `failed to stop pid=${pid} (${role}): ${error instanceof Error ? error.message : String(error)}; continuing`,
288
+ );
289
+ }
290
+ }
291
+ }
292
+
293
+ const isMain =
294
+ typeof process.argv[1] === 'string' &&
295
+ (process.argv[1].endsWith('stop-running-rbo.mjs') ||
296
+ process.argv[1].replaceAll('\\', '/').endsWith('/scripts/stop-running-rbo.mjs'));
297
+
298
+ if (isMain) {
299
+ stopRunningRbo().catch((error) => {
300
+ console.error(
301
+ `[rbo] stop-running-rbo failed: ${error instanceof Error ? error.message : String(error)}; continuing`,
302
+ );
303
+ // Never fail install/uninstall because of the stop helper.
304
+ process.exitCode = 0;
305
+ });
306
+ }