@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/src/update/job.ts CHANGED
@@ -43,7 +43,7 @@ import {
43
43
  type NpmCachePreflightReason,
44
44
  } from "./npm-cache-preflight.mjs";
45
45
 
46
- const RELEASE_NOTES_URL = "https://github.com/ESCANOR-001/remodex-android/releases/latest";
46
+ const RELEASES_URL = "https://github.com/ESCANOR-001/remodex-android/releases";
47
47
  const UPDATE_JOB_FILENAME = "update-job.json";
48
48
  const UPDATE_TIMEOUT_MS = 180_000;
49
49
  const RESTART_TIMEOUT_MS = 60_000;
@@ -65,6 +65,8 @@ export interface UpdateCheckResult {
65
65
  canUpdate: boolean;
66
66
  command: string;
67
67
  releaseNotesUrl: string;
68
+ /** Local time recorded after this exact runtime version was installed. */
69
+ lastInstalledAt?: string;
68
70
  reason?: string;
69
71
  }
70
72
 
@@ -81,6 +83,10 @@ export interface UpdateJobState {
81
83
  command: string;
82
84
  releaseNotesUrl: string;
83
85
  log: string[];
86
+ /** Exact package version whose installer completed successfully. */
87
+ installedVersion?: string;
88
+ /** Local installation event, persisted as an ISO-8601 timestamp. */
89
+ installedAt?: string;
84
90
  pid?: number;
85
91
  error?: string;
86
92
  exitCode?: number | null;
@@ -98,6 +104,7 @@ export interface UpdateCheckDeps {
98
104
  currentVersion: () => string;
99
105
  detectInstall: () => Installer;
100
106
  latestVersion: (tag: Channel) => string | null;
107
+ readUpdateJob?: () => UpdateJobState | null;
101
108
  }
102
109
 
103
110
  interface UpdateWorkerProcess {
@@ -280,6 +287,27 @@ function isVersionLike(value: unknown): value is string {
280
287
  && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value);
281
288
  }
282
289
 
290
+ /** Only timestamps written by this updater may cross the management API boundary. */
291
+ function normalizedUpdateTimestamp(value: unknown): string | null {
292
+ if (typeof value !== "string" || value.length > 40) return null;
293
+ const parsed = Date.parse(value);
294
+ if (!Number.isFinite(parsed)) return null;
295
+ const canonical = new Date(parsed).toISOString();
296
+ return canonical === value ? canonical : null;
297
+ }
298
+
299
+ function releaseNotesUrlForVersion(version: unknown): string {
300
+ return isVersionLike(version)
301
+ ? `${RELEASES_URL}/tag/v${version}`
302
+ : RELEASES_URL;
303
+ }
304
+
305
+ function isTrustedReleaseNotesUrl(value: unknown): value is string {
306
+ if (value === RELEASES_URL) return true;
307
+ if (typeof value !== "string" || !value.startsWith(`${RELEASES_URL}/tag/v`)) return false;
308
+ return releaseNotesUrlForVersion(value.slice(`${RELEASES_URL}/tag/v`.length)) === value;
309
+ }
310
+
283
311
  function withheldSummary(error: unknown): string {
284
312
  // `error.name` is writable, so it is external text like the message. A fixed classification
285
313
  // is the only part of an unknown error we can state without repeating something we were
@@ -319,7 +347,7 @@ function withheldSummary(error: unknown): string {
319
347
  */
320
348
  function brandOwnComposedText(key: string, value: unknown): unknown {
321
349
  if (key === "releaseNotesUrl") {
322
- return value === RELEASE_NOTES_URL ? value : "";
350
+ return isTrustedReleaseNotesUrl(value) ? value : "";
323
351
  }
324
352
  if (key === "command") {
325
353
  // Render the command shape first, then apply the same path test as every other field. The
@@ -412,6 +440,11 @@ export function readUpdateJob(jobId?: string | null): UpdateJobState | null {
412
440
  const parsed = JSON.parse(readFileSync(updateJobPath(), "utf8")) as UpdateJobState;
413
441
  if (jobId && parsed.id !== jobId) return null;
414
442
  if (!parsed || typeof parsed.id !== "string" || typeof parsed.status !== "string") return null;
443
+ if (parsed.installedAt !== undefined) {
444
+ const installedAt = normalizedUpdateTimestamp(parsed.installedAt);
445
+ if (installedAt) parsed.installedAt = installedAt;
446
+ else delete parsed.installedAt;
447
+ }
415
448
  return parsed;
416
449
  } catch {
417
450
  return null;
@@ -511,6 +544,12 @@ export function checkForUpdate(
511
544
  reason = "already_latest";
512
545
  }
513
546
 
547
+ const previousJob = (deps.readUpdateJob ?? readUpdateJob)();
548
+ const installedVersion = previousJob?.installedVersion ?? previousJob?.latestVersion;
549
+ const lastInstalledAt = installedVersion === current
550
+ ? normalizedUpdateTimestamp(previousJob?.installedAt)
551
+ : null;
552
+
514
553
  return {
515
554
  currentVersion: current,
516
555
  latestVersion: latest,
@@ -519,7 +558,8 @@ export function checkForUpdate(
519
558
  updateAvailable,
520
559
  canUpdate: installer !== "source" && updateAvailable,
521
560
  command,
522
- releaseNotesUrl: RELEASE_NOTES_URL,
561
+ releaseNotesUrl: releaseNotesUrlForVersion(latest ?? current),
562
+ ...(lastInstalledAt ? { lastInstalledAt } : {}),
523
563
  ...(reason ? { reason } : {}),
524
564
  };
525
565
  }
@@ -1745,6 +1785,8 @@ export interface GuiUpdateWorkerIo {
1745
1785
  checkForUpdateFn?: (channel: Channel) => ReturnType<typeof checkForUpdate>;
1746
1786
  /** Bypass the registry integrity probe, which runs before the cache gate and needs network. */
1747
1787
  integrityFn?: (version: string | null) => ReturnType<typeof checkUpdatePackageIntegrity>;
1788
+ /** Clock seam for deterministic install-event records in tests. */
1789
+ now?: () => string;
1748
1790
  /**
1749
1791
  * Immutable target selected by a caller that already resolved a registry version.
1750
1792
  * npm uses the hidden Node-launcher path so the package manager cannot re-resolve a
@@ -1767,7 +1809,7 @@ export async function runGuiUpdateWorker(
1767
1809
  ): Promise<void> {
1768
1810
  let job = readUpdateJob(jobId);
1769
1811
  const check = (io.checkForUpdateFn ?? checkForUpdate)(channel);
1770
- const now = new Date().toISOString();
1812
+ const now = io.now ?? (() => new Date().toISOString());
1771
1813
  // Capture the live listen target BEFORE the update command runs: the stop-first update
1772
1814
  // flow clears pid/runtime state, so this is the last moment the real port is knowable.
1773
1815
  // Only trust runtime-port.json when its pid matches the live pidfile process.
@@ -1789,8 +1831,8 @@ export async function runGuiUpdateWorker(
1789
1831
  job = {
1790
1832
  id: jobId,
1791
1833
  status: "running",
1792
- startedAt: now,
1793
- updatedAt: now,
1834
+ startedAt: now(),
1835
+ updatedAt: now(),
1794
1836
  currentVersion: check.currentVersion,
1795
1837
  latestVersion: check.latestVersion,
1796
1838
  channel: check.channel,
@@ -1885,6 +1927,12 @@ export async function runGuiUpdateWorker(
1885
1927
  return;
1886
1928
  }
1887
1929
 
1930
+ const installedVersion = io.exactVersion ?? check.latestVersion;
1931
+ job = updateJob(job, {
1932
+ ...(installedVersion ? { installedVersion } : {}),
1933
+ installedAt: now(),
1934
+ }, `Package ${installedVersion ?? "update"} installed successfully.`);
1935
+
1888
1936
  if (trayWasInstalled) {
1889
1937
  const trayArgs = [process.argv[1], ...planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs];
1890
1938
  const tray = runLoggedCommand(job, process.execPath, trayArgs, 20_000);
@@ -16,7 +16,7 @@ import {
16
16
 
17
17
  const VERSION_FILENAME = "version.json";
18
18
  const REFRESH_INTERVAL_MS = 20 * 60 * 60 * 1000; // 20h, matching codex-rs
19
- const RELEASE_NOTES_URL = "https://github.com/ESCANOR-001/remodex-android/releases/latest";
19
+ const RELEASES_URL = "https://github.com/ESCANOR-001/remodex-android/releases";
20
20
 
21
21
  export interface VersionCache {
22
22
  latest_version: string;
@@ -210,7 +210,7 @@ function renderPrompt(current: string, latest: string, channel: Channel): string
210
210
  "",
211
211
  ` \x1b[38;5;141m✨ Update available!\x1b[0m \x1b[2m${current} -> ${latest}\x1b[0m`,
212
212
  "",
213
- ` \x1b[2mRelease notes:\x1b[0m ${RELEASE_NOTES_URL}`,
213
+ ` \x1b[2mRelease notes:\x1b[0m ${RELEASES_URL}/tag/v${latest}`,
214
214
  "",
215
215
  ` 1) Update now (runs \`${command}\`)`,
216
216
  " 2) Skip",