twinclaw 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.
Files changed (132) hide show
  1. package/README.md +66 -0
  2. package/bin/npm-twinclaw.js +17 -0
  3. package/bin/run-twinbot-cli.js +36 -0
  4. package/bin/twinbot.js +4 -0
  5. package/bin/twinclaw.js +4 -0
  6. package/dist/api/handlers/browser.js +160 -0
  7. package/dist/api/handlers/callback.js +80 -0
  8. package/dist/api/handlers/config-validate.js +19 -0
  9. package/dist/api/handlers/health.js +117 -0
  10. package/dist/api/handlers/local-state-backup.js +118 -0
  11. package/dist/api/handlers/persona-state.js +59 -0
  12. package/dist/api/handlers/skill-packages.js +94 -0
  13. package/dist/api/router.js +278 -0
  14. package/dist/api/runtime-event-producer.js +99 -0
  15. package/dist/api/shared.js +82 -0
  16. package/dist/api/websocket-hub.js +305 -0
  17. package/dist/config/config-loader.js +2 -0
  18. package/dist/config/env-schema.js +202 -0
  19. package/dist/config/env-validator.js +223 -0
  20. package/dist/config/identity-bootstrap.js +115 -0
  21. package/dist/config/json-config.js +344 -0
  22. package/dist/config/workspace.js +186 -0
  23. package/dist/core/channels-cli.js +77 -0
  24. package/dist/core/cli.js +119 -0
  25. package/dist/core/context-assembly.js +33 -0
  26. package/dist/core/doctor.js +365 -0
  27. package/dist/core/gateway-cli.js +323 -0
  28. package/dist/core/gateway.js +416 -0
  29. package/dist/core/heartbeat.js +54 -0
  30. package/dist/core/install-cli.js +320 -0
  31. package/dist/core/lane-executor.js +134 -0
  32. package/dist/core/logs-cli.js +70 -0
  33. package/dist/core/onboarding.js +760 -0
  34. package/dist/core/pairing-cli.js +78 -0
  35. package/dist/core/secret-vault-cli.js +204 -0
  36. package/dist/core/types.js +1 -0
  37. package/dist/index.js +404 -0
  38. package/dist/interfaces/dispatcher.js +214 -0
  39. package/dist/interfaces/telegram_handler.js +82 -0
  40. package/dist/interfaces/tui-dashboard.js +53 -0
  41. package/dist/interfaces/whatsapp_handler.js +94 -0
  42. package/dist/release/cli.js +97 -0
  43. package/dist/release/mvp-gate-cli.js +118 -0
  44. package/dist/release/twinbot-config-schema.js +162 -0
  45. package/dist/release/twinclaw-config-schema.js +162 -0
  46. package/dist/services/block-chunker.js +174 -0
  47. package/dist/services/browser-service.js +334 -0
  48. package/dist/services/context-lifecycle.js +314 -0
  49. package/dist/services/db.js +1055 -0
  50. package/dist/services/delivery-tracker.js +110 -0
  51. package/dist/services/dm-pairing.js +245 -0
  52. package/dist/services/embedding-service.js +125 -0
  53. package/dist/services/file-watcher.js +125 -0
  54. package/dist/services/inbound-debounce.js +92 -0
  55. package/dist/services/incident-manager.js +516 -0
  56. package/dist/services/job-scheduler.js +176 -0
  57. package/dist/services/local-state-backup.js +682 -0
  58. package/dist/services/mcp-client-adapter.js +291 -0
  59. package/dist/services/mcp-server-manager.js +143 -0
  60. package/dist/services/model-router.js +927 -0
  61. package/dist/services/mvp-gate.js +845 -0
  62. package/dist/services/orchestration-service.js +422 -0
  63. package/dist/services/persona-state.js +256 -0
  64. package/dist/services/policy-engine.js +92 -0
  65. package/dist/services/proactive-notifier.js +94 -0
  66. package/dist/services/queue-service.js +146 -0
  67. package/dist/services/release-pipeline.js +652 -0
  68. package/dist/services/runtime-budget-governor.js +415 -0
  69. package/dist/services/secret-vault.js +704 -0
  70. package/dist/services/semantic-memory.js +249 -0
  71. package/dist/services/skill-package-manager.js +806 -0
  72. package/dist/services/skill-registry.js +122 -0
  73. package/dist/services/streaming-output.js +75 -0
  74. package/dist/services/stt-service.js +39 -0
  75. package/dist/services/tts-service.js +44 -0
  76. package/dist/skills/builtin.js +250 -0
  77. package/dist/skills/shell.js +87 -0
  78. package/dist/skills/types.js +1 -0
  79. package/dist/types/api.js +1 -0
  80. package/dist/types/context-budget.js +1 -0
  81. package/dist/types/doctor.js +1 -0
  82. package/dist/types/file-watcher.js +1 -0
  83. package/dist/types/incident.js +1 -0
  84. package/dist/types/local-state-backup.js +1 -0
  85. package/dist/types/mcp.js +1 -0
  86. package/dist/types/messaging.js +1 -0
  87. package/dist/types/model-routing.js +1 -0
  88. package/dist/types/mvp-gate.js +2 -0
  89. package/dist/types/orchestration.js +1 -0
  90. package/dist/types/persona-state.js +22 -0
  91. package/dist/types/policy.js +1 -0
  92. package/dist/types/reasoning-graph.js +1 -0
  93. package/dist/types/release.js +1 -0
  94. package/dist/types/reliability.js +1 -0
  95. package/dist/types/runtime-budget.js +1 -0
  96. package/dist/types/scheduler.js +1 -0
  97. package/dist/types/secret-vault.js +1 -0
  98. package/dist/types/skill-packages.js +1 -0
  99. package/dist/types/websocket.js +14 -0
  100. package/dist/utils/logger.js +57 -0
  101. package/dist/utils/retry.js +61 -0
  102. package/dist/utils/secret-scan.js +208 -0
  103. package/mcp-servers.json +179 -0
  104. package/package.json +81 -0
  105. package/skill-packages.json +92 -0
  106. package/skill-packages.lock.json +5 -0
  107. package/src/skills/builtin.ts +275 -0
  108. package/src/skills/shell.ts +118 -0
  109. package/src/skills/types.ts +30 -0
  110. package/src/types/api.ts +252 -0
  111. package/src/types/blessed-contrib.d.ts +4 -0
  112. package/src/types/context-budget.ts +76 -0
  113. package/src/types/doctor.ts +29 -0
  114. package/src/types/file-watcher.ts +26 -0
  115. package/src/types/incident.ts +57 -0
  116. package/src/types/local-state-backup.ts +121 -0
  117. package/src/types/mcp.ts +106 -0
  118. package/src/types/messaging.ts +35 -0
  119. package/src/types/model-routing.ts +61 -0
  120. package/src/types/mvp-gate.ts +99 -0
  121. package/src/types/orchestration.ts +65 -0
  122. package/src/types/persona-state.ts +61 -0
  123. package/src/types/policy.ts +27 -0
  124. package/src/types/reasoning-graph.ts +58 -0
  125. package/src/types/release.ts +115 -0
  126. package/src/types/reliability.ts +43 -0
  127. package/src/types/runtime-budget.ts +85 -0
  128. package/src/types/scheduler.ts +47 -0
  129. package/src/types/secret-vault.ts +62 -0
  130. package/src/types/skill-packages.ts +81 -0
  131. package/src/types/sqlite-vec.d.ts +5 -0
  132. package/src/types/websocket.ts +122 -0
