infinicode 2.8.23 → 2.8.25

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.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * RoboPark — `robopark doctor`.
3
+ *
4
+ * A fast, all-in-one health check that replaces the manual curl-by-curl
5
+ * triage session: hub federation status, scheduler reachability, LiveKit
6
+ * server registration, device heartbeat freshness, and production mode.
7
+ *
8
+ * Usage:
9
+ * robopark doctor
10
+ * robopark doctor --hub-url http://100.64.1.2:47913 --scheduler-url http://100.64.1.2:8080
11
+ */
12
+ import chalk from 'chalk';
13
+ import { discoverContext } from './discovery.js';
14
+ async function fetchJson(url, init) {
15
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(5000) });
16
+ if (!res.ok)
17
+ throw new Error(`${res.status} ${res.statusText}`);
18
+ return res.json();
19
+ }
20
+ const STALE_HEARTBEAT_MS = 60_000;
21
+ function parseTimestamp(value) {
22
+ if (typeof value !== 'string' || !value)
23
+ return undefined;
24
+ // Scheduler stores naive UTC timestamps (no trailing 'Z'); normalize so
25
+ // Date.parse treats them as UTC instead of local time.
26
+ const iso = /[zZ]|[+-]\d\d:\d\d$/.test(value) ? value : `${value}Z`;
27
+ const ms = Date.parse(iso);
28
+ return Number.isNaN(ms) ? undefined : ms;
29
+ }
30
+ export async function roboparkDoctor(opts) {
31
+ const ctx = await discoverContext();
32
+ const hubUrl = (opts.hubUrl ?? (ctx.hub ? `http://${ctx.hub.ip}:47913` : 'http://localhost:47913')).replace(/\/$/, '');
33
+ const schedulerUrl = (opts.schedulerUrl ?? (ctx.hub ? `http://${ctx.hub.ip}:8080` : 'http://localhost:8080')).replace(/\/$/, '');
34
+ const authHeaders = opts.token ? { Authorization: `Bearer ${opts.token}` } : {};
35
+ console.log(chalk.bold('\n robopark doctor'));
36
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
37
+ console.log(` hub: ${chalk.cyan(hubUrl)}`);
38
+ console.log(` scheduler: ${chalk.cyan(schedulerUrl)}`);
39
+ console.log();
40
+ const checks = [];
41
+ // 1. Hub /fed/status reachable + connected nodes.
42
+ try {
43
+ const status = (await fetchJson(`${hubUrl}/fed/status`, { headers: authHeaders }));
44
+ const nodes = status.nodes ?? [];
45
+ const connected = nodes.filter(n => n.connected);
46
+ checks.push({
47
+ name: 'hub reachable',
48
+ status: 'pass',
49
+ message: `self=${status.self?.displayName ?? '?'} (${status.self?.role ?? '?'})`,
50
+ });
51
+ if (nodes.length === 0) {
52
+ checks.push({ name: 'mesh peers', status: 'warn', message: 'no peers known to this hub', detail: 'Nothing has joined the mesh yet, or this node has not discovered peers.' });
53
+ }
54
+ else if (connected.length === 0) {
55
+ checks.push({ name: 'mesh peers', status: 'fail', message: `${nodes.length} known peer(s), 0 connected`, detail: 'Check Tailscale connectivity and that peer processes are running.' });
56
+ }
57
+ else {
58
+ const list = connected.map(n => `${n.displayName ?? n.nodeId}(${n.role ?? '?'})`).join(', ');
59
+ checks.push({ name: 'mesh peers', status: 'pass', message: `${connected.length}/${nodes.length} connected — ${list}` });
60
+ }
61
+ }
62
+ catch (e) {
63
+ checks.push({ name: 'hub reachable', status: 'fail', message: `could not reach ${hubUrl}/fed/status: ${e.message}`, detail: 'Is the hub node running? Is it on the same tailnet? Try --hub-url.' });
64
+ }
65
+ // 2. Scheduler /api/robots + /api/devices reachable.
66
+ let robots = [];
67
+ let devices = [];
68
+ try {
69
+ robots = (await fetchJson(`${schedulerUrl}/api/robots`));
70
+ checks.push({ name: 'scheduler /api/robots', status: 'pass', message: `${robots.length} robot(s)` });
71
+ }
72
+ catch (e) {
73
+ checks.push({ name: 'scheduler /api/robots', status: 'fail', message: `error: ${e.message}`, detail: 'Is robopark serve running? Check --scheduler-url.' });
74
+ }
75
+ try {
76
+ devices = (await fetchJson(`${schedulerUrl}/api/devices`));
77
+ checks.push({ name: 'scheduler /api/devices', status: 'pass', message: `${devices.length} device(s)` });
78
+ }
79
+ catch (e) {
80
+ checks.push({ name: 'scheduler /api/devices', status: 'fail', message: `error: ${e.message}` });
81
+ }
82
+ // 3. LiveKit servers registered + online.
83
+ try {
84
+ const servers = (await fetchJson(`${schedulerUrl}/api/servers`));
85
+ if (servers.length === 0) {
86
+ checks.push({ name: 'livekit servers', status: 'fail', message: 'no servers in scheduler', detail: 'Add one via the dashboard or robopark setup-livekit.' });
87
+ }
88
+ else {
89
+ for (const s of servers) {
90
+ const label = s.name ?? s.id;
91
+ if (!s.url) {
92
+ checks.push({ name: `server ${label}`, status: 'fail', message: 'no url configured' });
93
+ }
94
+ else if (s.status === 'online') {
95
+ checks.push({ name: `server ${label}`, status: 'pass', message: `online @ ${s.url}` });
96
+ }
97
+ else if (s.status === 'offline') {
98
+ checks.push({ name: `server ${label}`, status: 'fail', message: `offline @ ${s.url}`, detail: 'Check that the LiveKit container/process is running and reachable.' });
99
+ }
100
+ else {
101
+ checks.push({ name: `server ${label}`, status: 'warn', message: `status=${s.status ?? 'unknown'} @ ${s.url}` });
102
+ }
103
+ }
104
+ }
105
+ }
106
+ catch (e) {
107
+ checks.push({ name: 'livekit servers', status: 'fail', message: `error: ${e.message}` });
108
+ }
109
+ // 4. Stale device heartbeats.
110
+ const now = Date.now();
111
+ const staleDevices = devices.filter(d => {
112
+ const hb = parseTimestamp(d.last_heartbeat);
113
+ return hb !== undefined && now - hb > STALE_HEARTBEAT_MS;
114
+ });
115
+ const noHeartbeatDevices = devices.filter(d => parseTimestamp(d.last_heartbeat) === undefined);
116
+ if (devices.length === 0) {
117
+ // covered by the /api/devices check above
118
+ }
119
+ else if (staleDevices.length === 0 && noHeartbeatDevices.length === 0) {
120
+ checks.push({ name: 'device heartbeats', status: 'pass', message: `all ${devices.length} device(s) heartbeating within ${STALE_HEARTBEAT_MS / 1000}s` });
121
+ }
122
+ else {
123
+ const parts = [];
124
+ if (staleDevices.length)
125
+ parts.push(`${staleDevices.length} stale: ${staleDevices.map(d => d.name ?? d.id).join(', ')}`);
126
+ if (noHeartbeatDevices.length)
127
+ parts.push(`${noHeartbeatDevices.length} never heartbeat: ${noHeartbeatDevices.map(d => d.name ?? d.id).join(', ')}`);
128
+ checks.push({ name: 'device heartbeats', status: 'warn', message: parts.join('; '), detail: 'Is robopark preview-agent (or the device process) running on those machines?' });
129
+ }
130
+ // 5. Production mode.
131
+ try {
132
+ const settings = (await fetchJson(`${schedulerUrl}/api/settings`));
133
+ if (settings.production_mode) {
134
+ checks.push({ name: 'production mode', status: 'pass', message: 'ON' });
135
+ }
136
+ else {
137
+ checks.push({ name: 'production mode', status: 'warn', message: 'OFF', detail: 'New device enrollment and preview sessions are blocked until production mode is ON (toggle in the dashboard or PUT /api/settings).' });
138
+ }
139
+ if (settings.default_enrollment_token_set === false) {
140
+ checks.push({ name: 'enrollment token', status: 'warn', message: 'no default enrollment token set', detail: 'Rotate one via POST /api/settings/enrollment-token/rotate before enrolling new devices.' });
141
+ }
142
+ }
143
+ catch (e) {
144
+ checks.push({ name: 'production mode', status: 'fail', message: `error: ${e.message}` });
145
+ }
146
+ // Print report.
147
+ let pass = 0;
148
+ let warn = 0;
149
+ let fail = 0;
150
+ for (const c of checks) {
151
+ const icon = c.status === 'pass' ? chalk.green('✓') : c.status === 'warn' ? chalk.yellow('⚠') : chalk.red('✗');
152
+ const color = c.status === 'pass' ? chalk.green : c.status === 'warn' ? chalk.yellow : chalk.red;
153
+ console.log(` ${icon} ${color(c.name)} — ${c.message}`);
154
+ if (c.detail)
155
+ console.log(chalk.dim(` → ${c.detail}`));
156
+ if (c.status === 'pass')
157
+ pass++;
158
+ else if (c.status === 'warn')
159
+ warn++;
160
+ else
161
+ fail++;
162
+ }
163
+ console.log();
164
+ const total = checks.length;
165
+ if (fail === 0 && warn === 0) {
166
+ console.log(chalk.bold.green(` ${pass}/${total} checks passed — fleet is healthy.`));
167
+ }
168
+ else if (fail === 0) {
169
+ console.log(chalk.bold.yellow(` ${pass}/${total} passed, ${warn} warning(s) — review the items above.`));
170
+ }
171
+ else {
172
+ console.log(chalk.bold.red(` ${pass}/${total} passed, ${warn} warning(s), ${fail} failed — fix the items above.`));
173
+ process.exitCode = 1;
174
+ }
175
+ }
@@ -0,0 +1,8 @@
1
+ export interface LlmSetOptions {
2
+ envPath: string;
3
+ provider: string;
4
+ model?: string;
5
+ host?: string;
6
+ apiKey?: string;
7
+ }
8
+ export declare function roboparkLlmSet(opts: LlmSetOptions): Promise<void>;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * RoboPark — `robopark llm set`.
3
+ *
4
+ * Upserts LLM provider config into a ROBOVOICE .env file. ROBOVOICE lives in
5
+ * a separate repo, so the target .env path is always explicit (--env-path) —
6
+ * this command never guesses or writes infinicode's own config.
7
+ *
8
+ * Known keys (from ROBOVOICE's .env.example and src/caal/settings.py /
9
+ * src/caal/webhooks.py, which read OLLAMA_HOST and OLLAMA_MODEL):
10
+ * LLM_PROVIDER — "ollama" | "groq" (and whatever else ROBOVOICE adds)
11
+ * OLLAMA_HOST — Ollama server URL (only meaningful for ollama providers)
12
+ * OLLAMA_MODEL — Ollama model name (only meaningful for ollama providers)
13
+ * OLLAMA_API_KEY — NOT currently read by ROBOVOICE. src/caal/llm/providers/
14
+ * ollama_provider.py constructs `ollama.Client(host=base_url)`
15
+ * with no api_key/auth support, and no OLLAMA_API_KEY lookup
16
+ * exists anywhere in src/caal. We still write the key (for
17
+ * forward-compat with Ollama Cloud, which needs an API key)
18
+ * but it is a no-op until ROBOVOICE's ollama_provider.py is
19
+ * updated to read it — see the gap noted in the CLI output.
20
+ */
21
+ import chalk from 'chalk';
22
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
23
+ /** true for any provider name that talks to an Ollama-compatible server. */
24
+ function isOllamaProvider(provider) {
25
+ return provider === 'ollama' || provider === 'ollama-cloud';
26
+ }
27
+ /** Mask a secret so only the first/last 4 chars are visible, e.g. `abcd…wxyz`. */
28
+ function maskSecret(value) {
29
+ if (value.length <= 8)
30
+ return '*'.repeat(value.length);
31
+ return `${value.slice(0, 4)}…${value.slice(-4)}`;
32
+ }
33
+ /** Replace an existing `KEY=...` line, or append a new one if absent. */
34
+ function upsertEnvVar(lines, key, value) {
35
+ const pattern = new RegExp(`^${key}=`);
36
+ const idx = lines.findIndex(line => pattern.test(line));
37
+ const entry = `${key}=${value}`;
38
+ if (idx >= 0) {
39
+ lines[idx] = entry;
40
+ return lines;
41
+ }
42
+ return [...lines, entry];
43
+ }
44
+ export async function roboparkLlmSet(opts) {
45
+ if (!opts.envPath) {
46
+ console.log(chalk.red(' ✗ --env-path is required (points at the target ROBOVOICE .env file)'));
47
+ process.exit(1);
48
+ }
49
+ if (!existsSync(opts.envPath)) {
50
+ console.log(chalk.red(` ✗ .env file not found: ${opts.envPath}`));
51
+ process.exit(1);
52
+ }
53
+ if (!opts.provider) {
54
+ console.log(chalk.red(' ✗ --provider is required (e.g. ollama, ollama-cloud, groq)'));
55
+ process.exit(1);
56
+ }
57
+ const raw = readFileSync(opts.envPath, 'utf8');
58
+ const hadTrailingNewline = raw.endsWith('\n');
59
+ let lines = raw.split('\n');
60
+ // Drop a single trailing empty entry produced by the final newline so we
61
+ // don't accumulate blank lines across repeated `llm set` runs.
62
+ if (hadTrailingNewline && lines[lines.length - 1] === '')
63
+ lines = lines.slice(0, -1);
64
+ lines = upsertEnvVar(lines, 'LLM_PROVIDER', opts.provider);
65
+ const ollama = isOllamaProvider(opts.provider);
66
+ if (ollama && opts.host)
67
+ lines = upsertEnvVar(lines, 'OLLAMA_HOST', opts.host);
68
+ if (ollama && opts.model)
69
+ lines = upsertEnvVar(lines, 'OLLAMA_MODEL', opts.model);
70
+ if (ollama && opts.apiKey)
71
+ lines = upsertEnvVar(lines, 'OLLAMA_API_KEY', opts.apiKey);
72
+ writeFileSync(opts.envPath, lines.join('\n') + (hadTrailingNewline ? '\n' : ''), 'utf8');
73
+ console.log(chalk.bold('\n robopark llm set'));
74
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
75
+ console.log(` file: ${chalk.cyan(opts.envPath)}`);
76
+ console.log(` provider: ${chalk.yellow(opts.provider)}`);
77
+ if (ollama && opts.host)
78
+ console.log(` host: ${chalk.cyan(opts.host)}`);
79
+ if (ollama && opts.model)
80
+ console.log(` model: ${chalk.cyan(opts.model)}`);
81
+ if (ollama && opts.apiKey) {
82
+ console.log(` api key: ${chalk.cyan(maskSecret(opts.apiKey))} ${chalk.dim('(masked)')}`);
83
+ console.log(chalk.yellow(' ⚠ note: ROBOVOICE\'s ollama_provider.py does not read OLLAMA_API_KEY yet —'));
84
+ console.log(chalk.yellow(' this value is written for forward-compat but is currently unused.'));
85
+ }
86
+ console.log(chalk.green('\n ✓ .env updated\n'));
87
+ }
@@ -0,0 +1,40 @@
1
+ import { type DiscoveredContext } from './discovery.js';
2
+ export interface HubProfile {
3
+ hubUrl?: string;
4
+ token?: string;
5
+ schedulerUrl?: string;
6
+ site?: string;
7
+ }
8
+ /** Persist a hub profile to ~/.robopark/hub-profile.json. */
9
+ export declare function saveHubProfile(profile: HubProfile): void;
10
+ /** Load the saved hub profile, or null if none has been saved. */
11
+ export declare function loadHubProfile(): HubProfile | null;
12
+ /** Delete the saved hub profile, if any. */
13
+ export declare function clearHubProfile(): void;
14
+ /** Flags a caller may pass explicitly on the command line. */
15
+ export interface ExplicitContextOpts {
16
+ hubUrl?: string;
17
+ token?: string;
18
+ schedulerUrl?: string;
19
+ site?: string;
20
+ }
21
+ /** Resolved context: explicit opts > saved hub profile > auto-discovery. */
22
+ export interface ResolvedContext {
23
+ hubUrl?: string;
24
+ token?: string;
25
+ schedulerUrl?: string;
26
+ site?: string;
27
+ /** The full auto-discovery result, for callers (like setup.ts) that need
28
+ * more than the four flattened fields above (e.g. ctx.gateway, ctx.robots). */
29
+ discovered: DiscoveredContext;
30
+ }
31
+ /**
32
+ * Resolve hub/scheduler/token/site context with the standard precedence:
33
+ * explicit CLI flags win, then the saved hub profile, then discoverContext()
34
+ * auto-discovery as the final fallback.
35
+ *
36
+ * Kept as a separate wrapper (rather than changing discoverContext()'s
37
+ * signature/behavior) so every existing discoverContext() caller keeps
38
+ * working unchanged when no profile has been saved.
39
+ */
40
+ export declare function resolveContext(opts?: ExplicitContextOpts): Promise<ResolvedContext>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * RoboPark — persisted hub profile.
3
+ *
4
+ * Operators kept hand-typing the same --hub-url/--token/--scheduler-url/--site
5
+ * flags on every command. `robopark hub-use` (see robopark-cli.ts) saves those
6
+ * once to ~/.robopark/hub-profile.json; `resolveContext()` here is the single
7
+ * place that layers them back in as defaults so every command benefits without
8
+ * having to re-implement the precedence logic itself.
9
+ *
10
+ * Precedence (highest wins): explicit CLI flags > saved hub profile > existing
11
+ * discoverContext() auto-discovery (Tailscale/LAN/local config files).
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { discoverContext } from './discovery.js';
17
+ const PROFILE_DIR = join(homedir(), '.robopark');
18
+ const PROFILE_PATH = join(PROFILE_DIR, 'hub-profile.json');
19
+ /** Persist a hub profile to ~/.robopark/hub-profile.json. */
20
+ export function saveHubProfile(profile) {
21
+ mkdirSync(PROFILE_DIR, { recursive: true });
22
+ writeFileSync(PROFILE_PATH, JSON.stringify(profile, null, 2), 'utf8');
23
+ }
24
+ /** Load the saved hub profile, or null if none has been saved. */
25
+ export function loadHubProfile() {
26
+ if (!existsSync(PROFILE_PATH))
27
+ return null;
28
+ try {
29
+ return JSON.parse(readFileSync(PROFILE_PATH, 'utf8'));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /** Delete the saved hub profile, if any. */
36
+ export function clearHubProfile() {
37
+ if (existsSync(PROFILE_PATH)) {
38
+ try {
39
+ rmSync(PROFILE_PATH);
40
+ }
41
+ catch {
42
+ // ignore
43
+ }
44
+ }
45
+ }
46
+ const DEFAULT_MESH_PORT = 47913;
47
+ const DEFAULT_SCHEDULER_PORT = 8080;
48
+ /**
49
+ * Resolve hub/scheduler/token/site context with the standard precedence:
50
+ * explicit CLI flags win, then the saved hub profile, then discoverContext()
51
+ * auto-discovery as the final fallback.
52
+ *
53
+ * Kept as a separate wrapper (rather than changing discoverContext()'s
54
+ * signature/behavior) so every existing discoverContext() caller keeps
55
+ * working unchanged when no profile has been saved.
56
+ */
57
+ export async function resolveContext(opts = {}) {
58
+ const discovered = await discoverContext();
59
+ const profile = loadHubProfile();
60
+ const hubUrl = opts.hubUrl
61
+ ?? profile?.hubUrl
62
+ ?? (discovered.hub ? `http://${discovered.hub.ip}:${discovered.meshPort ?? DEFAULT_MESH_PORT}` : undefined);
63
+ const token = opts.token ?? profile?.token ?? discovered.meshToken;
64
+ const schedulerUrl = opts.schedulerUrl
65
+ ?? profile?.schedulerUrl
66
+ ?? (hubUrl ? hubUrl.replace(/:\d+$/, `:${DEFAULT_SCHEDULER_PORT}`) : undefined);
67
+ const site = opts.site ?? profile?.site;
68
+ return { hubUrl, token, schedulerUrl, site, discovered };
69
+ }
@@ -0,0 +1,13 @@
1
+ export interface ServerAddOptions {
2
+ hubUrl?: string;
3
+ schedulerUrl?: string;
4
+ token?: string;
5
+ id: string;
6
+ name?: string;
7
+ url: string;
8
+ webhookUrl?: string;
9
+ apiKey?: string;
10
+ apiSecret?: string;
11
+ maxSessions?: string;
12
+ }
13
+ export declare function roboparkServerAdd(opts: ServerAddOptions): Promise<void>;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * RoboPark — `robopark server add`.
3
+ *
4
+ * Registers a LiveKit server with the RoboPark scheduler via
5
+ * `POST /api/servers`, wrapping the curl call operators previously had to
6
+ * hand-type.
7
+ *
8
+ * Usage:
9
+ * robopark server add --id livekit-hub-docker --url ws://100.x.y.z:7880
10
+ * robopark server add --id livekit-hub-docker --url ws://100.x.y.z:7880 \
11
+ * --api-key devkey --api-secret secret --scheduler-url http://100.x.y.z:8080
12
+ *
13
+ * If --scheduler-url is omitted, it is derived from --hub-url (or discovery)
14
+ * the same way `robopark setup-livekit` does: hub host on port 8080.
15
+ */
16
+ import { readFileSync, existsSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import chalk from 'chalk';
20
+ import { discoverContext } from './discovery.js';
21
+ const DEFAULT_SCHEDULER_PORT = 8080;
22
+ const DEV_API_KEY = 'devkey';
23
+ const DEV_API_SECRET = 'secret';
24
+ function loadMeshToken() {
25
+ const paths = [
26
+ join(homedir(), '.robopark', 'mesh.token'),
27
+ join(homedir(), '.config', 'infinicode-nodejs', 'config.json'),
28
+ join(homedir(), '.infinicode-nodejs', 'config.json'),
29
+ join(homedir(), 'AppData', 'Roaming', 'infinicode-nodejs', 'Config', 'config.json'),
30
+ ];
31
+ for (const p of paths) {
32
+ if (!existsSync(p))
33
+ continue;
34
+ try {
35
+ if (p.endsWith('mesh.token'))
36
+ return readFileSync(p, 'utf8').trim();
37
+ const cfg = JSON.parse(readFileSync(p, 'utf8'));
38
+ if (cfg.federation?.token)
39
+ return cfg.federation.token;
40
+ }
41
+ catch { /* ignore */ }
42
+ }
43
+ return process.env.ROBOPARK_MESH_TOKEN;
44
+ }
45
+ async function postServer(schedulerUrl, body) {
46
+ const url = `${schedulerUrl.replace(/\/$/, '')}/api/servers`;
47
+ const res = await fetch(url, {
48
+ method: 'POST',
49
+ headers: { 'content-type': 'application/json' },
50
+ body: JSON.stringify(body),
51
+ });
52
+ if (!res.ok) {
53
+ const text = await res.text().catch(() => '');
54
+ throw new Error(`${res.status}: ${text || res.statusText}`);
55
+ }
56
+ }
57
+ export async function roboparkServerAdd(opts) {
58
+ const ctx = await discoverContext();
59
+ const hubUrl = opts.hubUrl ?? (ctx.hub ? `http://${ctx.hub.ip}:${ctx.meshPort ?? 47913}` : undefined);
60
+ // Kept for parity with setup-livekit's discovery flow, even though the
61
+ // scheduler's /api/servers endpoint itself is unauthenticated today.
62
+ void (opts.token ?? ctx.meshToken ?? loadMeshToken());
63
+ const schedulerUrl = opts.schedulerUrl ?? (hubUrl ? hubUrl.replace(/:\d+$/, `:${DEFAULT_SCHEDULER_PORT}`) : undefined);
64
+ console.log(chalk.bold('\n robopark server add'));
65
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
66
+ if (!schedulerUrl) {
67
+ console.log(chalk.red(' ✗ no scheduler URL. Pass --scheduler-url or --hub-url, or run robopark setup first.'));
68
+ process.exit(1);
69
+ }
70
+ if (!opts.id) {
71
+ console.log(chalk.red(' ✗ --id is required (unique server id, e.g. livekit-hub-docker).'));
72
+ process.exit(1);
73
+ }
74
+ if (!opts.url) {
75
+ console.log(chalk.red(' ✗ --url is required (LiveKit ws:// URL, e.g. ws://100.x.y.z:7880).'));
76
+ process.exit(1);
77
+ }
78
+ let apiKey = opts.apiKey;
79
+ let apiSecret = opts.apiSecret;
80
+ if (!apiKey || !apiSecret) {
81
+ console.log(chalk.yellow(` ⚠ --api-key/--api-secret not provided; defaulting to LiveKit --dev credentials ('${DEV_API_KEY}'/'${DEV_API_SECRET}')`));
82
+ apiKey = apiKey ?? DEV_API_KEY;
83
+ apiSecret = apiSecret ?? DEV_API_SECRET;
84
+ }
85
+ const body = {
86
+ id: opts.id,
87
+ name: opts.name ?? opts.id,
88
+ url: opts.url,
89
+ webhook_url: opts.webhookUrl ?? opts.url.replace(/^wss?/, 'http'),
90
+ api_key: apiKey,
91
+ api_secret: apiSecret,
92
+ gpu_name: 'none',
93
+ gpu_vram_mb: 0,
94
+ max_sessions: opts.maxSessions ? parseInt(opts.maxSessions, 10) : 8,
95
+ status: 'online',
96
+ };
97
+ console.log(` scheduler: ${chalk.cyan(schedulerUrl)}`);
98
+ console.log(` id: ${chalk.cyan(body.id)}`);
99
+ console.log(` url: ${chalk.cyan(body.url)}`);
100
+ console.log();
101
+ try {
102
+ await postServer(schedulerUrl, body);
103
+ console.log(chalk.green(` ✓ registered LiveKit server '${body.id}' with scheduler`));
104
+ console.log(chalk.dim(` name: ${body.name}`));
105
+ console.log(chalk.dim(` url: ${body.url}`));
106
+ console.log(chalk.dim(` webhook_url: ${body.webhook_url}`));
107
+ console.log(chalk.dim(` api_key: ${body.api_key}`));
108
+ console.log(chalk.dim(` max_sessions: ${body.max_sessions}`));
109
+ }
110
+ catch (e) {
111
+ console.log(chalk.red(` ✗ failed to register server: ${e.message}`));
112
+ process.exit(1);
113
+ }
114
+ }