@remodex/rmx 1.0.3 → 1.0.4

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,599 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { createAndroidRemoteStore } from "../android-remote/store";
4
+ import { ensureConfigFile } from "../config";
5
+ import {
6
+ currentExternalCodexModelProvider,
7
+ getCodexConfigPath,
8
+ } from "../codex/inject";
9
+ import {
10
+ setIntegrationEnabled,
11
+ type CodexDesiredStateResult,
12
+ } from "../codex/desired-state";
13
+ import { syncModelsToCodex, type CodexSyncResult } from "../codex/sync";
14
+ import { openUrl } from "../lib/open-url";
15
+ import {
16
+ assertServiceEnvironmentMatchesInstall,
17
+ diagnoseService,
18
+ type ServiceDiagnostic,
19
+ } from "../service";
20
+ import {
21
+ findLiveProxy,
22
+ probeHostname,
23
+ type LiveProxy,
24
+ } from "../server/proxy-liveness";
25
+ import {
26
+ getWindowsTrayStatusAsync,
27
+ type WindowsTrayStatus,
28
+ } from "../tray/windows";
29
+ import { runtimeRequest } from "./runtime-api";
30
+
31
+ export const ONBOARD_USAGE = "rmx onboard [--verbose] [--json] [--no-open]";
32
+ const ONBOARD_STEPS = 6;
33
+ const DEFAULT_PROXY_WAIT_MS = 45_000;
34
+ const DEFAULT_TUNNEL_WAIT_MS = 180_000;
35
+ const CAPTURE_LIMIT = 96 * 1024;
36
+
37
+ export type OnboardOptions = {
38
+ verbose: boolean;
39
+ json: boolean;
40
+ noOpen: boolean;
41
+ };
42
+
43
+ export type OnboardParseResult =
44
+ | { ok: true; options: OnboardOptions }
45
+ | { ok: false; message: string };
46
+
47
+ export type CapturedCommand = {
48
+ code: number;
49
+ stdout: string;
50
+ stderr: string;
51
+ };
52
+
53
+ export type OnboardRemoteStatus = {
54
+ controlEnabled: boolean;
55
+ pairingAvailable: boolean;
56
+ gateway: {
57
+ status: "stopped" | "starting" | "ready" | "error";
58
+ error?: string;
59
+ };
60
+ tunnel: {
61
+ configuration: {
62
+ mode: "quick" | "named";
63
+ namedHostname?: string;
64
+ hasNamedTunnelToken: boolean;
65
+ };
66
+ runtime: {
67
+ mode: "quick" | "named";
68
+ status: "stopped" | "starting" | "checking" | "ready" | "error";
69
+ publicUrl: string | null;
70
+ error: "cloudflared_unavailable" | "named_tunnel_incomplete" | "tunnel_failed" | "verification_failed" | null;
71
+ };
72
+ };
73
+ };
74
+
75
+ type OnboardOutput = Pick<Console, "log" | "error">;
76
+
77
+ export interface OnboardDeps {
78
+ platform?: NodeJS.Platform;
79
+ output?: OnboardOutput;
80
+ codexConfigExists?: () => boolean;
81
+ assertServiceOwnership?: () => void;
82
+ externalProvider?: () => string | null;
83
+ ensureConfig?: typeof ensureConfigFile;
84
+ enableCodex?: () => CodexDesiredStateResult;
85
+ configureAndroidRemote?: () => { mode: "quick" | "named"; hostname?: string };
86
+ diagnoseService?: () => ServiceDiagnostic;
87
+ runSubcommand?: (args: string[]) => Promise<CapturedCommand>;
88
+ trayStatus?: () => Promise<WindowsTrayStatus>;
89
+ findLive?: () => Promise<LiveProxy | null>;
90
+ applyAndroidRemote?: (baseUrl: string) => Promise<OnboardRemoteStatus>;
91
+ readAndroidRemote?: (baseUrl: string) => Promise<OnboardRemoteStatus>;
92
+ syncCodex?: (
93
+ port: number,
94
+ log: Pick<Console, "log" | "error"> | null,
95
+ ) => Promise<CodexSyncResult>;
96
+ open?: (url: string) => void;
97
+ sleep?: (milliseconds: number) => Promise<void>;
98
+ now?: () => number;
99
+ proxyWaitMs?: number;
100
+ tunnelWaitMs?: number;
101
+ }
102
+
103
+ export type OnboardResult = {
104
+ ok: boolean;
105
+ code: 0 | 1;
106
+ completedSteps: number;
107
+ platform: NodeJS.Platform;
108
+ provider: string | null;
109
+ codex: string | null;
110
+ service: "ready" | null;
111
+ tray: "ready" | "not-applicable" | null;
112
+ tunnel: {
113
+ mode: "quick" | "named";
114
+ status: "verified";
115
+ publicUrl: string;
116
+ } | null;
117
+ dashboardUrl: string | null;
118
+ error?: string;
119
+ };
120
+
121
+ class OnboardStepError extends Error {
122
+ constructor(
123
+ message: string,
124
+ readonly diagnostic?: string,
125
+ ) {
126
+ super(message);
127
+ this.name = "OnboardStepError";
128
+ }
129
+ }
130
+
131
+ function appendCaptured(current: string, value: unknown): string {
132
+ return `${current}${String(value)}`.slice(-CAPTURE_LIMIT);
133
+ }
134
+
135
+ /** Run an existing CLI command without leaking its routine output into onboarding. */
136
+ export function runCapturedCli(args: string[]): Promise<CapturedCommand> {
137
+ return new Promise(resolve => {
138
+ const cli = process.argv[1];
139
+ if (!cli) {
140
+ resolve({ code: 1, stdout: "", stderr: "Could not resolve the Remodex CLI entry point." });
141
+ return;
142
+ }
143
+ let stdout = "";
144
+ let stderr = "";
145
+ let settled = false;
146
+ const child = spawn(process.execPath, [cli, ...args], {
147
+ cwd: process.cwd(),
148
+ env: process.env,
149
+ shell: false,
150
+ windowsHide: true,
151
+ stdio: ["ignore", "pipe", "pipe"],
152
+ });
153
+ child.stdout?.on("data", chunk => { stdout = appendCaptured(stdout, chunk); });
154
+ child.stderr?.on("data", chunk => { stderr = appendCaptured(stderr, chunk); });
155
+ child.once("error", error => {
156
+ if (settled) return;
157
+ settled = true;
158
+ resolve({ code: 1, stdout, stderr: appendCaptured(stderr, error.message) });
159
+ });
160
+ child.once("close", code => {
161
+ if (settled) return;
162
+ settled = true;
163
+ resolve({ code: code ?? 1, stdout, stderr });
164
+ });
165
+ });
166
+ }
167
+
168
+ export function parseOnboardArgs(argv: string[]): OnboardParseResult {
169
+ const options: OnboardOptions = { verbose: false, json: false, noOpen: false };
170
+ for (const arg of argv) {
171
+ if (arg === "--verbose") options.verbose = true;
172
+ else if (arg === "--json") options.json = true;
173
+ else if (arg === "--no-open") options.noOpen = true;
174
+ else return { ok: false, message: `Unknown onboard option: ${arg}` };
175
+ }
176
+ // A machine-readable run must not launch a browser as an undocumented side effect.
177
+ if (options.json) options.noOpen = true;
178
+ return { ok: true, options };
179
+ }
180
+
181
+ function platformLabel(platform: NodeJS.Platform): string {
182
+ if (platform === "win32") return "Windows";
183
+ if (platform === "darwin") return "macOS";
184
+ if (platform === "linux") return "Linux";
185
+ return platform;
186
+ }
187
+
188
+ function safeTerminalValue(value: string): string {
189
+ return value.replace(/[\u0000-\u001f\u007f]/gu, " ").trim().slice(0, 96);
190
+ }
191
+
192
+ function defaultConfigureAndroidRemote(): { mode: "quick" | "named"; hostname?: string } {
193
+ const store = createAndroidRemoteStore();
194
+ const current = store.read();
195
+ const state = current.settings.controlEnabled
196
+ ? current
197
+ : store.updateSettings({ controlEnabled: true });
198
+ return {
199
+ mode: state.settings.tunnelMode,
200
+ ...(state.settings.tunnelMode === "named" && state.settings.namedTunnelHostname
201
+ ? { hostname: state.settings.namedTunnelHostname }
202
+ : {}),
203
+ };
204
+ }
205
+
206
+ async function defaultApplyAndroidRemote(baseUrl: string): Promise<OnboardRemoteStatus> {
207
+ return runtimeRequest<OnboardRemoteStatus>("/api/android-remote/settings", {
208
+ method: "PUT",
209
+ body: JSON.stringify({ controlEnabled: true }),
210
+ }, { baseUrl });
211
+ }
212
+
213
+ async function defaultReadAndroidRemote(baseUrl: string): Promise<OnboardRemoteStatus> {
214
+ return runtimeRequest<OnboardRemoteStatus>("/api/android-remote", {}, { baseUrl });
215
+ }
216
+
217
+ function indentedOutput(output: OnboardOutput): Pick<Console, "log" | "error"> {
218
+ return {
219
+ log: value => output.log(` ${String(value)}`),
220
+ error: value => output.error(` ${String(value)}`),
221
+ };
222
+ }
223
+
224
+ function commandDiagnostic(result: CapturedCommand): string {
225
+ return [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n").slice(-CAPTURE_LIMIT);
226
+ }
227
+
228
+ async function runRequiredSubcommand(
229
+ args: string[],
230
+ run: (args: string[]) => Promise<CapturedCommand>,
231
+ options: OnboardOptions,
232
+ output: OnboardOutput,
233
+ ): Promise<void> {
234
+ const result = await run(args);
235
+ const diagnostic = commandDiagnostic(result);
236
+ if (options.verbose) {
237
+ output.log(` $ rmx ${args.join(" ")}`);
238
+ if (diagnostic) {
239
+ for (const line of diagnostic.split(/\r?\n/u)) output.log(` ${line}`);
240
+ }
241
+ }
242
+ if (result.code !== 0) {
243
+ throw new OnboardStepError(
244
+ `The command 'rmx ${args.join(" ")}' did not complete.`,
245
+ diagnostic || `Command exited with status ${result.code}.`,
246
+ );
247
+ }
248
+ }
249
+
250
+ async function waitForLiveProxy(
251
+ find: () => Promise<LiveProxy | null>,
252
+ sleep: (milliseconds: number) => Promise<void>,
253
+ now: () => number,
254
+ timeoutMs: number,
255
+ ): Promise<LiveProxy> {
256
+ const deadline = now() + timeoutMs;
257
+ let lastError: unknown;
258
+ do {
259
+ try {
260
+ const live = await find();
261
+ if (live) return live;
262
+ } catch (error) {
263
+ lastError = error;
264
+ }
265
+ const remaining = deadline - now();
266
+ if (remaining <= 0) break;
267
+ await sleep(Math.min(500, remaining));
268
+ } while (now() < deadline);
269
+ throw new OnboardStepError(
270
+ "The background service was installed, but the local Remodex server did not become healthy.",
271
+ lastError instanceof Error ? lastError.message : undefined,
272
+ );
273
+ }
274
+
275
+ function tunnelWaitMessage(status: OnboardRemoteStatus): string {
276
+ const runtime = status.tunnel.runtime;
277
+ if (status.gateway.status === "error") {
278
+ return status.gateway.error
279
+ ? `The Android gateway could not start: ${safeTerminalValue(status.gateway.error)}`
280
+ : "The Android gateway could not start.";
281
+ }
282
+ if (runtime.error === "named_tunnel_incomplete") {
283
+ return "The saved custom domain needs both a hostname and connector token. Complete it in Android Remote, then retry.";
284
+ }
285
+ if (runtime.error === "cloudflared_unavailable") {
286
+ return "Cloudflared could not be installed or started. Check the network connection, then retry.";
287
+ }
288
+ if (runtime.error === "tunnel_failed") {
289
+ return "The Cloudflare Tunnel stopped before its public connection could be verified.";
290
+ }
291
+ if (runtime.status === "checking" || runtime.error === "verification_failed") {
292
+ return "Cloudflare did not verify the public address in time. Remodex kept the unverified link hidden; retry after propagation finishes.";
293
+ }
294
+ return "The Cloudflare Tunnel did not produce a verified public connection in time.";
295
+ }
296
+
297
+ async function waitForVerifiedTunnel(
298
+ baseUrl: string,
299
+ expectedMode: "quick" | "named",
300
+ read: (baseUrl: string) => Promise<OnboardRemoteStatus>,
301
+ sleep: (milliseconds: number) => Promise<void>,
302
+ now: () => number,
303
+ timeoutMs: number,
304
+ onTransition: (status: OnboardRemoteStatus["tunnel"]["runtime"]["status"]) => void,
305
+ ): Promise<OnboardRemoteStatus> {
306
+ const deadline = now() + timeoutMs;
307
+ let lastStatus: OnboardRemoteStatus | null = null;
308
+ let lastReadError: unknown;
309
+ let observed: OnboardRemoteStatus["tunnel"]["runtime"]["status"] | null = null;
310
+ do {
311
+ try {
312
+ const status = await read(baseUrl);
313
+ lastStatus = status;
314
+ lastReadError = null;
315
+ const runtime = status.tunnel.runtime;
316
+ if (runtime.status !== observed) {
317
+ observed = runtime.status;
318
+ onTransition(runtime.status);
319
+ }
320
+ if (status.gateway.status === "error" || runtime.error === "named_tunnel_incomplete") {
321
+ throw new OnboardStepError(tunnelWaitMessage(status));
322
+ }
323
+ if (runtime.mode === expectedMode
324
+ && runtime.status === "ready"
325
+ && typeof runtime.publicUrl === "string"
326
+ && runtime.publicUrl.startsWith("https://")
327
+ && status.controlEnabled
328
+ && status.pairingAvailable) {
329
+ return status;
330
+ }
331
+ } catch (error) {
332
+ if (error instanceof OnboardStepError) throw error;
333
+ lastReadError = error;
334
+ }
335
+ const remaining = deadline - now();
336
+ if (remaining <= 0) break;
337
+ await sleep(Math.min(1_000, remaining));
338
+ } while (now() < deadline);
339
+
340
+ throw new OnboardStepError(
341
+ lastStatus ? tunnelWaitMessage(lastStatus) : "Android Remote status could not be read from the local server.",
342
+ lastReadError instanceof Error ? lastReadError.message : undefined,
343
+ );
344
+ }
345
+
346
+ function routingSummary(result: CodexSyncResult, provider: string | null): string {
347
+ if (result.routingApplied === true) return "Connected through Remodex";
348
+ if (provider) return `${safeTerminalValue(provider)} preserved`;
349
+ return "Existing Codex routing preserved";
350
+ }
351
+
352
+ function failureResult(
353
+ completedSteps: number,
354
+ platform: NodeJS.Platform,
355
+ provider: string | null,
356
+ codex: string | null,
357
+ service: "ready" | null,
358
+ tray: "ready" | "not-applicable" | null,
359
+ dashboardUrl: string | null,
360
+ error: string,
361
+ ): OnboardResult {
362
+ return {
363
+ ok: false,
364
+ code: 1,
365
+ completedSteps,
366
+ platform,
367
+ provider,
368
+ codex,
369
+ service,
370
+ tray,
371
+ tunnel: null,
372
+ dashboardUrl,
373
+ error,
374
+ };
375
+ }
376
+
377
+ export async function runOnboard(
378
+ options: OnboardOptions,
379
+ deps: OnboardDeps = {},
380
+ ): Promise<OnboardResult> {
381
+ const platform = deps.platform ?? process.platform;
382
+ const output = deps.output ?? console;
383
+ const sleep = deps.sleep ?? (milliseconds => Bun.sleep(milliseconds));
384
+ const now = deps.now ?? Date.now;
385
+ const ensureConfig = deps.ensureConfig ?? ensureConfigFile;
386
+ const enableCodex = deps.enableCodex ?? (() => setIntegrationEnabled("codex", true));
387
+ const configureAndroidRemote = deps.configureAndroidRemote ?? defaultConfigureAndroidRemote;
388
+ const serviceDiagnostic = deps.diagnoseService ?? diagnoseService;
389
+ const runSubcommand = deps.runSubcommand ?? runCapturedCli;
390
+ const findLive = deps.findLive ?? (() => findLiveProxy());
391
+ const applyAndroidRemote = deps.applyAndroidRemote ?? defaultApplyAndroidRemote;
392
+ const readAndroidRemote = deps.readAndroidRemote ?? defaultReadAndroidRemote;
393
+ const syncCodex = deps.syncCodex ?? ((port, log) => syncModelsToCodex(port, undefined, log));
394
+ const trayStatus = deps.trayStatus ?? getWindowsTrayStatusAsync;
395
+ const open = deps.open ?? openUrl;
396
+ let completedSteps = 0;
397
+ let provider: string | null = null;
398
+ let codex: string | null = null;
399
+ let service: "ready" | null = null;
400
+ let tray: "ready" | "not-applicable" | null = platform === "win32" ? null : "not-applicable";
401
+ let dashboardUrl: string | null = null;
402
+
403
+ const begin = (step: number, title: string): void => {
404
+ if (!options.json) output.log(`[${step}/${ONBOARD_STEPS}] ${title}`);
405
+ };
406
+ const complete = (step: number, detail: string): void => {
407
+ completedSteps = step;
408
+ if (!options.json) output.log(` ${detail}\n`);
409
+ };
410
+
411
+ if (!options.json) output.log("Setting up Remodex\n");
412
+
413
+ try {
414
+ begin(1, "Checking this computer");
415
+ (deps.assertServiceOwnership ?? assertServiceEnvironmentMatchesInstall)();
416
+ const codexExists = (deps.codexConfigExists ?? (() => existsSync(getCodexConfigPath())))();
417
+ if (!codexExists) {
418
+ throw new OnboardStepError("Codex settings were not found. Open Codex once, finish sign-in, then retry.");
419
+ }
420
+ provider = (deps.externalProvider ?? currentExternalCodexModelProvider)();
421
+ complete(1, `${platformLabel(platform)} · Codex found`);
422
+
423
+ begin(2, "Configuring Remodex");
424
+ const bootstrap = ensureConfig();
425
+ if (bootstrap.status === "invalid") {
426
+ throw new OnboardStepError("Remodex config.json is malformed. It was preserved; repair it before retrying.");
427
+ }
428
+ let desired = enableCodex();
429
+ if (!desired.ok && desired.retryable) desired = enableCodex();
430
+ if (!desired.ok) throw new OnboardStepError(desired.message);
431
+ complete(2, provider
432
+ ? `Existing provider: ${safeTerminalValue(provider)} · preserved`
433
+ : "Safe defaults ready");
434
+
435
+ begin(3, "Preparing Android Remote");
436
+ const android = configureAndroidRemote();
437
+ complete(3, android.mode === "quick"
438
+ ? "Quick Tunnel selected"
439
+ : `Custom domain preserved${android.hostname ? ` · ${safeTerminalValue(android.hostname)}` : ""}`);
440
+
441
+ begin(4, "Starting background service");
442
+ let diagnosed = serviceDiagnostic();
443
+ if (!diagnosed.supported) {
444
+ throw new OnboardStepError(`A background service is unavailable on this system: ${safeTerminalValue(diagnosed.summary)}`);
445
+ }
446
+ if (diagnosed.conflict) {
447
+ throw new OnboardStepError("Two background-service backends are installed. Resolve the conflict shown by 'rmx service status', then retry.");
448
+ }
449
+ if (!diagnosed.installed) {
450
+ await runRequiredSubcommand(["service", "install"], runSubcommand, options, output);
451
+ } else if (!diagnosed.viable) {
452
+ await runRequiredSubcommand(["service", "repair"], runSubcommand, options, output);
453
+ }
454
+ diagnosed = serviceDiagnostic();
455
+ if (!diagnosed.installed || diagnosed.stale || diagnosed.conflict) {
456
+ throw new OnboardStepError("The background-service registration is not healthy after setup.", diagnosed.summary);
457
+ }
458
+ let live: LiveProxy;
459
+ try {
460
+ live = await waitForLiveProxy(
461
+ findLive,
462
+ sleep,
463
+ now,
464
+ deps.proxyWaitMs ?? DEFAULT_PROXY_WAIT_MS,
465
+ );
466
+ } catch (error) {
467
+ if (!(error instanceof OnboardStepError) || !diagnosed.viable) throw error;
468
+ if (!options.json) output.log(" Service did not answer. Restarting once…");
469
+ await runRequiredSubcommand(["service", "repair"], runSubcommand, options, output);
470
+ diagnosed = serviceDiagnostic();
471
+ if (!diagnosed.installed || !diagnosed.viable || diagnosed.stale || diagnosed.conflict) {
472
+ throw new OnboardStepError("The background service is still unhealthy after one restart.", diagnosed.summary);
473
+ }
474
+ live = await waitForLiveProxy(
475
+ findLive,
476
+ sleep,
477
+ now,
478
+ deps.proxyWaitMs ?? DEFAULT_PROXY_WAIT_MS,
479
+ );
480
+ }
481
+ const host = probeHostname(live.hostname);
482
+ const displayHost = host === "127.0.0.1" ? "localhost" : host;
483
+ const baseUrl = `http://${host}:${live.port}`;
484
+ dashboardUrl = `http://${displayHost}:${live.port}/#android-remote/pair`;
485
+ await applyAndroidRemote(baseUrl);
486
+
487
+ if (platform === "win32") {
488
+ let status = await trayStatus();
489
+ if (!status.supported) throw new OnboardStepError("The Windows tray is unavailable on this installation.");
490
+ if (!status.installed || !status.running || status.stale) {
491
+ await runRequiredSubcommand(["tray", "install"], runSubcommand, options, output);
492
+ status = await trayStatus();
493
+ }
494
+ if (!status.installed || !status.running || status.stale) {
495
+ throw new OnboardStepError("The Windows tray did not become ready after installation.", status.summary);
496
+ }
497
+ tray = "ready";
498
+ }
499
+ service = "ready";
500
+ complete(4, platform === "win32" ? "Running automatically · tray ready" : "Running automatically");
501
+
502
+ begin(5, "Connecting Codex");
503
+ const synced = await syncCodex(live.port, options.verbose ? indentedOutput(output) : null);
504
+ if (synced.status === "skipped") {
505
+ throw new OnboardStepError("Codex integration changed while setup was running. Retry to converge the saved choice.");
506
+ }
507
+ if (!synced.ok) throw new OnboardStepError("Codex configuration could not be verified.", synced.message);
508
+ codex = routingSummary(synced, provider);
509
+ complete(5, codex);
510
+
511
+ begin(6, "Verifying phone connection");
512
+ const verified = await waitForVerifiedTunnel(
513
+ baseUrl,
514
+ android.mode,
515
+ readAndroidRemote,
516
+ sleep,
517
+ now,
518
+ deps.tunnelWaitMs ?? DEFAULT_TUNNEL_WAIT_MS,
519
+ state => {
520
+ if (options.json || state === "ready" || state === "stopped") return;
521
+ if (state === "starting") output.log(" Starting secure tunnel…");
522
+ else if (state === "checking") output.log(" Public address found · verifying…");
523
+ else if (state === "error") output.log(" Waiting for Cloudflare to recover…");
524
+ },
525
+ );
526
+ const publicUrl = verified.tunnel.runtime.publicUrl!;
527
+ complete(6, "Public connection verified");
528
+
529
+ if (!options.noOpen && dashboardUrl) open(dashboardUrl);
530
+
531
+ const result: OnboardResult = {
532
+ ok: true,
533
+ code: 0,
534
+ completedSteps,
535
+ platform,
536
+ provider,
537
+ codex,
538
+ service,
539
+ tray,
540
+ tunnel: { mode: android.mode, status: "verified", publicUrl },
541
+ dashboardUrl,
542
+ };
543
+ if (options.json) {
544
+ output.log(JSON.stringify(result));
545
+ } else {
546
+ output.log("Remodex is ready\n");
547
+ output.log(`Codex ${codex}`);
548
+ output.log("Service Running automatically");
549
+ if (platform === "win32") output.log("Tray Ready");
550
+ output.log(`Phone link ${android.mode === "quick" ? "Quick Tunnel" : "Custom domain"} · verified`);
551
+ output.log(`Dashboard ${dashboardUrl}\n`);
552
+ output.log("Next: Scan the QR code in the Remodex Android app.");
553
+ output.log("Status: rmx status");
554
+ output.log("Help: rmx doctor");
555
+ }
556
+ return result;
557
+ } catch (error) {
558
+ const message = error instanceof Error ? error.message : String(error);
559
+ const diagnostic = error instanceof OnboardStepError ? error.diagnostic : undefined;
560
+ const result = failureResult(
561
+ completedSteps,
562
+ platform,
563
+ provider,
564
+ codex,
565
+ service,
566
+ tray,
567
+ dashboardUrl,
568
+ message,
569
+ );
570
+ if (options.json) {
571
+ output.log(JSON.stringify({ ...result, ...(options.verbose && diagnostic ? { diagnostic } : {}) }));
572
+ } else {
573
+ output.error("Setup paused\n");
574
+ output.error(`Completed ${completedSteps}/${ONBOARD_STEPS} steps`);
575
+ output.error(`Issue ${message}`);
576
+ if (diagnostic) {
577
+ output.error("Details");
578
+ for (const line of diagnostic.split(/\r?\n/u)) output.error(` ${line}`);
579
+ }
580
+ output.error("\nRetry: rmx onboard");
581
+ if (!options.verbose) output.error("More: rmx onboard --verbose");
582
+ }
583
+ return result;
584
+ }
585
+ }
586
+
587
+ export async function runOnboardCommand(
588
+ argv: string[],
589
+ deps: OnboardDeps = {},
590
+ ): Promise<number> {
591
+ const parsed = parseOnboardArgs(argv);
592
+ const output = deps.output ?? console;
593
+ if (!parsed.ok) {
594
+ output.error(parsed.message);
595
+ output.error(`Usage: ${ONBOARD_USAGE}`);
596
+ return 64;
597
+ }
598
+ return (await runOnboard(parsed.options, deps)).code;
599
+ }
@@ -20,10 +20,7 @@ const USAGE = `Usage:
20
20
  rmx system update check [--channel <latest|preview>] [--json]
21
21
  rmx system update run [--channel <latest|preview>] [--restart <on|off>] --yes [--json]
22
22
  rmx system update status <job-id> [--json]
23
- rmx system update auto <on|off|status> [--channel <latest|preview>] [--json]
24
- rmx system desktop-update check [--channel <latest|preview>] [--json]
25
- rmx system desktop-update run [--channel <latest|preview>] [--json]
26
- rmx system desktop-update status [--json]`;
23
+ rmx system update auto <on|off|status> [--channel <latest|preview>] [--json]`;
27
24
 
28
25
  async function status(argv: string[], deps: RuntimeApiDeps): Promise<void> {
29
26
  const args = [...argv];
@@ -132,86 +129,6 @@ async function update(argv: string[], deps: RuntimeApiDeps): Promise<void> {
132
129
  printData(result, wantsJson, [`Update started (${channel}).`]);
133
130
  }
134
131
 
135
- function desktopUpdateSummary(value: unknown): string[] {
136
- if (!value || typeof value !== "object") return [`Desktop update: ${String(value)}`];
137
- const record = value as Record<string, unknown>;
138
- const state = record.state;
139
- const check = record;
140
- const lines: string[] = [];
141
-
142
- if (typeof check.currentVersion === "string") {
143
- lines.push(`Current version: ${check.currentVersion}`);
144
- }
145
- if (typeof check.latestVersion === "string") {
146
- lines.push(`Latest version: ${check.latestVersion}`);
147
- }
148
- if (typeof check.phase === "string") {
149
- lines.push(`Status: ${check.phase}`);
150
- }
151
- if (state && typeof state === "object" && !Array.isArray(state)) {
152
- const stateRecord = state as Record<string, unknown>;
153
- if (typeof stateRecord.phase === "string") lines.push(`Status: ${stateRecord.phase}`);
154
- if (typeof stateRecord.latestVersion === "string") {
155
- lines.push(`Latest version: ${stateRecord.latestVersion}`);
156
- }
157
- if (typeof stateRecord.progress === "number") {
158
- lines.push(`Progress: ${Math.round(stateRecord.progress)}%`);
159
- }
160
- if (typeof stateRecord.errorCode === "string") lines.push(`Error: ${stateRecord.errorCode}`);
161
- }
162
- if (check.updateAvailable === true) lines.push("Update available.");
163
- else if (check.updateAvailable === false) lines.push("No desktop update available.");
164
- if (lines.length > 0) return lines;
165
- return summaryLines(value);
166
- }
167
-
168
- async function desktopUpdate(argv: string[], deps: RuntimeApiDeps): Promise<void> {
169
- const args = [...argv];
170
- const action = (args.shift() ?? "check").toLowerCase();
171
- const wantsJson = takeFlag(args, "--json");
172
-
173
- if (action === "status") {
174
- rejectArgs(args, USAGE);
175
- const result = await runtimeRequest("/api/desktop-update/status", {}, deps);
176
- printData(result, wantsJson, desktopUpdateSummary(result));
177
- return;
178
- }
179
-
180
- if (action !== "check" && action !== "run") {
181
- throw new CliUsageError(`unknown desktop-update action ${action}`, USAGE);
182
- }
183
-
184
- const channel = takeOption(args, "--channel") ?? "latest";
185
- if (channel !== "latest" && channel !== "preview") {
186
- throw new CliUsageError("--channel must be latest or preview", USAGE);
187
- }
188
- rejectArgs(args, USAGE);
189
-
190
- if (action === "check") {
191
- const result = await runtimeRequest(
192
- `/api/desktop-update/check?channel=${encodeURIComponent(channel)}`,
193
- {},
194
- deps,
195
- );
196
- printData(result, wantsJson, desktopUpdateSummary(result));
197
- return;
198
- }
199
-
200
- // Headless callers can fetch and verify a native installer, but installation
201
- // stays restricted to the exact-origin desktop/Tauri bridge. This mirrors
202
- // the management API contract and prevents a shell or remote dashboard from
203
- // silently launching a platform installer.
204
- const result = await runtimeRequest(
205
- "/api/desktop-update/run",
206
- {
207
- method: "POST",
208
- body: JSON.stringify({ channel, install: false }),
209
- },
210
- deps,
211
- );
212
- printData(result, wantsJson, [`Desktop update download started (${channel}).`]);
213
- }
214
-
215
132
  export async function handleSystemCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
216
133
  return runCliAction(async () => {
217
134
  const [sub = "status", ...rest] = argv;
@@ -225,7 +142,6 @@ export async function handleSystemCommand(argv: string[], deps: RuntimeApiDeps =
225
142
  const args = [...rest]; const wantsJson = takeFlag(args, "--json"); rejectArgs(args, USAGE);
226
143
  printData(await runtimeRequest("/api/sync", { method: "POST" }, deps), wantsJson);
227
144
  } else if (sub === "update") await update(rest, deps);
228
- else if (sub === "desktop-update") await desktopUpdate(rest, deps);
229
145
  else throw new CliUsageError(`unknown system command ${sub}`, USAGE);
230
146
  });
231
147
  }