remote-access-mcp 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.fa.md +34 -0
  2. package/README.md +56 -2
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/main.js +282 -2
  5. package/dist/cli/main.js.map +1 -1
  6. package/dist/core/config.d.ts +10 -0
  7. package/dist/core/config.d.ts.map +1 -1
  8. package/dist/core/config.js +5 -0
  9. package/dist/core/config.js.map +1 -1
  10. package/dist/core/fleet.d.ts +71 -0
  11. package/dist/core/fleet.d.ts.map +1 -0
  12. package/dist/core/fleet.js +148 -0
  13. package/dist/core/fleet.js.map +1 -0
  14. package/dist/core/policy.d.ts.map +1 -1
  15. package/dist/core/policy.js +1 -0
  16. package/dist/core/policy.js.map +1 -1
  17. package/dist/core/tunnel.d.ts.map +1 -1
  18. package/dist/core/tunnel.js +4 -0
  19. package/dist/core/tunnel.js.map +1 -1
  20. package/dist/core/webhooks.d.ts +18 -0
  21. package/dist/core/webhooks.d.ts.map +1 -0
  22. package/dist/core/webhooks.js +81 -0
  23. package/dist/core/webhooks.js.map +1 -0
  24. package/dist/server/app.d.ts.map +1 -1
  25. package/dist/server/app.js +7 -0
  26. package/dist/server/app.js.map +1 -1
  27. package/dist/server/run.d.ts.map +1 -1
  28. package/dist/server/run.js +40 -8
  29. package/dist/server/run.js.map +1 -1
  30. package/dist/tools/filesystem.d.ts.map +1 -1
  31. package/dist/tools/filesystem.js +59 -12
  32. package/dist/tools/filesystem.js.map +1 -1
  33. package/dist/tools/fleet.d.ts +4 -0
  34. package/dist/tools/fleet.d.ts.map +1 -0
  35. package/dist/tools/fleet.js +39 -0
  36. package/dist/tools/fleet.js.map +1 -0
  37. package/dist/tools/index.d.ts.map +1 -1
  38. package/dist/tools/index.js +2 -0
  39. package/dist/tools/index.js.map +1 -1
  40. package/dist/tools/logs.d.ts.map +1 -1
  41. package/dist/tools/logs.js +24 -3
  42. package/dist/tools/logs.js.map +1 -1
  43. package/dist/tools/packages.d.ts.map +1 -1
  44. package/dist/tools/packages.js +11 -1
  45. package/dist/tools/packages.js.map +1 -1
  46. package/dist/tools/services.d.ts.map +1 -1
  47. package/dist/tools/services.js +21 -4
  48. package/dist/tools/services.js.map +1 -1
  49. package/dist/tools/shell.d.ts +4 -0
  50. package/dist/tools/shell.d.ts.map +1 -1
  51. package/dist/tools/shell.js +34 -6
  52. package/dist/tools/shell.js.map +1 -1
  53. package/package.json +1 -1
package/README.fa.md CHANGED
@@ -24,6 +24,8 @@ ramcp init
24
24
 
25
25
  ## شروع سریع
26
26
 
27
+ روی **سرور** با دامنه:
28
+
27
29
  ```bash
28
30
  ramcp init # کانفیگ + اولین توکن
29
31
  ramcp policy allow /srv/myapp # چه مسیرهایی رو AI ببینه
@@ -33,6 +35,17 @@ ramcp doctor # بررسی سلامت همه‌چیز
33
35
  ramcp url # URL کانکتور برای چت‌بات
34
36
  ```
35
37
 
38
+ روی **لپ‌تاپ / دسکتاپ** (بدون دامنه و بدون پورت‌فوروارد):
39
+
40
+ ```bash
41
+ ramcp tunnel
42
+ # → دفعه اول cloudflared رو خودکار دانلود می‌کنه (بدون نیاز به اکانت)
43
+ # یه URL عمومی https میده مثل https://random-words.trycloudflare.com
44
+ # دستور `ramcp url` توی ترمینال دیگه، لینک زنده کانکتور رو نشون میده.
45
+ ```
46
+
47
+ روی ویندوز، مک و لینوکس یکسانه — PowerShell/cmd روی ویندوز، launchd روی مک، systemd روی لینوکس برای سرویس خودکار.
48
+
36
49
  ## توکن‌های چندگانه — کمترین دسترسی به‌صورت پیش‌فرض
