wicked-bus 1.1.0 → 2.0.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/commands/cli.js CHANGED
@@ -33,7 +33,7 @@ function printUsage() {
33
33
  commands: [
34
34
  'init', 'emit', 'subscribe', 'status', 'replay',
35
35
  'cleanup', 'register', 'deregister', 'list', 'ack',
36
- 'dlq',
36
+ 'dlq', 'daemon', 'ui',
37
37
  ],
38
38
  global_flags: ['--db-path <path>', '--json', '--log-level <level>'],
39
39
  };
@@ -128,6 +128,16 @@ async function main() {
128
128
  await cmdDlq(args, globals, args._positional || []);
129
129
  break;
130
130
  }
131
+ case 'daemon': {
132
+ const { cmdDaemon } = await import('./cmd-daemon.js');
133
+ await cmdDaemon(args, globals, args._positional || []);
134
+ break;
135
+ }
136
+ case 'ui': {
137
+ const { cmdUi } = await import('./cmd-ui.js');
138
+ await cmdUi(args, globals);
139
+ break;
140
+ }
131
141
  default:
132
142
  printUsage();
133
143
  process.exit(command ? 1 : 0);
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `wicked-bus daemon <subcommand>` — start, stop, status.
3
+ *
4
+ * - `start [--detached]` spawn the daemon in this process or detach a child
5
+ * - `stop` send SIGTERM to the running daemon (clean shutdown)
6
+ * - `status` JSON snapshot: socket path, pid, subscribers
7
+ *
8
+ * Lifecycle uses lib/daemon-singleton.js for the PID lock and
9
+ * lib/daemon.js for the socket server. Subscribers connect via the
10
+ * library / wrapper APIs; this CLI is operator-facing only.
11
+ *
12
+ * @module commands/cmd-daemon
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { spawn } from 'node:child_process';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { resolveDataDir, ensureDataDir } from '../lib/paths.js';
20
+ import {
21
+ startDaemon,
22
+ socketPath,
23
+ } from '../lib/daemon.js';
24
+ import {
25
+ acquireDaemonLock,
26
+ } from '../lib/daemon-singleton.js';
27
+ import { probeDaemon } from '../lib/daemon-client.js';
28
+
29
+ const __filename = fileURLToPath(import.meta.url);
30
+ const __dirname = path.dirname(__filename);
31
+
32
+ export async function cmdDaemon(args, globals, positional) {
33
+ const sub = (positional && positional[0]) || null;
34
+ if (!sub) {
35
+ return emitJson({
36
+ error: 'usage',
37
+ message: 'daemon requires a subcommand: start | stop | status',
38
+ });
39
+ }
40
+
41
+ switch (sub) {
42
+ case 'start': return cmdDaemonStart(args, globals);
43
+ case 'stop': return cmdDaemonStop(args, globals);
44
+ case 'status': return cmdDaemonStatus(args, globals);
45
+ default:
46
+ return emitJson({
47
+ error: 'usage',
48
+ message: `unknown daemon subcommand: ${sub}`,
49
+ });
50
+ }
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // start
55
+ // ---------------------------------------------------------------------------
56
+
57
+ async function cmdDaemonStart(args /*, globals */) {
58
+ const dataDir = resolveDataDir();
59
+ ensureDataDir();
60
+
61
+ // Detached: fork ourselves with `--no-detach` so the child runs the
62
+ // foreground branch. Parent waits for the socket to come up, prints
63
+ // status, exits 0. Stderr is captured to a log file in dataDir so the
64
+ // child's startup errors are diagnosable even when stdio is detached.
65
+ if (args.detached !== false && (args.detached === true || args['detach'] === true)) {
66
+ const logPath = path.join(dataDir, 'daemon.log');
67
+ const logFd = fs.openSync(logPath, 'a');
68
+ const child = spawn(
69
+ process.execPath,
70
+ [path.resolve(__dirname, 'cli.js'), 'daemon', 'start', '--no-detach'],
71
+ {
72
+ detached: true,
73
+ stdio: ['ignore', logFd, logFd],
74
+ env: process.env,
75
+ cwd: dataDir,
76
+ },
77
+ );
78
+ fs.closeSync(logFd);
79
+ child.unref();
80
+
81
+ const ok = await waitForSocket(dataDir, 5000);
82
+ if (!ok) {
83
+ let logTail = '';
84
+ try { logTail = fs.readFileSync(logPath, 'utf8').slice(-2000); }
85
+ catch (_e) { /* ignore */ }
86
+ return emitJsonExit({
87
+ error: 'daemon-start-timeout',
88
+ message: 'spawned daemon did not start listening within 5s',
89
+ socket: socketPath(dataDir),
90
+ log_tail: logTail,
91
+ }, 1);
92
+ }
93
+ return emitJson({
94
+ ok: true,
95
+ mode: 'detached',
96
+ pid: child.pid,
97
+ socket: socketPath(dataDir),
98
+ });
99
+ }
100
+
101
+ // Foreground: hold the lock, run the daemon, hand off to a SIGTERM/SIGINT
102
+ // handler for clean shutdown. This branch is what `--detached` re-enters.
103
+ let lock;
104
+ try {
105
+ lock = acquireDaemonLock(dataDir);
106
+ } catch (e) {
107
+ if (e.code === 'EALREADY_RUNNING') {
108
+ return emitJsonExit({
109
+ error: 'EALREADY_RUNNING',
110
+ message: e.message,
111
+ prior_pid: e.priorPid,
112
+ socket: socketPath(dataDir),
113
+ }, 1);
114
+ }
115
+ throw e;
116
+ }
117
+
118
+ const daemon = await startDaemon({
119
+ dataDir,
120
+ inline_payload_max_bytes: numFlag(args, 'inline-payload-max-bytes'),
121
+ subscriber_queue_max: numFlag(args, 'subscriber-queue-max'),
122
+ });
123
+
124
+ const shutdown = async () => {
125
+ try { await daemon.stop(); } catch (_e) { /* ignore */ }
126
+ try { lock.release(); } catch (_e) { /* ignore */ }
127
+ process.exit(0);
128
+ };
129
+ process.on('SIGTERM', shutdown);
130
+ process.on('SIGINT', shutdown);
131
+
132
+ emitJson({
133
+ ok: true,
134
+ mode: 'foreground',
135
+ pid: process.pid,
136
+ socket: daemon.socketPath,
137
+ });
138
+
139
+ // Stay alive until a signal fires shutdown(). The Node event loop keeps
140
+ // the process running because the server holds an active handle.
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // stop
145
+ // ---------------------------------------------------------------------------
146
+
147
+ async function cmdDaemonStop(/* args, globals */) {
148
+ const dataDir = resolveDataDir();
149
+ const lockPath = path.join(dataDir, 'daemon.lock');
150
+
151
+ let pid;
152
+ try {
153
+ pid = parseInt(fs.readFileSync(lockPath, 'utf8'), 10) || null;
154
+ } catch (_e) {
155
+ pid = null;
156
+ }
157
+
158
+ if (!pid) {
159
+ return emitJson({ ok: false, reason: 'no-lock-file', socket: socketPath(dataDir) });
160
+ }
161
+
162
+ try {
163
+ process.kill(pid, 'SIGTERM');
164
+ } catch (e) {
165
+ return emitJsonExit({
166
+ error: 'kill-failed',
167
+ pid,
168
+ message: e.message,
169
+ }, 1);
170
+ }
171
+
172
+ // Best-effort wait for the daemon to release the lock.
173
+ const released = await waitForLockRelease(lockPath, 5000);
174
+ return emitJson({
175
+ ok: true,
176
+ pid,
177
+ released,
178
+ });
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // status
183
+ // ---------------------------------------------------------------------------
184
+
185
+ async function cmdDaemonStatus(/* args, globals */) {
186
+ const dataDir = resolveDataDir();
187
+ const lockPath = path.join(dataDir, 'daemon.lock');
188
+ const sockPath = socketPath(dataDir);
189
+
190
+ let pid = null;
191
+ try { pid = parseInt(fs.readFileSync(lockPath, 'utf8'), 10) || null; }
192
+ catch (_e) { /* no lock */ }
193
+
194
+ const reachable = await probeDaemon(dataDir, 200);
195
+
196
+ return emitJson({
197
+ socket: sockPath,
198
+ lock_path: lockPath,
199
+ pid,
200
+ reachable,
201
+ });
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+
206
+ function emitJson(obj) {
207
+ process.stdout.write(JSON.stringify(obj) + '\n');
208
+ }
209
+
210
+ function emitJsonExit(obj, code) {
211
+ process.stderr.write(JSON.stringify(obj) + '\n');
212
+ process.exit(code);
213
+ }
214
+
215
+ function numFlag(args, name) {
216
+ const v = args[name];
217
+ if (v == null || v === true) return undefined;
218
+ const n = Number(v);
219
+ return Number.isFinite(n) ? n : undefined;
220
+ }
221
+
222
+ async function waitForSocket(dataDir, timeoutMs) {
223
+ const deadline = Date.now() + timeoutMs;
224
+ while (Date.now() < deadline) {
225
+ if (await probeDaemon(dataDir, 100)) return true;
226
+ await new Promise(r => setTimeout(r, 50));
227
+ }
228
+ return false;
229
+ }
230
+
231
+ async function waitForLockRelease(lockPath, timeoutMs) {
232
+ const deadline = Date.now() + timeoutMs;
233
+ while (Date.now() < deadline) {
234
+ if (!fs.existsSync(lockPath)) return true;
235
+ await new Promise(r => setTimeout(r, 50));
236
+ }
237
+ return false;
238
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `wicked-bus ui` — operator-facing CLI for the read-only UI server.
3
+ *
4
+ * wicked-bus ui start in foreground, JSON to stdout
5
+ * wicked-bus ui --detached fork a child, print connection info, exit 0
6
+ * wicked-bus ui --rotate-token regenerate the bearer token
7
+ * wicked-bus ui --host 0.0.0.0 bind non-loopback (warning printed)
8
+ * wicked-bus ui --port 7842 bind a specific port (default 0 = ephemeral
9
+ * in foreground; default 7842 in detached
10
+ * mode so a known port is reachable)
11
+ *
12
+ * Foreground sets up SIGTERM/SIGINT handlers for clean shutdown. Detached
13
+ * redirects child stdio to `<dataDir>/ui.log` so startup failures are
14
+ * diagnosable (same lesson as the daemon CLI: stdio:'ignore' on a long-
15
+ * running spawn hides everything).
16
+ *
17
+ * @module commands/cmd-ui
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { spawn } from 'node:child_process';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { resolveDataDir, ensureDataDir } from '../lib/paths.js';
25
+ import { openDb } from '../lib/db.js';
26
+ import { startUiServer, DEFAULT_HOST, DEFAULT_PORT } from '../lib/ui-server.js';
27
+
28
+ const __filename = fileURLToPath(import.meta.url);
29
+ const __dirname = path.dirname(__filename);
30
+
31
+ export async function cmdUi(args /*, globals */) {
32
+ const dataDir = resolveDataDir();
33
+ ensureDataDir();
34
+
35
+ // --no-detach is the inner branch; --detached is the outer fork.
36
+ if (args.detached === true || args['detach'] === true) {
37
+ return startDetached(args, dataDir);
38
+ }
39
+
40
+ return startForeground(args, dataDir);
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+
45
+ async function startDetached(args, dataDir) {
46
+ const logPath = path.join(dataDir, 'ui.log');
47
+ const logFd = fs.openSync(logPath, 'a');
48
+
49
+ // Re-invoke ourselves with --no-detach + a known port so the child can
50
+ // start cleanly and the parent can probe.
51
+ const port = args.port ? String(args.port) : String(DEFAULT_PORT);
52
+ const host = args.host ? String(args.host) : DEFAULT_HOST;
53
+
54
+ const childArgs = [
55
+ path.resolve(__dirname, 'cli.js'),
56
+ 'ui', '--no-detach',
57
+ '--port', port,
58
+ '--host', host,
59
+ ];
60
+ if (args['rotate-token'] === true || args.rotate_token === true) {
61
+ childArgs.push('--rotate-token');
62
+ }
63
+
64
+ const child = spawn(process.execPath, childArgs, {
65
+ detached: true,
66
+ stdio: ['ignore', logFd, logFd],
67
+ env: process.env,
68
+ cwd: dataDir,
69
+ });
70
+ fs.closeSync(logFd);
71
+ child.unref();
72
+
73
+ const reachable = await waitForUi(host, port, 5000);
74
+ if (!reachable) {
75
+ let logTail = '';
76
+ try { logTail = fs.readFileSync(logPath, 'utf8').slice(-2000); }
77
+ catch (_e) { /* ignore */ }
78
+ return emitJsonExit({
79
+ error: 'ui-start-timeout',
80
+ message: 'spawned UI did not start listening within 5s',
81
+ log_tail: logTail,
82
+ }, 1);
83
+ }
84
+
85
+ return emitJson({
86
+ ok: true,
87
+ mode: 'detached',
88
+ pid: child.pid,
89
+ host,
90
+ port: Number(port),
91
+ });
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+
96
+ async function startForeground(args, dataDir) {
97
+ const liveDb = openDb();
98
+ const ui = await startUiServer({
99
+ dataDir,
100
+ liveDb,
101
+ host: args.host ?? DEFAULT_HOST,
102
+ port: args.port != null ? Number(args.port) : DEFAULT_PORT,
103
+ rotate_token: args['rotate-token'] === true || args.rotate_token === true,
104
+ });
105
+
106
+ const shutdown = async () => {
107
+ try { await ui.stop(); } catch (_e) { /* ignore */ }
108
+ try { liveDb.close(); } catch (_e) { /* ignore */ }
109
+ process.exit(0);
110
+ };
111
+ process.on('SIGTERM', shutdown);
112
+ process.on('SIGINT', shutdown);
113
+
114
+ emitJson({
115
+ ok: true,
116
+ mode: 'foreground',
117
+ pid: process.pid,
118
+ host: ui.host,
119
+ port: ui.port,
120
+ token_path: ui.token_path,
121
+ });
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+
126
+ async function waitForUi(host, port, timeoutMs) {
127
+ const http = await import('node:http');
128
+ const deadline = Date.now() + timeoutMs;
129
+ while (Date.now() < deadline) {
130
+ if (await ping(http.default, host, port)) return true;
131
+ await new Promise(r => setTimeout(r, 50));
132
+ }
133
+ return false;
134
+ }
135
+
136
+ function ping(http, host, port) {
137
+ return new Promise((resolve) => {
138
+ const req = http.request({ host, port, path: '/healthz', method: 'GET', timeout: 200 }, (res) => {
139
+ // Drain so the connection releases.
140
+ res.resume();
141
+ resolve(res.statusCode === 200);
142
+ });
143
+ req.on('error', () => resolve(false));
144
+ req.on('timeout', () => { req.destroy(); resolve(false); });
145
+ req.end();
146
+ });
147
+ }
148
+
149
+ function emitJson(obj) {
150
+ process.stdout.write(JSON.stringify(obj) + '\n');
151
+ }
152
+
153
+ function emitJsonExit(obj, code) {
154
+ process.stderr.write(JSON.stringify(obj) + '\n');
155
+ process.exit(code);
156
+ }
package/install.mjs CHANGED
@@ -11,8 +11,46 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url));
11
11
  const skillsSource = join(__dirname, "skills");
12
12
  const home = homedir();
13
13
 
14
+ // Claude-root candidate builder. Mirrors the 1.1.1 wicked-testing /
15
+ // wicked-brain fix: $CLAUDE_CONFIG_DIR is authoritative when set;
16
+ // otherwise probe common alt-config layouts. Claude Code's config root
17
+ // is redirectable, and hardcoded ~/.claude silently misses users on
18
+ // shared-home / multi-tenant setups.
19
+ function buildClaudeTarget(rootDir, source, { trusted = false } = {}) {
20
+ return {
21
+ name: "claude",
22
+ rootDir,
23
+ dir: join(rootDir, "skills"),
24
+ platform: "claude",
25
+ identityMarkers: ["settings.json", "plugins", "projects"],
26
+ source,
27
+ trusted,
28
+ };
29
+ }
30
+
31
+ function resolveClaudeCandidates() {
32
+ const envDir = process.env.CLAUDE_CONFIG_DIR;
33
+ if (envDir && typeof envDir === "string" && envDir.trim()) {
34
+ // Function replacement avoids `$&` etc. being interpreted as regex
35
+ // back-references if $HOME contains those literals.
36
+ const root = resolve(envDir.trim().replace(/^~/, () => home));
37
+ return [buildClaudeTarget(root, "env:CLAUDE_CONFIG_DIR", { trusted: true })];
38
+ }
39
+ return [
40
+ buildClaudeTarget(join(home, ".claude"), "default"),
41
+ buildClaudeTarget(join(home, "alt-configs", ".claude"), "alt-configs"),
42
+ buildClaudeTarget(join(home, ".config", "claude"), "xdg"),
43
+ ];
44
+ }
45
+
46
+ function claudeHasIdentityMarker(target) {
47
+ if (target.trusted) return true;
48
+ if (!existsSync(target.rootDir)) return false;
49
+ return (target.identityMarkers || []).some(m => existsSync(join(target.rootDir, m)));
50
+ }
51
+
52
+ // Non-claude canonical targets. Claude is expanded dynamically above.
14
53
  const CLI_TARGETS = [
15
- { name: "claude", dir: join(home, ".claude", "skills"), platform: "claude" },
16
54
  { name: "gemini", dir: join(home, ".gemini", "skills"), platform: "gemini" },
17
55
  { name: "copilot", dir: join(home, ".github", "skills"), platform: "copilot" },
18
56
  { name: "codex", dir: join(home, ".codex", "skills"), platform: "codex" },
@@ -24,19 +62,43 @@ const CLI_TARGETS = [
24
62
  console.log("wicked-bus installer\n");
25
63
 
26
64
  const args = argv.slice(2);
27
- const argValue = (a) => a.split("=")[1];
28
- const cliArg = args.find((a) => a.startsWith("--cli="));
29
- const pathArg = args.find((a) => a.startsWith("--path="));
65
+
66
+ // Flag parser supporting both --flag=value and --flag value forms, plus
67
+ // narrow string-boolean coercion ("true" / "false" → booleans). Previously
68
+ // the ad-hoc parser silently dropped space-separated values — same bug
69
+ // that hit wicked-testing 0.3.2 / wicked-brain 0.3.7.
70
+ const flagValue = (name) => {
71
+ const f = args.find(a => a === `--${name}` || a.startsWith(`--${name}=`));
72
+ if (!f) return null;
73
+ let val;
74
+ if (f.includes("=")) {
75
+ // slice from the first '=' forward — split("=")[1] would truncate at
76
+ // the second '=' (e.g. --path=/volumes/build=artifacts).
77
+ val = f.slice(f.indexOf("=") + 1);
78
+ } else {
79
+ const idx = args.indexOf(f);
80
+ const next = args[idx + 1];
81
+ val = (next && !next.startsWith("-")) ? next : true;
82
+ }
83
+ if (val === "false") return false;
84
+ if (val === "true") return true;
85
+ return val;
86
+ };
87
+
88
+ const cliArg = flagValue("cli");
89
+ const pathArg = flagValue("path");
90
+
91
+ // Validate --cli upfront so a mistyped --cli / --cli= fails fast
92
+ // instead of silently falling through to "all detected".
93
+ if (cliArg === true || cliArg === "") {
94
+ console.error("Error: --cli requires a value (e.g. --cli=claude or --cli claude)");
95
+ process.exit(1);
96
+ }
30
97
 
31
98
  let targets;
32
99
 
33
- if (pathArg) {
34
- const rawPath = argValue(pathArg);
35
- if (!rawPath) {
36
- console.error("Error: --path requires a value (e.g. --path=~/.claude)");
37
- process.exit(1);
38
- }
39
- const customPath = resolve(rawPath.replace(/^~/, home));
100
+ if (pathArg && typeof pathArg === "string" && pathArg !== "") {
101
+ const customPath = resolve(pathArg.replace(/^~/, () => home));
40
102
  const dirName = basename(customPath).replace(/^\./, "");
41
103
  targets = [{
42
104
  name: dirName,
@@ -44,18 +106,29 @@ if (pathArg) {
44
106
  platform: dirName,
45
107
  }];
46
108
  console.log(`Custom path: ${customPath}\n`);
109
+ } else if (pathArg === true || pathArg === "") {
110
+ console.error("Error: --path requires a value (e.g. --path=~/.claude or --path ~/.claude)");
111
+ process.exit(1);
47
112
  } else {
48
- const detected = CLI_TARGETS.filter((t) => existsSync(resolve(t.dir, "..")));
113
+ // Expanded detection: claude candidates (env var OR alt-config probes,
114
+ // identity-marker gated) + non-claude parent-dir-exists heuristic.
115
+ const claudeDetected = resolveClaudeCandidates().filter(claudeHasIdentityMarker);
116
+ const otherDetected = CLI_TARGETS.filter((t) => existsSync(resolve(t.dir, "..")));
117
+ const detected = [...claudeDetected, ...otherDetected];
49
118
 
50
119
  if (detected.length === 0) {
51
120
  console.log("No supported AI CLIs detected. Supported: claude, gemini, copilot, codex, cursor, kiro, antigravity");
52
- console.log("Install skills manually by copying the skills/ directory.");
121
+ console.log("Install skills manually by copying the skills/ directory, or set CLAUDE_CONFIG_DIR.");
53
122
  process.exit(1);
54
123
  }
55
124
 
56
- console.log(`Detected CLIs: ${detected.map((d) => d.name).join(", ")}\n`);
125
+ const claudeCount = claudeDetected.length;
126
+ const label = (d) => d.name === "claude" && claudeCount > 1 && d.source
127
+ ? `${d.name}[${d.source}]`
128
+ : d.name;
129
+ console.log(`Detected CLIs: ${detected.map(label).join(", ")}\n`);
57
130
 
58
- const cliFilter = cliArg ? argValue(cliArg).split(",") : null;
131
+ const cliFilter = (typeof cliArg === "string" && cliArg !== "") ? cliArg.split(",") : null;
59
132
  targets = cliFilter ? detected.filter((d) => cliFilter.includes(d.name)) : detected;
60
133
  }
61
134