@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.
- package/PLUGIN.md +94 -0
- package/bin/cli.ts +75 -0
- package/cli/bun-setup.ts +133 -0
- package/cli/detect-runtime.ts +40 -0
- package/cli/docker-setup.ts +54 -0
- package/cli/download-nats.ts +110 -0
- package/cli/env-writer.ts +58 -0
- package/cli/lifecycle.ts +109 -0
- package/cli/nats-config.ts +32 -0
- package/cli/paths.ts +20 -0
- package/cli/service-units.ts +168 -0
- package/cli/setup.ts +23 -0
- package/dashboard/dist/assets/index-CafgidIc.css +2 -0
- package/dashboard/dist/assets/index-OUWnIZmb.js +15 -0
- package/dashboard/dist/index.html +13 -0
- package/docker/docker-compose.yml +48 -0
- package/hooks/command-publisher/HOOK.md +13 -0
- package/hooks/command-publisher/handler.ts +23 -0
- package/hooks/gateway-startup/HOOK.md +13 -0
- package/hooks/gateway-startup/handler.ts +31 -0
- package/hooks/lifecycle-publisher/HOOK.md +12 -0
- package/hooks/lifecycle-publisher/handler.ts +20 -0
- package/hooks/shared/sidecar-client.ts +23 -0
- package/index.ts +3 -0
- package/openclaw.plugin.json +8 -0
- package/package.json +48 -0
- package/plugins/nats-context-engine/PLUGIN.md +14 -0
- package/plugins/nats-context-engine/http-handler.ts +131 -0
- package/plugins/nats-context-engine/index.ts +89 -0
- package/sidecar/Dockerfile +11 -0
- package/sidecar/bun.lock +212 -0
- package/sidecar/drizzle.config.ts +10 -0
- package/sidecar/package.json +28 -0
- package/sidecar/src/app.module.ts +33 -0
- package/sidecar/src/auth/api-key.middleware.ts +39 -0
- package/sidecar/src/config.ts +40 -0
- package/sidecar/src/consumer/consumer.module.ts +12 -0
- package/sidecar/src/consumer/consumer.service.ts +113 -0
- package/sidecar/src/db/migrations/0000_complete_mulholland_black.sql +5 -0
- package/sidecar/src/db/migrations/0001_high_psylocke.sql +9 -0
- package/sidecar/src/db/migrations/0002_common_stellaris.sql +1 -0
- package/sidecar/src/db/migrations/meta/0000_snapshot.json +49 -0
- package/sidecar/src/db/migrations/meta/0001_snapshot.json +109 -0
- package/sidecar/src/db/migrations/meta/0002_snapshot.json +117 -0
- package/sidecar/src/db/migrations/meta/_journal.json +27 -0
- package/sidecar/src/db/schema.ts +22 -0
- package/sidecar/src/dedup/dedup.module.ts +9 -0
- package/sidecar/src/dedup/dedup.repository.ts +29 -0
- package/sidecar/src/dedup/dedup.service.ts +38 -0
- package/sidecar/src/gateway/gateway-client.module.ts +8 -0
- package/sidecar/src/gateway/gateway-client.service.ts +131 -0
- package/sidecar/src/health/health.controller.ts +15 -0
- package/sidecar/src/health/health.module.ts +13 -0
- package/sidecar/src/health/health.service.ts +51 -0
- package/sidecar/src/index.ts +21 -0
- package/sidecar/src/nats-streams/nats-adapter.service.ts +133 -0
- package/sidecar/src/nats-streams/nats-streams.module.ts +8 -0
- package/sidecar/src/pending/pending.controller.ts +24 -0
- package/sidecar/src/pending/pending.module.ts +11 -0
- package/sidecar/src/pending/pending.repository.ts +62 -0
- package/sidecar/src/pending/pending.service.ts +38 -0
- package/sidecar/src/pre-handlers/dedup.handler.ts +22 -0
- package/sidecar/src/pre-handlers/enrich.handler.ts +14 -0
- package/sidecar/src/pre-handlers/filter.handler.ts +39 -0
- package/sidecar/src/pre-handlers/pipeline.service.ts +38 -0
- package/sidecar/src/pre-handlers/pre-handler.interface.ts +10 -0
- package/sidecar/src/pre-handlers/pre-handlers.module.ts +14 -0
- package/sidecar/src/pre-handlers/priority.handler.ts +14 -0
- package/sidecar/src/publisher/envelope.ts +36 -0
- package/sidecar/src/publisher/publisher.controller.ts +21 -0
- package/sidecar/src/publisher/publisher.module.ts +12 -0
- package/sidecar/src/publisher/publisher.service.ts +20 -0
- package/sidecar/src/validation/schemas.ts +19 -0
- 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
|
+
}
|
package/cli/bun-setup.ts
ADDED
|
@@ -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
|
+
}
|
package/cli/lifecycle.ts
ADDED
|
@@ -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
|
+
}
|