@remodex/rmx 1.0.4 → 1.0.6
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/gui/dist/assets/{index-D3MaPif3.js → index-BOZwc-1w.js} +13 -13
- package/gui/dist/assets/{index-1SDbgh2-.css → index-s2Il3gAr.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/android-remote/codex-app-server.ts +43 -7
- package/src/android-remote/gateway.ts +73 -4
- package/src/cli/index.ts +0 -2
- package/src/cli/onboard.ts +47 -4
- package/src/server/management/config-routes.ts +9 -1
- package/src/service.ts +0 -6
- package/src/update/auto-scheduler.ts +21 -1
- package/src/update/notify.ts +2 -5
- package/src/update/release-notes.ts +111 -0
package/gui/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-BOZwc-1w.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-s2Il3gAr.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -6,6 +6,8 @@ import { resolveCodexRuntime } from "../codex/runtime";
|
|
|
6
6
|
const DEFAULT_CODEX_APP_SERVER_PORT = 10106;
|
|
7
7
|
const START_TIMEOUT_MS = 12_000;
|
|
8
8
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
9
|
+
const OWNED_PROCESS_TERM_GRACE_MS = 1_000;
|
|
10
|
+
const OWNED_PROCESS_KILL_GRACE_MS = 1_000;
|
|
9
11
|
|
|
10
12
|
const APP_SERVER_PROFILE_KEYS = new Set([
|
|
11
13
|
"model_provider",
|
|
@@ -353,6 +355,45 @@ async function waitUntilReady(port: number, child: Bun.Subprocess): Promise<void
|
|
|
353
355
|
throw new Error("Codex task server did not become ready");
|
|
354
356
|
}
|
|
355
357
|
|
|
358
|
+
type KillableSubprocess = Pick<Bun.Subprocess, "kill" | "exited">;
|
|
359
|
+
|
|
360
|
+
function waitForSubprocessExit(child: KillableSubprocess, timeoutMs: number): Promise<boolean> {
|
|
361
|
+
return new Promise(resolve => {
|
|
362
|
+
let settled = false;
|
|
363
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
364
|
+
const finish = (exited: boolean): void => {
|
|
365
|
+
if (settled) return;
|
|
366
|
+
settled = true;
|
|
367
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
368
|
+
resolve(exited);
|
|
369
|
+
};
|
|
370
|
+
timer = setTimeout(() => finish(false), Math.max(0, timeoutMs));
|
|
371
|
+
// A failed exit promise still means the child is no longer running. The
|
|
372
|
+
// rejection is consumed here so shutdown never creates an unhandled error.
|
|
373
|
+
void child.exited.then(() => finish(true), () => finish(true));
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Stop a process that the current Remodex runtime explicitly spawned.
|
|
379
|
+
*
|
|
380
|
+
* Codex normally honors SIGTERM, but an app-server can be wedged while its
|
|
381
|
+
* listener is being torn down. Keep the wait bounded and escalate only for
|
|
382
|
+
* this caller-owned handle; callers must never pass a process they did not
|
|
383
|
+
* create and positively mark as owned.
|
|
384
|
+
*/
|
|
385
|
+
export async function terminateOwnedCodexProcess(
|
|
386
|
+
child: KillableSubprocess,
|
|
387
|
+
termGraceMs = OWNED_PROCESS_TERM_GRACE_MS,
|
|
388
|
+
killGraceMs = OWNED_PROCESS_KILL_GRACE_MS,
|
|
389
|
+
): Promise<void> {
|
|
390
|
+
try { child.kill("SIGTERM"); } catch { /* already exited */ }
|
|
391
|
+
if (await waitForSubprocessExit(child, termGraceMs)) return;
|
|
392
|
+
|
|
393
|
+
try { child.kill("SIGKILL"); } catch { /* already exited */ }
|
|
394
|
+
await waitForSubprocessExit(child, killGraceMs);
|
|
395
|
+
}
|
|
396
|
+
|
|
356
397
|
export type AndroidCodexRuntimeStatus = {
|
|
357
398
|
connected: boolean;
|
|
358
399
|
port: number;
|
|
@@ -553,13 +594,8 @@ export class AndroidCodexRuntime {
|
|
|
553
594
|
private async stopOwnedProcess(): Promise<void> {
|
|
554
595
|
const child = this.child;
|
|
555
596
|
this.child = null;
|
|
556
|
-
|
|
557
|
-
try { child.kill(); } catch { /* already exited */ }
|
|
558
|
-
await Promise.race([
|
|
559
|
-
child.exited.catch(() => -1),
|
|
560
|
-
new Promise(resolve => setTimeout(resolve, 1_000)),
|
|
561
|
-
]);
|
|
562
|
-
}
|
|
597
|
+
const owned = child !== null && this.ownedProcess;
|
|
563
598
|
this.ownedProcess = false;
|
|
599
|
+
if (owned) await terminateOwnedCodexProcess(child);
|
|
564
600
|
}
|
|
565
601
|
}
|
|
@@ -5,6 +5,7 @@ import { homedir, networkInterfaces, hostname as readHostname } from "node:os";
|
|
|
5
5
|
import { basename, dirname, isAbsolute, join, parse, resolve } from "node:path";
|
|
6
6
|
import type { Server, ServerWebSocket } from "bun";
|
|
7
7
|
import { getConfigDir } from "../config";
|
|
8
|
+
import { redactSecretString } from "../lib/redact";
|
|
8
9
|
import { commandInvocation } from "../lib/win-exec";
|
|
9
10
|
import type { ManagementModelRow } from "../server/management/model-rows";
|
|
10
11
|
import { modelSourceDisplayName } from "../model-sources";
|
|
@@ -351,6 +352,58 @@ function stringValue(value: unknown, maximum = 4096): string {
|
|
|
351
352
|
return typeof value === "string" ? value.trim().slice(0, maximum) : "";
|
|
352
353
|
}
|
|
353
354
|
|
|
355
|
+
/**
|
|
356
|
+
* Keep the lifecycle error actionable without echoing credentials or an
|
|
357
|
+
* unbounded exception string through the management API. Startup failures
|
|
358
|
+
* are otherwise indistinguishable from a transient tunnel failure, which
|
|
359
|
+
* makes `rmx onboard --verbose` misleading.
|
|
360
|
+
*/
|
|
361
|
+
function startupErrorText(error: unknown): string {
|
|
362
|
+
const parts: string[] = [];
|
|
363
|
+
const seen = new Set<object>();
|
|
364
|
+
let current: unknown = error;
|
|
365
|
+
for (let depth = 0; depth < 4 && current !== undefined && current !== null; depth += 1) {
|
|
366
|
+
if ((typeof current === "object" || typeof current === "function") && current !== null) {
|
|
367
|
+
if (seen.has(current)) break;
|
|
368
|
+
seen.add(current);
|
|
369
|
+
}
|
|
370
|
+
if (current instanceof Error) {
|
|
371
|
+
if (current.message.trim()) parts.push(current.message.trim());
|
|
372
|
+
current = current.cause;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const object = record(current);
|
|
376
|
+
if (object) {
|
|
377
|
+
if (typeof object.message === "string" && object.message.trim()) {
|
|
378
|
+
parts.push(object.message.trim());
|
|
379
|
+
}
|
|
380
|
+
current = object.cause;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const text = String(current).trim();
|
|
384
|
+
if (text) parts.push(text);
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
return parts.filter((part, index) => index === 0 || part !== parts[index - 1]).join(": ");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function gatewayStartupError(error: unknown): string {
|
|
391
|
+
const raw = startupErrorText(error);
|
|
392
|
+
const safe = redactSecretString(raw)
|
|
393
|
+
.replace(/[\u0000-\u001f\u007f]/gu, " ")
|
|
394
|
+
.replace(/\s+/gu, " ")
|
|
395
|
+
.trim()
|
|
396
|
+
.slice(0, 240);
|
|
397
|
+
const prefix = "Could not start the Android Remote gateway";
|
|
398
|
+
// Runtime adapters may already use the public prefix. Strip any repeated
|
|
399
|
+
// wrapper before adding one canonical, actionable message.
|
|
400
|
+
let detail = safe;
|
|
401
|
+
while (new RegExp(`^${prefix}(?::|\\s|$)`, "iu").test(detail)) {
|
|
402
|
+
detail = detail.slice(prefix.length).replace(/^:\s*/u, "").trim();
|
|
403
|
+
}
|
|
404
|
+
return detail ? `${prefix}: ${detail}` : prefix;
|
|
405
|
+
}
|
|
406
|
+
|
|
354
407
|
function desktopConversationIsActive(value: unknown): boolean | null {
|
|
355
408
|
const state = record(value);
|
|
356
409
|
if (!state) return null;
|
|
@@ -1978,6 +2031,8 @@ export class AndroidRemoteGatewayController {
|
|
|
1978
2031
|
private server: Server<GatewayWsData> | null = null;
|
|
1979
2032
|
private codex: AndroidCodexClient | null = null;
|
|
1980
2033
|
private unsubscribeCodex: (() => void) | null = null;
|
|
2034
|
+
/** All callers must await one physical gateway startup. */
|
|
2035
|
+
private startFlight: Promise<void> | null = null;
|
|
1981
2036
|
private transition: Promise<void> = Promise.resolve();
|
|
1982
2037
|
private readonly sockets = new Set<ServerWebSocket<GatewayWsData>>();
|
|
1983
2038
|
private readonly socketsByClient = new Map<string, Set<ServerWebSocket<GatewayWsData>>>();
|
|
@@ -2371,7 +2426,19 @@ export class AndroidRemoteGatewayController {
|
|
|
2371
2426
|
}
|
|
2372
2427
|
|
|
2373
2428
|
async start(): Promise<void> {
|
|
2374
|
-
if (this.gatewayStatus === "ready"
|
|
2429
|
+
if (this.gatewayStatus === "ready") return;
|
|
2430
|
+
if (this.startFlight) return this.startFlight;
|
|
2431
|
+
|
|
2432
|
+
const flight = this.startInternal();
|
|
2433
|
+
this.startFlight = flight;
|
|
2434
|
+
try {
|
|
2435
|
+
await flight;
|
|
2436
|
+
} finally {
|
|
2437
|
+
if (this.startFlight === flight) this.startFlight = null;
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
private async startInternal(): Promise<void> {
|
|
2375
2442
|
this.gatewayStatus = "starting";
|
|
2376
2443
|
this.statusError = undefined;
|
|
2377
2444
|
try {
|
|
@@ -2396,15 +2463,17 @@ export class AndroidRemoteGatewayController {
|
|
|
2396
2463
|
for (const remoteThreadId of this.queuedTurns.keys()) {
|
|
2397
2464
|
void this.startNextQueuedTurn(remoteThreadId).catch(() => undefined);
|
|
2398
2465
|
}
|
|
2399
|
-
} catch {
|
|
2466
|
+
} catch (error) {
|
|
2400
2467
|
this.gatewayStatus = "error";
|
|
2401
|
-
this.statusError =
|
|
2468
|
+
this.statusError = gatewayStartupError(error);
|
|
2402
2469
|
await this.stopResources();
|
|
2403
|
-
throw new Error(this.statusError);
|
|
2470
|
+
throw new Error(this.statusError, { cause: error });
|
|
2404
2471
|
}
|
|
2405
2472
|
}
|
|
2406
2473
|
|
|
2407
2474
|
async stop(): Promise<void> {
|
|
2475
|
+
const starting = this.startFlight;
|
|
2476
|
+
if (starting) await starting.catch(() => undefined);
|
|
2408
2477
|
await this.stopResources();
|
|
2409
2478
|
this.gatewayStatus = "stopped";
|
|
2410
2479
|
this.statusError = undefined;
|
package/src/cli/index.ts
CHANGED
|
@@ -53,7 +53,6 @@ import { installShellHook, uninstallShellHook } from "../server/system-env";
|
|
|
53
53
|
import { startTokenGuardian } from "../oauth/token-guardian";
|
|
54
54
|
import { startHistoryMigrationGuardian } from "../codex/history-migration-guardian";
|
|
55
55
|
import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore";
|
|
56
|
-
import { maybeShowStarPrompt } from "./star-prompt";
|
|
57
56
|
import { scheduleCatalogPrewarm } from "./catalog-prewarm";
|
|
58
57
|
import { maybeShowUpdatePrompt } from "../update/notify";
|
|
59
58
|
import { syncModelsToCodex } from "../codex/sync";
|
|
@@ -399,7 +398,6 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
399
398
|
// Auto-install .zshrc hook (idempotent — skips if already present).
|
|
400
399
|
installShellHook();
|
|
401
400
|
|
|
402
|
-
await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start
|
|
403
401
|
// Post-startup sync drives the readiness gate AND the #1046 stale app-server
|
|
404
402
|
// warning. `syncCodexOnStartIfEnabled` respects the Codex integration toggle
|
|
405
403
|
// (OFF → no sync) and reports whether anything was written; the readiness gate
|
package/src/cli/onboard.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "../codex/desired-state";
|
|
13
13
|
import { syncModelsToCodex, type CodexSyncResult } from "../codex/sync";
|
|
14
14
|
import { openUrl } from "../lib/open-url";
|
|
15
|
+
import { redactSecretString } from "../lib/redact";
|
|
15
16
|
import {
|
|
16
17
|
assertServiceEnvironmentMatchesInstall,
|
|
17
18
|
diagnoseService,
|
|
@@ -33,6 +34,8 @@ const ONBOARD_STEPS = 6;
|
|
|
33
34
|
const DEFAULT_PROXY_WAIT_MS = 45_000;
|
|
34
35
|
const DEFAULT_TUNNEL_WAIT_MS = 180_000;
|
|
35
36
|
const CAPTURE_LIMIT = 96 * 1024;
|
|
37
|
+
const GATEWAY_RETRY_LIMIT = 2;
|
|
38
|
+
const GATEWAY_RETRY_DELAY_MS = 750;
|
|
36
39
|
|
|
37
40
|
export type OnboardOptions = {
|
|
38
41
|
verbose: boolean;
|
|
@@ -186,7 +189,20 @@ function platformLabel(platform: NodeJS.Platform): string {
|
|
|
186
189
|
}
|
|
187
190
|
|
|
188
191
|
function safeTerminalValue(value: string): string {
|
|
189
|
-
return value
|
|
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;
|
|
190
206
|
}
|
|
191
207
|
|
|
192
208
|
function defaultConfigureAndroidRemote(): { mode: "quick" | "named"; hostname?: string } {
|
|
@@ -275,8 +291,9 @@ async function waitForLiveProxy(
|
|
|
275
291
|
function tunnelWaitMessage(status: OnboardRemoteStatus): string {
|
|
276
292
|
const runtime = status.tunnel.runtime;
|
|
277
293
|
if (status.gateway.status === "error") {
|
|
278
|
-
|
|
279
|
-
|
|
294
|
+
const detail = gatewayErrorDetail(status.gateway.error);
|
|
295
|
+
return detail
|
|
296
|
+
? `The Android gateway could not start: ${safeTerminalValue(detail)}`
|
|
280
297
|
: "The Android gateway could not start.";
|
|
281
298
|
}
|
|
282
299
|
if (runtime.error === "named_tunnel_incomplete") {
|
|
@@ -302,11 +319,13 @@ async function waitForVerifiedTunnel(
|
|
|
302
319
|
now: () => number,
|
|
303
320
|
timeoutMs: number,
|
|
304
321
|
onTransition: (status: OnboardRemoteStatus["tunnel"]["runtime"]["status"]) => void,
|
|
322
|
+
retryGateway?: () => Promise<void>,
|
|
305
323
|
): Promise<OnboardRemoteStatus> {
|
|
306
324
|
const deadline = now() + timeoutMs;
|
|
307
325
|
let lastStatus: OnboardRemoteStatus | null = null;
|
|
308
326
|
let lastReadError: unknown;
|
|
309
327
|
let observed: OnboardRemoteStatus["tunnel"]["runtime"]["status"] | null = null;
|
|
328
|
+
let gatewayRetries = 0;
|
|
310
329
|
do {
|
|
311
330
|
try {
|
|
312
331
|
const status = await read(baseUrl);
|
|
@@ -317,7 +336,27 @@ async function waitForVerifiedTunnel(
|
|
|
317
336
|
observed = runtime.status;
|
|
318
337
|
onTransition(runtime.status);
|
|
319
338
|
}
|
|
320
|
-
if (status.gateway.status === "error"
|
|
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") {
|
|
321
360
|
throw new OnboardStepError(tunnelWaitMessage(status));
|
|
322
361
|
}
|
|
323
362
|
if (runtime.mode === expectedMode
|
|
@@ -522,6 +561,10 @@ export async function runOnboard(
|
|
|
522
561
|
else if (state === "checking") output.log(" Public address found · verifying…");
|
|
523
562
|
else if (state === "error") output.log(" Waiting for Cloudflare to recover…");
|
|
524
563
|
},
|
|
564
|
+
async () => {
|
|
565
|
+
if (!options.json) output.log(" Android gateway did not start; retrying…");
|
|
566
|
+
await applyAndroidRemote(baseUrl);
|
|
567
|
+
},
|
|
525
568
|
);
|
|
526
569
|
const publicUrl = verified.tunnel.runtime.publicUrl!;
|
|
527
570
|
complete(6, "Public connection verified");
|
|
@@ -343,11 +343,19 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
343
343
|
|
|
344
344
|
if (url.pathname === "/api/update/check" && req.method === "GET") {
|
|
345
345
|
const { checkForUpdate, normalizeUpdateChannel } = await import("../../update/job");
|
|
346
|
+
const { fetchReleaseNotesForVersion } = await import("../../update/release-notes");
|
|
346
347
|
const rawTag = url.searchParams.get("tag");
|
|
347
348
|
if (rawTag && rawTag !== "latest" && rawTag !== "preview") {
|
|
348
349
|
return jsonResponse({ error: "tag must be latest or preview" }, 400);
|
|
349
350
|
}
|
|
350
|
-
|
|
351
|
+
const check = checkForUpdate(normalizeUpdateChannel(rawTag));
|
|
352
|
+
const releaseNotesVersion = check.latestVersion ?? check.currentVersion;
|
|
353
|
+
const releaseNotes = await fetchReleaseNotesForVersion(releaseNotesVersion);
|
|
354
|
+
return jsonResponse({
|
|
355
|
+
...check,
|
|
356
|
+
releaseNotesVersion,
|
|
357
|
+
...(releaseNotes ? { releaseNotes } : {}),
|
|
358
|
+
});
|
|
351
359
|
}
|
|
352
360
|
|
|
353
361
|
if (url.pathname === "/api/update/run" && req.method === "POST") {
|
package/src/service.ts
CHANGED
|
@@ -53,7 +53,6 @@ import { defaultWinswEntry, installWinswService, startWinswService, stopWinswSer
|
|
|
53
53
|
import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
|
|
54
54
|
import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
|
|
55
55
|
import { rebaseConfigOwnershipRoot, recordOwnedConfigPath } from "./lib/config-ownership";
|
|
56
|
-
import { maybeShowStarPrompt } from "./cli/star-prompt";
|
|
57
56
|
import {
|
|
58
57
|
type DefaultRemodexHomeResolution,
|
|
59
58
|
LEGACY_REMODEX_HOME_DIRNAME,
|
|
@@ -2916,11 +2915,6 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
2916
2915
|
// installed artifact instead.
|
|
2917
2916
|
await reportServiceServing("installed", { port: resolveServiceListenPort() });
|
|
2918
2917
|
if (process.platform === "linux") console.log(" For auto-start on boot: loginctl enable-linger $USER");
|
|
2919
|
-
// Service users never reach the `rmx start` prompt: the proxy they run is the
|
|
2920
|
-
// supervised child, which always carries OCX_SERVICE=1. This command, though, is
|
|
2921
|
-
// hand-typed in a real terminal, so it is the one interactive moment they get.
|
|
2922
|
-
// Same one-time marker and same guards (TTY, gh auth, agent deferral) apply.
|
|
2923
|
-
await maybeShowStarPrompt();
|
|
2924
2918
|
break;
|
|
2925
2919
|
case "start":
|
|
2926
2920
|
prepareServiceProviderEnvironment();
|
|
@@ -736,6 +736,26 @@ function systemdQuote(value: string): string {
|
|
|
736
736
|
.replace(/%/g, "%%")}"`;
|
|
737
737
|
}
|
|
738
738
|
|
|
739
|
+
/**
|
|
740
|
+
* `WorkingDirectory=` accepts a path value, not the quoted argument form used
|
|
741
|
+
* by `ExecStart=` and `Environment=`. Encode whitespace and unit-significant
|
|
742
|
+
* characters as systemd escapes so paths with spaces remain valid without
|
|
743
|
+
* leaving literal quotes in the directory name.
|
|
744
|
+
*/
|
|
745
|
+
function systemdPath(value: string): string {
|
|
746
|
+
let escaped = "";
|
|
747
|
+
for (const character of value) {
|
|
748
|
+
if (character === "\\") escaped += "\\\\";
|
|
749
|
+
else if (character === "%") escaped += "%%";
|
|
750
|
+
else if (character === " ") escaped += "\\x20";
|
|
751
|
+
else if (character === "\t") escaped += "\\x09";
|
|
752
|
+
else if (character === "\n") escaped += "\\x0a";
|
|
753
|
+
else if (character === '"') escaped += "\\x22";
|
|
754
|
+
else escaped += character;
|
|
755
|
+
}
|
|
756
|
+
return escaped;
|
|
757
|
+
}
|
|
758
|
+
|
|
739
759
|
function systemdEnvironment(name: string, value: string): string {
|
|
740
760
|
return `Environment=${systemdQuote(`${name}=${value}`)}`;
|
|
741
761
|
}
|
|
@@ -760,7 +780,7 @@ Wants=network-online.target
|
|
|
760
780
|
[Service]
|
|
761
781
|
Type=oneshot
|
|
762
782
|
ExecStart=${systemdQuote(input.nodePath)} ${systemdQuote(input.launcherPath)} __auto-update
|
|
763
|
-
WorkingDirectory=${
|
|
783
|
+
WorkingDirectory=${systemdPath(input.configDir)}
|
|
764
784
|
${env.join("\n")}
|
|
765
785
|
TimeoutStartSec=15min
|
|
766
786
|
|
package/src/update/notify.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
5
|
import { atomicWriteFile, getConfigDir } from "../config";
|
|
6
|
-
import { hasStarPromptRun } from "../cli/star-prompt";
|
|
7
6
|
import {
|
|
8
7
|
type Channel,
|
|
9
8
|
currentVersion,
|
|
@@ -120,7 +119,7 @@ export function isSourceBuildVersion(v: string): boolean {
|
|
|
120
119
|
return v.trim() === "0.0.0";
|
|
121
120
|
}
|
|
122
121
|
|
|
123
|
-
/** The interactive/TTY + install-method gate
|
|
122
|
+
/** The interactive/TTY + install-method gate for update notifications. */
|
|
124
123
|
function interactiveGuardOk(): boolean {
|
|
125
124
|
return !(process.env.OCX_SERVICE || !process.stdin.isTTY || !process.stdout.isTTY);
|
|
126
125
|
}
|
|
@@ -128,15 +127,13 @@ function interactiveGuardOk(): boolean {
|
|
|
128
127
|
/**
|
|
129
128
|
* Decide whether this run should even consider showing the prompt. Returns the
|
|
130
129
|
* channel + current version when eligible, else null. Eligibility requires a
|
|
131
|
-
* real global install, a non-source version, the interactive guard
|
|
132
|
-
* the one-time star prompt has already run (first-run yield, O1).
|
|
130
|
+
* real global install, a non-source version, and the interactive guard.
|
|
133
131
|
*/
|
|
134
132
|
export function shouldConsider(): { channel: Channel; current: string } | null {
|
|
135
133
|
if (detectInstall() === "source") return null;
|
|
136
134
|
const current = currentVersion();
|
|
137
135
|
if (current === "?" || isSourceBuildVersion(current)) return null;
|
|
138
136
|
if (!interactiveGuardOk()) return null;
|
|
139
|
-
if (!hasStarPromptRun()) return null; // yield on the very first run
|
|
140
137
|
return { channel: updateTag(current), current };
|
|
141
138
|
}
|
|
142
139
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
2
|
+
|
|
3
|
+
/** Public repository that carries the release notes for the npm package. */
|
|
4
|
+
export const RELEASE_NOTES_REPOSITORY = "ESCANOR-001/remodex-android";
|
|
5
|
+
const RELEASE_NOTES_API_URL = `https://api.github.com/repos/${RELEASE_NOTES_REPOSITORY}/releases/tags`;
|
|
6
|
+
export const RELEASE_NOTES_MAX_BYTES = 32 * 1024;
|
|
7
|
+
const RELEASE_RESPONSE_MAX_BYTES = 64 * 1024;
|
|
8
|
+
const FETCH_TIMEOUT_MS = 6_000;
|
|
9
|
+
const BODY_TIMEOUT_MS = 5_000;
|
|
10
|
+
const CACHE_TTL_MS = 10 * 60_000;
|
|
11
|
+
|
|
12
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
13
|
+
|
|
14
|
+
interface CacheEntry {
|
|
15
|
+
body: string;
|
|
16
|
+
expiresAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const notesCache = new Map<string, CacheEntry>();
|
|
20
|
+
|
|
21
|
+
function isReleaseVersion(value: string): boolean {
|
|
22
|
+
return value.length <= 64 && VERSION_PATTERN.test(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeReleaseNotes(value: string): string {
|
|
30
|
+
const normalized = value
|
|
31
|
+
.replace(/\r\n?/g, "\n")
|
|
32
|
+
// Keep markdown and whitespace, but never let terminal/control bytes reach the GUI.
|
|
33
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
|
|
34
|
+
.trim();
|
|
35
|
+
if (Buffer.byteLength(normalized, "utf8") <= RELEASE_NOTES_MAX_BYTES) return normalized;
|
|
36
|
+
|
|
37
|
+
// The response reader already enforces a byte ceiling. This second guard accounts for the
|
|
38
|
+
// JSON envelope and makes the field safe even when this function is called directly in tests.
|
|
39
|
+
const clipped = Buffer.from(normalized, "utf8")
|
|
40
|
+
.subarray(0, RELEASE_NOTES_MAX_BYTES)
|
|
41
|
+
.toString("utf8")
|
|
42
|
+
.replace(/\uFFFD$/u, "")
|
|
43
|
+
.trimEnd();
|
|
44
|
+
return `${clipped}\n…`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Clear the in-memory release-note cache. Intended for focused tests and development reloads. */
|
|
48
|
+
export function clearReleaseNotesCacheForTests(): void {
|
|
49
|
+
notesCache.clear();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fetch the exact GitHub Release body for a version.
|
|
54
|
+
*
|
|
55
|
+
* Release notes are display-only metadata. A registry/API failure therefore returns `null` and
|
|
56
|
+
* leaves the npm version check usable. The response is bounded, rendered as text by React, and
|
|
57
|
+
* accepted only when GitHub confirms the requested tag.
|
|
58
|
+
*/
|
|
59
|
+
export async function fetchReleaseNotesForVersion(
|
|
60
|
+
version: string,
|
|
61
|
+
fetchFn: typeof fetch = fetch,
|
|
62
|
+
): Promise<string | null> {
|
|
63
|
+
if (!isReleaseVersion(version)) return null;
|
|
64
|
+
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const cached = notesCache.get(version);
|
|
67
|
+
if (cached && cached.expiresAt > now) return cached.body;
|
|
68
|
+
if (cached) notesCache.delete(version);
|
|
69
|
+
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetchFn(`${RELEASE_NOTES_API_URL}/v${encodeURIComponent(version)}`, {
|
|
74
|
+
headers: {
|
|
75
|
+
Accept: "application/vnd.github+json",
|
|
76
|
+
"User-Agent": "Remodex-update-check",
|
|
77
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
78
|
+
},
|
|
79
|
+
redirect: "error",
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
});
|
|
82
|
+
if (!response.ok) return null;
|
|
83
|
+
|
|
84
|
+
const bounded = await readBoundedResponseBody(response, {
|
|
85
|
+
// The JSON envelope and escaping can be larger than the final note body.
|
|
86
|
+
maxBytes: RELEASE_RESPONSE_MAX_BYTES,
|
|
87
|
+
totalTimeoutMs: BODY_TIMEOUT_MS,
|
|
88
|
+
inactivityTimeoutMs: BODY_TIMEOUT_MS,
|
|
89
|
+
});
|
|
90
|
+
if (!bounded.displaySafe || bounded.oversized || bounded.timedOut) return null;
|
|
91
|
+
|
|
92
|
+
let parsed: unknown;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(bounded.text);
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (!isRecord(parsed) || parsed.tag_name !== `v${version}` || typeof parsed.body !== "string") {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const body = normalizeReleaseNotes(parsed.body);
|
|
103
|
+
if (!body) return null;
|
|
104
|
+
notesCache.set(version, { body, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
105
|
+
return body;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timeout);
|
|
110
|
+
}
|
|
111
|
+
}
|