wicked-bus 1.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.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * wicked-bus subscribe command -- streaming NDJSON poll.
3
+ */
4
+
5
+ import { loadConfig } from '../lib/config.js';
6
+ import { openDb } from '../lib/db.js';
7
+ import { poll, ack } from '../lib/poll.js';
8
+ import { register } from '../lib/register.js';
9
+ import { startSweep } from '../lib/sweep.js';
10
+
11
+ export async function cmdSubscribe(args, globals) {
12
+ const configOverrides = {};
13
+ if (globals.db_path) configOverrides.db_path = globals.db_path;
14
+ if (globals.log_level) configOverrides.log_level = globals.log_level;
15
+
16
+ const config = loadConfig(configOverrides);
17
+ const db = openDb(config);
18
+
19
+ const plugin = args.plugin;
20
+ const filter = args.filter;
21
+ const pollIntervalMs = Number(args['poll-interval-ms']) || 1000;
22
+ const batchSize = Number(args['batch-size']) || 100;
23
+ const noAck = args['no-ack'] === true;
24
+
25
+ let cursorId = args['cursor-id'] || null;
26
+
27
+ // If no cursor-id, try implicit cursor lookup or register
28
+ if (!cursorId) {
29
+ // Look for an existing active subscription matching plugin + filter
30
+ const existing = db.prepare(`
31
+ SELECT s.subscription_id, c.cursor_id
32
+ FROM subscriptions s
33
+ JOIN cursors c ON c.subscription_id = s.subscription_id
34
+ WHERE s.plugin = ? AND s.event_type_filter = ?
35
+ AND s.role = 'subscriber'
36
+ AND s.deregistered_at IS NULL
37
+ AND c.deregistered_at IS NULL
38
+ `).all(plugin, filter);
39
+
40
+ if (existing.length === 1) {
41
+ cursorId = existing[0].cursor_id;
42
+ } else if (existing.length > 1) {
43
+ throw new Error(
44
+ 'Multiple active subscriptions match plugin + filter. ' +
45
+ 'Provide --cursor-id to disambiguate.'
46
+ );
47
+ } else {
48
+ // Auto-register
49
+ const cursorInit = args['cursor-init'] || 'latest';
50
+ const reg = register(db, { plugin, role: 'subscriber', filter, cursor_init: cursorInit });
51
+ cursorId = reg.cursor_id;
52
+ }
53
+ }
54
+
55
+ // Start background sweep
56
+ const sweepHandle = startSweep(db, config);
57
+
58
+ // Graceful shutdown
59
+ let running = true;
60
+ const shutdown = () => {
61
+ running = false;
62
+ if (sweepHandle) clearInterval(sweepHandle);
63
+ db.close();
64
+ process.exit(0);
65
+ };
66
+
67
+ process.on('SIGINT', shutdown);
68
+ process.on('SIGTERM', shutdown);
69
+
70
+ // Poll loop
71
+ while (running) {
72
+ try {
73
+ const events = poll(db, cursorId, { batchSize });
74
+ for (const event of events) {
75
+ // Output NDJSON
76
+ process.stdout.write(JSON.stringify(event) + '\n');
77
+
78
+ // Auto-ack unless --no-ack
79
+ if (!noAck) {
80
+ ack(db, cursorId, event.event_id);
81
+ }
82
+ }
83
+ } catch (err) {
84
+ // Output error but continue polling if possible
85
+ process.stderr.write(JSON.stringify({
86
+ error: err.error || 'UNKNOWN',
87
+ code: err.code || 'POLL_ERROR',
88
+ message: err.message,
89
+ }) + '\n');
90
+
91
+ // Fatal errors: WB-003, WB-006
92
+ if (err.error === 'WB-003' || err.error === 'WB-006') {
93
+ if (sweepHandle) clearInterval(sweepHandle);
94
+ db.close();
95
+ throw err;
96
+ }
97
+ }
98
+
99
+ // Wait before next poll
100
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
101
+ }
102
+ }
package/install.mjs ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ // wicked-bus installer — detects CLIs and installs skills
3
+
4
+ import { existsSync, mkdirSync, cpSync, readdirSync } from "node:fs";
5
+ import { join, resolve, basename } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { argv } from "node:process";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
11
+ const skillsSource = join(__dirname, "skills");
12
+ const home = homedir();
13
+
14
+ const CLI_TARGETS = [
15
+ { name: "claude", dir: join(home, ".claude", "skills"), platform: "claude" },
16
+ { name: "gemini", dir: join(home, ".gemini", "skills"), platform: "gemini" },
17
+ { name: "copilot", dir: join(home, ".github", "skills"), platform: "copilot" },
18
+ { name: "codex", dir: join(home, ".codex", "skills"), platform: "codex" },
19
+ { name: "cursor", dir: join(home, ".cursor", "skills"), platform: "cursor" },
20
+ { name: "kiro", dir: join(home, ".kiro", "skills"), platform: "kiro" },
21
+ { name: "antigravity", dir: join(home, ".antigravity", "skills"), platform: "antigravity" },
22
+ ];
23
+
24
+ console.log("wicked-bus installer\n");
25
+
26
+ 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="));
30
+
31
+ let targets;
32
+
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));
40
+ const dirName = basename(customPath).replace(/^\./, "");
41
+ targets = [{
42
+ name: dirName,
43
+ dir: join(customPath, "skills"),
44
+ platform: dirName,
45
+ }];
46
+ console.log(`Custom path: ${customPath}\n`);
47
+ } else {
48
+ const detected = CLI_TARGETS.filter((t) => existsSync(resolve(t.dir, "..")));
49
+
50
+ if (detected.length === 0) {
51
+ 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.");
53
+ process.exit(1);
54
+ }
55
+
56
+ console.log(`Detected CLIs: ${detected.map((d) => d.name).join(", ")}\n`);
57
+
58
+ const cliFilter = cliArg ? argValue(cliArg).split(",") : null;
59
+ targets = cliFilter ? detected.filter((d) => cliFilter.includes(d.name)) : detected;
60
+ }
61
+
62
+ // Copy skills to each target CLI
63
+ const skillDirs = readdirSync(skillsSource).filter((d) => !d.startsWith("."));
64
+
65
+ for (const target of targets) {
66
+ console.log(`Installing to ${target.name} (${target.dir})...`);
67
+ mkdirSync(target.dir, { recursive: true });
68
+
69
+ for (const skill of skillDirs) {
70
+ const src = join(skillsSource, skill);
71
+ const dest = join(target.dir, skill);
72
+ cpSync(src, dest, { recursive: true });
73
+ }
74
+
75
+ console.log(` ${skillDirs.length} skills installed`);
76
+ }
77
+
78
+ console.log(`\nwicked-bus skills installed! Available skills:`);
79
+ console.log(` wicked-bus/init — Initialize or connect to the bus`);
80
+ console.log(` wicked-bus/emit — Publish events`);
81
+ console.log(` wicked-bus/subscribe — Consume events`);
82
+ console.log(` wicked-bus/naming — Event naming conventions`);
83
+ console.log(` wicked-bus/query — Query and debug the bus`);
package/lib/config.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Configuration loading and validation.
3
+ * @module lib/config
4
+ */
5
+
6
+ import { join } from 'node:path';
7
+ import { readFileSync, writeFileSync } from 'node:fs';
8
+ import { resolveDataDir } from './paths.js';
9
+
10
+ export const DEFAULTS = {
11
+ ttl_hours: 72,
12
+ dedup_ttl_hours: 24,
13
+ sweep_interval_minutes: 15,
14
+ archive_mode: false,
15
+ log_level: 'warn',
16
+ db_path: null,
17
+ max_payload_bytes: 1048576,
18
+ };
19
+
20
+ const VALID_LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
21
+
22
+ /**
23
+ * Load config from <dataDir>/config.json, merged with defaults.
24
+ * Malformed JSON is silently ignored (defaults used).
25
+ * @param {object} [overrides] - CLI flag overrides (e.g. { db_path, log_level })
26
+ * @returns {object}
27
+ */
28
+ export function loadConfig(overrides = {}) {
29
+ let userConfig = {};
30
+ try {
31
+ const dataDir = resolveDataDir();
32
+ const configPath = join(dataDir, 'config.json');
33
+ const raw = readFileSync(configPath, 'utf8');
34
+ userConfig = JSON.parse(raw);
35
+ } catch (_) {
36
+ // File missing or malformed JSON -- use defaults
37
+ }
38
+
39
+ const config = { ...DEFAULTS, ...userConfig, ...overrides };
40
+
41
+ // Remove null/undefined overrides so defaults aren't clobbered
42
+ for (const key of Object.keys(overrides)) {
43
+ if (overrides[key] == null) delete config[key];
44
+ if (overrides[key] == null && DEFAULTS[key] != null) {
45
+ config[key] = userConfig[key] != null ? userConfig[key] : DEFAULTS[key];
46
+ }
47
+ }
48
+
49
+ // Validate
50
+ if (config.dedup_ttl_hours > config.ttl_hours) {
51
+ throw new Error(
52
+ `Invalid config: dedup_ttl_hours (${config.dedup_ttl_hours}) must be <= ttl_hours (${config.ttl_hours})`
53
+ );
54
+ }
55
+ if (config.sweep_interval_minutes < 0) {
56
+ throw new Error('Invalid config: sweep_interval_minutes must be >= 0');
57
+ }
58
+ if (config.max_payload_bytes < 1) {
59
+ throw new Error('Invalid config: max_payload_bytes must be >= 1');
60
+ }
61
+ if (!VALID_LOG_LEVELS.includes(config.log_level)) {
62
+ throw new Error(
63
+ `Invalid config: log_level must be one of ${VALID_LOG_LEVELS.join(', ')}`
64
+ );
65
+ }
66
+
67
+ return config;
68
+ }
69
+
70
+ /**
71
+ * Write the default config to <dataDir>/config.json.
72
+ * Does not overwrite if file already exists unless force=true.
73
+ * @param {string} dataDir
74
+ * @param {boolean} [force=false]
75
+ */
76
+ export function writeDefaultConfig(dataDir, force = false) {
77
+ const configPath = join(dataDir, 'config.json');
78
+ if (!force) {
79
+ try {
80
+ readFileSync(configPath);
81
+ return; // Already exists
82
+ } catch (_) {
83
+ // File doesn't exist, write it
84
+ }
85
+ }
86
+ writeFileSync(configPath, JSON.stringify(DEFAULTS, null, 2) + '\n', 'utf8');
87
+ }
package/lib/db.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Database connection manager.
3
+ * @module lib/db
4
+ */
5
+
6
+ import { readFileSync } from 'node:fs';
7
+ import { join, dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { createRequire } from 'node:module';
10
+ import { resolveDbPath, ensureDataDir } from './paths.js';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const require = createRequire(import.meta.url);
14
+ const SCHEMA_SQL_PATH = join(__dirname, 'schema.sql');
15
+ const MAX_SUPPORTED_SCHEMA_VERSION = 1;
16
+
17
+ /**
18
+ * Open (or create) the SQLite database, apply PRAGMAs and schema.
19
+ * better-sqlite3 is synchronous, so this function is synchronous.
20
+ * @param {object} [config] - Merged config object
21
+ * @returns {import('better-sqlite3').Database}
22
+ */
23
+ export function openDb(config = {}) {
24
+ const Database = require('better-sqlite3');
25
+
26
+ ensureDataDir();
27
+ const dbPath = resolveDbPath(config);
28
+
29
+ const db = new Database(dbPath);
30
+
31
+ // Apply PRAGMAs in order
32
+ db.pragma('journal_mode = WAL');
33
+ db.pragma('synchronous = NORMAL');
34
+ db.pragma('foreign_keys = ON');
35
+ db.pragma('busy_timeout = 5000');
36
+
37
+ // Execute schema DDL (idempotent)
38
+ const schemaSql = readFileSync(SCHEMA_SQL_PATH, 'utf8');
39
+ // Split by semicolons and execute each statement
40
+ // Filter out PRAGMA lines since we already applied them
41
+ const statements = schemaSql
42
+ .split(';')
43
+ .map(s => s.trim())
44
+ .filter(s => s.length > 0 && !s.toUpperCase().startsWith('PRAGMA'));
45
+
46
+ for (const stmt of statements) {
47
+ db.exec(stmt + ';');
48
+ }
49
+
50
+ // Schema version check
51
+ checkSchemaVersion(db);
52
+
53
+ return db;
54
+ }
55
+
56
+ /**
57
+ * Check that the DB schema version is supported.
58
+ * @param {import('better-sqlite3').Database} db
59
+ */
60
+ function checkSchemaVersion(db) {
61
+ const row = db.prepare(
62
+ 'SELECT MAX(version) as max_version FROM schema_migrations'
63
+ ).get();
64
+
65
+ if (row && row.max_version > MAX_SUPPORTED_SCHEMA_VERSION) {
66
+ process.stderr.write(JSON.stringify({
67
+ error: 'SCHEMA_VERSION_MISMATCH',
68
+ message: `Database schema version ${row.max_version} is newer than supported (${MAX_SUPPORTED_SCHEMA_VERSION}). Run: npm install -g wicked-bus`,
69
+ }) + '\n');
70
+ process.exit(1);
71
+ }
72
+ }
package/lib/emit.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Event emission with idempotency and TTL computation.
3
+ * @module lib/emit
4
+ */
5
+
6
+ import { v4 as uuidv4 } from 'uuid';
7
+ import { validateEvent } from './validate.js';
8
+ import { WBError } from './errors.js';
9
+
10
+ /**
11
+ * Emit an event to the bus.
12
+ * @param {import('better-sqlite3').Database} db
13
+ * @param {object} config - Merged config
14
+ * @param {object} event - Event data
15
+ * @param {string} event.event_type
16
+ * @param {string} event.domain
17
+ * @param {string} [event.subdomain]
18
+ * @param {object|string} event.payload
19
+ * @param {string} [event.schema_version]
20
+ * @param {string} [event.idempotency_key]
21
+ * @param {number} [event.ttl_hours] - Per-event TTL override
22
+ * @param {string|object} [event.metadata]
23
+ * @returns {{ event_id: number, idempotency_key: string }}
24
+ */
25
+ export function emit(db, config, event) {
26
+ // Validate
27
+ validateEvent(event, config);
28
+
29
+ const idempotencyKey = event.idempotency_key || uuidv4();
30
+ const emittedAt = Date.now();
31
+ const ttlHours = event.ttl_hours != null ? event.ttl_hours : config.ttl_hours;
32
+ const expiresAt = emittedAt + (ttlHours * 3_600_000);
33
+ const dedupExpiresAt = emittedAt + (config.dedup_ttl_hours * 3_600_000);
34
+
35
+ const payloadStr = typeof event.payload === 'string'
36
+ ? event.payload
37
+ : JSON.stringify(event.payload);
38
+
39
+ const metadataStr = event.metadata != null
40
+ ? (typeof event.metadata === 'string' ? event.metadata : JSON.stringify(event.metadata))
41
+ : null;
42
+
43
+ const schemaVersion = event.schema_version || '1.0.0';
44
+
45
+ try {
46
+ const subdomain = event.subdomain || '';
47
+
48
+ const stmt = db.prepare(`
49
+ INSERT INTO events (
50
+ event_type, domain, subdomain, payload, schema_version,
51
+ idempotency_key, emitted_at, expires_at, dedup_expires_at, metadata
52
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
53
+ `);
54
+
55
+ const result = stmt.run(
56
+ event.event_type,
57
+ event.domain,
58
+ subdomain,
59
+ payloadStr,
60
+ schemaVersion,
61
+ idempotencyKey,
62
+ emittedAt,
63
+ expiresAt,
64
+ dedupExpiresAt,
65
+ metadataStr
66
+ );
67
+
68
+ return {
69
+ event_id: Number(result.lastInsertRowid),
70
+ idempotency_key: idempotencyKey,
71
+ };
72
+ } catch (err) {
73
+ // Duplicate idempotency_key
74
+ if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
75
+ const existing = db.prepare(
76
+ 'SELECT event_id FROM events WHERE idempotency_key = ?'
77
+ ).get(idempotencyKey);
78
+
79
+ throw new WBError('WB-002', 'DUPLICATE_EVENT', {
80
+ message: `Duplicate idempotency_key: ${idempotencyKey}`,
81
+ original_event_id: existing ? existing.event_id : null,
82
+ idempotency_key: idempotencyKey,
83
+ });
84
+ }
85
+
86
+ // Disk full
87
+ if (
88
+ (err.code && err.code.includes('SQLITE_FULL')) ||
89
+ (err.code === 'ENOSPC') ||
90
+ (err.message && err.message.includes('SQLITE_FULL'))
91
+ ) {
92
+ const dbPath = db.name;
93
+ try { db.close(); } catch (_) { /* ignore close errors */ }
94
+ // Re-open to verify integrity
95
+ try {
96
+ const Database = db.constructor;
97
+ const verifyDb = new Database(dbPath);
98
+ verifyDb.pragma('integrity_check');
99
+ verifyDb.close();
100
+ } catch (_) { /* ignore verification errors */ }
101
+
102
+ throw new WBError('WB-004', 'DISK_FULL', {
103
+ message: 'Database disk is full',
104
+ sqlite_error: err.code || 'SQLITE_FULL',
105
+ db_path: dbPath,
106
+ });
107
+ }
108
+
109
+ throw err;
110
+ }
111
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * wicked-bus error classes and error codes.
3
+ * @module lib/errors
4
+ */
5
+
6
+ export const ERROR_CODES = {
7
+ 'WB-001': 'INVALID_EVENT_SCHEMA',
8
+ 'WB-002': 'DUPLICATE_EVENT',
9
+ 'WB-003': 'CURSOR_BEHIND_TTL_WINDOW',
10
+ 'WB-004': 'DISK_FULL',
11
+ 'WB-005': 'SCHEMA_VERSION_UNSUPPORTED',
12
+ 'WB-006': 'CURSOR_NOT_FOUND',
13
+ };
14
+
15
+ export const EXIT_CODES = {
16
+ 'WB-001': 1,
17
+ 'WB-002': 2,
18
+ 'WB-003': 3,
19
+ 'WB-004': 4,
20
+ 'WB-005': 5,
21
+ 'WB-006': 6,
22
+ };
23
+
24
+ export class WBError extends Error {
25
+ /**
26
+ * @param {string} error - Error code, e.g. 'WB-001'
27
+ * @param {string} code - Machine-readable name, e.g. 'INVALID_EVENT_SCHEMA'
28
+ * @param {object} context - Additional context
29
+ */
30
+ constructor(error, code, context = {}) {
31
+ super(context.message || code);
32
+ this.error = error;
33
+ this.code = code;
34
+ this.context = context;
35
+ }
36
+
37
+ toJSON() {
38
+ return {
39
+ error: this.error,
40
+ code: this.code,
41
+ message: this.message,
42
+ context: this.context,
43
+ };
44
+ }
45
+ }
package/lib/index.cjs ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * CJS shim for wicked-bus.
3
+ * Usage: const bus = await import('wicked-bus');
4
+ * Or: import('wicked-bus').then(bus => { ... });
5
+ */
6
+
7
+ let _mod;
8
+ module.exports = new Proxy({}, {
9
+ get(_, prop) {
10
+ if (!_mod) {
11
+ throw new Error(
12
+ 'wicked-bus CJS shim: module not yet loaded. ' +
13
+ 'Use: const bus = await import("wicked-bus")'
14
+ );
15
+ }
16
+ return _mod[prop];
17
+ }
18
+ });
19
+
20
+ import('./index.js').then(m => { _mod = m; });
package/lib/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * wicked-bus public API.
3
+ * @module wicked-bus
4
+ */
5
+
6
+ export { emit } from './emit.js';
7
+ export { poll, ack, matchesFilter } from './poll.js';
8
+ export { register, deregister } from './register.js';
9
+ export { openDb } from './db.js';
10
+ export { loadConfig } from './config.js';
11
+ export { resolveDataDir, ensureDataDir, resolveDbPath } from './paths.js';
12
+ export { startSweep, runSweep } from './sweep.js';
13
+ export { WBError, ERROR_CODES, EXIT_CODES } from './errors.js';
package/lib/paths.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Cross-platform data directory resolution.
3
+ * @module lib/paths
4
+ */
5
+
6
+ import { join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import { mkdirSync } from 'node:fs';
9
+
10
+ const SUBDIR = join('.something-wicked', 'wicked-bus');
11
+
12
+ /**
13
+ * Resolve the wicked-bus data directory path (pure resolution, no side effects).
14
+ * Priority: WICKED_BUS_DATA_DIR env > platform-specific home.
15
+ * @returns {string}
16
+ */
17
+ export function resolveDataDir() {
18
+ const envDir = process.env.WICKED_BUS_DATA_DIR;
19
+ if (envDir) return envDir;
20
+
21
+ let base;
22
+ if (process.platform === 'win32') {
23
+ base =
24
+ process.env.APPDATA ||
25
+ process.env.USERPROFILE ||
26
+ process.env.HOME ||
27
+ null;
28
+ if (!base) {
29
+ // Last resort on Windows
30
+ try { base = homedir(); } catch (_) { /* ignore */ }
31
+ }
32
+ if (!base) {
33
+ base = process.cwd();
34
+ }
35
+ } else {
36
+ base = process.env.HOME || null;
37
+ if (!base) {
38
+ try { base = homedir(); } catch (_) { /* ignore */ }
39
+ }
40
+ if (!base) {
41
+ throw new Error(
42
+ 'Cannot resolve data directory: $HOME is not set. ' +
43
+ 'Set WICKED_BUS_DATA_DIR or export HOME.'
44
+ );
45
+ }
46
+ }
47
+
48
+ return join(base, SUBDIR);
49
+ }
50
+
51
+ /**
52
+ * Ensure the data directory exists (creates recursively).
53
+ * @returns {string} The data directory path.
54
+ */
55
+ export function ensureDataDir() {
56
+ const dir = resolveDataDir();
57
+ mkdirSync(dir, { recursive: true });
58
+ return dir;
59
+ }
60
+
61
+ /**
62
+ * Resolve the full database file path.
63
+ * @param {object} [config] - Config object; uses config.db_path if set.
64
+ * @returns {string}
65
+ */
66
+ export function resolveDbPath(config) {
67
+ if (config && config.db_path) return config.db_path;
68
+ return join(resolveDataDir(), 'bus.db');
69
+ }