37
50
 
38
51
  ```bash
@@ -49,6 +62,27 @@ ramcp token add --name deploy --paths /srv/app --scopes filesystem,git,shell --s
49
62
 
50
63
  فایل‌سیستم (۷)، شل (۳)، سیستم (۳)، HTTP با محافظ SSRF (۳)، گیت با whitelist فعل‌ها (۱)، SQLite تک-دستوره (۲)، لاگ/journalctl (۳)، سرویس‌های systemd (۲)، پکیج‌ها apt/npm (۳)، زمان‌بند (۳)، اسکن امنیتی (۲)، تحلیل پروژه (۲)، پلنینگ + اسنپ‌شات/rollback (۴)، مدیریت پالیسی (۴).
51
64
 
65
+ ## Fleet: کنترل چند سرور با SSH
66
+
67
+ گیت‌وی روی یک ماشین می‌مونه؛ ابزارهای remote-capable پارامتر `host` می‌گیرن و از طریق SSH روی ماشین دیگه اجرا میشن. روی سرورهای ریموت هیچ نرم‌افزاری نصب نمیشه — فقط یه SSH server و کلید شما روی گیت‌وی.
68
+
69
+ ```bash
70
+ ramcp fleet add --name web1 --host deploy@10.0.0.5 --tools shell,fs --note "app server"
71
+ ramcp fleet test # تست دسترسی همه هاست‌ها
72
+ ```
73
+
74
+ بعد از داخل چت‌بات: «لاگ nginx روی web1 رو ببین» → `journal(unit: "nginx.service", host: "web1")`.
75
+
76
+ هر هاست allowlist ابزار خودش رو داره (`shell fs logs services packages`) — هاستی که گروهی رو نگرفته با خطای واضح رد میشه، قبل از هر تلاش SSH. نوشتن فایل ریموت از طریق stdin (base64) انجام میشه — بدون فایل موقت. SSH با BatchMode اجرا میشه — هیچ پرامپت پسوردی نیست.
77
+
78
+ ## Webhook و بکاپ
79
+
80
+ ```bash
81
+ ramcp webhook add --url https://hooks.example.com/ramcp --events tool.error
82
+ ramcp config export --out backup.json # اسنپ‌شات کامل — توکن‌های زنده داره!
83
+ ramcp config import backup.json --merge # ادغام با حفظ هویت محلی
84
+ ```
85
+
52
86
  ## مدل امنیتی
53
87
 
54
88
  - **فقط loopback** — سرور روی `127.0.0.1` گوش میده
package/README.md CHANGED
@@ -23,7 +23,14 @@ The server binds to `127.0.0.1` only. You put it behind nginx (with Cloudflare o
23
23
 
24
24
  ## Install
25
25
 
26
- ### One-liner (any Ubuntu/Debian server)
26
+ ### Any machine with Node 18+ — Linux, macOS, or Windows
27
+
28
+ ```bash
29
+ npm install -g remote-access-mcp
30
+ ramcp init
31
+ ```
32
+
33
+ ### One-liner (Ubuntu/Debian servers)
27
34
 
28
35
  ```bash
29
36
  curl -fsSL https://raw.githubusercontent.com/AmirAliManzar/remote-access-mcp/main/install.sh | bash
@@ -39,6 +46,8 @@ ramcp init
39
46
 
40
47
  ## Quick start
41
48
 
49
+ On a **server** with a domain:
50
+
42
51
  ```bash
43
52
  ramcp init # config + first token
44
53
  ramcp policy allow /srv/myapp # what the AI may touch
@@ -48,6 +57,18 @@ ramcp doctor # verify everything end-to-end
48
57
  ramcp url # connector URL for your chatbot
49
58
  ```
50
59
 
60
+ On a **laptop or desktop** (no domain, no port forwarding):
61
+
62
+ ```bash
63
+ ramcp tunnel
64
+ # → downloads cloudflared on first run (no account needed),
65
+ # prints a public https URL like https://random-words.trycloudflare.com
66
+ # `ramcp url` in another terminal shows the live connector link.
67
+ ```
68
+
69
+ Works the same on Windows, macOS, and Linux — PowerShell/cmd on Windows,
70
+ launchd on macOS, systemd on Linux for the autostart service.
71
+
51
72
  ## Commands
