robopark 2.8.35 → 3.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 (68) hide show
  1. package/README.md +88 -63
  2. package/bin/robopark.js +7 -17
  3. package/conversation/elevenlabs_agent.py +1985 -0
  4. package/conversation/requirements.txt +3 -0
  5. package/conversation/supervisor_store.py +189 -0
  6. package/dist/kernel/config-schema.js +37 -0
  7. package/dist/kernel/types.js +7 -0
  8. package/dist/robopark/access.js +99 -0
  9. package/dist/robopark/add-robot.js +188 -0
  10. package/dist/robopark/agent-ctl.js +305 -0
  11. package/dist/robopark/auto-start.js +289 -0
  12. package/dist/robopark/conversation.js +505 -0
  13. package/dist/robopark/deployment-commands.js +47 -0
  14. package/dist/robopark/discovery.js +180 -0
  15. package/dist/robopark/doctor.js +175 -0
  16. package/dist/robopark/enroll.js +68 -0
  17. package/dist/robopark/llm-set.js +87 -0
  18. package/dist/robopark/motor-control.js +195 -0
  19. package/dist/robopark/preview-agent-launcher.js +77 -0
  20. package/dist/robopark/probe.js +138 -0
  21. package/dist/robopark/profile.js +69 -0
  22. package/dist/robopark/python-env.js +162 -0
  23. package/dist/robopark/robot-runtime.js +489 -0
  24. package/dist/robopark/scan.js +97 -0
  25. package/dist/robopark/screen-control.js +55 -0
  26. package/dist/robopark/secrets.js +41 -0
  27. package/dist/robopark/serve.js +285 -0
  28. package/dist/robopark/server-add.js +114 -0
  29. package/dist/robopark/setup-livekit.js +300 -0
  30. package/dist/robopark/setup.js +286 -0
  31. package/dist/robopark/standalone.js +466 -0
  32. package/dist/robopark/stop-all.js +141 -0
  33. package/dist/robopark/verify.js +192 -0
  34. package/dist/robopark/vision-agent-launcher.js +98 -0
  35. package/dist/robopark/vision-control.js +81 -0
  36. package/dist/robopark-cli.js +799 -0
  37. package/package.json +21 -5
  38. package/pi-client/_install_steps.sh +29 -29
  39. package/pi-client/client.py +61 -2
  40. package/pi-client/install.sh +40 -40
  41. package/pi-client/join_convo.sh +54 -54
  42. package/pi-client/livekit_bridge.py +16 -7
  43. package/pi-client/motor_bridge.py +6 -3
  44. package/scheduler/fleet_config.json +75 -0
  45. package/scheduler/main.py +4505 -135
  46. package/scheduler/media_lock.py +57 -0
  47. package/scheduler/preview_agent.py +1465 -87
  48. package/scheduler/production_config.json +139 -0
  49. package/scheduler/robot_supervisor.py +1705 -0
  50. package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
  51. package/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
  52. package/scheduler/scripts/robopark-supervisor.service +20 -0
  53. package/scheduler/scripts/start-scheduler-local.ps1 +50 -0
  54. package/scheduler/supervisor.example.json +26 -0
  55. package/scheduler/vision_motion_trigger.py +101 -0
  56. package/screen/screen_runtime.py +75 -0
  57. package/vision/app_pi_clean.py +253 -16
  58. package/vision/audio_server_pi.py +19 -0
  59. package/vision/install.sh +34 -34
  60. package/vision/motor_server.py +224 -61
  61. package/vision/requirements_camera.txt +6 -0
  62. package/vision/requirements_motor.txt +4 -0
  63. package/vision/requirements_pi_unified.txt +1 -0
  64. package/vision/requirements_vision_agent.txt +19 -0
  65. package/vision/run.sh +244 -244
  66. package/vision/services/services.sh +12 -12
  67. package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  68. package/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
