infinicode 2.8.22 → 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.
@@ -77,8 +77,22 @@ export class PeerMesh {
77
77
  peer.lastSeenAt = Date.now();
78
78
  if (!peer.connected) {
79
79
  peer.connected = true;
80
+ // A peer coming back online may be a fresh process with a changed
81
+ // role (e.g. someone fixed a bad `--hub` flag and relaunched as
82
+ // `--role satellite`) — the nodeId is stable across restarts (it's
83
+ // a persisted identity, not per-process), so without this refetch
84
+ // the stale role from the peer's *first ever* connect() would
85
+ // stick forever, since nothing else in this class ever updates it.
86
+ try {
87
+ const manifest = await this.opts.client.fetchManifest(peer.url);
88
+ peer.role = manifest.role;
89
+ peer.displayName = manifest.displayName;
90
+ peer.version = manifest.version;
91
+ peer.manifest = manifest;
92
+ }
93
+ catch { /* keep last-known manifest fields if the refetch fails */ }
80
94
  this.openStream(peer); // revive stream after an outage
81
- this.opts.logger.info(`[federation] peer ${peer.displayName} back online`);
95
+ this.opts.logger.info(`[federation] peer ${peer.displayName} back online (role: ${peer.role})`);
82
96
  this.opts.onPeerChange?.();
83
97
  }
84
98
  }
@@ -15,6 +15,8 @@ export interface OllamaProviderOptions {
15
15
  fallbackURL?: string;
16
16
  defaultModel?: string;
17
17
  timeoutMs?: number;
18
+ /** Bearer token for Ollama Cloud (ollama.com) — unset for local Ollama, which has no auth. */
19
+ apiKey?: string;
18
20
  }