52
73
 
53
74
  | Command | Description |
@@ -131,6 +152,39 @@ Endpoint `https://your-domain.com/mcp` + header `Authorization: Bearer <token>`
131
152
 
132
153
  **Policy** (4) `list_allowed_paths` `allow_path` `deny_path` `shell_enabled` — each token manages only its own sandbox
133
154
 
155
+ ## Fleet: drive other machines over SSH
156
+
157
+ The gateway itself stays on one box; every remote-capable tool gains a `host` parameter that routes over SSH. No agent software on the remotes — just an SSH server and your key on the gateway.
158
+
159
+ ```bash
160
+ # on the gateway:
161
+ ramcp fleet add --name web1 --host deploy@10.0.0.5 --tools shell,fs --note "app server"
162
+ ramcp fleet test # reachability probe for all hosts
163
+ ```
164
+
165
+ Then from the chatbot: *"read the nginx log on web1"* → `journal(unit: "nginx.service", host: "web1")`.
166
+
167
+ Each host gets an explicit tool allowlist (`shell fs logs services packages`) — a host that was never granted a group refuses it with a clear error, before any SSH attempt. Remote file writes pipe content over stdin (base64), never temp files. SSH runs with `BatchMode` — no password prompts, ever.
168
+
169
+ ## Webhooks
170
+
171
+ Get notified when tools run (or fail) — incident bots, Slack relays, anything that accepts a POST:
172
+
173
+ ```bash
174
+ ramcp webhook add --url https://hooks.example.com/ramcp --events tool.error
175
+ ramcp webhook list
176
+ ```
177
+
178
+ Fire-and-forget: a dead endpoint never delays a tool call (5s cap, deduped within 10s).
179
+
180
+ ## Backup & restore
181
+
182
+ ```bash
183
+ ramcp config export --out backup.json # full snapshot, 0600 perms — contains live tokens!
184
+ ramcp config import backup.json # replace
185
+ ramcp config import backup.json --merge # union: keeps local identity, adds new tokens/hosts/hooks
186
+ ```
187
+
134
188
  ## Security model
135
189
 
136
190
  - **Loopback only.** The gateway listens on `127.0.0.1` — unreachable directly from the network.
@@ -164,7 +218,7 @@ cd remote-access-mcp
164
218
  npm ci && npm run build && npm test