@@ -0,0 +1,365 @@
1
+ import { execSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { getConfigPath } from '../config/json-config.js';
5
+ import { getConfigValue } from '../config/config-loader.js';
6
+ import { getSecretVaultService } from '../services/secret-vault.js';
7
+ import { getIdentityDir, getWorkspaceSubdir } from '../config/workspace.js';
8
+ import { IDENTITY_FILE_CHECKS } from '../config/identity-bootstrap.js';
9
+ // ── Built-in check definitions ───────────────────────────────────────────────
10
+ export const BINARY_CHECKS = [
11
+ {
12
+ kind: 'binary',
13
+ name: 'node',
14
+ description: 'Node.js runtime (v22+ required)',
15
+ severity: 'critical',
16
+ remediation: 'Install Node.js v22+ from https://nodejs.org',
17
+ },
18
+ {
19
+ kind: 'binary',
20
+ name: 'git',
21
+ description: 'Git version control',
22
+ severity: 'warning',
23
+ remediation: 'Install Git from https://git-scm.com',
24
+ },
25
+ ];
26
+ export const ENV_VAR_CHECKS = [
27
+ {
28
+ kind: 'env-var',
29
+ name: 'GROQ_API_KEY',
30
+ description: 'Groq API key for Speech-to-Text and TTS',
31
+ severity: 'critical',
32
+ remediation: 'Get a free API key at https://console.groq.com and set GROQ_API_KEY via `twinbot onboard`.',
33
+ },
34
+ {
35
+ kind: 'env-var',
36
+ name: 'TELEGRAM_BOT_TOKEN',
37
+ description: 'Telegram Bot token for messaging integration',
38
+ severity: 'warning',
39
+ remediation: 'Create a bot via @BotFather on Telegram and set TELEGRAM_BOT_TOKEN via `twinbot onboard`.',
40
+ },
41
+ {
42
+ kind: 'env-var',
43
+ name: 'TELEGRAM_USER_ID',
44
+ description: 'Optional Telegram user ID bootstrap allowlist seed',
45
+ severity: 'info',
46
+ remediation: 'Optional: set TELEGRAM_USER_ID via `twinbot onboard` to pre-authorize your own Telegram account.',
47
+ },
48
+ {
49
+ kind: 'env-var',
50
+ name: 'API_SECRET',
51
+ description: 'API secret for webhook callback authentication',
52
+ severity: 'critical',
53
+ remediation: 'Generate a strong secret and set API_SECRET via `twinbot onboard`. Example: openssl rand -hex 32',
54
+ },
55
+ {
56
+ kind: 'env-var',
57
+ name: 'OPENROUTER_API_KEY',
58
+ description: 'Primary AI model provider routing',
59
+ severity: 'warning',
60
+ remediation: 'Get an OpenRouter key at https://openrouter.ai/keys and set OPENROUTER_API_KEY via `twinbot onboard`.',
61
+ },
62
+ {
63
+ kind: 'env-var',
64
+ name: 'ELEVENLABS_API_KEY',
65
+ description: 'Text-to-Speech voice synthesis',
66
+ severity: 'warning',
67
+ remediation: 'Get an ElevenLabs key at https://elevenlabs.io and set ELEVENLABS_API_KEY via `twinbot onboard`.',
68
+ },
69
+ {
70
+ kind: 'env-var',
71
+ name: 'GEMINI_API_KEY',
72
+ description: 'Fallback AI model provider',
73
+ severity: 'info',
74
+ remediation: 'Get a Gemini API key at https://aistudio.google.com and set GEMINI_API_KEY via `twinbot onboard`.',
75
+ },
76
+ ];
77
+ export const FILESYSTEM_CHECKS = [
78
+ {
79
+ kind: 'filesystem',
80
+ name: 'memory-dir',
81
+ description: 'Memory directory for logs and state persistence',
82
+ severity: 'warning',
83
+ remediation: 'Create the ./memory directory: mkdir -p memory',
84
+ },
85
+ {
86
+ kind: 'filesystem',
87
+ name: 'env-file',
88
+ description: '.env configuration file',
89
+ severity: 'info',
90
+ remediation: 'Copy .env.example to .env and fill in required values: cp .env.example .env',
91
+ },
92
+ ...IDENTITY_FILE_CHECKS,
93
+ ];
94
+ export const CONFIG_CHECKS = [
95
+ {
96
+ kind: 'config-schema',
97
+ name: 'twinbot.json',
98
+ description: 'TwinBot local JSON configuration schema',
99
+ severity: 'critical',
100
+ remediation: 'Run `node src/index.ts onboard` to initialize or repair your configuration.',
101
+ }
102
+ ];
103
+ export const CHANNEL_CHECKS = [
104
+ {
105
+ kind: 'channel-auth',
106
+ name: 'whatsapp',
107
+ description: 'WhatsApp QR session linking state',
108
+ severity: 'warning',
109
+ remediation: 'Run `node src/index.ts channels login whatsapp` to link your device.',
110
+ }
111
+ ];
112
+ /** All built-in checks in default run order. */
113
+ export const DEFAULT_CHECKS = [
114
+ ...BINARY_CHECKS,
115
+ ...ENV_VAR_CHECKS,
116
+ ...FILESYSTEM_CHECKS,
117
+ ...CONFIG_CHECKS,
118
+ ...CHANNEL_CHECKS,
119
+ ];
120
+ // ── Individual check executors ───────────────────────────────────────────────
121
+ /** @internal exported for testing */
122
+ export function checkBinary(check) {
123
+ try {
124
+ const cmd = `where ${check.name}`;
125
+ execSync(cmd, { stdio: 'pipe', timeout: 5_000 });
126
+ if (check.name === 'node') {
127
+ const versionRaw = execSync('node --version', { stdio: 'pipe' })
128
+ .toString()
129
+ .trim();
130
+ const match = versionRaw.match(/^v(\d+)\./);
131
+ const major = match ? parseInt(match[1], 10) : 0;
132
+ if (major < 22) {
133
+ return {
134
+ check,
135
+ passed: false,
136
+ actual: versionRaw,
137
+ message: `Node.js ${versionRaw} found but v22+ is required.`,
138
+ };
139
+ }
140
+ return {
141
+ check,
142
+ passed: true,
143
+ actual: versionRaw,
144
+ message: `Node.js ${versionRaw} found.`,
145
+ };
146
+ }
147
+ return {
148
+ check,
149
+ passed: true,
150
+ message: `'${check.name}' binary found.`,
151
+ };
152
+ }
153
+ catch {
154
+ return {
155
+ check,
156
+ passed: false,
157
+ message: `'${check.name}' binary not found in PATH.`,
158
+ };
159
+ }
160
+ }
161
+ /** @internal exported for testing */
162
+ export function checkEnvVar(check) {
163
+ const secret = getSecretVaultService().readSecret(check.name);
164
+ const value = secret ?? getConfigValue(check.name);
165
+ if (!value || value.trim().length === 0) {
166
+ return {
167
+ check,
168
+ passed: false,
169
+ message: `Environment variable '${check.name}' is not set.`,
170
+ };
171
+ }
172
+ // Mask the value for safe display
173
+ const masked = value.length > 8
174
+ ? `${value.slice(0, 4)}${'*'.repeat(Math.min(8, value.length - 4))}`
175
+ : '****';
176
+ return {
177
+ check,
178
+ passed: true,
179
+ actual: masked,
180
+ message: `'${check.name}' is set (${masked}).`,
181
+ };
182
+ }
183
+ /** @internal exported for testing */
184
+ export function checkFilesystem(check) {
185
+ const targetMap = {
186
+ 'memory-dir': path.resolve('memory'),
187
+ 'env-file': path.resolve('.env'),
188
+ 'identity-soul': path.join(getIdentityDir(), 'soul.md'),
189
+ 'identity-identity': path.join(getIdentityDir(), 'identity.md'),
190
+ 'identity-memory': path.join(getWorkspaceSubdir('memory'), 'memory.md'),
191
+ };
192
+ const target = targetMap[check.name];
193
+ if (!target) {
194
+ return {
195
+ check,
196
+ passed: false,
197
+ message: `Unknown filesystem check target: '${check.name}'.`,
198
+ };
199
+ }
200
+ if (!fs.existsSync(target)) {
201
+ return {
202
+ check,
203
+ passed: false,
204
+ actual: target,
205
+ message: `'${target}' does not exist.`,
206
+ };
207
+ }
208
+ return {
209
+ check,
210
+ passed: true,
211
+ actual: target,
212
+ message: `'${target}' exists.`,
213
+ };
214
+ }
215
+ // ── Individual check executors ───────────────────────────────────────────────
216
+ /** @internal exported for testing */
217
+ export function checkConfigSchema(check) {
218
+ const configPath = getConfigPath();
219
+ if (!fs.existsSync(configPath)) {
220
+ return {
221
+ check,
222
+ passed: false,
223
+ message: `Config file not found at ${configPath}.`,
224
+ };
225
+ }
226
+ try {
227
+ const raw = fs.readFileSync(configPath, 'utf8');
228
+ JSON.parse(raw);
229
+ return {
230
+ check,
231
+ passed: true,
232
+ message: 'twinbot.json config is valid JSON and accessible.',
233
+ };
234
+ }
235
+ catch (error) {
236
+ const msg = error instanceof Error ? error.message : String(error);
237
+ return {
238
+ check,
239
+ passed: false,
240
+ message: `Config file is invalid JSON: ${msg}`,
241
+ };
242
+ }
243
+ }
244
+ /** @internal exported for testing */
245
+ export function checkChannelAuth(check) {
246
+ if (check.name === 'whatsapp') {
247
+ const authDir = path.resolve('memory', 'whatsapp_auth');
248
+ if (!fs.existsSync(authDir)) {
249
+ return {
250
+ check,
251
+ passed: false,
252
+ message: 'WhatsApp auth session directory does not exist.',
253
+ };
254
+ }
255
+ // simple heuristic: if it has files, it might be linked
256
+ return {
257
+ check,
258
+ passed: true,
259
+ message: 'WhatsApp auth session directory exists.',
260
+ };
261
+ }
262
+ return {
263
+ check,
264
+ passed: false,
265
+ message: `Unknown channel: ${check.name}`,
266
+ };
267
+ }
268
+ // ── Report utilities ─────────────────────────────────────────────────────────
269
+ function deriveStatus(results) {
270
+ const hasCriticalFailure = results.some((r) => !r.passed && r.check.severity === 'critical');
271
+ if (hasCriticalFailure)
272
+ return 'critical';
273
+ if (results.some((r) => !r.passed))
274
+ return 'degraded';
275
+ return 'ok';
276
+ }
277
+ // ── Public API ───────────────────────────────────────────────────────────────
278
+ /**
279
+ * Execute all doctor checks and return a structured report.
280
+ * Accepts an optional override list for targeted or test-only runs.
281
+ */
282
+ export function runDoctorChecks(checks) {
283
+ const allChecks = checks ?? DEFAULT_CHECKS;
284
+ const results = allChecks.map((check) => {
285
+ switch (check.kind) {
286
+ case 'binary':
287
+ return checkBinary(check);
288
+ case 'env-var':
289
+ return checkEnvVar(check);
290
+ case 'filesystem':
291
+ return checkFilesystem(check);
292
+ case 'config-schema':
293
+ return checkConfigSchema(check);
294
+ case 'channel-auth':
295
+ return checkChannelAuth(check);
296
+ case 'service-endpoint':
297
+ // Service endpoint checks are intentionally deferred for interactive local use
298
+ return {
299
+ check,
300
+ passed: true,
301
+ message: 'Service endpoint checks are skipped in local mode.',
302
+ };
303
+ default: {
304
+ const exhaustive = check.kind;
305
+ return {
306
+ check,
307
+ passed: false,
308
+ message: `Unknown check kind: '${exhaustive}'.`,
309
+ };
310
+ }
311
+ }
312
+ });
313
+ const status = deriveStatus(results);
314
+ const passed = results.filter((r) => r.passed).length;
315
+ return {
316
+ status,
317
+ results,
318
+ checkedAt: new Date().toISOString(),
319
+ passed,
320
+ failed: results.length - passed,
321
+ };
322
+ }
323
+ const SEVERITY_ICON = {
324
+ critical: '✗',
325
+ warning: '⚠',
326
+ info: 'ℹ',
327
+ };
328
+ const STATUS_LABEL = {
329
+ ok: '✓ OK',
330
+ degraded: '⚠ DEGRADED',
331
+ critical: '✗ CRITICAL',
332
+ };
333
+ /**
334
+ * Format a DoctorReport for display.
335
+ * Set `asJson=true` to emit machine-readable JSON.
336
+ */
337
+ export function formatDoctorReport(report, asJson = false) {
338
+ if (asJson) {
339
+ return JSON.stringify(report, null, 2);
340
+ }
341
+ const lines = [];
342
+ lines.push(`TwinBot Doctor — ${STATUS_LABEL[report.status]}`);
343
+ lines.push(`Checked at: ${report.checkedAt}`);
344
+ lines.push(`Passed: ${report.passed} Failed: ${report.failed}`);
345
+ lines.push('');
346
+ for (const result of report.results) {
347
+ const icon = result.passed ? '✓' : SEVERITY_ICON[result.check.severity];
348
+ const kindLabel = `[${result.check.kind}]`;
349
+ lines.push(` ${icon} ${kindLabel} ${result.check.name}: ${result.message}`);
350
+ if (!result.passed) {
351
+ lines.push(` → Remediation: ${result.check.remediation}`);
352
+ }
353
+ }
354
+ lines.push('');
355
+ if (report.status === 'ok') {
356
+ lines.push('All checks passed. TwinBot is ready to run.');
357
+ }
358
+ else if (report.status === 'degraded') {
359
+ lines.push('Some checks failed. TwinBot may run with limited functionality.');
360
+ }
361
+ else {
362
+ lines.push('Critical checks failed. Fix the issues above before running TwinBot.');
363
+ }
364
+ return lines.join('\n');
365
+ }
@@ -0,0 +1,323 @@
1
+ import { execSync } from 'node:child_process';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { getConfigPath, readConfig } from '../config/config-loader.js';
6
+ const GATEWAY_COMMANDS = new Set([
7
+ 'install',
8
+ 'start',
9
+ 'stop',
10
+ 'restart',
11
+ 'status',
12
+ 'uninstall',
13
+ 'tailscale',
14
+ ]);
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = path.dirname(__filename);
17
+ const PROJECT_ROOT = path.resolve(__dirname, '../../');
18
+ const ENTRY_SCRIPT = path.join(PROJECT_ROOT, 'dist', 'index.js');
19
+ const BASE_SERVICE_ID = 'ai.twinbot.gateway';
20
+ const BASE_SERVICE_LABEL = 'TwinBot Gateway';
21
+ const DEFAULT_API_PORT = 18789;
22
+ function ensureCommand(input) {
23
+ if (!input || !GATEWAY_COMMANDS.has(input)) {
24
+ throw new Error(`Unknown gateway command '${input ?? ''}'. Available commands: ${[...GATEWAY_COMMANDS].join(', ')}`);
25
+ }
26
+ return input;
27
+ }
28
+ function requireValue(argv, index, flag) {
29
+ const next = argv[index + 1];
30
+ if (!next || next.startsWith('--')) {
31
+ throw new Error(`Missing value for ${flag}.`);
32
+ }
33
+ return next;
34
+ }
35
+ function parsePort(raw) {
36
+ const parsed = Number(raw);
37
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
38
+ throw new Error(`Invalid port '${raw}'. Expected integer in range 1-65535.`);
39
+ }
40
+ return parsed;
41
+ }
42
+ export function sanitizeServiceToken(raw) {
43
+ const token = raw
44
+ .trim()
45
+ .toLowerCase()
46
+ .replace(/[^a-z0-9._-]+/g, '-')
47
+ .replace(/^-+|-+$/g, '');
48
+ if (!token) {
49
+ throw new Error(`Service token '${raw}' is invalid.`);
50
+ }
51
+ return token;
52
+ }
53
+ export function parseGatewayArgs(argv) {
54
+ if (argv[0] !== 'gateway') {
55
+ throw new Error('Gateway parser expects argv beginning with "gateway".');
56
+ }
57
+ const parsed = {
58
+ command: ensureCommand(argv[1]),
59
+ asJson: false,
60
+ deep: false,
61
+ };
62
+ for (let i = 2; i < argv.length; i += 1) {
63
+ const token = argv[i];
64
+ switch (token) {
65
+ case '--instance':
66
+ case '-i':
67
+ parsed.instance = sanitizeServiceToken(requireValue(argv, i, token));
68
+ i += 1;
69
+ break;
70
+ case '--name':
71
+ parsed.serviceName = sanitizeServiceToken(requireValue(argv, i, token));
72
+ i += 1;
73
+ break;
74
+ case '--config':
75
+ parsed.configPathOverride = requireValue(argv, i, token);
76
+ i += 1;
77
+ break;
78
+ case '--port':
79
+ parsed.apiPortOverride = parsePort(requireValue(argv, i, token));
80
+ i += 1;
81
+ break;
82
+ case '--json':
83
+ parsed.asJson = true;
84
+ break;
85
+ case '--deep':
86
+ parsed.deep = true;
87
+ break;
88
+ default:
89
+ throw new Error(`Unknown gateway option '${token}'.`);
90
+ }
91
+ }
92
+ if (parsed.instance && parsed.serviceName) {
93
+ throw new Error('Use either --instance or --name, not both.');
94
+ }
95
+ return parsed;
96
+ }
97
+ export function buildTailscaleInstructions(context) {
98
+ return `
99
+ TwinBot Remote Access Setup
100
+ ──────────────────────────────────────────────────
101
+ Gateway service : ${context.serviceId}
102
+ Config path : ${context.configPath}
103
+ API port : ${context.apiPort}
104
+ WebSocket path : ws://127.0.0.1:${context.apiPort}/ws
105
+
106
+ Recommended (Tailscale Funnel):
107
+ 1. twinbot gateway start${context.instance ? ` --instance ${context.instance}` : ''}
108
+ 2. tailscale funnel ${context.apiPort}
109
+ 3. Use the printed HTTPS URL and authenticate using your API_SECRET token.
110
+
111
+ Alternative (SSH tunnel):
112
+ ssh -N -L ${context.apiPort}:127.0.0.1:${context.apiPort} <user>@<host>
113
+ Then connect locally to ws://127.0.0.1:${context.apiPort}/ws
114
+
115
+ Never expose 0.0.0.0:${context.apiPort} directly to the public internet.
116
+ `;
117
+ }
118
+ function ensureWindowsPlatform(platform) {
119
+ if (platform !== 'win32') {
120
+ throw new Error(`TwinBot gateway commands support Windows only. Current platform '${platform}' is out of scope.`);
121
+ }
122
+ }
123
+ export async function resolveGatewayContext(parsed, platform = os.platform()) {
124
+ ensureWindowsPlatform(platform);
125
+ const configPath = getConfigPath(parsed.configPathOverride);
126
+ let configPort = DEFAULT_API_PORT;
127
+ try {
128
+ const config = await readConfig(parsed.configPathOverride);
129
+ if (Number.isInteger(config.runtime.apiPort)) {
130
+ configPort = config.runtime.apiPort;
131
+ }
132
+ }
133
+ catch {
134
+ // If config is malformed, continue with defaults and explicit overrides.
135
+ }
136
+ const suffix = parsed.serviceName ?? parsed.instance ?? '';
137
+ const serviceId = suffix ? `${BASE_SERVICE_ID}.${sanitizeServiceToken(suffix)}` : BASE_SERVICE_ID;
138
+ const serviceLabel = suffix
139
+ ? `${BASE_SERVICE_LABEL} (${sanitizeServiceToken(suffix)})`
140
+ : BASE_SERVICE_LABEL;
141
+ const apiPort = parsed.apiPortOverride ?? configPort;
142
+ return {
143
+ ...parsed,
144
+ platform,
145
+ projectRoot: PROJECT_ROOT,
146
+ nodePath: process.execPath,
147
+ entryScript: ENTRY_SCRIPT,
148
+ serviceId,
149
+ serviceLabel,
150
+ configPath,
151
+ apiPort,
152
+ };
153
+ }
154
+ function runCommand(command, ignoreFailure = false) {
155
+ try {
156
+ return execSync(command, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
157
+ }
158
+ catch (error) {
159
+ if (ignoreFailure) {
160
+ const message = error instanceof Error ? error.message : String(error);
161
+ return message.trim();
162
+ }
163
+ throw error;
164
+ }
165
+ }
166
+ async function installWindowsService(context) {
167
+ const nodeWindows = await import('node-windows');
168
+ const svc = new nodeWindows.Service({
169
+ name: context.serviceLabel,
170
+ description: 'TwinBot background gateway service',
171
+ script: context.entryScript,
172
+ env: [
173
+ { name: 'NODE_ENV', value: 'production' },
174
+ { name: 'API_PORT', value: String(context.apiPort) },
175
+ { name: 'TWINBOT_CONFIG_PATH', value: context.configPath },
176
+ ],
177
+ });
178
+ await new Promise((resolve, reject) => {
179
+ svc.on('install', () => resolve());
180
+ svc.on('alreadyinstalled', () => resolve());
181
+ svc.on('error', (error) => reject(error));
182
+ svc.install();
183
+ });
184
+ }
185
+ async function installService(context) {
186
+ await installWindowsService(context);
187
+ }
188
+ async function startService(context) {
189
+ const nodeWindows = await import('node-windows');
190
+ const svc = new nodeWindows.Service({
191
+ name: context.serviceLabel,
192
+ description: 'TwinBot background gateway service',
193
+ script: context.entryScript,
194
+ });
195
+ await new Promise((resolve, reject) => {
196
+ svc.on('start', () => resolve());
197
+ svc.on('error', (error) => reject(error));
198
+ svc.start();
199
+ });
200
+ }
201
+ async function stopService(context) {
202
+ const nodeWindows = await import('node-windows');
203
+ const svc = new nodeWindows.Service({
204
+ name: context.serviceLabel,
205
+ description: 'TwinBot background gateway service',
206
+ script: context.entryScript,
207
+ });
208
+ await new Promise((resolve, reject) => {
209
+ svc.on('stop', () => resolve());
210
+ svc.on('error', (error) => reject(error));
211
+ svc.stop();
212
+ });
213
+ }
214
+ async function restartService(context) {
215
+ await stopService(context);
216
+ await startService(context);
217
+ }
218
+ async function uninstallService(context) {
219
+ const nodeWindows = await import('node-windows');
220
+ const svc = new nodeWindows.Service({
221
+ name: context.serviceLabel,
222
+ description: 'TwinBot background gateway service',
223
+ script: context.entryScript,
224
+ });
225
+ await new Promise((resolve, reject) => {
226
+ svc.on('uninstall', () => resolve());
227
+ svc.on('alreadyuninstalled', () => resolve());
228
+ svc.on('error', (error) => reject(error));
229
+ svc.uninstall();
230
+ });
231
+ }
232
+ function queryStatus(context) {
233
+ const command = `sc query "${context.serviceLabel}"`;
234
+ const details = runCommand(command, true);
235
+ return { running: /\bRUNNING\b/.test(details), details, command };
236
+ }
237
+ function printStatus(context, status) {
238
+ const payload = {
239
+ service: context.serviceId,
240
+ label: context.serviceLabel,
241
+ running: status.running,
242
+ platform: context.platform,
243
+ apiPort: context.apiPort,
244
+ configPath: context.configPath,
245
+ command: status.command,
246
+ details: status.details,
247
+ };
248
+ if (context.asJson) {
249
+ console.log(JSON.stringify(payload, null, 2));
250
+ return;
251
+ }
252
+ console.log(`[TwinBot] Gateway '${context.serviceId}' is ${status.running ? 'ACTIVE' : 'INACTIVE'} on ${context.platform}.`);
253
+ if (context.deep) {
254
+ console.log(`Config Path : ${context.configPath}`);
255
+ console.log(`API Port : ${context.apiPort}`);
256
+ console.log('Mode : windows service');
257
+ if (status.details.length > 0) {
258
+ console.log('\nStatus details:');
259
+ console.log(status.details);
260
+ }
261
+ }
262
+ }
263
+ function printGatewayUsage() {
264
+ console.log(`Gateway command usage:
265
+ gateway install [--instance <id>] [--name <suffix>] [--config <path>] [--port <num>]
266
+ gateway start [--instance <id>] [--name <suffix>]
267
+ gateway stop [--instance <id>] [--name <suffix>]
268
+ gateway restart [--instance <id>] [--name <suffix>]
269
+ gateway status [--instance <id>] [--name <suffix>] [--json | --deep]
270
+ gateway uninstall [--instance <id>] [--name <suffix>]
271
+ gateway tailscale [--instance <id>] [--name <suffix>] [--port <num>] [--config <path>]
272
+ `);
273
+ }
274
+ /**
275
+ * Handle `gateway` lifecycle commands.
276
+ */
277
+ export async function handleGatewayCli(argv) {
278
+ if (argv[0] !== 'gateway') {
279
+ return false;
280
+ }
281
+ try {
282
+ const parsed = parseGatewayArgs(argv);
283
+ const context = await resolveGatewayContext(parsed);
284
+ switch (context.command) {
285
+ case 'install':
286
+ await installService(context);
287
+ console.log(`[TwinBot] Installed gateway service '${context.serviceId}'.`);
288
+ break;
289
+ case 'start':
290
+ await startService(context);
291
+ console.log(`[TwinBot] Started gateway service '${context.serviceId}'.`);
292
+ break;
293
+ case 'stop':
294
+ await stopService(context);
295
+ console.log(`[TwinBot] Stopped gateway service '${context.serviceId}'.`);
296
+ break;
297
+ case 'restart':
298
+ await restartService(context);
299
+ console.log(`[TwinBot] Restarted gateway service '${context.serviceId}'.`);
300
+ break;
301
+ case 'status': {
302
+ const status = queryStatus(context);
303
+ printStatus(context, status);
304
+ process.exitCode = status.running ? 0 : 1;
305
+ break;
306
+ }
307
+ case 'uninstall':
308
+ await uninstallService(context);
309
+ console.log(`[TwinBot] Uninstalled gateway service '${context.serviceId}'.`);
310
+ break;
311
+ case 'tailscale':
312
+ console.log(buildTailscaleInstructions(context).trimStart());
313
+ break;
314
+ }
315
+ }
316
+ catch (error) {
317
+ const message = error instanceof Error ? error.message : String(error);
318
+ console.error(`[TwinBot Gateway] ${message}`);
319
+ printGatewayUsage();
320
+ process.exitCode = 1;
321
+ }
322
+ return true;
323
+ }