19
21
  export declare class OllamaProvider implements ProviderInterface {
20
22
  info: ProviderInfo;
@@ -23,6 +25,7 @@ export declare class OllamaProvider implements ProviderInterface {
23
25
  private activeURL;
24
26
  private defaultModel?;
25
27
  private timeoutMs;
28
+ private apiKey?;
26
29
  private cachedModels;
27
30
  constructor(options: OllamaProviderOptions);
28
31
  capabilities(): Promise<ProviderCapabilities>;
@@ -34,6 +37,7 @@ export declare class OllamaProvider implements ProviderInterface {
34
37
  latencyMs: number;
35
38
  error?: string;
36
39
  }>;
40
+ private headers;
37
41
  private fetchModels;
38
42
  private toProviderModel;
39
43
  private guessContextLength;
@@ -5,6 +5,7 @@ export class OllamaProvider {
5
5
  activeURL;
6
6
  defaultModel;
7
7
  timeoutMs;
8
+ apiKey;
8
9
  cachedModels = [];
9
10
  constructor(options) {
10
11
  const id = options.id ?? 'ollama';
@@ -13,6 +14,7 @@ export class OllamaProvider {
13
14
  this.activeURL = this.baseURL;
14
15
  this.defaultModel = options.defaultModel;
15
16
  this.timeoutMs = options.timeoutMs ?? 10_000;
17
+ this.apiKey = options.apiKey;
16
18
  this.info = {
17
19
  id,
18
20
  name: options.name ?? 'Ollama',
@@ -45,7 +47,7 @@ export class OllamaProvider {
45
47
  };
46
48
  const response = await fetch(endpoint, {
47
49
  method: 'POST',
48
- headers: { 'Content-Type': 'application/json' },
50
+ headers: this.headers(),
49
51
  body: JSON.stringify(body),
50
52
  signal: AbortSignal.timeout(this.timeoutMs * 3),
51
53
  });
@@ -77,7 +79,7 @@ export class OllamaProvider {
77
79
  };
78
80
  const response = await fetch(endpoint, {
79
81
  method: 'POST',
80
- headers: { 'Content-Type': 'application/json' },
82
+ headers: this.headers(),
81
83
  body: JSON.stringify(body),
82
84
  signal: AbortSignal.timeout(this.timeoutMs * 6),
83
85
  });
@@ -133,7 +135,19 @@ export class OllamaProvider {
133
135
  return { ok: false, latencyMs: Date.now() - start, error: err instanceof Error ? err.message : String(err) };
134
136
  }
135
137
  }
138
+ headers() {
139
+ const h = { 'Content-Type': 'application/json' };
140
+ if (this.apiKey)
141
+ h['Authorization'] = `Bearer ${this.apiKey}`;
142
+ return h;
143
+ }
136
144
  async fetchModels() {
145
+ // Ollama Cloud has no /api/tags (that lists locally-pulled models, which
146
+ // isn't a concept cloud-hosted models have) — a configured defaultModel
147
+ // is used directly by complete()/completeStream() regardless, so treat
148
+ // this as best-effort rather than failing capabilities()/verify().
149
+ if (this.apiKey)
150
+ return this.cachedModels;
137
151
  const url = await this.resolveURL();
138
152
  const response = await fetch(`${url}/api/tags`, {
139
153
  signal: AbortSignal.timeout(this.timeoutMs),
@@ -79,6 +79,19 @@ export function buildKernelFromConfig(config, options) {
79
79
  };
80
80
  const kernel = new Kernel({ loadBuiltinWorkers: true, logger: options?.logger });
81
81
  kernel.registerProvider('ollama', new OllamaProvider(ollamaOptions));
82
+ // Ollama Cloud (ollama.com) — separate from the local instance above:
83
+ // different base URL, requires a Bearer token, and its /v1/chat/completions
84
+ // is used directly rather than the local-only /api/tags model listing.
85
+ const ollamaCloudKey = process.env.OLLAMA_CLOUD_API_KEY;
86
+ if (ollamaCloudKey) {
87
+ kernel.registerProvider('ollama-cloud', new OllamaProvider({
88
+ id: 'ollama-cloud',
89
+ name: 'Ollama Cloud',
90
+ baseURL: 'https://ollama.com',
91
+ defaultModel: process.env.OLLAMA_CLOUD_MODEL ?? 'gemma3:27b',
92
+ apiKey: ollamaCloudKey,
93
+ }));
94
+ }
82
95
  // Register cloud providers
83
96
  registerCloudProviders(kernel, config.get('cloudProviders') ?? []);
84
97
  // Register local OpenAI-compatible providers (LM Studio, vLLM, SGLang,
@@ -0,0 +1,8 @@
1
+ import type Conf from 'conf';
2
+ import type { InfinicodeConfig } from '../kernel/config-schema.js';
3
+ import { type ExplicitContextOpts } from './profile.js';
4
+ export interface AddRobotOptions extends ExplicitContextOpts {
5
+ name: string;
6
+ start?: boolean;
7
+ }
8
+ export declare function roboparkAddRobot(config: Conf<InfinicodeConfig>, opts: AddRobotOptions): Promise<void>;
@@ -0,0 +1,113 @@
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
+ async function mintEnrollmentToken(schedulerUrl, name) {
28
+ const url = `${schedulerUrl.replace(/\/$/, '')}/api/devices`;
29
+ const res = await fetch(url, {
30
+ method: 'POST',
31
+ headers: { 'content-type': 'application/json' },
32
+ body: JSON.stringify({ name }),
33
+ });
34
+ if (!res.ok) {
35
+ const text = await res.text().catch(() => '');
36
+ throw new Error(`${res.status}: ${text || res.statusText}`);
37
+ }
38
+ return (await res.json());
39
+ }
40
+ /**
41
+ * Build a Windows-task-friendly command string from an argv array.
42
+ *
43
+ * Mirrors setup.ts's local `commandString()` helper (kept private there) so
44
+ * the one-liner this command prints looks exactly like the ones `robopark
45
+ * setup` itself prints — same quoting convention, so it's paste-safe on both
46
+ * POSIX and Windows shells.
47
+ */
48
+ function commandString(argv) {
49
+ return argv.map(a => /[^a-zA-Z0-9_./:=,-]/.test(a) ? `"${a.replace(/"/g, '""')}"` : a).join(' ');
50
+ }
51
+ export async function roboparkAddRobot(config, opts) {
52
+ console.log(chalk.bold('\n robopark add-robot'));
53
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
54
+ if (!opts.name || !opts.name.trim()) {
55
+ console.log(chalk.red(' ✗ --name is required, e.g. --name robobmw'));
56
+ process.exit(1);
57
+ }
58
+ const ctx = await resolveContext(opts);
59
+ if (!ctx.schedulerUrl) {
60
+ 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.'));
61
+ process.exit(1);
62
+ }
63
+ console.log(` scheduler: ${chalk.cyan(ctx.schedulerUrl)}`);
64
+ console.log(` name: ${chalk.cyan(opts.name)}${ctx.site ? chalk.dim(' @ ' + ctx.site) : ''}`);
65
+ console.log();
66
+ let minted;
67
+ try {
68
+ minted = await mintEnrollmentToken(ctx.schedulerUrl, opts.name);
69
+ }
70
+ catch (e) {
71
+ console.log(chalk.red(` ✗ failed to mint enrollment token: ${e.message}`));
72
+ process.exit(1);
73
+ return;
74
+ }
75
+ // The API returns this once and does not store it in plaintext — always
76
+ // show it, even when auto-starting, so it's available for reference or a
77
+ // future re-enrollment against the same scheduler.
78
+ console.log(chalk.green(` ✓ minted enrollment token for '${opts.name}' (device ${minted.id})`));
79
+ console.log(` enrollment token: ${chalk.yellow(minted.enrollment_token)}`);
80
+ console.log();
81
+ if (opts.start) {
82
+ console.log(chalk.dim(' --start given — starting robot node on this machine…\n'));
83
+ await roboparkSetup(config, {
84
+ role: 'robot',
85
+ name: opts.name,
86
+ site: ctx.site,
87
+ hub: ctx.hubUrl,
88
+ token: ctx.token,
89
+ schedulerPort: ctx.schedulerUrl ? String(new URL(ctx.schedulerUrl).port || '8080') : undefined,
90
+ enrollmentToken: minted.enrollment_token,
91
+ start: true,
92
+ });
93
+ return;
94
+ }
95
+ // Default: print the ready-to-paste one-liner for running on the actual
96
+ // (different) robot machine, in the same format `robopark setup` itself
97
+ // prints/expects.
98
+ const argv = ['robopark', 'setup', '--robot', '--name', opts.name];
99
+ if (ctx.site)
100
+ argv.push('--site', ctx.site);
101
+ if (ctx.hubUrl)
102
+ argv.push('--hub-url', ctx.hubUrl);
103
+ if (ctx.token)
104
+ argv.push('--token', ctx.token);
105
+ if (ctx.schedulerUrl) {
106
+ const schedulerPort = new URL(ctx.schedulerUrl).port || '8080';
107
+ argv.push('--scheduler-port', schedulerPort);
108
+ }
109
+ argv.push('--enrollment-token', minted.enrollment_token, '--start', '--auto-start');
110
+ console.log(chalk.dim(' run this ON THE ROBOT:'));
111
+ console.log(' ' + chalk.cyan(commandString(argv)));
112
+ console.log();
113
+ }
@@ -0,0 +1,20 @@
1
+ export interface AgentUpOptions {
2
+ path: string;
3
+ schedulerUrl?: string;
4
+ hubUrl?: string;
5
+ token?: string;
6
+ serverId?: string;
7
+ serverName?: string;
8
+ register?: boolean;
9
+ }
10
+ export interface AgentDownOptions {
11
+ path: string;
12
+ }
13
+ export interface AgentLogsOptions {
14
+ path: string;
15
+ follow?: boolean;
16
+ service?: string;
17
+ }
18
+ export declare function roboparkAgentUp(opts: AgentUpOptions): Promise<void>;
19
+ export declare function roboparkAgentDown(opts: AgentDownOptions): Promise<void>;
20
+ export declare function roboparkAgentLogs(opts: AgentLogsOptions): Promise<void>;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * RoboPark — `robopark agent up/down/logs`.
3
+ *
4
+ * ROBOVOICE (the production voice stack: LiveKit + STT/TTS + agent) today
5
+ * requires an operator to manually `docker compose up -d` in that checkout,
6
+ * then manually `curl POST /api/servers` on the RoboPark scheduler to
7
+ * register the LiveKit instance it brought up. This file collapses both
8
+ * steps into one command so a hub/GPU machine only needs:
9
+ *
10
+ * robopark setup --role hub
11
+ * robopark agent-up --path C:\path\to\ROBOVOICE
12
+ *
13
+ * to be fully wired end to end.
14
+ *
15
+ * Reuses `roboparkServerAdd` (server-add.ts) for the exact POST /api/servers
16
+ * shape, and `resolveContext` (profile.ts) for hub/scheduler/token
17
+ * resolution with saved hub-profile fallback — nothing about the
18
+ * registration mechanics is reimplemented here.
19
+ */
20
+ import { spawn, execFile } from 'node:child_process';
21
+ import { existsSync } from 'node:fs';
22
+ import { hostname, networkInterfaces } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import chalk from 'chalk';
25
+ import { roboparkServerAdd } from './server-add.js';
26
+ import { resolveContext } from './profile.js';
27
+ const LIVEKIT_PORT = 7880;
28
+ const LIVEKIT_DEV_API_KEY = 'devkey';
29
+ const LIVEKIT_DEV_API_SECRET = 'secret';
30
+ /** This machine's first non-internal IPv4 LAN address, if any. */
31
+ function getLanIps() {
32
+ const out = [];
33
+ for (const addrs of Object.values(networkInterfaces())) {
34
+ if (!addrs)
35
+ continue;
36
+ for (const a of addrs) {
37
+ if (a.family === 'IPv4' && !a.internal)
38
+ out.push(a.address);
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+ /** Run `cmd version` quietly; resolve true if it exits 0. */
44
+ function commandWorks(cmd, args) {
45
+ return new Promise((resolve) => {
46
+ execFile(cmd, args, { timeout: 8000 }, (err) => resolve(!err));
47
+ });
48
+ }
49
+ /**
50
+ * Detect whether to use Docker Compose v2 (`docker compose`, space-separated
51
+ * subcommand) or the legacy v1 standalone `docker-compose` binary. v2 is
52
+ * tried first since it's bundled with modern Docker Desktop/Engine; v1 is
53
+ * the fallback for older hosts that only have the standalone binary.
54
+ */
55
+ async function resolveComposeCommand() {
56
+ if (await commandWorks('docker', ['compose', 'version'])) {
57
+ return { cmd: 'docker', baseArgs: ['compose'] };
58
+ }
59
+ if (await commandWorks('docker-compose', ['version'])) {
60
+ return { cmd: 'docker-compose', baseArgs: [] };
61
+ }
62
+ return null;
63
+ }
64
+ /** Spawn a compose subcommand, streaming stdout/stderr to the console. */
65
+ function runCompose(compose, args, cwd) {
66
+ return new Promise((resolve, reject) => {
67
+ const proc = spawn(compose.cmd, [...compose.baseArgs, ...args], {
68
+ cwd,
69
+ stdio: 'inherit',
70
+ });
71
+ proc.on('error', reject);
72
+ proc.on('close', (code) => resolve(code ?? 1));
73
+ });
74
+ }
75
+ function sleep(ms) {
76
+ return new Promise((resolve) => setTimeout(resolve, ms));
77
+ }
78
+ /** Poll http://localhost:<port> until it responds (any HTTP status counts as "up" — LiveKit's bare HTTP port returns 404 for GET, which is fine). */
79
+ async function waitForLivekitHealthy(port, timeoutMs, intervalMs) {
80
+ const deadline = Date.now() + timeoutMs;
81
+ while (Date.now() < deadline) {
82
+ try {
83
+ const res = await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(2000) });
84
+ // LiveKit's plain HTTP listener answers (even 4xx) once the process is up.
85
+ if (res.status < 500)
86
+ return true;
87
+ }
88
+ catch {
89
+ // not up yet — fall through to retry
90
+ }
91
+ await sleep(intervalMs);
92
+ }
93
+ return false;
94
+ }
95
+ /** Validate that `path` looks like a ROBOVOICE checkout (has a docker-compose.yaml). */
96
+ function validateComposePath(path) {
97
+ const candidates = ['docker-compose.yaml', 'docker-compose.yml'];
98
+ for (const name of candidates) {
99
+ const full = join(path, name);
100
+ if (existsSync(full))
101
+ return full;
102
+ }
103
+ throw new Error(`no docker-compose.yaml/.yml found in '${path}'. Pass --path pointing at a ROBOVOICE checkout ` +
104
+ `(the directory containing docker-compose.yaml), not this repo.`);
105
+ }
106
+ export async function roboparkAgentUp(opts) {
107
+ console.log(chalk.bold('\n robopark agent up'));
108
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
109
+ if (!existsSync(opts.path)) {
110
+ console.log(chalk.red(` ✗ path does not exist: ${opts.path}`));
111
+ process.exit(1);
112
+ }
113
+ try {
114
+ validateComposePath(opts.path);
115
+ }
116
+ catch (e) {
117
+ console.log(chalk.red(` ✗ ${e.message}`));
118
+ process.exit(1);
119
+ }
120
+ const compose = await resolveComposeCommand();
121
+ if (!compose) {
122
+ console.log(chalk.red(' ✗ neither `docker compose` (v2) nor `docker-compose` (v1) is available on PATH.'));
123
+ process.exit(1);
124
+ }
125
+ console.log(chalk.dim(` using: ${compose.cmd} ${compose.baseArgs.join(' ')}`.trimEnd()));
126
+ console.log(` path: ${chalk.cyan(opts.path)}`);
127
+ console.log();
128
+ console.log(chalk.bold(' 1. starting ROBOVOICE docker stack (docker compose up -d)…'));
129
+ const upCode = await runCompose(compose, ['up', '-d'], opts.path);
130
+ if (upCode !== 0) {
131
+ console.log(chalk.red(`\n ✗ docker compose up -d exited with code ${upCode}`));
132
+ process.exit(upCode);
133
+ }
134
+ console.log(chalk.green(' ✓ docker compose up -d completed'));
135
+ console.log();
136
+ console.log(chalk.bold(` 2. waiting for LiveKit to become healthy on :${LIVEKIT_PORT} (up to 60s)…`));
137
+ const healthy = await waitForLivekitHealthy(LIVEKIT_PORT, 60_000, 3_000);
138
+ if (!healthy) {
139
+ console.log(chalk.red(` ✗ LiveKit did not respond on http://localhost:${LIVEKIT_PORT} within 60s.`));
140
+ console.log(chalk.dim(` check container health: ${compose.cmd} ${compose.baseArgs.join(' ')} ps`.trim()));
141
+ process.exit(1);
142
+ }
143
+ console.log(chalk.green(` ✓ LiveKit is up on :${LIVEKIT_PORT}`));
144
+ if (opts.register === false) {
145
+ console.log();
146
+ console.log(chalk.dim(' --no-register passed; skipping scheduler registration.'));
147
+ console.log(chalk.green('\n ✓ robopark agent up complete (docker only)\n'));
148
+ return;
149
+ }
150
+ console.log();
151
+ console.log(chalk.bold(' 3. registering LiveKit server with the RoboPark scheduler…'));
152
+ const ctx = await resolveContext({ hubUrl: opts.hubUrl, token: opts.token, schedulerUrl: opts.schedulerUrl });
153
+ if (!ctx.schedulerUrl) {
154
+ console.log(chalk.red(' ✗ no scheduler URL resolved. Pass --scheduler-url/--hub-url, or run `robopark setup --role hub` first.'));
155
+ process.exit(1);
156
+ }
157
+ // Advertise this machine's own reachable address. This is a best-effort
158
+ // guess (first non-internal LAN IPv4); if the scheduler runs on a
159
+ // *different* machine than the one running `agent up` and that machine
160
+ // can't reach this LAN IP (e.g. NAT, VPN split), correct the registration
161
+ // manually afterwards with `robopark server-add --id <id> --url ws://<correct-host>:7880`.
162
+ const lanIps = getLanIps();
163
+ const advertiseHost = lanIps[0] ?? hostname();
164
+ if (lanIps.length === 0) {
165
+ console.log(chalk.yellow(` ⚠ no LAN IPv4 detected; advertising hostname '${advertiseHost}'. Correct with 'robopark server-add' if the scheduler can't resolve it.`));
166
+ }
167
+ const id = opts.serverId ?? `robovoice-${hostname()}`;
168
+ const name = opts.serverName ?? `ROBOVOICE (${hostname()})`;
169
+ const url = `ws://${advertiseHost}:${LIVEKIT_PORT}`;
170
+ await roboparkServerAdd({
171
+ hubUrl: ctx.hubUrl,
172
+ schedulerUrl: ctx.schedulerUrl,
173
+ token: ctx.token,
174
+ id,
175
+ name,
176
+ url,
177
+ apiKey: LIVEKIT_DEV_API_KEY,
178
+ apiSecret: LIVEKIT_DEV_API_SECRET,
179
+ });
180
+ console.log(chalk.green('\n ✓ robopark agent up complete — ROBOVOICE is running and registered with the scheduler\n'));
181
+ }
182
+ export async function roboparkAgentDown(opts) {
183
+ console.log(chalk.bold('\n robopark agent down'));
184
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
185
+ if (!existsSync(opts.path)) {
186
+ console.log(chalk.red(` ✗ path does not exist: ${opts.path}`));
187
+ process.exit(1);
188
+ }
189
+ try {
190
+ validateComposePath(opts.path);
191
+ }
192
+ catch (e) {
193
+ console.log(chalk.red(` ✗ ${e.message}`));
194
+ process.exit(1);
195
+ }
196
+ const compose = await resolveComposeCommand();
197
+ if (!compose) {
198
+ console.log(chalk.red(' ✗ neither `docker compose` (v2) nor `docker-compose` (v1) is available on PATH.'));
199
+ process.exit(1);
200
+ }
201
+ console.log(` path: ${chalk.cyan(opts.path)}`);
202
+ console.log();
203
+ const code = await runCompose(compose, ['down'], opts.path);
204
+ if (code !== 0) {
205
+ console.log(chalk.red(`\n ✗ docker compose down exited with code ${code}`));
206
+ process.exit(code);
207
+ }
208
+ console.log(chalk.green('\n ✓ ROBOVOICE stack stopped\n'));
209
+ }
210
+ export async function roboparkAgentLogs(opts) {
211
+ if (!existsSync(opts.path)) {
212
+ console.log(chalk.red(` ✗ path does not exist: ${opts.path}`));
213
+ process.exit(1);
214
+ }
215
+ try {
216
+ validateComposePath(opts.path);
217
+ }
218
+ catch (e) {
219
+ console.log(chalk.red(` ✗ ${e.message}`));
220
+ process.exit(1);
221
+ }
222
+ const compose = await resolveComposeCommand();
223
+ if (!compose) {
224
+ console.log(chalk.red(' ✗ neither `docker compose` (v2) nor `docker-compose` (v1) is available on PATH.'));
225
+ process.exit(1);
226
+ }
227
+ const args = ['logs'];
228
+ if (opts.follow)
229
+ args.push('-f');
230
+ if (opts.service)
231
+ args.push(opts.service);
232
+ // Live tail: no buffering, no capture — inherit stdio directly.
233
+ const code = await runCompose(compose, args, opts.path);
234
+ if (code !== 0 && !opts.follow) {
235
+ process.exit(code);
236
+ }
237
+ }
@@ -0,0 +1,6 @@
1
+ export interface DoctorOptions {
2
+ hubUrl?: string;
3
+ token?: string;
4
+ schedulerUrl?: string;
5
+ }
6
+ export declare function roboparkDoctor(opts: DoctorOptions): Promise<void>;