165
219
  ```
166
220
 
167
- 42 tests: policy engine, auth matrix, tools-over-MCP integration, full v2 security matrix (scopes, read-only, SSRF, injection, audit chain), CLI lifecycle. CI runs on Node 18/20/22.
221
+ 122 tests: policy engine, auth matrix, transport compatibility (stateful, stateless, legacy SSE), cross-platform platform layer, tunnel wiring, CLI lifecycle, crash regressions. CI runs on Node 18/20/22.
168
222
 
169
223
  ## License
170
224
 
@@ -1 +1 @@
1
- {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":"AAgyBA,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAyBxD"}
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":"AAmgCA,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA4BxD"}
package/dist/cli/main.js CHANGED
@@ -7,7 +7,9 @@ import { fileURLToPath } from 'node:url';
7
7
  import { loadConfig, saveConfig, generateToken, newTokenRecord, configPath, configDir, primaryToken, } from '../core/config.js';
8
8
  import { resolveReal, TOOL_SCOPES } from '../core/policy.js';
9
9
  import { AuditLog } from '../core/audit.js';
10
- import { platform, isWindows, isMac, isLinux, hasSystemd, platformLabel, } from '../core/platform.js';
10
+ import { fleetProbe, isFleetCapability, FLEET_CAPABILITIES } from '../core/fleet.js';
11
+ import { webhookStats } from '../core/webhooks.js';
12
+ import { platform, isWindows, isMac, isLinux, hasSystemd, platformLabel, readRuntimeState, } from '../core/platform.js';
11
13
  import { resolveCloudflared } from '../core/tunnel.js';
12
14
  const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
13
15
  const PKG = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8'));
@@ -56,6 +58,18 @@ Other:
56
58
  schedule list [--json]
57
59
  scopes List available tool groups
58
60
 
61
+ Fleet (remote machines over SSH):
62
+ fleet add --name N --host user@h --tools shell,fs [--port P] [--note T]
63
+ fleet list | remove N | edit N [--tools …] | test | status
64
+ (tools accept host: run_command, read_file, write_file, … → run remotely)
65
+
66
+ Webhooks:
67
+ webhook add --url https://… [--events tool.error,tool.success]
68
+ webhook list | off URL | on URL | remove URL
69
+
70
+ Config:
71
+ config show | export [--out FILE] | import FILE [--merge]
72
+
59
73
  Options:
60
74
  -h, --help Show this help
61
75
  --json Machine-readable output where supported
@@ -66,7 +80,7 @@ function parseArgs(argv) {
66
80
  const flags = new Set();
67
81
  const values = new Map();
68
82
  // Flags that always take a value (so `--paths /a /b` still works positionally)
69
- const valueFlags = new Set(['name', 'paths', 'deny', 'scopes', 'rpm', 'expires', 'token', 'host', 'port', 'domain', 'tool', 'since', 'limit']);
83
+ const valueFlags = new Set(['name', 'paths', 'deny', 'scopes', 'rpm', 'expires', 'token', 'host', 'port', 'domain', 'tool', 'since', 'limit', 'tools', 'url', 'events', 'out', 'note']);
70
84
  for (let i = 0; i < rest.length; i++) {
71
85
  const a = rest[i];
72
86
  if (a.startsWith('--')) {
@@ -203,6 +217,15 @@ async function cmdTunnel(args) {
203
217
  function cmdUrl(args) {
204
218
  const cfg = loadConfig();
205
219
  const t = findToken(cfg, args.sub[0] || args.values.get('token'));
220
+ // A live tunnel (pid-checked) wins: its URL only exists while that
221
+ // gateway process runs, and it is the address chatbots must use right now.
222
+ const rt = readRuntimeState();
223
+ if (rt?.tunnel_url) {
224
+ const base = rt.tunnel_url.replace(/\/+$/, '');
225
+ console.log(`${base}/${t.token}${cfg.mcp_path}`);
226
+ console.log(`\n(live tunnel — pid ${rt.pid}, since ${rt.started})`);
227
+ return;
228
+ }
206
229
  const host = cfg.public_host || `${cfg.host}:${cfg.port}`;
207
230
  const scheme = cfg.public_host ? 'https' : 'http';
208
231
  console.log(`${scheme}://${host}/${t.token}${cfg.mcp_path}`);
