@omnixal/openclaw-nats-plugin 0.1.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.
Files changed (74) hide show
  1. package/PLUGIN.md +94 -0
  2. package/bin/cli.ts +75 -0
  3. package/cli/bun-setup.ts +133 -0
  4. package/cli/detect-runtime.ts +40 -0
  5. package/cli/docker-setup.ts +54 -0
  6. package/cli/download-nats.ts +110 -0
  7. package/cli/env-writer.ts +58 -0
  8. package/cli/lifecycle.ts +109 -0
  9. package/cli/nats-config.ts +32 -0
  10. package/cli/paths.ts +20 -0
  11. package/cli/service-units.ts +168 -0
  12. package/cli/setup.ts +23 -0
  13. package/dashboard/dist/assets/index-CafgidIc.css +2 -0
  14. package/dashboard/dist/assets/index-OUWnIZmb.js +15 -0
  15. package/dashboard/dist/index.html +13 -0
  16. package/docker/docker-compose.yml +48 -0
  17. package/hooks/command-publisher/HOOK.md +13 -0
  18. package/hooks/command-publisher/handler.ts +23 -0
  19. package/hooks/gateway-startup/HOOK.md +13 -0
  20. package/hooks/gateway-startup/handler.ts +31 -0
  21. package/hooks/lifecycle-publisher/HOOK.md +12 -0
  22. package/hooks/lifecycle-publisher/handler.ts +20 -0
  23. package/hooks/shared/sidecar-client.ts +23 -0
  24. package/index.ts +3 -0
  25. package/openclaw.plugin.json +8 -0
  26. package/package.json +48 -0
  27. package/plugins/nats-context-engine/PLUGIN.md +14 -0
  28. package/plugins/nats-context-engine/http-handler.ts +131 -0
  29. package/plugins/nats-context-engine/index.ts +89 -0
  30. package/sidecar/Dockerfile +11 -0
  31. package/sidecar/bun.lock +212 -0
  32. package/sidecar/drizzle.config.ts +10 -0
  33. package/sidecar/package.json +28 -0
  34. package/sidecar/src/app.module.ts +33 -0
  35. package/sidecar/src/auth/api-key.middleware.ts +39 -0
  36. package/sidecar/src/config.ts +40 -0
  37. package/sidecar/src/consumer/consumer.module.ts +12 -0
  38. package/sidecar/src/consumer/consumer.service.ts +113 -0
  39. package/sidecar/src/db/migrations/0000_complete_mulholland_black.sql +5 -0
  40. package/sidecar/src/db/migrations/0001_high_psylocke.sql +9 -0
  41. package/sidecar/src/db/migrations/0002_common_stellaris.sql +1 -0
  42. package/sidecar/src/db/migrations/meta/0000_snapshot.json +49 -0
  43. package/sidecar/src/db/migrations/meta/0001_snapshot.json +109 -0
  44. package/sidecar/src/db/migrations/meta/0002_snapshot.json +117 -0
  45. package/sidecar/src/db/migrations/meta/_journal.json +27 -0
  46. package/sidecar/src/db/schema.ts +22 -0
  47. package/sidecar/src/dedup/dedup.module.ts +9 -0
  48. package/sidecar/src/dedup/dedup.repository.ts +29 -0
  49. package/sidecar/src/dedup/dedup.service.ts +38 -0
  50. package/sidecar/src/gateway/gateway-client.module.ts +8 -0
  51. package/sidecar/src/gateway/gateway-client.service.ts +131 -0
  52. package/sidecar/src/health/health.controller.ts +15 -0
  53. package/sidecar/src/health/health.module.ts +13 -0
  54. package/sidecar/src/health/health.service.ts +51 -0
  55. package/sidecar/src/index.ts +21 -0
  56. package/sidecar/src/nats-streams/nats-adapter.service.ts +133 -0
  57. package/sidecar/src/nats-streams/nats-streams.module.ts +8 -0
  58. package/sidecar/src/pending/pending.controller.ts +24 -0
  59. package/sidecar/src/pending/pending.module.ts +11 -0
  60. package/sidecar/src/pending/pending.repository.ts +62 -0
  61. package/sidecar/src/pending/pending.service.ts +38 -0
  62. package/sidecar/src/pre-handlers/dedup.handler.ts +22 -0
  63. package/sidecar/src/pre-handlers/enrich.handler.ts +14 -0
  64. package/sidecar/src/pre-handlers/filter.handler.ts +39 -0
  65. package/sidecar/src/pre-handlers/pipeline.service.ts +38 -0
  66. package/sidecar/src/pre-handlers/pre-handler.interface.ts +10 -0
  67. package/sidecar/src/pre-handlers/pre-handlers.module.ts +14 -0
  68. package/sidecar/src/pre-handlers/priority.handler.ts +14 -0
  69. package/sidecar/src/publisher/envelope.ts +36 -0
  70. package/sidecar/src/publisher/publisher.controller.ts +21 -0
  71. package/sidecar/src/publisher/publisher.module.ts +12 -0
  72. package/sidecar/src/publisher/publisher.service.ts +20 -0
  73. package/sidecar/src/validation/schemas.ts +19 -0
  74. package/sidecar/tsconfig.json +16 -0
