@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.
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-D3MaPif3.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-1SDbgh2-.css">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remodex/rmx",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Remodex universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "displayName": "Remodex",
6
6
  "type": "module",
@@ -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
- if (child && this.ownedProcess) {
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" || this.gatewayStatus === "starting") return;
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 = "Could not start the Android Remote gateway";
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
@@ -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.replace(/[\u0000-\u001f\u007f]/gu, " ").trim().slice(0, 96);
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
- return status.gateway.error
279
- ? `The Android gateway could not start: ${safeTerminalValue(status.gateway.error)}`
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" || runtime.error === "named_tunnel_incomplete") {
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
- return jsonResponse(checkForUpdate(normalizeUpdateChannel(rawTag)));
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=${systemdQuote(input.configDir)}
783
+ WorkingDirectory=${systemdPath(input.configDir)}
764
784
  ${env.join("\n")}
765
785
  TimeoutStartSec=15min
766
786
 
@@ -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 shared with the star prompt. */
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, and that
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
+ }