@@ -477,6 +500,20 @@ async function cmdDoctor(args) {
477
500
  // tunnel readiness
478
501
  const cf = resolveCloudflared();
479
502
  cf ? ok('tunnel', `cloudflared ready (${cf})`) : warn('tunnel', 'cloudflared not installed yet — `ramcp tunnel` downloads it automatically');
503
+ // live tunnel state (a running `ramcp tunnel` in another terminal)
504
+ const rt = readRuntimeState();
505
+ if (rt?.tunnel_url) {
506
+ try {
507
+ const resp = await fetch(`${rt.tunnel_url}/health`, { signal: AbortSignal.timeout(8000) });
508
+ const body = (await resp.json());
509
+ body.status === 'ok'
510
+ ? ok('tunnel-live', `${rt.tunnel_url} (pid ${rt.pid})`)
511
+ : warn('tunnel-live', `edge answered ${resp.status}`);
512
+ }
513
+ catch (e) {
514
+ warn('tunnel-live', `runtime state says pid ${rt.pid}, but unreachable (${e.message})`);
515
+ }
516
+ }
480
517
  // public reachability
481
518
  if (cfg.public_host) {
482
519
  try {
@@ -788,11 +825,14 @@ function uninstallService() {
788
825
  // ---------------------------------------------------------------------------
789
826
  function cmdStatus() {
790
827
  const cfg = loadConfig();
828
+ const rt = readRuntimeState();
791
829
  console.log(`platform: ${platformLabel()}`);
792
830
  console.log(`config: ${configPath()}`);
793
831
  console.log(`${serviceLabel()}:${' '.repeat(Math.max(1, 19 - serviceLabel().length - 1))}${serviceIsInstalled() ? (serviceIsActive() ? 'installed, active' : 'installed, inactive') : 'not installed'}`);
794
832
  console.log(`listen: ${cfg.host}:${cfg.port}${cfg.mcp_path}`);
833
+ console.log(`gateway process: ${rt ? `running (pid ${rt.pid})` : 'not running'}`);
795
834
  console.log(`public_host: ${cfg.public_host || '(none — use `ramcp tunnel`)'}`);
835
+ console.log(`live tunnel: ${rt?.tunnel_url || '(none)'}`);
796
836
  console.log(`tokens: ${cfg.tokens.length}`);
797
837
  console.log(`allowed paths: ${cfg.tokens.reduce((n, t) => n + t.allowed_paths.length, 0)}`);
798
838
  console.log(`audit: ${cfg.audit.enabled ? 'on' : 'off'}`);
@@ -858,6 +898,237 @@ async function cmdUpgrade(args) {
858
898
  }
859
899
  }
860
900
  // ---------------------------------------------------------------------------
901
+ // fleet — SSH remote machines
902
+ // ---------------------------------------------------------------------------
903
+ function cmdFleet(args) {
904
+ const cfg = loadConfig();
905
+ const sub = args.sub[0] || 'list';
906
+ const hosts = cfg.fleet.hosts;
907
+ if (sub === 'add') {
908
+ const name = args.values.get('name') || args.sub[1];
909
+ const host = args.values.get('host');
910
+ if (!name || !host) {
911
+ console.error('Usage: ramcp fleet add --name NAME --host user@hostname [--port N] --tools shell,fs [--note TEXT]');
912
+ process.exit(1);
913
+ }
914
+ if (hosts.some(h => h.name === name)) {
915
+ console.error(`Host "${name}" already exists (remove it first: ramcp fleet remove ${name})`);
916
+ process.exit(1);
917
+ }
918
+ const tools = splitList(args.values.get('tools'));
919
+ const bad = tools.filter(t => !isFleetCapability(t));
920
+ if (bad.length) {
921
+ console.error(`Unknown tool group(s): ${bad.join(', ')}. Available: ${FLEET_CAPABILITIES.join(', ')}`);
922
+ process.exit(1);
923
+ }
924
+ if (!tools.length) {
925
+ console.error('Refusing to add a host with no tools — grant at least one of: ' + FLEET_CAPABILITIES.join(', '));
926
+ process.exit(1);
927
+ }
928
+ const rec = {
929
+ name, host,
930
+ port: args.values.has('port') ? parseInt(args.values.get('port'), 10) : undefined,
931
+ tools, note: args.values.get('note'),
932
+ added: new Date().toISOString(),
933
+ };
934
+ hosts.push(rec);
935
+ saveConfig(cfg);
936
+ console.log(`✔ fleet host "${name}" added (${host}) with tools: ${tools.join(', ')}`);
937
+ const probe = fleetProbe(rec).then(r => {
938
+ console.log(r.ok ? ` SSH check: reachable` : ` SSH check: FAILED — ${r.detail}`);
939
+ console.log(r.ok ? ` (grant access from a chatbot with the fleet scope or all-tools tokens)` : ` (check: ssh keys loaded? BatchMode disables password prompts)`);
940
+ });
941
+ return;
942
+ }
943
+ if (sub === 'remove') {
944
+ const name = args.sub[1];
945
+ if (!name) {
946
+ console.error('Usage: ramcp fleet remove NAME');
947
+ process.exit(1);
948
+ }
949
+ const i = hosts.findIndex(h => h.name === name);
950
+ if (i === -1) {
951
+ console.error(`No fleet host named "${name}".`);
952
+ process.exit(1);
953
+ }
954
+ hosts.splice(i, 1);
955
+ saveConfig(cfg);
956
+ console.log(`✔ removed ${name}`);
957
+ return;
958
+ }
959
+ if (sub === 'edit') {
960
+ const name = args.sub[1];
961
+ if (!name) {
962
+ console.error('Usage: ramcp fleet edit NAME [--tools shell,fs] [--host user@h] [--note T]');
963
+ process.exit(1);
964
+ }
965
+ const h = hosts.find(h => h.name === name);
966
+ if (!h) {
967
+ console.error(`No fleet host named "${name}".`);
968
+ process.exit(1);
969
+ }
970
+ if (args.values.has('tools')) {
971
+ const tools = splitList(args.values.get('tools'));
972
+ const bad = tools.filter(t => !isFleetCapability(t));
973
+ if (bad.length) {
974
+ console.error(`Unknown: ${bad.join(', ')}. Available: ${FLEET_CAPABILITIES.join(', ')}`);
975
+ process.exit(1);
976
+ }
977
+ h.tools = tools;
978
+ }
979
+ if (args.values.has('host'))
980
+ h.host = args.values.get('host');
981
+ if (args.values.has('port'))
982
+ h.port = parseInt(args.values.get('port'), 10);
983
+ if (args.values.has('note'))
984
+ h.note = args.values.get('note');
985
+ saveConfig(cfg);
986
+ console.log(`✔ ${name} updated. tools: ${h.tools.join(',') || '(none)'}`);
987
+ return;
988
+ }
989
+ if (sub === 'test') {
990
+ (async () => {
991
+ for (const h of hosts) {
992
+ const r = await fleetProbe(h);
993
+ console.log(`${r.ok ? '✔' : '✖'} ${h.name.padEnd(14)} ${h.host}${h.port ? ':' + h.port : ''} ${r.ok ? 'reachable' : 'unreachable — ' + r.detail}`);
994
+ }
995
+ if (!hosts.length)
996
+ console.log('(no fleet hosts)');
997
+ })();
998
+ return;
999
+ }
1000
+ if (sub === 'status') {
1001
+ const { runtime } = { runtime: null };
1002
+ for (const h of hosts) {
1003
+ console.log(`${h.name.padEnd(14)} ${h.host}${h.port ? ':' + h.port : ''} tools: ${h.tools.join(',')}${h.note ? ' — ' + h.note : ''}`);
1004
+ }
1005
+ if (!hosts.length)
1006
+ console.log('(no fleet hosts — add one with `ramcp fleet add`)');
1007
+ return;
1008
+ }
1009
+ // list / default
1010
+ for (const h of hosts) {
1011
+ console.log(`${h.name.padEnd(14)} ${h.host}${h.port ? ':' + h.port : ''} tools: ${h.tools.join(',')}${h.note ? ' — ' + h.note : ''}`);
1012
+ }
1013
+ if (!hosts.length)
1014
+ console.log('(no fleet hosts — add one with `ramcp fleet add --name NAME --host user@host --tools shell,fs`)');
1015
+ }
1016
+ // ---------------------------------------------------------------------------
1017
+ // webhook — outbound notifications
1018
+ // ---------------------------------------------------------------------------
1019
+ function cmdWebhook(args) {
1020
+ const cfg = loadConfig();
1021
+ const sub = args.sub[0] || 'list';
1022
+ if (sub === 'add') {
1023
+ const url = args.values.get('url');
1024
+ if (!url || !/^https?:\/\//.test(url)) {
1025
+ console.error('Usage: ramcp webhook add --url https://… [--events tool.error,tool.success]');
1026
+ process.exit(1);
1027
+ }
1028
+ const events = splitList(args.values.get('events') || 'tool.error');
1029
+ cfg.webhooks.push({ url, events, enabled: true });
1030
+ saveConfig(cfg);
1031
+ console.log(`✔ webhook added: ${url}`);
1032
+ console.log(` events: ${events.join(', ')}`);
1033
+ console.log(` (fires fire-and-forget after tool calls; 5s timeout, deduped within 10s)`);
1034
+ return;
1035
+ }
1036
+ if (sub === 'remove') {
1037
+ const i = cfg.webhooks.findIndex(w => w.url === args.sub[1]);
1038
+ if (i === -1) {
1039
+ console.error('No webhook with that URL.');
1040
+ process.exit(1);
1041
+ }
1042
+ cfg.webhooks.splice(i, 1);
1043
+ saveConfig(cfg);
1044
+ console.log('✔ removed');
1045
+ return;
1046
+ }
1047
+ if (sub === 'off' || sub === 'on') {
1048
+ const w = cfg.webhooks.find(w => w.url === args.sub[1]);
1049
+ if (!w) {
1050
+ console.error('No webhook with that URL.');
1051
+ process.exit(1);
1052
+ }
1053
+ w.enabled = sub === 'on';
1054
+ saveConfig(cfg);
1055
+ console.log(`✔ ${w.url} ${w.enabled ? 'enabled' : 'disabled'}`);
1056
+ return;
1057
+ }
1058
+ // list
1059
+ const stats = webhookStats();
1060
+ if (!cfg.webhooks.length) {
1061
+ console.log('(no webhooks — add with `ramcp webhook add --url …`)');
1062
+ return;
1063
+ }
1064
+ for (const w of cfg.webhooks) {
1065
+ console.log(`${w.enabled ? '✔' : '⏸'} ${w.url}`);
1066
+ console.log(` events: ${w.events.join(', ')}`);
1067
+ }
1068
+ console.log(`\nsession stats: sent=${stats.sent} failed=${stats.failed}`);
1069
+ }
1070
+ // ---------------------------------------------------------------------------
1071
+ // config — export / import / show
1072
+ // ---------------------------------------------------------------------------
1073
+ function cmdConfig(args) {
1074
+ const cfg = loadConfig();
1075
+ const sub = args.sub[0] || 'show';
1076
+ if (sub === 'export') {
1077
+ const out = args.values.get('out') || 'ramcp-export.json';
1078
+ const snapshot = JSON.parse(JSON.stringify(cfg));
1079
+ // strip nothing — export is a full backup by design; tokens are the
1080
+ // secrets that make the backup useful. Warn loudly instead.
1081
+ fs.writeFileSync(out, JSON.stringify(snapshot, null, 2), { mode: 0o600 });
1082
+ console.log(`✔ exported ${cfg.tokens.length} token(s), ${(cfg.fleet?.hosts || []).length} fleet host(s), ${(cfg.webhooks || []).length} webhook(s) → ${out}`);
1083
+ console.log('⚠ this file contains live tokens — store it encrypted and delete when done.');
1084
+ return;
1085
+ }
1086
+ if (sub === 'import') {
1087
+ const file = args.sub[1];
1088
+ if (!file) {
1089
+ console.error('Usage: ramcp config import <file> [--merge]');
1090
+ process.exit(1);
1091
+ }
1092
+ let incoming;
1093
+ try {
1094
+ incoming = JSON.parse(fs.readFileSync(file, 'utf8'));
1095
+ }
1096
+ catch (e) {
1097
+ console.error(`cannot read ${file}: ${e.message}`);
1098
+ process.exit(1);
1099
+ }
1100
+ if (!Array.isArray(incoming.tokens)) {
1101
+ console.error('not a ramcp export (no tokens array)');
1102
+ process.exit(1);
1103
+ }
1104
+ if (args.flags.has('merge')) {
1105
+ // merge: keep local identity (host/port/domain/paths), union tokens,
1106
+ // add fleet hosts and webhooks that are new.
1107
+ const existingUrls = new Set((cfg.webhooks || []).map(w => w.url));
1108
+ for (const w of incoming.webhooks || [])
1109
+ if (!existingUrls.has(w.url))
1110
+ cfg.webhooks.push(w);
1111
+ const existingHosts = new Set((cfg.fleet?.hosts || []).map(h => h.name));
1112
+ for (const h of incoming.fleet?.hosts || [])
1113
+ if (!existingHosts.has(h.name))
1114
+ cfg.fleet.hosts.push(h);
1115
+ for (const t of incoming.tokens)
1116
+ if (!cfg.tokens.some(x => x.token === t.token))
1117
+ cfg.tokens.push(t);
1118
+ saveConfig(cfg);
1119
+ console.log('✔ merged (local host/port/domain and existing entries preserved)');
1120
+ }
1121
+ else {
1122
+ saveConfig(incoming);
1123
+ console.log(`✔ imported ${incoming.tokens.length} token(s) — replaced existing config`);
1124
+ console.log('(restart the service or reconnect clients to apply)');
1125
+ }
1126
+ return;
1127
+ }
1128
+ // show
1129
+ console.log(JSON.stringify(cfg, null, 2));
1130
+ }
1131
+ // ---------------------------------------------------------------------------
861
1132
  // main
862
1133
  // ---------------------------------------------------------------------------
863
1134
  export async function main(argv) {
@@ -906,6 +1177,15 @@ export async function main(argv) {
906
1177
  case 'upgrade':
907
1178
  await cmdUpgrade(args);
908
1179
  break;
1180
+ case 'fleet':
1181
+ cmdFleet(args);
1182
+ break;
1183
+ case 'webhook':
1184
+ cmdWebhook(args);
1185
+ break;
1186
+ case 'config':
1187
+ cmdConfig(args);
1188
+ break;
909
1189
  case 'version':
910
1190
  console.log(PKG.version);
911
1191
  break;