infinicode 2.7.0 → 2.8.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.
@@ -63,28 +63,19 @@ export interface FederationOptions {
63
63
  /** Coder profiles + rotation config (from node config `coder`). Merged over
64
64
  * built-in defaults, so the mesh rotates across backends out of the box. */
65
65
  coder?: Parameters<typeof loadCoderConfig>[0];
66
- /** Serve the web control dashboard at GET / and /ui (off unless enabled). */
67
- dashboard?: boolean;
68
- /** Publish this node's shared config to the fleet (backs POST /fed/apply +
69
- * the dashboard's "Apply config to fleet" button). */
66
+ /** Publish this node's shared config to the fleet (backs POST /fed/apply). */
70
67
  onApply?: () => Promise<unknown> | unknown;
71
- /** Gate remote/dashboard slash-commands. Returns {ok:false,reason} to reject —
72
- * used to keep an unauthenticated, network-exposed node read-only. */
68
+ /** Gate remote slash-commands. Returns {ok:false,reason} to reject — used to
69
+ * keep an unauthenticated, network-exposed node read-only. */
73
70
  commandGate?: (input: string) => {
74
71
  ok: boolean;
75
72
  reason?: string;
76
73
  };
77
- /** TLS material — when set, the node + dashboard are served over HTTPS. */
74
+ /** TLS material — when set, the mesh is served over HTTPS. */
78
75
  tls?: {
79
76
  cert: Buffer;
80
77
  key: Buffer;
81
78
  };
82
- /** RoboPark scheduler base URL — proxied under /robopark/* for the dashboard. */
83
- schedulerUrl?: string;
84
- /** Bearer token presented to the scheduler on proxied requests. */
85
- schedulerToken?: string;
86
- /** Refuse write actions through the scheduler proxy (read-only console). */
87
- schedulerReadOnly?: boolean;
88
79
  }
89
80
  /** Consolidated fleet view served at GET /fed/status. */
90
81
  export interface FleetStatus {
@@ -98,11 +89,9 @@ export interface FleetStatus {
98
89
  peerId: string;
99
90
  f: StreamFrame;
100
91
  }>;
101
- /** Console capabilities for the dashboard: whether control writes are allowed
102
- * on this node and whether a RoboPark scheduler is linked. */
92
+ /** Console capabilities: whether control writes are allowed on this node. */
103
93
  control: {
104
94
  readonly: boolean;
105
- schedulerLinked: boolean;
106
95
  };
107
96
  ts: number;
108
97
  }
@@ -1,5 +1,4 @@
1
1
  import { MeshServer, MeshClient } from './transport-http.js';
2
- import { DASHBOARD_HTML } from './dashboard-html.js';
3
2
  import { PeerMesh } from './peer-mesh.js';
4
3
  import { EventBridge } from './event-bridge.js';
5
4
  import { ConfigSync } from './config-sync.js';
@@ -89,11 +88,7 @@ export class Federation {
89
88
  runCommand: (input) => this.deps.kernel.command(input),
90
89
  commandGate: options.commandGate,
91
90
  onApply: options.onApply,
92
- dashboardHtml: options.dashboard ? DASHBOARD_HTML : undefined,
93
91
  tls: options.tls,
94
- schedulerUrl: options.schedulerUrl,
95
- schedulerToken: options.schedulerToken,
96
- schedulerReadOnly: options.schedulerReadOnly,
97
92
  onRpc: (env, remote) => this.onRpc(env, remote),
98
93
  });
99
94
  await this.server.start();
@@ -694,8 +689,7 @@ export class Federation {
694
689
  agents: this.localAgents(),
695
690
  activity: this.recentFrames(60),
696
691
  control: {
697
- readonly: Boolean(this.deps.options.schedulerReadOnly),
698
- schedulerLinked: Boolean(this.deps.options.schedulerUrl),
692
+ readonly: Boolean(this.deps.options.commandGate && !this.deps.options.commandGate('/status').ok),
699
693
  },
700
694
  ts: Date.now(),
701
695
  };
@@ -23,23 +23,11 @@ export interface MeshServerOptions {
23
23
  };
24
24
  /** Optional: publish this node's shared config to the fleet (POST /fed/apply). */
25
25
  onApply?: () => Promise<unknown> | unknown;
26
- /** Optional: static HTML for the control dashboard, served at GET / and /ui. */
27
- dashboardHtml?: string;
28
26
  /** Optional TLS — when set, the node serves HTTPS/WSS instead of plain HTTP. */
