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