infinicode 2.7.1 → 2.8.1
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/dist/cli.js +1 -4
- package/dist/commands/robopark.d.ts +3 -1
- package/dist/commands/robopark.js +46 -62
- package/dist/commands/serve.d.ts +0 -3
- package/dist/commands/serve.js +13 -23
- package/dist/kernel/federation/federation.d.ts +5 -16
- package/dist/kernel/federation/federation.js +1 -7
- package/dist/kernel/federation/transport-http.d.ts +0 -14
- package/dist/kernel/federation/transport-http.js +3 -88
- package/dist/robopark/auto-start.d.ts +16 -0
- package/dist/robopark/auto-start.js +142 -0
- package/dist/robopark/discovery.d.ts +62 -0
- package/dist/robopark/discovery.js +144 -0
- package/dist/robopark/scan.d.ts +10 -0
- package/dist/robopark/scan.js +97 -0
- package/dist/robopark/serve.d.ts +19 -0
- package/dist/robopark/serve.js +164 -0
- package/dist/robopark/setup.d.ts +21 -0
- package/dist/robopark/setup.js +198 -0
- package/dist/robopark-cli.d.ts +0 -1
- package/dist/robopark-cli.js +84 -15
- package/package.json +1 -1
|
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')).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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')}</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 {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoboPark — `robopark scan`.
|
|
3
|
+
*
|
|
4
|
+
* Scans Tailscale + LAN + local config and emits ready-to-run setup commands
|
|
5
|
+
* for every machine in the fleet. No manual IP/token typing.
|
|
6
|
+
*/
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import { discoverContext, generateToken } from './discovery.js';
|
|
9
|
+
function hubCommand(ctx, opts) {
|
|
10
|
+
const name = ctx.hub?.name ?? 'livekit-1';
|
|
11
|
+
const site = 'Tel Aviv'; // TODO: discover from role context or prompt
|
|
12
|
+
const token = opts.token ?? ctx.meshToken ?? generateToken();
|
|
13
|
+
const gw = opts.gateway !== false ? ctx.gateway : undefined;
|
|
14
|
+
const parts = [
|
|
15
|
+
'robopark setup --hub',
|
|
16
|
+
`--name ${name}`,
|
|
17
|
+
`--site "${site}"`,
|
|
18
|
+
`--token ${token}`,
|
|
19
|
+
'--start',
|
|
20
|
+
'--auto-start',
|
|
21
|
+
];
|
|
22
|
+
if (ctx.tailscaleAuthKey)
|
|
23
|
+
parts.push(`--tailscale-auth ${ctx.tailscaleAuthKey}`);
|
|
24
|
+
if (gw)
|
|
25
|
+
parts.push(`--gateway ws://${gw.host}:${gw.port}`, `--gateway-token ${gw.token ?? 'SET_TOKEN'}`);
|
|
26
|
+
return parts.join(' ');
|
|
27
|
+
}
|
|
28
|
+
function robotCommand(robot, ctx, opts) {
|
|
29
|
+
const token = opts.token ?? ctx.meshToken ?? generateToken();
|
|
30
|
+
const hubUrl = ctx.hub ? `http://${ctx.hub.ip}:47913` : 'http://HUB_IP:47913';
|
|
31
|
+
const parts = [
|
|
32
|
+
'robopark setup --robot',
|
|
33
|
+
`--name ${robot.name}`,
|
|
34
|
+
`--hub-url ${hubUrl}`,
|
|
35
|
+
`--token ${token}`,
|
|
36
|
+
'--start',
|
|
37
|
+
'--auto-start',
|
|
38
|
+
];
|
|
39
|
+
if (ctx.tailscaleAuthKey)
|
|
40
|
+
parts.push(`--tailscale-auth ${ctx.tailscaleAuthKey}`);
|
|
41
|
+
return parts.join(' ');
|
|
42
|
+
}
|
|
43
|
+
function controlCommand(ctx, opts) {
|
|
44
|
+
const token = opts.token ?? ctx.meshToken ?? generateToken();
|
|
45
|
+
const hubUrl = ctx.hub ? `http://${ctx.hub.ip}:47913` : 'http://HUB_IP:47913';
|
|
46
|
+
const parts = [
|
|
47
|
+
'robopark setup --control',
|
|
48
|
+
`--hub-url ${hubUrl}`,
|
|
49
|
+
`--token ${token}`,
|
|
50
|
+
'--start',
|
|
51
|
+
'--auto-start',
|
|
52
|
+
];
|
|
53
|
+
return parts.join(' ');
|
|
54
|
+
}
|
|
55
|
+
export async function roboparkScan(opts = {}) {
|
|
56
|
+
console.log(chalk.bold('\n robopark scan — auto-discovering your fleet\n'));
|
|
57
|
+
const ctx = await discoverContext();
|
|
58
|
+
if (!ctx.hub) {
|
|
59
|
+
console.log(chalk.yellow(' ⚠ no hub found on Tailscale. Look for a machine named like livekit-* / hub / scheduler.'));
|
|
60
|
+
console.log(chalk.dim(' make sure the hub box is online and tagged, or run `robopark setup --hub` on it first.'));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
console.log(` ${chalk.green('✓')} hub found: ${chalk.cyan(ctx.hub.name)} @ ${chalk.cyan(ctx.hub.ip)}`);
|
|
64
|
+
}
|
|
65
|
+
if (!ctx.gateway) {
|
|
66
|
+
console.log(chalk.dim(' · no InfiniBot gateway discovered (set one with --gateway or INFINIBOT_GATEWAY env)'));
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
console.log(` ${chalk.green('✓')} gateway found: ${chalk.cyan(`${ctx.gateway.host}:${ctx.gateway.port}`)}`);
|
|
70
|
+
}
|
|
71
|
+
if (!ctx.robots.length) {
|
|
72
|
+
console.log(chalk.dim(' · no robots discovered yet. They will LAN-join after the hub is running.'));
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
console.log(` ${chalk.green('✓')} ${ctx.robots.length} robot(s) found:`);
|
|
76
|
+
for (const r of ctx.robots)
|
|
77
|
+
console.log(` · ${chalk.cyan(r.name)} @ ${chalk.cyan(r.ip)}`);
|
|
78
|
+
}
|
|
79
|
+
const token = opts.token ?? ctx.meshToken ?? generateToken();
|
|
80
|
+
console.log(`\n ${chalk.green('✓')} using mesh token: ${chalk.cyan(token)}`);
|
|
81
|
+
console.log(chalk.dim(' save it with: echo "${TOKEN}" > ~/.robopark/mesh.token'));
|
|
82
|
+
console.log(chalk.bold('\n Generated setup commands:\n'));
|
|
83
|
+
console.log(chalk.dim(' # on the hub box'));
|
|
84
|
+
console.log(' ' + chalk.cyan(hubCommand(ctx, opts)));
|
|
85
|
+
console.log();
|
|
86
|
+
console.log(chalk.dim(' # on each robot'));
|
|
87
|
+
for (const r of ctx.robots) {
|
|
88
|
+
console.log(' ' + chalk.cyan(robotCommand(r, ctx, opts)));
|
|
89
|
+
}
|
|
90
|
+
if (!ctx.robots.length) {
|
|
91
|
+
console.log(' ' + chalk.cyan(robotCommand({ name: 'robobmw', ip: 'HUB_IP', online: true, source: 'tailscale' }, ctx, opts).replace('robobmw', '<ROBOT_NAME>')));
|
|
92
|
+
}
|
|
93
|
+
console.log();
|
|
94
|
+
console.log(chalk.dim(' # on this dev laptop'));
|
|
95
|
+
console.log(' ' + chalk.cyan(controlCommand(ctx, opts)));
|
|
96
|
+
console.log();
|
|
97
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface ServeOptions {
|
|
2
|
+
port?: string;
|
|
3
|
+
host?: string;
|
|
4
|
+
dataDir?: string;
|
|
5
|
+
gateway?: string;
|
|
6
|
+
gatewayToken?: string;
|
|
7
|
+
gatewayPassword?: string;
|
|
8
|
+
open?: boolean;
|
|
9
|
+
foreground?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Resolve the actual infinicode CLI entry point so `robopark setup --start`
|
|
12
|
+
* works even when global bins are not on PATH. */
|
|
13
|
+
export declare function resolveInfinicodeBin(): Promise<{
|
|
14
|
+
node: string;
|
|
15
|
+
script: string;
|
|
16
|
+
} | null>;
|
|
17
|
+
export declare function roboparkServe(opts: ServeOptions): Promise<void>;
|
|
18
|
+
/** Quick health check: is the scheduler responding? */
|
|
19
|
+
export declare function schedulerHealthy(url: string, timeoutMs?: number): Promise<boolean>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoboPark — `robopark serve`.
|
|
3
|
+
*
|
|
4
|
+
* Starts the RoboPark scheduler (Python FastAPI) and the web UI in one pass.
|
|
5
|
+
* The scheduler lives next to this package in `scheduler/main.py`.
|
|
6
|
+
*
|
|
7
|
+
* robopark serve --port 8080
|
|
8
|
+
* robopark serve --gateway ws://infinibot:18789 --gateway-token <token>
|
|
9
|
+
*/
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
import { spawn, execSync } from 'node:child_process';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
function pkgDir() {
|
|
16
|
+
try {
|
|
17
|
+
// dist/robopark/serve.js -> project root
|
|
18
|
+
return dirname(dirname(fileURLToPath(import.meta.url)));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return process.cwd();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function findScheduler() {
|
|
25
|
+
const candidates = [
|
|
26
|
+
join(pkgDir(), 'packages', 'robopark', 'scheduler', 'main.py'),
|
|
27
|
+
join(pkgDir(), 'scheduler', 'main.py'),
|
|
28
|
+
join(process.cwd(), 'packages', 'robopark', 'scheduler', 'main.py'),
|
|
29
|
+
join(process.cwd(), 'scheduler', 'main.py'),
|
|
30
|
+
];
|
|
31
|
+
for (const p of candidates) {
|
|
32
|
+
if (existsSync(p))
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
function findPython() {
|
|
38
|
+
for (const name of ['python3.12', 'python3.11', 'python3.10', 'python3', 'python']) {
|
|
39
|
+
try {
|
|
40
|
+
execSync(`${name} --version`, { stdio: 'ignore' });
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return 'python3';
|
|
48
|
+
}
|
|
49
|
+
/** Resolve the actual infinicode CLI entry point so `robopark setup --start`
|
|
50
|
+
* works even when global bins are not on PATH. */
|
|
51
|
+
export async function resolveInfinicodeBin() {
|
|
52
|
+
try {
|
|
53
|
+
// When running from the infinicode package itself, the CLI entry is nearby.
|
|
54
|
+
const local = join(pkgDir(), 'bin', 'infinicode.js');
|
|
55
|
+
if (existsSync(local))
|
|
56
|
+
return { node: process.execPath, script: local };
|
|
57
|
+
}
|
|
58
|
+
catch { /* ignore */ }
|
|
59
|
+
try {
|
|
60
|
+
// When running from the robopark wrapper, resolve infinicode from npm.
|
|
61
|
+
const { createRequire } = await import('node:module');
|
|
62
|
+
const require = createRequire(import.meta.url);
|
|
63
|
+
const pkgMain = require.resolve('infinicode/package.json');
|
|
64
|
+
const candidate = join(dirname(pkgMain), 'bin', 'infinicode.js');
|
|
65
|
+
if (existsSync(candidate))
|
|
66
|
+
return { node: process.execPath, script: candidate };
|
|
67
|
+
}
|
|
68
|
+
catch { /* ignore */ }
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
export async function roboparkServe(opts) {
|
|
72
|
+
const schedulerPath = findScheduler();
|
|
73
|
+
if (!schedulerPath) {
|
|
74
|
+
console.log(chalk.red(' ✗ could not find scheduler/main.py'));
|
|
75
|
+
console.log(chalk.dim(' make sure robopark is installed from npm, not a partial checkout.'));
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
const port = opts.port ? parseInt(opts.port, 10) : 8080;
|
|
79
|
+
const host = opts.host ?? '0.0.0.0';
|
|
80
|
+
const python = findPython();
|
|
81
|
+
const env = {
|
|
82
|
+
...process.env,
|
|
83
|
+
SCHEDULER_HOST: host,
|
|
84
|
+
SCHEDULER_PORT: String(port),
|
|
85
|
+
SCHEDULER_DATA_DIR: opts.dataDir ?? join(pkgDir(), '..', 'data'),
|
|
86
|
+
};
|
|
87
|
+
if (opts.gateway)
|
|
88
|
+
env.INFINIBOT_GATEWAY_URL = opts.gateway;
|
|
89
|
+
if (opts.gatewayToken)
|
|
90
|
+
env.INFINIBOT_GATEWAY_TOKEN = opts.gatewayToken;
|
|
91
|
+
if (opts.gatewayPassword)
|
|
92
|
+
env.INFINIBOT_GATEWAY_PASSWORD = opts.gatewayPassword;
|
|
93
|
+
console.log(chalk.bold('\n robopark serve'));
|
|
94
|
+
console.log(chalk.dim(' ' + '─'.repeat(52)));
|
|
95
|
+
console.log(` scheduler: ${chalk.cyan(schedulerPath)}`);
|
|
96
|
+
console.log(` python: ${chalk.cyan(python)}`);
|
|
97
|
+
console.log(` listen: ${chalk.cyan(`${host}:${port}`)}`);
|
|
98
|
+
if (opts.gateway)
|
|
99
|
+
console.log(` gateway: ${chalk.cyan(opts.gateway)}`);
|
|
100
|
+
console.log();
|
|
101
|
+
const proc = spawn(python, ['main.py'], {
|
|
102
|
+
cwd: dirname(schedulerPath),
|
|
103
|
+
env,
|
|
104
|
+
stdio: 'pipe',
|
|
105
|
+
detached: !opts.foreground,
|
|
106
|
+
});
|
|
107
|
+
let ready = false;
|
|
108
|
+
proc.stdout?.on('data', (d) => {
|
|
109
|
+
const line = d.toString().trim();
|
|
110
|
+
if (line)
|
|
111
|
+
console.log(chalk.dim(' [scheduler] ' + line));
|
|
112
|
+
if (/Uvicorn running|Application startup complete/i.test(line)) {
|
|
113
|
+
ready = true;
|
|
114
|
+
printUrl(port);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
proc.stderr?.on('data', (d) => {
|
|
118
|
+
const line = d.toString().trim();
|
|
119
|
+
if (line)
|
|
120
|
+
console.log(chalk.yellow(' [scheduler] ' + line));
|
|
121
|
+
});
|
|
122
|
+
proc.on('error', (err) => {
|
|
123
|
+
console.log(chalk.red(` ✗ scheduler failed to start: ${err.message}`));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
});
|
|
126
|
+
proc.on('exit', (code) => {
|
|
127
|
+
if (!ready && code !== 0) {
|
|
128
|
+
console.log(chalk.red(` ✗ scheduler exited ${code ?? ''}`));
|
|
129
|
+
process.exit(code ?? 1);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
if (opts.foreground) {
|
|
133
|
+
process.on('SIGINT', () => {
|
|
134
|
+
console.log(chalk.dim('\n stopping scheduler…'));
|
|
135
|
+
try {
|
|
136
|
+
proc.kill('SIGINT');
|
|
137
|
+
}
|
|
138
|
+
catch { /* ignore */ }
|
|
139
|
+
});
|
|
140
|
+
proc.on('exit', () => process.exit(0));
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
// Detached mode: let the user walk away. The process keeps running.
|
|
144
|
+
proc.unref();
|
|
145
|
+
printUrl(port);
|
|
146
|
+
console.log(chalk.dim(' running in background. Use Task Manager / systemctl / launchctl to stop.'));
|
|
147
|
+
// give the scheduler a moment to bind before printing URL
|
|
148
|
+
setTimeout(() => process.exit(0), 1200);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function printUrl(port) {
|
|
152
|
+
const url = `http://localhost:${port}`;
|
|
153
|
+
console.log(chalk.bold(` dashboard: ${chalk.cyan(url)}`));
|
|
154
|
+
}
|
|
155
|
+
/** Quick health check: is the scheduler responding? */
|
|
156
|
+
export async function schedulerHealthy(url, timeoutMs = 3000) {
|
|
157
|
+
try {
|
|
158
|
+
const res = await fetch(`${url.replace(/\/$/, '')}/api/settings`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
159
|
+
return res.ok;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type Conf from 'conf';
|
|
2
|
+
import type { InfinicodeConfig } from '../kernel/config-schema.js';
|
|
3
|
+
export interface SetupOptions {
|
|
4
|
+
role?: 'hub' | 'robot' | 'control';
|
|
5
|
+
name?: string;
|
|
6
|
+
site?: string;
|
|
7
|
+
token?: string;
|
|
8
|
+
hub?: string;
|
|
9
|
+
port?: string;
|
|
10
|
+
gateway?: string;
|
|
11
|
+
gatewayToken?: string;
|
|
12
|
+
gatewayPassword?: string;
|
|
13
|
+
tailscaleAuth?: string;
|
|
14
|
+
tag?: string;
|
|
15
|
+
schedulerPort?: string;
|
|
16
|
+
start?: boolean;
|
|
17
|
+
autoStart?: boolean;
|
|
18
|
+
yes?: boolean;
|
|
19
|
+
foreground?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare function roboparkSetup(config: Conf<InfinicodeConfig>, opts: SetupOptions): Promise<void>;
|