package/PLUGIN.md ADDED
@@ -0,0 +1,94 @@
1
+ # NATS JetStream Plugin
2
+
3
+ Event-driven plugin for OpenClaw that replaces polling-based heartbeat with NATS JetStream messaging.
4
+
5
+ ## Quick Start
6
+
7
+ # Install plugin
8
+ openclaw plugins install @omnixal/openclaw-nats-plugin
9
+
10
+ # Setup infrastructure (auto-detects Bun or Docker)
11
+ npx @omnixal/openclaw-nats-plugin setup
12
+
13
+ # Restart gateway
14
+ openclaw gateway restart
15
+
16
+ ## Setup Options
17
+
18
+ # Force Bun mode (downloads nats-server binary, creates systemd/launchd units)
19
+ npx @omnixal/openclaw-nats-plugin setup --runtime=bun
20
+
21
+ # Force Docker mode (creates docker-compose with nats + sidecar containers)
22
+ npx @omnixal/openclaw-nats-plugin setup --runtime=docker
23
+
24
+ Auto-detect priority: Bun > Docker.
25
+
26
+ ## Management
27
+
28
+ npx @omnixal/openclaw-nats-plugin start # Start services
29
+ npx @omnixal/openclaw-nats-plugin stop # Stop services
30
+ npx @omnixal/openclaw-nats-plugin status # Health check
31
+ npx @omnixal/openclaw-nats-plugin uninstall # Remove (keeps data)
32
+ npx @omnixal/openclaw-nats-plugin uninstall --purge # Remove everything
33
+
34
+ ## Architecture
35
+
36
+ Plugin hooks make lightweight HTTP calls to a NATS sidecar service for event publishing.
37
+
38
+ ### Hooks
39
+ - **gateway-startup** — Publishes gateway startup event, verifies sidecar connectivity
40
+ - **lifecycle-publisher** — Publishes tool_result_persist events (tool completed/failed)
41
+ - **command-publisher** — Publishes command events (/new, /reset, /stop)
42
+
43
+ ### Plugins
44
+ - **nats-context-engine** — Publishes agent lifecycle events via plugin SDK:
45
+ - Subagent spawned/ended
46
+ - Session started/ended
47
+ - Agent run ended
48
+ - Message sent (delivery status)
49
+ - Context compacted (after history summarization)
50
+
51
+ ### Published Events
52
+
53
+ | Subject | Trigger |
54
+ |---|---|
55
+ | `agent.events.gateway.startup` | Gateway starts |
56
+ | `agent.events.session.new` | `/new` command |
57
+ | `agent.events.session.reset` | `/reset` command |
58
+ | `agent.events.session.stop` | `/stop` command |
59
+ | `agent.events.session.started` | Session begins |
60
+ | `agent.events.session.ended` | Session ends |
61
+ | `agent.events.tool.{name}.completed` | Tool succeeds |
62
+ | `agent.events.tool.{name}.failed` | Tool fails |
63
+ | `agent.events.subagent.spawned` | Subagent created |
64
+ | `agent.events.subagent.ended` | Subagent finished |
65
+ | `agent.events.agent.run_ended` | Agent run completes |
66
+ | `agent.events.message.sent` | Message delivered |
67
+ | `agent.events.context.compacted` | Context history compressed |
68
+
69
+ ## Dashboard
70
+
71
+ The plugin includes a web dashboard at `/nats-dashboard` on the Gateway.
72
+
73
+ It shows:
74
+ - **Health status** — NATS server, Gateway WebSocket, and sidecar connectivity
75
+ - **Pending events** — queued inbound events with priority and age
76
+ - **Configuration** — streams, consumer name, dedup TTL
77
+
78
+ The dashboard auto-refreshes every 5 seconds. API calls are proxied through the Gateway (no direct sidecar access needed from the browser).
79
+
80
+ Build the dashboard (required after install):
81
+
82
+ cd openclaw-nats-plugin/dashboard && bun run build
83
+
84
+ ## Configuration
85
+
86
+ Environment variables (auto-configured by setup):
87
+ - `NATS_SIDECAR_URL` — Sidecar URL (default: `http://127.0.0.1:3104`)
88
+ - `NATS_PLUGIN_API_KEY` — API key for sidecar auth (auto-generated)
89
+ - `NATS_SERVERS` — NATS server URL (default: `nats://127.0.0.1:4222`)
90
+
91
+ ## Requirements
92
+
93
+ - OpenClaw Gateway v2026.3+
94
+ - Bun (recommended) or Docker
package/bin/cli.ts ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bun
2
+ import { parseArgs } from 'node:util';
3
+
4
+ const COMMANDS = ['setup', 'start', 'stop', 'status', 'uninstall'] as const;
5
+ type Command = typeof COMMANDS[number];
6
+
7
+ function printUsage(): void {
8
+ console.log(`
9
+ Usage: npx @omnixal/openclaw-nats-plugin <command> [options]
10
+
11
+ Commands:
12
+ setup Install and start NATS + sidecar (auto-detects runtime)
13
+ start Start NATS + sidecar services
14
+ stop Stop NATS + sidecar services
15
+ status Check service health
16
+ uninstall Remove services and config
17
+
18
+ Options:
19
+ --runtime=bun|docker Force specific runtime (default: auto-detect)
20
+ --help Show this help
21
+ `);
22
+ }
23
+
24
+ const args = process.argv.slice(2);
25
+ const command = args[0] as Command;
26
+
27
+ if (!command || command === '--help' || !COMMANDS.includes(command)) {
28
+ printUsage();
29
+ process.exit(command === '--help' ? 0 : 1);
30
+ }
31
+
32
+ const { values } = parseArgs({
33
+ args: args.slice(1),
34
+ options: {
35
+ runtime: { type: 'string' },
36
+ purge: { type: 'boolean', default: false },
37
+ help: { type: 'boolean', default: false },
38
+ },
39
+ strict: false,
40
+ });
41
+
42
+ if (values.help) {
43
+ printUsage();
44
+ process.exit(0);
45
+ }
46
+
47
+ const runtime = values.runtime as 'bun' | 'docker' | undefined;
48
+
49
+ switch (command) {
50
+ case 'setup': {
51
+ const { runSetup } = await import('../cli/setup.ts');
52
+ await runSetup(runtime);
53
+ break;
54
+ }
55
+ case 'start': {
56
+ const { runStart } = await import('../cli/lifecycle.ts');
57
+ await runStart();
58
+ break;
59
+ }
60
+ case 'stop': {
61
+ const { runStop } = await import('../cli/lifecycle.ts');
62
+ await runStop();
63
+ break;
64
+ }
65
+ case 'status': {
66
+ const { runStatus } = await import('../cli/lifecycle.ts');
67
+ await runStatus();
68
+ break;
69
+ }
70
+ case 'uninstall': {
71
+ const { runUninstall } = await import('../cli/lifecycle.ts');
72
+ await runUninstall(values.purge as boolean);
73
+ break;
74
+ }
75
+ }
@@ -0,0 +1,133 @@
1
+ import { mkdirSync, existsSync, cpSync, writeFileSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { join, dirname } from 'node:path';
4
+ import {
5
+ PLUGIN_DIR, SIDECAR_DIR, DATA_DIR, JETSTREAM_DIR, BIN_DIR,
6
+ NATS_SERVER_BIN, STATE_FILE, NATS_CONF, type PluginState,
7
+ } from './paths';
8
+ import { downloadNatsServer, NATS_VERSION } from './download-nats';
9
+ import { writeNatsConfig } from './nats-config';
10
+ import { generateApiKey, writeEnvVariables } from './env-writer';
11
+ import {
12
+ getServiceManager, generateSystemdUnit, generateLaunchdPlist,
13
+ installSystemdUnit, installLaunchdPlist, startService,
14
+ } from './service-units';
15
+
16
+ const NATS_SERVICE = 'openclaw-nats';
17
+ const SIDECAR_SERVICE = 'openclaw-nats-sidecar';
18
+
19
+ export async function bunSetup(): Promise<void> {
20
+ console.log('Setting up NATS plugin (Bun mode)...\n');
21
+
22
+ // 1. Create directories
23
+ for (const dir of [PLUGIN_DIR, BIN_DIR, SIDECAR_DIR, DATA_DIR, JETSTREAM_DIR]) {
24
+ mkdirSync(dir, { recursive: true });
25
+ }
26
+ mkdirSync(join(PLUGIN_DIR, 'logs'), { recursive: true });
27
+
28
+ // 2. Download nats-server
29
+ await downloadNatsServer();
30
+
31
+ // 3. Copy sidecar source into plugin dir
32
+ // When installed via npm, the sidecar/ dir is inside the package
33
+ const pluginRoot = join(dirname(new URL(import.meta.url).pathname), '..');
34
+ const sidecarSrc = join(pluginRoot, 'sidecar');
35
+ if (existsSync(sidecarSrc)) {
36
+ console.log('Copying sidecar source...');
37
+ cpSync(sidecarSrc, SIDECAR_DIR, { recursive: true });
38
+ } else {
39
+ throw new Error(`Sidecar source not found at ${sidecarSrc}`);
40
+ }
41
+
42
+ // 4. Install sidecar dependencies
43
+ console.log('Installing sidecar dependencies...');
44
+ execFileSync('bun', ['install', '--frozen-lockfile'], { cwd: SIDECAR_DIR, stdio: 'inherit' });
45
+
46
+ // 5. Generate NATS config
47
+ writeNatsConfig();
48
+
49
+ // 6. Generate API key
50
+ const apiKey = generateApiKey();
51
+
52
+ // 7. Write env variables
53
+ writeEnvVariables({
54
+ NATS_SIDECAR_URL: 'http://127.0.0.1:3104',
55
+ NATS_PLUGIN_API_KEY: apiKey,
56
+ NATS_SERVERS: 'nats://127.0.0.1:4222',
57
+ });
58
+
59
+ // 8. Generate and install service units
60
+ const manager = getServiceManager();
61
+
62
+ if (manager === 'systemd') {
63
+ const natsUnit = generateSystemdUnit({
64
+ name: NATS_SERVICE,
65
+ description: 'NATS Server for OpenClaw',
66
+ execStart: NATS_SERVER_BIN,
67
+ args: ['-c', NATS_CONF],
68
+ workingDirectory: PLUGIN_DIR,
69
+ });
70
+ installSystemdUnit(NATS_SERVICE, natsUnit);
71
+
72
+ const sidecarUnit = generateSystemdUnit({
73
+ name: SIDECAR_SERVICE,
74
+ description: 'NATS Sidecar for OpenClaw',
75
+ execStart: 'bun',
76
+ args: ['run', join(SIDECAR_DIR, 'src/index.ts')],
77
+ workingDirectory: SIDECAR_DIR,
78
+ after: `${NATS_SERVICE}.service`,
79
+ });
80
+ installSystemdUnit(SIDECAR_SERVICE, sidecarUnit);
81
+ } else {
82
+ const natsPlist = generateLaunchdPlist({
83
+ label: 'com.openclaw.nats',
84
+ program: NATS_SERVER_BIN,
85
+ programArguments: ['-c', NATS_CONF],
86
+ workingDirectory: PLUGIN_DIR,
87
+ });
88
+ installLaunchdPlist('com.openclaw.nats', natsPlist);
89
+
90
+ const sidecarPlist = generateLaunchdPlist({
91
+ label: 'com.openclaw.nats-sidecar',
92
+ program: 'bun',
93
+ programArguments: ['run', join(SIDECAR_DIR, 'src/index.ts')],
94
+ workingDirectory: SIDECAR_DIR,
95
+ });
96
+ installLaunchdPlist('com.openclaw.nats-sidecar', sidecarPlist);
97
+ }
98
+
99
+ // 9. Start services
100
+ console.log('\nStarting services...');
101
+ startService(NATS_SERVICE);
102
+ await waitForPort(8222, 10_000);
103
+ startService(SIDECAR_SERVICE);
104
+
105
+ // 10. Save state
106
+ const state: PluginState = {
107
+ runtime: 'bun',
108
+ installedAt: new Date().toISOString(),
109
+ natsServerVersion: NATS_VERSION,
110
+ };
111
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
112
+
113
+ console.log('\nNATS plugin setup complete (Bun mode)');
114
+ console.log(' NATS server: 127.0.0.1:4222');
115
+ console.log(' Sidecar: 127.0.0.1:3104');
116
+ console.log('\nRestart OpenClaw gateway to activate the plugin.');
117
+ }
118
+
119
+ async function waitForPort(port: number, timeoutMs: number): Promise<void> {
120
+ const start = Date.now();
121
+ while (Date.now() - start < timeoutMs) {
122
+ try {
123
+ const res = await fetch(`http://127.0.0.1:${port}/`, {
124
+ signal: AbortSignal.timeout(1000),
125
+ });
126
+ if (res.ok || res.status === 404) return;
127
+ } catch {
128
+ // Not ready yet
129
+ }
130
+ await new Promise((r) => setTimeout(r, 500));
131
+ }
132
+ console.warn(`Warning: port ${port} not ready after ${timeoutMs}ms`);
133
+ }
@@ -0,0 +1,40 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ export function checkBun(): boolean {
4
+ try {
5
+ execFileSync('bun', ['--version'], { stdio: 'pipe' });
6
+ return true;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+
12
+ export function checkDocker(): boolean {
13
+ try {
14
+ execFileSync('docker', ['info'], { stdio: 'pipe', timeout: 5000 });
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ export type Runtime = 'bun' | 'docker';
22
+
23
+ export async function detectRuntime(preferred?: Runtime): Promise<Runtime> {
24
+ if (preferred) {
25
+ const check = preferred === 'bun' ? checkBun() : checkDocker();
26
+ if (!check) {
27
+ throw new Error(`Requested runtime "${preferred}" is not available`);
28
+ }
29
+ return preferred;
30
+ }
31
+
32
+ if (checkBun()) return 'bun';
33
+ if (checkDocker()) return 'docker';
34
+
35
+ throw new Error(
36
+ 'Neither Bun nor Docker found. Install one of:\n' +
37
+ ' - Bun: https://bun.sh\n' +
38
+ ' - Docker: https://docs.docker.com/get-docker/'
39
+ );
40
+ }
@@ -0,0 +1,54 @@
1
+ import { mkdirSync, cpSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { join, dirname } from 'node:path';
4
+ import { PLUGIN_DIR, DOCKER_DIR, STATE_FILE, type PluginState } from './paths';
5
+ import { generateApiKey, writeEnvVariables } from './env-writer';
6
+
7
+ export async function dockerSetup(): Promise<void> {
8
+ console.log('Setting up NATS plugin (Docker mode)...\n');
9
+
10
+ mkdirSync(DOCKER_DIR, { recursive: true });
11
+
12
+ // 1. Copy docker compose and sidecar source
13
+ const pluginRoot = join(dirname(new URL(import.meta.url).pathname), '..');
14
+ const templateDir = join(pluginRoot, 'docker');
15
+ const sidecarSrc = join(pluginRoot, 'sidecar');
16
+
17
+ if (!existsSync(join(templateDir, 'docker-compose.yml'))) {
18
+ throw new Error(`Docker compose template not found at ${templateDir}`);
19
+ }
20
+
21
+ if (!existsSync(sidecarSrc)) {
22
+ throw new Error(`Sidecar source not found at ${sidecarSrc}. The plugin package may be incomplete.`);
23
+ }
24
+
25
+ cpSync(templateDir, DOCKER_DIR, { recursive: true });
26
+ cpSync(sidecarSrc, join(DOCKER_DIR, 'sidecar'), { recursive: true });
27
+
28
+ // 2. Generate API key and write .env for compose
29
+ const apiKey = generateApiKey();
30
+ writeFileSync(join(DOCKER_DIR, '.env'), `NATS_PLUGIN_API_KEY=${apiKey}\n`);
31
+
32
+ // 3. Build and start
33
+ console.log('Building and starting containers...');
34
+ execFileSync('docker', ['compose', 'up', '-d', '--build'], { cwd: DOCKER_DIR, stdio: 'inherit' });
35
+
36
+ // 4. Write env variables for OpenClaw
37
+ writeEnvVariables({
38
+ NATS_SIDECAR_URL: 'http://127.0.0.1:3104',
39
+ NATS_PLUGIN_API_KEY: apiKey,
40
+ NATS_SERVERS: 'nats://127.0.0.1:4222',
41
+ });
42
+
43
+ // 5. Save state
44
+ const state: PluginState = {
45
+ runtime: 'docker',
46
+ installedAt: new Date().toISOString(),
47
+ };
48
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
49
+
50
+ console.log('\nNATS plugin setup complete (Docker mode)');
51
+ console.log(' NATS server: 127.0.0.1:4222');
52
+ console.log(' Sidecar: 127.0.0.1:3104');
53
+ console.log('\nRestart OpenClaw gateway to activate the plugin.');
54
+ }
@@ -0,0 +1,110 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdirSync, existsSync, chmodSync, renameSync, rmSync, readFileSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { join } from 'node:path';
5
+ import { NATS_SERVER_BIN, BIN_DIR } from './paths';
6
+
7
+ const NATS_VERSION = '2.10.24';
8
+
9
+ export function detectPlatform(): { os: string; arch: string } {
10
+ const platform = process.platform;
11
+ const architecture = process.arch;
12
+
13
+ if (platform !== 'darwin' && platform !== 'linux') {
14
+ throw new Error(`Unsupported platform: ${platform}. Only linux and darwin are supported.`);
15
+ }
16
+ if (architecture !== 'arm64' && architecture !== 'x64') {
17
+ throw new Error(`Unsupported architecture: ${architecture}. Only arm64 and x64 are supported.`);
18
+ }
19
+
20
+ const os = platform === 'darwin' ? 'darwin' : 'linux';
21
+ const arch = architecture === 'arm64' ? 'arm64' : 'amd64';
22
+
23
+ return { os, arch };
24
+ }
25
+
26
+ export function getNatsDownloadUrl(version: string, os: string, arch: string): string {
27
+ return `https://github.com/nats-io/nats-server/releases/download/v${version}/nats-server-v${version}-${os}-${arch}.zip`;
28
+ }
29
+
30
+ export async function downloadNatsServer(): Promise<string> {
31
+ if (existsSync(NATS_SERVER_BIN)) {
32
+ console.log('nats-server already installed, skipping download');
33
+ return NATS_SERVER_BIN;
34
+ }
35
+
36
+ const { os, arch } = detectPlatform();
37
+ const url = getNatsDownloadUrl(NATS_VERSION, os, arch);
38
+ const zipPath = join(BIN_DIR, 'nats-server.zip');
39
+ const extractDir = join(BIN_DIR, 'nats-extract');
40
+
41
+ mkdirSync(BIN_DIR, { recursive: true });
42
+
43
+ console.log(`Downloading nats-server v${NATS_VERSION} (${os}/${arch})...`);
44
+
45
+ let lastError: Error | null = null;
46
+ for (let attempt = 1; attempt <= 3; attempt++) {
47
+ try {
48
+ execFileSync('curl', ['-fsSL', '-o', zipPath, url], { stdio: 'pipe', timeout: 120_000 });
49
+ lastError = null;
50
+ break;
51
+ } catch (e) {
52
+ lastError = e as Error;
53
+ console.warn(`Download attempt ${attempt}/3 failed, retrying...`);
54
+ }
55
+ }
56
+
57
+ if (lastError) {
58
+ throw new Error(
59
+ `Failed to download nats-server after 3 attempts.\n` +
60
+ `Download manually from: ${url}\n` +
61
+ `Place binary at: ${NATS_SERVER_BIN}`
62
+ );
63
+ }
64
+
65
+ // Verify SHA256 checksum
66
+ const checksumUrl = `https://github.com/nats-io/nats-server/releases/download/v${NATS_VERSION}/SHA256SUMS`;
67
+ try {
68
+ execFileSync('curl', ['-fsSL', '-o', join(BIN_DIR, 'SHA256SUMS'), checksumUrl], { stdio: 'pipe', timeout: 30_000 });
69
+
70
+ const zipData = readFileSync(zipPath);
71
+ const actualHash = createHash('sha256').update(zipData).digest('hex');
72
+ const checksumContent = readFileSync(join(BIN_DIR, 'SHA256SUMS'), 'utf-8');
73
+ const expectedLine = checksumContent.split('\n').find(line => line.includes(`nats-server-v${NATS_VERSION}-${os}-${arch}.zip`));
74
+ if (expectedLine) {
75
+ const expectedHash = expectedLine.split(/\s+/)[0];
76
+ if (actualHash !== expectedHash) {
77
+ rmSync(zipPath);
78
+ throw new Error(`Checksum mismatch for nats-server download.\nExpected: ${expectedHash}\nActual: ${actualHash}`);
79
+ }
80
+ console.log('Checksum verified');
81
+ } else {
82
+ console.warn('Warning: Could not find checksum for this platform, skipping verification');
83
+ }
84
+ } catch (e) {
85
+ if (e instanceof Error && e.message.includes('Checksum mismatch')) throw e;
86
+ console.warn('Warning: Could not download checksums, skipping verification');
87
+ } finally {
88
+ const sumsPath = join(BIN_DIR, 'SHA256SUMS');
89
+ if (existsSync(sumsPath)) rmSync(sumsPath);
90
+ }
91
+
92
+ try {
93
+ mkdirSync(extractDir, { recursive: true });
94
+ execFileSync('unzip', ['-o', zipPath, '-d', extractDir], { stdio: 'pipe' });
95
+
96
+ const innerDir = `nats-server-v${NATS_VERSION}-${os}-${arch}`;
97
+ const extractedBin = join(extractDir, innerDir, 'nats-server');
98
+
99
+ renameSync(extractedBin, NATS_SERVER_BIN);
100
+ chmodSync(NATS_SERVER_BIN, 0o755);
101
+ } finally {
102
+ if (existsSync(zipPath)) rmSync(zipPath);
103
+ if (existsSync(extractDir)) rmSync(extractDir, { recursive: true });
104
+ }
105
+
106
+ console.log(`nats-server installed at ${NATS_SERVER_BIN}`);
107
+ return NATS_SERVER_BIN;
108
+ }
109
+
110
+ export { NATS_VERSION };
@@ -0,0 +1,58 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { dirname } from 'node:path';
4
+ import { OPENCLAW_ENV } from './paths';
5
+
6
+ export function generateApiKey(): string {
7
+ return randomBytes(32).toString('hex');
8
+ }
9
+
10
+ export function mergeEnvContent(
11
+ existingContent: string,
12
+ variables: Record<string, string>,
13
+ ): string {
14
+ const lines = existingContent.split('\n');
15
+ const updatedKeys = new Set<string>();
16
+
17
+ const updatedLines = lines.map((line) => {
18
+ const trimmed = line.trim();
19
+ if (!trimmed || trimmed.startsWith('#')) return line;
20
+
21
+ const eqIndex = trimmed.indexOf('=');
22
+ if (eqIndex === -1) return line;
23
+
24
+ const key = trimmed.slice(0, eqIndex);
25
+ if (key in variables) {
26
+ updatedKeys.add(key);
27
+ return `${key}=${variables[key]}`;
28
+ }
29
+ return line;
30
+ });
31
+
32
+ const newVars = Object.entries(variables)
33
+ .filter(([key]) => !updatedKeys.has(key))
34
+ .map(([key, value]) => `${key}=${value}`);
35
+
36
+ if (newVars.length > 0) {
37
+ const lastLine = updatedLines[updatedLines.length - 1]?.trim();
38
+ if (lastLine !== '' && updatedLines.length > 0) {
39
+ updatedLines.push('');
40
+ }
41
+ updatedLines.push('# NATS Plugin');
42
+ updatedLines.push(...newVars);
43
+ }
44
+
45
+ return updatedLines.join('\n');
46
+ }
47
+
48
+ export function writeEnvVariables(variables: Record<string, string>): void {
49
+ mkdirSync(dirname(OPENCLAW_ENV), { recursive: true });
50
+
51
+ const existing = existsSync(OPENCLAW_ENV)
52
+ ? readFileSync(OPENCLAW_ENV, 'utf-8')
53
+ : '';
54
+
55
+ const merged = mergeEnvContent(existing, variables);
56
+ writeFileSync(OPENCLAW_ENV, merged, 'utf-8');
57
+ console.log(`Environment variables written to ${OPENCLAW_ENV}`);
58
+ }
@@ -0,0 +1,109 @@
1
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { STATE_FILE, PLUGIN_DIR, DOCKER_DIR, type PluginState } from './paths';
4
+ import {
5
+ startService, stopService, isServiceRunning, removeServiceUnit,
6
+ } from './service-units';
7
+
8
+ const NATS_SERVICE = 'openclaw-nats';
9
+ const SIDECAR_SERVICE = 'openclaw-nats-sidecar';
10
+
11
+ function loadState(): PluginState {
12
+ if (!existsSync(STATE_FILE)) {
13
+ throw new Error('NATS plugin not installed. Run: npx @omnixal/openclaw-nats-plugin setup');
14
+ }
15
+ return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
16
+ }
17
+
18
+ export async function runStart(): Promise<void> {
19
+ const state = loadState();
20
+
21
+ if (state.runtime === 'bun') {
22
+ startService(NATS_SERVICE);
23
+ startService(SIDECAR_SERVICE);
24
+ console.log('NATS services started');
25
+ } else {
26
+ execFileSync('docker', ['compose', 'up', '-d'], { cwd: DOCKER_DIR, stdio: 'inherit' });
27
+ console.log('NATS containers started');
28
+ }
29
+ }
30
+
31
+ export async function runStop(): Promise<void> {
32
+ const state = loadState();
33
+
34
+ if (state.runtime === 'bun') {
35
+ stopService(SIDECAR_SERVICE);
36
+ stopService(NATS_SERVICE);
37
+ console.log('NATS services stopped');
38
+ } else {
39
+ execFileSync('docker', ['compose', 'stop'], { cwd: DOCKER_DIR, stdio: 'inherit' });
40
+ console.log('NATS containers stopped');
41
+ }
42
+ }
43
+
44
+ export async function runStatus(): Promise<void> {
45
+ const state = loadState();
46
+
47
+ console.log(`Runtime: ${state.runtime}`);
48
+ console.log(`Installed: ${state.installedAt}\n`);
49
+
50
+ if (state.runtime === 'bun') {
51
+ const natsRunning = isServiceRunning(NATS_SERVICE);
52
+ const sidecarRunning = isServiceRunning(SIDECAR_SERVICE);
53
+ console.log(`NATS server: ${natsRunning ? 'running' : 'stopped'}`);
54
+ console.log(`Sidecar: ${sidecarRunning ? 'running' : 'stopped'}`);
55
+ } else {
56
+ try {
57
+ execFileSync('docker', ['compose', 'ps'], { cwd: DOCKER_DIR, stdio: 'inherit' });
58
+ } catch {
59
+ console.log('Docker containers not found');
60
+ }
61
+ }
62
+
63
+ // Check connectivity
64
+ try {
65
+ const res = await fetch('http://127.0.0.1:3104/metrics', {
66
+ signal: AbortSignal.timeout(3000),
67
+ });
68
+ console.log(`\nSidecar API: ${res.ok ? 'healthy' : 'unhealthy'}`);
69
+ } catch {
70
+ console.log('\nSidecar API: unreachable');
71
+ }
72
+
73
+ try {
74
+ const res = await fetch('http://127.0.0.1:8222/healthz', {
75
+ signal: AbortSignal.timeout(3000),
76
+ });
77
+ console.log(`NATS health: ${res.ok ? 'healthy' : 'unhealthy'}`);
78
+ } catch {
79
+ console.log('NATS health: unreachable');
80
+ }
81
+ }
82
+
83
+ export async function runUninstall(purge: boolean): Promise<void> {
84
+ const state = loadState();
85
+
86
+ console.log(`Uninstalling NATS plugin (${state.runtime} mode)...`);
87
+
88
+ if (state.runtime === 'bun') {
89
+ removeServiceUnit(SIDECAR_SERVICE);
90
+ removeServiceUnit(NATS_SERVICE);
91
+ } else {
92
+ try {
93
+ execFileSync('docker', ['compose', 'down'], { cwd: DOCKER_DIR, stdio: 'inherit' });
94
+ } catch {
95
+ // Containers might not exist
96
+ }
97
+ }
98
+
99
+ if (purge) {
100
+ console.log('Purging all data...');
101
+ rmSync(PLUGIN_DIR, { recursive: true, force: true });
102
+ } else {
103
+ rmSync(STATE_FILE, { force: true });
104
+ console.log('Services removed. Data preserved at ' + PLUGIN_DIR);
105
+ console.log('Use --purge to remove all data.');
106
+ }
107
+
108
+ console.log('Uninstall complete.');
109
+ }