@@ -0,0 +1,3 @@
1
+ elevenlabs[pyaudio]>=2.39.0,<3
2
+ audioop-lts>=0.2.2; python_version >= "3.13"
3
+ gpiozero>=2.0
@@ -0,0 +1,189 @@
1
+ """Durable state for the robot-local voice supervisor.
2
+
3
+ This module deliberately has no scheduler dependency. The voice process writes
4
+ locally first and the management plane may consume events later by cursor.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sqlite3
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+
17
+
18
+ COMMAND_STATES = {
19
+ "queued", "delivered", "acknowledged", "running", "succeeded",
20
+ "failed", "expired", "superseded",
21
+ }
22
+
23
+
24
+ class SupervisorStore:
25
+ def __init__(self, config_path: str, robot_id: str, max_events: int = 50_000) -> None:
26
+ source = Path(config_path)
27
+ self.path = source.with_suffix(source.suffix + ".supervisor.sqlite3")
28
+ self.robot_id = robot_id
29
+ self.max_events = max(1_000, int(max_events))
30
+ self.lock = threading.RLock()
31
+ self.path.parent.mkdir(parents=True, exist_ok=True)
32
+ self._initialize()
33
+
34
+ def _connect(self) -> sqlite3.Connection:
35
+ connection = sqlite3.connect(self.path, timeout=10)
36
+ connection.row_factory = sqlite3.Row
37
+ connection.execute("PRAGMA journal_mode=WAL")
38
+ connection.execute("PRAGMA synchronous=NORMAL")
39
+ return connection
40
+
41
+ @contextmanager
42
+ def _db(self):
43
+ connection = self._connect()
44
+ try:
45
+ with connection:
46
+ yield connection
47
+ finally:
48
+ connection.close()
49
+
50
+ def _initialize(self) -> None:
51
+ with self.lock, self._db() as db:
52
+ db.executescript(
53
+ """
54
+ CREATE TABLE IF NOT EXISTS events (
55
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
56
+ event_id TEXT NOT NULL UNIQUE,
57
+ robot_id TEXT NOT NULL,
58
+ session_id TEXT,
59
+ type TEXT NOT NULL,
60
+ timestamp REAL NOT NULL,
61
+ payload TEXT NOT NULL,
62
+ acknowledged INTEGER NOT NULL DEFAULT 0
63
+ );
64
+ CREATE INDEX IF NOT EXISTS idx_voice_events_ack_sequence
65
+ ON events(acknowledged, sequence);
66
+ CREATE TABLE IF NOT EXISTS state (
67
+ key TEXT PRIMARY KEY,
68
+ value TEXT NOT NULL
69
+ );
70
+ CREATE TABLE IF NOT EXISTS completed_commands (
71
+ command_id TEXT PRIMARY KEY,
72
+ status TEXT NOT NULL,
73
+ result TEXT NOT NULL,
74
+ completed_at REAL NOT NULL
75
+ );
76
+ """
77
+ )
78
+
79
+ def get_state(self, key: str, default=None):
80
+ with self.lock, self._db() as db:
81
+ row = db.execute("SELECT value FROM state WHERE key = ?", (key,)).fetchone()
82
+ return json.loads(row["value"]) if row else default
83
+
84
+ def set_state(self, key: str, value) -> None:
85
+ encoded = json.dumps(value, separators=(",", ":"), ensure_ascii=True)
86
+ with self.lock, self._db() as db:
87
+ db.execute(
88
+ "INSERT INTO state(key, value) VALUES(?, ?) "
89
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
90
+ (key, encoded),
91
+ )
92
+
93
+ def append_event(self, event_type: str, payload: dict, session_id: str | None = None) -> dict:
94
+ event_id = str(uuid.uuid4())
95
+ timestamp = time.time()
96
+ with self.lock, self._db() as db:
97
+ cursor = db.execute(
98
+ "INSERT INTO events(event_id, robot_id, session_id, type, timestamp, payload) "
99
+ "VALUES(?, ?, ?, ?, ?, ?)",
100
+ (
101
+ event_id,
102
+ self.robot_id,
103
+ session_id,
104
+ event_type,
105
+ timestamp,
106
+ json.dumps(payload, separators=(",", ":"), ensure_ascii=True),
107
+ ),
108
+ )
109
+ sequence = int(cursor.lastrowid)
110
+ overflow = db.execute(
111
+ "SELECT sequence FROM events ORDER BY sequence DESC LIMIT 1 OFFSET ?",
112
+ (self.max_events,),
113
+ ).fetchone()
114
+ if overflow:
115
+ db.execute("DELETE FROM events WHERE sequence <= ? AND acknowledged = 1", (overflow["sequence"],))
116
+ return {
117
+ "event_id": event_id,
118
+ "sequence": sequence,
119
+ "robot_id": self.robot_id,
120
+ "session_id": session_id,
121
+ "type": event_type,
122
+ "timestamp": timestamp,
123
+ "payload": payload,
124
+ }
125
+
126
+ def events_after(self, cursor: int, limit: int = 250) -> list[dict]:
127
+ safe_limit = min(1_000, max(1, int(limit)))
128
+ with self.lock, self._db() as db:
129
+ rows = db.execute(
130
+ "SELECT * FROM events WHERE sequence > ? ORDER BY sequence LIMIT ?",
131
+ (max(0, int(cursor)), safe_limit),
132
+ ).fetchall()
133
+ return [
134
+ {
135
+ "event_id": row["event_id"],
136
+ "sequence": row["sequence"],
137
+ "robot_id": row["robot_id"],
138
+ "session_id": row["session_id"],
139
+ "type": row["type"],
140
+ "timestamp": row["timestamp"],
141
+ "payload": json.loads(row["payload"]),
142
+ }
143
+ for row in rows
144
+ ]
145
+
146
+ def acknowledge(self, sequence: int) -> int:
147
+ highest = max(0, int(sequence))
148
+ with self.lock, self._db() as db:
149
+ db.execute("UPDATE events SET acknowledged = 1 WHERE sequence <= ?", (highest,))
150
+ db.execute("DELETE FROM events WHERE acknowledged = 1 AND sequence <= ?", (highest,))
151
+ return highest
152
+
153
+ def stats(self) -> dict:
154
+ with self.lock, self._db() as db:
155
+ row = db.execute(
156
+ "SELECT COUNT(*) total, COALESCE(MIN(sequence), 0) first_sequence, "
157
+ "COALESCE(MAX(sequence), 0) last_sequence, "
158
+ "SUM(CASE WHEN acknowledged = 0 THEN 1 ELSE 0 END) pending FROM events"
159
+ ).fetchone()
160
+ return {
161
+ "total": int(row["total"] or 0),
162
+ "pending": int(row["pending"] or 0),
163
+ "first_sequence": int(row["first_sequence"] or 0),
164
+ "last_sequence": int(row["last_sequence"] or 0),
165
+ }
166
+
167
+ def command_result(self, command_id: str) -> dict | None:
168
+ with self.lock, self._db() as db:
169
+ row = db.execute(
170
+ "SELECT status, result, completed_at FROM completed_commands WHERE command_id = ?",
171
+ (command_id,),
172
+ ).fetchone()
173
+ if not row:
174
+ return None
175
+ return {
176
+ "status": row["status"],
177
+ "result": json.loads(row["result"]),
178
+ "completed_at": row["completed_at"],
179
+ }
180
+
181
+ def complete_command(self, command_id: str, status: str, result: dict) -> None:
182
+ if status not in COMMAND_STATES:
183
+ raise ValueError(f"invalid command state: {status}")
184
+ with self.lock, self._db() as db:
185
+ db.execute(
186
+ "INSERT OR REPLACE INTO completed_commands(command_id, status, result, completed_at) "
187
+ "VALUES(?, ?, ?, ?)",
188
+ (command_id, status, json.dumps(result, ensure_ascii=True), time.time()),
189
+ )
@@ -0,0 +1,37 @@
1
+ export const DEFAULT_CONFIG = {
2
+ masterUrl: 'http://localhost:11434',
3
+ defaultModel: 'qwen2.5-coder:14b',
4
+ theme: 'infini',
5
+ // Auto-routes across all providers by capability + benchmark. Use 'offline'
6
+ // to lock to local, or 'quality'/'fastest'/'free-first' to bias the router.
7
+ policy: 'balanced',
8
+ workerModels: {},
9
+ cloudProviders: [],
10
+ localProviders: [],
11
+ };
12
+ export const WORKER_TYPES_ORDER = [
13
+ 'coding',
14
+ 'frontend',
15
+ 'research',
16
+ 'architecture',
17
+ 'review',
18
+ 'documentation',
19
+ 'translation',
20
+ 'terminal',
21
+ 'verification',
22
+ 'vision',
23
+ 'browser',
24
+ ];
25
+ export const WORKER_DESCRIPTIONS = {
26
+ coding: 'Writes code — functions, implementations, fixes',
27
+ frontend: 'Builds production-grade UI (HTML/CSS/vanilla JS) — design systems, components, animation',
28
+ research: 'Searches, crawls, summarizes with citations',
29
+ architecture: 'Designs structure, modules, data flow, tradeoffs',
30
+ review: 'Critiques code, design, or plans',
31
+ documentation: 'Produces docs in Markdown',
32
+ translation: 'Translates content across languages',
33
+ terminal: 'Produces shell commands',
34
+ verification: 'Verifies tasks meet acceptance criteria',
35
+ vision: 'Analyzes images, OCR, visual content',
36
+ browser: 'Drives a browser via Playwright',
37
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * OpenKernel — Core Type Definitions
3
+ *
4
+ * Provider-agnostic AI execution kernel types.
5
+ * Mission → Objectives → Tasks → Workers → Providers.
6
+ */
7
+ export {};
@@ -0,0 +1,99 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ const ACCESS_HASH = 'af6bf6bcf8f63360a09a15c211f0824b608d3ce462e8a53fc45dfc63b42e4751';
6
+ const STATE_DIR = process.env.ROBOPARK_HOME
7
+ ? resolve(process.env.ROBOPARK_HOME)
8
+ : join(homedir(), '.robopark');
9
+ const ACCESS_PATH = join(STATE_DIR, 'package-access.json');
10
+ function digest(value) {
11
+ return createHash('sha256').update(value, 'utf8').digest();
12
+ }
13
+ export function passwordMatches(value) {
14
+ const expected = Buffer.from(ACCESS_HASH, 'hex');
15
+ const actual = digest(value);
16
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
17
+ }
18
+ function jsonHas(path, keys) {
19
+ if (!existsSync(path))
20
+ return false;
21
+ try {
22
+ const value = JSON.parse(readFileSync(path, 'utf8'));
23
+ return keys.every(key => typeof value[key] === 'string' && String(value[key]).trim().length > 0);
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ function validDeviceToken() {
30
+ const path = join(STATE_DIR, 'device_token');
31
+ if (!existsSync(path))
32
+ return false;
33
+ try {
34
+ return readFileSync(path, 'utf8').trim().length >= 20;
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ function validSchedulerDatabase() {
41
+ const path = join(STATE_DIR, 'scheduler', 'scheduler.db');
42
+ if (!existsSync(path) || statSync(path).size < 100)
43
+ return false;
44
+ try {
45
+ const header = readFileSync(path).subarray(0, 16).toString('utf8');
46
+ return header === 'SQLite format 3\u0000';
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ /** Existing enrolled robots and gateways upgrade without an activation outage. */
53
+ export function hasExistingRoboParkIdentity() {
54
+ return validDeviceToken()
55
+ || validSchedulerDatabase()
56
+ || jsonHas(join(STATE_DIR, 'connection.json'), ['schedulerUrl', 'deviceId', 'deviceToken'])
57
+ || jsonHas(join(STATE_DIR, 'preview_agent.json'), ['scheduler_url'])
58
+ || jsonHas(join(STATE_DIR, 'hub-profile.json'), ['hubUrl']);
59
+ }
60
+ function activated() {
61
+ if (!existsSync(ACCESS_PATH))
62
+ return false;
63
+ try {
64
+ const record = JSON.parse(readFileSync(ACCESS_PATH, 'utf8'));
65
+ return record.accessHash === ACCESS_HASH;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ function writeActivation(source) {
72
+ mkdirSync(STATE_DIR, { recursive: true });
73
+ const temp = `${ACCESS_PATH}.${process.pid}.tmp`;
74
+ writeFileSync(temp, `${JSON.stringify({
75
+ version: 1,
76
+ accessHash: ACCESS_HASH,
77
+ source,
78
+ activatedAt: new Date().toISOString(),
79
+ }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
80
+ renameSync(temp, ACCESS_PATH);
81
+ }
82
+ export function activateFromPairing() {
83
+ if (!activated())
84
+ writeActivation('pairing');
85
+ }
86
+ export function requirePackageAccess(password) {
87
+ if (activated())
88
+ return;
89
+ if (hasExistingRoboParkIdentity()) {
90
+ writeActivation('existing-install');
91
+ return;
92
+ }
93
+ const candidate = password?.trim() || process.env.ROBOPARK_PACKAGE_PASSWORD?.trim();
94
+ if (candidate && passwordMatches(candidate)) {
95
+ writeActivation('password');
96
+ return;
97
+ }
98
+ throw new Error('RoboPark activation required. Set ROBOPARK_PACKAGE_PASSWORD or pass `robopark --password <password> ...` once.');
99
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * RoboPark — `robopark add-robot`.
3
+ *
4
+ * Collapses what used to be two manual steps — hand-curling
5
+ * `POST /api/devices` to mint an enrollment token, then hand-typing the full
6
+ * `robopark setup --robot --hub-url ... --token ... --scheduler-url ...
7
+ * --enrollment-token ...` one-liner on the actual robot machine — into one
8
+ * command.
9
+ *
10
+ * Usage:
11
+ * robopark add-robot --name robobmw --site "Tel Aviv"
12
+ * → mints an enrollment token against the resolved scheduler and prints
13
+ * the ready-to-paste `robopark setup --robot ...` command to run ON
14
+ * THE ROBOT.
15
+ *
16
+ * robopark add-robot --name robobmw --start
17
+ * → mints the token AND starts the robot node right here, on this
18
+ * machine (useful when this machine IS the robot).
19
+ *
20
+ * Hub/scheduler/token context is resolved the standard way: explicit flags >
21
+ * saved hub profile (see profile.ts / `robopark hub-use`) > discoverContext()
22
+ * auto-discovery.
23
+ */
24
+ import chalk from 'chalk';
25
+ import { resolveContext } from './profile.js';
26
+ import { roboparkSetup } from './setup.js';
27
+ function robotReachableSchedulerUrl(schedulerUrl, hubUrl) {
28
+ if (!hubUrl)
29
+ return schedulerUrl;
30
+ try {
31
+ // Port 8080 is intentionally private to the hub. Enrollment, character
32
+ // lookup, heartbeats, and robot sessions must all use the authenticated
33
+ // mesh proxy, whether the hidden scheduler is loopback or a 192.168.x.x
34
+ // address that is only meaningful on the hub's own LAN.
35
+ new URL(schedulerUrl);
36
+ new URL(hubUrl);
37
+ return `${hubUrl.replace(/\/+$/, '')}/robopark`;
38
+ }
39
+ catch {
40
+ // Keep the original value so the existing request reports a useful error.
41
+ }
42
+ return schedulerUrl;
43
+ }
44
+ function schedulerApiUrl(schedulerUrl, path, token) {
45
+ const url = new URL(`${schedulerUrl.replace(/\/+$/, '')}${path}`);
46
+ if (token && url.pathname.startsWith('/robopark/')) {
47
+ url.searchParams.set('token', token);
48
+ }
49
+ return url.toString();
50
+ }
51
+ async function mintEnrollmentToken(schedulerUrl, name, characterId, token) {
52
+ const url = schedulerApiUrl(schedulerUrl, '/api/devices', token);
53
+ let res;
54
+ try {
55
+ res = await fetch(url, {
56
+ method: 'POST',
57
+ headers: { 'content-type': 'application/json' },
58
+ body: JSON.stringify({ name, character_id: characterId }),
59
+ });
60
+ }
61
+ catch (error) {
62
+ const cause = error.cause;
63
+ throw new Error(`cannot reach scheduler through ${schedulerUrl}: ${cause?.message ?? error.message}`);
64
+ }
65
+ if (!res.ok) {
66
+ const text = await res.text().catch(() => '');
67
+ throw new Error(`${res.status}: ${text || res.statusText}`);
68
+ }
69
+ return (await res.json());
70
+ }
71
+ async function pickCharacterPreset(schedulerUrl, name, token) {
72
+ const lower = name.toLowerCase();
73
+ try {
74
+ const res = await fetch(schedulerApiUrl(schedulerUrl, '/api/character-presets', token));
75
+ if (!res.ok)
76
+ return undefined;
77
+ const presets = (await res.json());
78
+ // Exact id match first, then fuzzy name match.
79
+ const exact = presets.find(p => p.id.toLowerCase() === lower || p.name.toLowerCase() === lower);
80
+ if (exact)
81
+ return exact.id;
82
+ const partial = presets.find(p => lower.includes(p.id.toLowerCase()) || p.name.toLowerCase().includes(lower));
83
+ return partial?.id;
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ /**
90
+ * Build a Windows-task-friendly command string from an argv array.
91
+ *
92
+ * Mirrors setup.ts's local `commandString()` helper (kept private there) so
93
+ * the one-liner this command prints looks exactly like the ones `robopark
94
+ * setup` itself prints — same quoting convention, so it's paste-safe on both
95
+ * POSIX and Windows shells.
96
+ */
97
+ function commandString(argv) {
98
+ return argv.map(a => /[^a-zA-Z0-9_./:=,-]/.test(a) ? `"${a.replace(/"/g, '""')}"` : a).join(' ');
99
+ }
100
+ export async function roboparkAddRobot(config, opts) {
101
+ console.log(chalk.bold('\n robopark add-robot'));
102
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
103
+ if (!opts.name || !opts.name.trim()) {
104
+ console.log(chalk.red(' ✗ --name is required, e.g. --name robobmw'));
105
+ process.exit(1);
106
+ }
107
+ if (opts.lan && opts.tailscale) {
108
+ console.log(chalk.red(' ✗ choose exactly one network mode: --lan or --tailscale'));
109
+ process.exit(1);
110
+ }
111
+ const network = opts.tailscale ? 'tailscale' : 'lan';
112
+ const ctx = await resolveContext(opts);
113
+ if (!ctx.schedulerUrl) {
114
+ console.log(chalk.red(' ✗ no scheduler URL resolved. Pass --scheduler-url/--hub-url, run `robopark hub-use` first, or run this on/near the hub.'));
115
+ process.exit(1);
116
+ }
117
+ const schedulerUrl = robotReachableSchedulerUrl(ctx.schedulerUrl, ctx.hubUrl);
118
+ if (schedulerUrl.endsWith('/robopark') && !ctx.token) {
119
+ console.log(chalk.red(' ✗ hub mesh token is required to use the scheduler proxy. Pass --token or run `robopark hub-use` first.'));
120
+ process.exit(1);
121
+ }
122
+ console.log(` scheduler: ${chalk.cyan(schedulerUrl)}`);
123
+ console.log(` name: ${chalk.cyan(opts.name)}${ctx.site ? chalk.dim(' @ ' + ctx.site) : ''}`);
124
+ console.log(` network: ${chalk.cyan(network)}`);
125
+ console.log();
126
+ const characterId = opts.character?.trim() || await pickCharacterPreset(schedulerUrl, opts.name, ctx.token);
127
+ if (characterId) {
128
+ console.log(` character: ${chalk.cyan(characterId)}${opts.character ? chalk.dim(' (forced via --character)') : chalk.dim(' (auto-matched)')}`);
129
+ }
130
+ else {
131
+ console.log(chalk.yellow(` ⚠ no character preset matched '${opts.name}' — pass --character <id> to bind this robot to a specific park-map pad`));
132
+ }
133
+ let minted;
134
+ try {
135
+ minted = await mintEnrollmentToken(schedulerUrl, opts.name, characterId, ctx.token);
136
+ }
137
+ catch (e) {
138
+ console.log(chalk.red(` ✗ failed to mint enrollment token: ${e.message}`));
139
+ process.exit(1);
140
+ return;
141
+ }
142
+ // The API returns this once and does not store it in plaintext — always
143
+ // show it, even when auto-starting, so it's available for reference or a
144
+ // future re-enrollment against the same scheduler.
145
+ console.log(chalk.green(` ✓ minted enrollment token for '${opts.name}' (device ${minted.id})`));
146
+ console.log(` enrollment token: ${chalk.yellow(minted.enrollment_token)}`);
147
+ console.log();
148
+ if (opts.start) {
149
+ console.log(chalk.dim(' --start given — starting robot node on this machine…\n'));
150
+ await roboparkSetup(config, {
151
+ role: 'robot',
152
+ name: opts.name,
153
+ site: ctx.site,
154
+ hub: ctx.hubUrl,
155
+ token: ctx.token,
156
+ schedulerPort: ctx.schedulerUrl ? String(new URL(ctx.schedulerUrl).port || '8080') : undefined,
157
+ enrollmentToken: minted.enrollment_token,
158
+ start: true,
159
+ autoStart: opts.autoStart !== false,
160
+ yes: opts.yes !== false,
161
+ lan: opts.lan,
162
+ tailscale: opts.tailscale,
163
+ });
164
+ return;
165
+ }
166
+ // Default: print the ready-to-paste one-liner for running on the actual
167
+ // (different) robot machine, in the same format `robopark setup` itself
168
+ // prints/expects.
169
+ const argv = ['robopark', 'setup', '--robot', '--name', opts.name];
170
+ if (ctx.site)
171
+ argv.push('--site', ctx.site);
172
+ if (ctx.hubUrl)
173
+ argv.push('--hub-url', ctx.hubUrl);
174
+ if (ctx.token)
175
+ argv.push('--token', ctx.token);
176
+ if (ctx.schedulerUrl) {
177
+ const schedulerPort = new URL(ctx.schedulerUrl).port || '8080';
178
+ argv.push('--scheduler-port', schedulerPort);
179
+ }
180
+ argv.push(network === 'tailscale' ? '--tailscale' : '--lan');
181
+ argv.push('--enrollment-token', minted.enrollment_token, '--start', '--auto-start', '--yes');
182
+ console.log(chalk.dim(` run this ON THE ROBOT${characterId ? ` (binds to pad: ${characterId})` : ''}:`));
183
+ console.log(' ' + chalk.cyan(commandString(argv)));
184
+ if (!ctx.hubUrl) {
185
+ console.log(chalk.yellow(' ⚠ no hub URL resolved — the command above omits --hub-url; run `robopark hub-use` first or pass --hub-url explicitly if the robot needs it.'));
186
+ }
187
+ console.log();
188
+ }