29
27
  tls?: {
30
28
  cert: Buffer;
31
29
  key: Buffer;
32
30
  };
33
- /** Optional: base URL of the RoboPark scheduler. When set, the node proxies
34
- * GET/POST /robopark/* to it so the dashboard can show sessions + telemetry
35
- * (product data) alongside the mesh. */
36
- schedulerUrl?: string;
37
- /** Optional bearer token to present to the scheduler on proxied requests. */
38
- schedulerToken?: string;
39
- /** When true, only GET/HEAD are proxied to the scheduler — write actions
40
- * (end-session, production_mode toggle, trigger) are refused. Set for a
41
- * network-exposed, tokenless node so the operator console stays read-only. */
42
- schedulerReadOnly?: boolean;
43
31
  onRpc: RpcHandler;
44
32
  logger: Logger;
45
33
  }
@@ -51,8 +39,6 @@ export declare class MeshServer {
51
39
  constructor(opts: MeshServerOptions);
52
40
  get streamCount(): number;
53
41
  start(): Promise<void>;
54
- /** Proxy a dashboard request to the RoboPark scheduler (product data). */
55
- private proxyToScheduler;
56
42
  /** Push a frame to every connected stream subscriber. */
57
43
  broadcast(frame: StreamFrame): void;
58
44
  /** Token presented on a request — Bearer header (peers/CLI) OR ?token= query
@@ -16,28 +16,7 @@
16
16
  import { createServer } from 'node:http';
17
17
  import { createServer as createHttpsServer } from 'node:https';
18
18
  import { timingSafeEqual } from 'node:crypto';
19
- import { readFileSync } from 'node:fs';
20
- import { createRequire } from 'node:module';
21
19
  import { frameToSSE, parseSSE } from './protocol.js';
22
- /** Read the livekit-client UMD bundle from the installed dependency, once.
23
- * Returns null (cached) if the package isn't present — the dashboard then
24
- * falls back to an idle camera panel instead of erroring. */
25
- let _lkUmd;
26
- function loadLiveKitUmd() {
27
- if (_lkUmd !== undefined)
28
- return _lkUmd;
29
- try {
30
- const require = createRequire(import.meta.url);
31
- // The package's exports map blocks deep subpaths, but its main/require entry
32
- // IS the UMD bundle — so a bare resolve returns dist/livekit-client.umd.js.
33
- const file = require.resolve('livekit-client');
34
- _lkUmd = readFileSync(file, 'utf8');
35
- }
36
- catch {
37
- _lkUmd = null;
38
- }
39
- return _lkUmd;
40
- }
41
20
  /** Constant-time string compare — avoids leaking the token via response timing. */
42
21
  function safeEqual(a, b) {
43
22
  const ab = Buffer.from(a);
@@ -72,32 +51,6 @@ export class MeshServer {
72
51
  const scheme = this.opts.tls ? 'https' : 'http';
73
52
  this.opts.logger.info(`[federation] mesh listening on ${scheme}://${this.opts.host ?? '0.0.0.0'}:${this.opts.port}`);
74
53
  }
75
- /** Proxy a dashboard request to the RoboPark scheduler (product data). */
76
- proxyToScheduler(req, res, path) {
77
- const base = this.opts.schedulerUrl.replace(/\/+$/, '');
78
- // strip the /robopark prefix; forward the rest (incl. query) to the scheduler
79
- const rest = path.replace(/^\/robopark/, '') || '/';
80
- const target = `${base}${rest}`;
81
- this.readBody(req)
82
- .then(async (body) => {
83
- const headers = { 'content-type': req.headers['content-type'] ?? 'application/json' };
84
- if (this.opts.schedulerToken)
85
- headers['authorization'] = `Bearer ${this.opts.schedulerToken}`;
86
- const upstream = await fetch(target, {
87
- method: req.method,
88
- headers,
89
- body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body,
90
- signal: AbortSignal.timeout(10_000),
91
- });
92
- const text = await upstream.text();
93
- res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') ?? 'application/json' });
94
- res.end(text);
95
- })
96
- .catch(err => {
97
- res.writeHead(502, { 'content-type': 'application/json' });
98
- res.end(JSON.stringify({ ok: false, error: `scheduler unreachable: ${err instanceof Error ? err.message : String(err)}` }));
99
- });
100
- }
101
54
  /** Push a frame to every connected stream subscriber. */
102
55
  broadcast(frame) {
103
56
  if (this.sse.size === 0)
@@ -141,29 +94,10 @@ export class MeshServer {
141
94
  return;
142
95
  }
143
96
  const remote = req.socket.remoteAddress ?? 'unknown';
144
- // Control dashboard (single self-contained page). Same-origin fetches carry
145
- // the ?token= through, so no CORS is opened.
97
+ // Mesh root: redirect to /fed/status so browsers and probes get useful data.
146
98
  if (req.method === 'GET' && (path === '/' || path === '/ui' || path === '/dashboard')) {
147
- if (!this.opts.dashboardHtml) {
148
- res.writeHead(404).end('dashboard not enabled (start with --dashboard)');
149
- return;
150
- }
151
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
152
- res.end(this.opts.dashboardHtml);
153
- return;
154
- }
155
- // LiveKit browser SDK, served from the installed dependency so the Park
156
- // tab can subscribe to a robot's live camera track. Loaded by the dashboard
157
- // with the ?token= appended (so it passes the auth gate above). Optional:
158
- // if livekit-client isn't installed, the dashboard just shows an idle cam.
159
- if (req.method === 'GET' && path === '/vendor/livekit-client.umd.js') {
160
- const js = loadLiveKitUmd();
161
- if (!js) {
162
- res.writeHead(404).end('livekit-client not installed');
163
- return;
164
- }
165
- res.writeHead(200, { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'public, max-age=86400' });
166
- res.end(js);
99
+ res.writeHead(302, { location: '/fed/status' });
100
+ res.end();
167
101
  return;
168
102
  }
169
103
  if (req.method === 'GET' && path === '/fed/status') {
@@ -171,25 +105,6 @@ export class MeshServer {
171
105
  res.end(JSON.stringify(this.opts.getStatus?.() ?? {}));
172
106
  return;
173
107
  }
174
- // RoboPark product data (sessions, telemetry, robots) — proxied to the
175
- // scheduler so the dashboard is one pane of glass. Already token-gated above.
176
- if (path.startsWith('/robopark/')) {
177
- if (!this.opts.schedulerUrl) {
178
- res.writeHead(200, { 'content-type': 'application/json' });
179
- res.end(JSON.stringify({ ok: false, disabled: true, error: 'no scheduler linked — start with --scheduler-url' }));
180
- return;
181
- }
182
- // Keep a network-exposed, tokenless node read-only: mesh writes are gated
183
- // by commandGate; the scheduler proxy is the product-side equivalent.
184
- const method = (req.method ?? 'GET').toUpperCase();
185
- if (this.opts.schedulerReadOnly && method !== 'GET' && method !== 'HEAD') {
186
- res.writeHead(403, { 'content-type': 'application/json' });
187
- res.end(JSON.stringify({ ok: false, readonly: true, error: 'this node is network-exposed without a token — control actions are disabled. Restart with --token <secret> to enable them.' }));
188
- return;
189
- }
190
- this.proxyToScheduler(req, res, path);
191
- return;
192
- }
193
108
  if (req.method === 'POST' && path.startsWith('/fed/apply')) {
194
109
  if (!this.opts.onApply) {
195
110
  res.writeHead(404).end('no apply handler');
@@ -0,0 +1,16 @@
1
+ export interface ServiceArgs {
2
+ role: 'hub' | 'robot' | 'control' | 'hub-scheduler';
3
+ name: string;
4
+ command: string;
5
+ args: string[];
6
+ workingDir?: string;
7
+ env?: Record<string, string>;
8
+ }
9
+ export declare function registerAutoStart(service: ServiceArgs): Promise<{
10
+ ok: boolean;
11
+ message: string;
12
+ }>;
13
+ export declare function unregisterAutoStart(role: string, name: string): {
14
+ ok: boolean;
15
+ message: string;
16
+ };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * RoboPark — auto-start registration for production machines.
3
+ *
4
+ * Registers the RoboPark service to start on boot:
5
+ * - Windows → Task Scheduler
6
+ * - Linux → systemd
7
+ * - macOS → launchd
8
+ *
9
+ * This is used by `robopark setup --auto-start`.
10
+ */
11
+ import { writeFileSync, mkdirSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { dirname, join } from 'node:path';
14
+ import { spawn } from 'node:child_process';
15
+ function shellQuote(s) {
16
+ if (!/[^a-zA-Z0-9_./:=,-]/.test(s))
17
+ return s;
18
+ return "'" + s.replace(/'/g, "'\\''") + "'";
19
+ }
20
+ export async function registerAutoStart(service) {
21
+ const platform = process.platform;
22
+ if (platform === 'win32')
23
+ return registerWindows(service);
24
+ if (platform === 'linux')
25
+ return registerSystemd(service);
26
+ if (platform === 'darwin')
27
+ return registerLaunchd(service);
28
+ return { ok: false, message: `auto-start not supported on ${platform}` };
29
+ }
30
+ async function registerWindows(service) {
31
+ const taskName = `RoboPark-${service.role}-${service.name}`;
32
+ const logDir = join(homedir(), 'AppData', 'Roaming', 'robopark', 'logs');
33
+ mkdirSync(logDir, { recursive: true });
34
+ const outLog = join(logDir, `${taskName}.log`);
35
+ const errLog = join(logDir, `${taskName}.err.log`);
36
+ const script = [service.command, ...service.args].map(shellQuote).join(' ');
37
+ const cmd = ['powershell.exe', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-Command', script];
38
+ const xmlPath = join(homedir(), 'AppData', 'Roaming', 'robopark', `${taskName}.xml`);
39
+ mkdirSync(dirname(xmlPath), { recursive: true });
40
+ const xml = `<?xml version="1.0" encoding="UTF-16"?>\n<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">\n <RegistrationInfo>\n <Description>RoboPark ${service.role} for ${service.name}</Description>\n </RegistrationInfo>\n <Triggers>\n <BootTrigger>\n <Enabled>true</Enabled>\n </BootTrigger>\n <LogonTrigger>\n <Enabled>true</Enabled>\n </LogonTrigger>\n </Triggers>\n <Principals>\n <Principal id="Author">\n <LogonType>S4U</LogonType>\n <RunLevel>HighestAvailable</RunLevel>\n </Principal>\n </Principals>\n <Settings>\n <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n <StartWhenAvailable>true</StartWhenAvailable>\n <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n <IdleSettings>\n <StopOnIdleEnd>false</StopOnIdleEnd>\n <RestartOnIdle>false</RestartOnIdle>\n </IdleSettings>\n <AllowStartOnDemand>true</AllowStartOnDemand>\n <Enabled>true</Enabled>\n <Hidden>false</Hidden>\n <RunOnlyIfIdle>false</RunOnlyIfIdle>\n <DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>\n <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>\n <WakeToRun>false</WakeToRun>\n <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n </Settings>\n <Actions Context="Author">\n <Exec>\n <Command>${cmd[0]}</Command>\n <Arguments>${cmd.slice(1).map(a => a.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')).join(' ')}</Arguments>\n <WorkingDirectory>${service.workingDir ?? homedir()}</WorkingDirectory>\n </Exec>\n </Actions>\n</Task>`;
41
+ // schtasks /XML requires UTF-16 LE *with* BOM when the XML declares UTF-16.
42
+ const bom = Buffer.from([0xff, 0xfe]);
43
+ const body = Buffer.from(xml, 'utf16le');
44
+ writeFileSync(xmlPath, Buffer.concat([bom, body]));
45
+ return new Promise((resolve) => {
46
+ const schtasks = spawn('schtasks', ['/Create', '/TN', taskName, '/XML', xmlPath, '/F'], { stdio: 'pipe' });
47
+ let out = '';
48
+ let err = '';
49
+ schtasks.stdout.on('data', d => (out += d.toString()));
50
+ schtasks.stderr.on('data', d => (err += d.toString()));
51
+ schtasks.on('close', code => {
52
+ if (code === 0) {
53
+ resolve({ ok: true, message: `registered Windows Task Scheduler task "${taskName}"` });
54
+ }
55
+ else {
56
+ resolve({ ok: false, message: `schtasks failed (${code}): ${err || out}` });
57
+ }
58
+ });
59
+ schtasks.on('error', e => resolve({ ok: false, message: `could not run schtasks: ${e.message}` }));
60
+ });
61
+ }
62
+ async function registerSystemd(service) {
63
+ const unitName = `robopark-${service.role}-${service.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.service`;
64
+ const unitPath = `/etc/systemd/system/${unitName}`;
65
+ const envLines = Object.entries(service.env ?? {})
66
+ .map(([k, v]) => `Environment="${k}=${v.replace(/"/g, '\\"')}"`)
67
+ .join('\n');
68
+ const unit = `[Unit]
69
+ Description=RoboPark ${service.role} for ${service.name}
70
+ After=network-online.target
71
+ Wants=network-online.target
72
+
73
+ [Service]
74
+ Type=simple
75
+ ExecStart=${service.command} ${service.args.map(shellQuote).join(' ')}
76
+ WorkingDirectory=${service.workingDir ?? '/opt/robopark'}
77
+ Restart=always
78
+ RestartSec=5
79
+ ${envLines}
80
+
81
+ [Install]
82
+ WantedBy=multi-user.target
83
+ `;
84
+ try {
85
+ writeFileSync(unitPath, unit, 'utf8');
86
+ return { ok: true, message: `wrote systemd unit ${unitPath}. Run: systemctl enable --now ${unitName}` };
87
+ }
88
+ catch (e) {
89
+ return { ok: false, message: `could not write ${unitPath}: ${e instanceof Error ? e.message : String(e)}` };
90
+ }
91
+ }
92
+ async function registerLaunchd(service) {
93
+ const label = `ai.robopark.${service.role}.${service.name}`;
94
+ const plistPath = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
95
+ mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true });
96
+ const env = service.env ?? {};
97
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
98
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
99
+ <plist version="1.0">
100
+ <dict>
101
+ <key>Label</key>
102
+ <string>${label}</string>
103
+ <key>ProgramArguments</key>
104
+ <array>
105
+ <string>${service.command}</string>
106
+ ${service.args.map(a => `<string>${a.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')}</string>`).join('\n ')}
107
+ </array>
108
+ <key>WorkingDirectory</key>
109
+ <string>${service.workingDir ?? homedir()}</string>
110
+ <key>RunAtLoad</key>
111
+ <true/>
112
+ <key>KeepAlive</key>
113
+ <true/>
114
+ <key>StandardOutPath</key>
115
+ <string>${join(homedir(), 'Library', 'Logs', `${label}.log`)}</string>
116
+ <key>StandardErrorPath</key>
117
+ <string>${join(homedir(), 'Library', 'Logs', `${label}.err.log`)}</string>
118
+ ${Object.entries(env).map(([k, v]) => `<key>EnvironmentVariables</key>\n <dict>\n <key>${k}</key>\n <string>${v}</string>\n </dict>`).join('\n ')}
119
+ </dict>
120
+ </plist>`;
121
+ try {
122
+ writeFileSync(plistPath, plist, 'utf8');
123
+ return { ok: true, message: `wrote launchd plist ${plistPath}. Run: launchctl load ${plistPath}` };
124
+ }
125
+ catch (e) {
126
+ return { ok: false, message: `could not write ${plistPath}: ${e instanceof Error ? e.message : String(e)}` };
127
+ }
128
+ }
129
+ export function unregisterAutoStart(role, name) {
130
+ const platform = process.platform;
131
+ const taskName = `RoboPark-${role}-${name}`;
132
+ if (platform === 'win32') {
133
+ try {
134
+ spawn('schtasks', ['/Delete', '/TN', taskName, '/F'], { stdio: 'ignore' }).unref();
135
+ return { ok: true, message: `deleted Windows task "${taskName}"` };
136
+ }
137
+ catch (e) {
138
+ return { ok: false, message: `failed: ${e instanceof Error ? e.message : String(e)}` };
139
+ }
140
+ }
141
+ return { ok: false, message: `unregister not implemented for ${platform}` };
142
+ }
@@ -0,0 +1,62 @@
1
+ export interface DiscoveredPeer {
2
+ name: string;
3
+ ip: string;
4
+ tags?: string[];
5
+ online: boolean;
6
+ source: 'tailscale' | 'lan';
7
+ }
8
+ export interface DiscoveredContext {
9
+ /** Best hub candidate (the on-site box). */
10
+ hub?: DiscoveredPeer;
11
+ /** InfiniBot gateway if found on the tailnet. */
12
+ gateway?: {
13
+ host: string;
14
+ port: number;
15
+ token?: string;
16
+ };
17
+ /** Robots discovered on LAN or Tailscale. */
18
+ robots: DiscoveredPeer[];
19
+ /** Tailscale auth key if one is cached locally. */
20
+ tailscaleAuthKey?: string;
21
+ /** Existing infinicode mesh token if already configured. */
22
+ meshToken?: string;
23
+ /** Existing infinicode mesh port. */
24
+ meshPort?: number;
25
+ /** This machine's hostname. */
26
+ localName: string;
27
+ }
28
+ interface TailscalePeer {
29
+ HostName?: string;
30
+ DNSName?: string;
31
+ TailscaleIPs?: string[];
32
+ Online?: boolean;
33
+ Tags?: string[];
34
+ }
35
+ interface TailscaleStatus {
36
+ Self?: TailscalePeer;
37
+ Peer?: Record<string, TailscalePeer>;
38
+ }
39
+ /** Run `tailscale status --json` and parse. */
40
+ export declare function tailscaleStatus(): Promise<TailscaleStatus | null>;
41
+ /** Discover peers from Tailscale. */
42
+ export declare function discoverTailscale(): Promise<DiscoveredPeer[]>;
43
+ /** Scan LAN UDP beacons on the default mesh discovery port. */
44
+ export declare function discoverLan(port?: number, timeoutMs?: number): Promise<DiscoveredPeer[]>;
45
+ /** Read a Tailscale auth key from common locations. */
46
+ export declare function findTailscaleAuthKey(): string | undefined;
47
+ /** Read InfiniBot gateway config from common locations. */
48
+ export declare function findInfinibotGateway(): {
49
+ host: string;
50
+ port: number;
51
+ token?: string;
52
+ } | undefined;
53
+ /** Read existing infinicode mesh token/port from config. */
54
+ export declare function readInfinicodeFederation(): {
55
+ token?: string;
56
+ port?: number;
57
+ };
58
+ /** Build the full discovered context for this environment. */
59
+ export declare function discoverContext(): Promise<DiscoveredContext>;
60
+ /** Generate a random token for mesh auth or scheduler enrollment. */
61
+ export declare function generateToken(length?: number): string;
62
+ export {};
@@ -0,0 +1,144 @@
1
+ /**
2
+ * RoboPark — auto-discovery helpers.
3
+ *
4
+ * Scans the environment so operators never type IPs, URLs, or tokens:
5
+ * - Tailscale status → find the RoboPark hub and InfiniBot gateway
6
+ * - LAN beacons → find robots
7
+ * - Known config files → read Tailscale auth key, InfiniBot gateway token
8
+ * - Running infinicode nodes → reuse mesh identity + token
9
+ */
10
+ import { execa } from 'execa';
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { homedir, hostname } from 'node:os';
13
+ import { join } from 'node:path';
14
+ const HUB_HINTS = /livekit|hub|scheduler|robopark/i;
15
+ const GATEWAY_HINTS = /infinibot|gateway/i;
16
+ const ROBOT_HINTS = /robo|panda|bear|car|dragon|frog|bmw/i;
17
+ /** Best IPv4-ish address from a Tailscale peer. */
18
+ function ipv4(peer) {
19
+ return (peer.TailscaleIPs ?? []).find(ip => ip.includes('.'));
20
+ }
21
+ /** Run `tailscale status --json` and parse. */
22
+ export async function tailscaleStatus() {
23
+ try {
24
+ const { stdout } = await execa('tailscale', ['status', '--json'], { timeout: 5000 });
25
+ return JSON.parse(stdout);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /** Discover peers from Tailscale. */
32
+ export async function discoverTailscale() {
33
+ const status = await tailscaleStatus();
34
+ if (!status?.Peer)
35
+ return [];
36
+ return Object.values(status.Peer)
37
+ .filter(p => p.Online)
38
+ .map(p => ({
39
+ name: p.HostName ?? p.DNSName?.split('.')[0] ?? 'unknown',
40
+ ip: ipv4(p) ?? 'unknown',
41
+ tags: p.Tags,
42
+ online: true,
43
+ source: 'tailscale',
44
+ }))
45
+ .filter(p => p.ip !== 'unknown');
46
+ }
47
+ /** Scan LAN UDP beacons on the default mesh discovery port. */
48
+ export async function discoverLan(port = 47915, timeoutMs = 3000) {
49
+ // LAN discovery currently relies on the infinicode beacon protocol.
50
+ // For auto-setup we reuse Tailscale when available; LAN fallback is a TODO
51
+ // once the beacon protocol is stable enough to parse here.
52
+ return [];
53
+ }
54
+ /** Read a Tailscale auth key from common locations. */
55
+ export function findTailscaleAuthKey() {
56
+ const paths = [
57
+ join(homedir(), '.robopark', 'tailscale.key'),
58
+ join(homedir(), '.config', 'robopark', 'tailscale.key'),
59
+ process.env.ROBOPARK_TAILSCALE_KEY ? '__env__' : undefined,
60
+ ].filter(Boolean);
61
+ for (const p of paths) {
62
+ if (p === '__env__')
63
+ return process.env.ROBOPARK_TAILSCALE_KEY;
64
+ if (existsSync(p)) {
65
+ const key = readFileSync(p, 'utf8').trim();
66
+ if (key.startsWith('tskey-'))
67
+ return key;
68
+ }
69
+ }
70
+ return undefined;
71
+ }
72
+ /** Read InfiniBot gateway config from common locations. */
73
+ export function findInfinibotGateway() {
74
+ const candidates = [
75
+ join(homedir(), '.infinibot', 'config.json'),
76
+ join(homedir(), '.config', 'infinibot', 'config.json'),
77
+ join(homedir(), 'AppData', 'Roaming', 'infinibot', 'config.json'),
78
+ ];
79
+ for (const p of candidates) {
80
+ if (!existsSync(p))
81
+ continue;
82
+ try {
83
+ const cfg = JSON.parse(readFileSync(p, 'utf8'));
84
+ const gateway = cfg.gatewayUrl ?? cfg.gateway ?? cfg.gateway_url;
85
+ const token = cfg.gatewayToken ?? cfg.gateway_token ?? cfg.token;
86
+ if (typeof gateway === 'string') {
87
+ const u = new URL(gateway);
88
+ return { host: u.hostname, port: parseInt(u.port || '18789', 10), token: typeof token === 'string' ? token : undefined };
89
+ }
90
+ }
91
+ catch {
92
+ // ignore malformed
93
+ }
94
+ }
95
+ return undefined;
96
+ }
97
+ /** Read existing infinicode mesh token/port from config. */
98
+ export function readInfinicodeFederation() {
99
+ const paths = [
100
+ join(homedir(), 'AppData', 'Roaming', 'infinicode-nodejs', 'Config', 'config.json'),
101
+ join(homedir(), '.config', 'infinicode-nodejs', 'config.json'),
102
+ join(homedir(), '.infinicode-nodejs', 'config.json'),
103
+ ];
104
+ for (const p of paths) {
105
+ if (!existsSync(p))
106
+ continue;
107
+ try {
108
+ const cfg = JSON.parse(readFileSync(p, 'utf8'));
109
+ return { token: cfg.federation?.token, port: cfg.federation?.port };
110
+ }
111
+ catch {
112
+ // ignore
113
+ }
114
+ }
115
+ return {};
116
+ }
117
+ /** Build the full discovered context for this environment. */
118
+ export async function discoverContext() {
119
+ const peers = await discoverTailscale();
120
+ // Exclude this machine from the fleet tables.
121
+ const selfName = hostname();
122
+ const others = peers.filter(p => p.name !== selfName && p.online);
123
+ const hub = others.find(p => HUB_HINTS.test(p.name));
124
+ const robots = others.filter(p => ROBOT_HINTS.test(p.name) || p.tags?.some(t => /robopark/i.test(t)));
125
+ const gateway = findInfinibotGateway();
126
+ const fed = readInfinicodeFederation();
127
+ return {
128
+ hub,
129
+ gateway,
130
+ robots,
131
+ tailscaleAuthKey: findTailscaleAuthKey(),
132
+ meshToken: fed.token,
133
+ meshPort: fed.port,
134
+ localName: hostname(),
135
+ };
136
+ }
137
+ /** Generate a random token for mesh auth or scheduler enrollment. */
138
+ export function generateToken(length = 32) {
139
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
140
+ let out = '';
141
+ for (let i = 0; i < length; i++)
142
+ out += chars[Math.floor(Math.random() * chars.length)];
143
+ return out;
144
+ }
@@ -0,0 +1,10 @@
1
+ interface ScanOptions {
2
+ /** Write generated install scripts to this directory. */
3
+ output?: string;
4
+ /** Include InfiniBot gateway in hub command if found. */
5
+ gateway?: boolean;
6
+ /** Mesh auth token to use on all devices. */
7
+ token?: string;
8
+ }
9
+ export declare function roboparkScan(opts?: ScanOptions): Promise<void>;
10
+ export {};