pi-profile-switch 0.9.2 → 0.11.0

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,636 @@
1
+ /**
2
+ * Startup notifier: best-effort startup notices for the pi-profile launcher.
3
+ *
4
+ * Two independent sources, checked once per Pi process launch and presented
5
+ * through a caller-supplied NoticeSurface:
6
+ *
7
+ * - npm package metadata: compare the running package version against the
8
+ * registry's installable `latest` dist-tag and remind once per target
9
+ * version (design decision 2: authority and cache).
10
+ * - a single maintainer-reviewed `announcements.json` feed (design decision
11
+ * 1: repository-hosted structured feed): show only applicable, unexpired,
12
+ * not-yet-shown announcements; an announcement requiring an upgrade
13
+ * replaces the ordinary reminder on that launch.
14
+ *
15
+ * Everything here is best-effort: expected network/offline failures return
16
+ * normally, invalid remote content produces one bounded source-specific
17
+ * diagnostic and never replaces a valid cache, and unexpected errors are
18
+ * caught by the top-level `runStartupNotifications` and reported without
19
+ * changing Pi's exit code. Remote checks are time- and size-bounded, honor
20
+ * an external AbortSignal, and never write outside the global workspace
21
+ * (`workspaceDir`, i.e. `getProfileSwitchDir()`).
22
+ */
23
+
24
+ import { createHash } from "node:crypto";
25
+ import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
26
+ import path from "node:path";
27
+
28
+ import { isRecord } from "./json-file.ts";
29
+
30
+ /** The single reviewed announcement feed (ADR-0014; fixed external address). */
31
+ export const ANNOUNCEMENTS_URL =
32
+ "https://raw.githubusercontent.com/VincentFF/pi-profile-switch/main/announcements.json";
33
+
34
+ /** npm registry package metadata; the installable stable version is read
35
+ * from `dist-tags.latest`, never from the largest published version. */
36
+ const NPM_METADATA_URL = "https://registry.npmjs.org/pi-profile-switch";
37
+
38
+ /** Hard ceiling on one remote check; the timer is unref'd so a pending
39
+ * check never keeps a short-lived Pi process alive. */
40
+ export const FETCH_TIMEOUT_MS = 5_000;
41
+
42
+ /** Remote results are refreshed at most once per day per source during
43
+ * normal operation; unsuccessful checks back off instead of requesting on
44
+ * every start. */
45
+ const DAILY_REFRESH_MS = 24 * 60 * 60 * 1000;
46
+ const FAILURE_BACKOFF_MS = 30 * 60 * 1000;
47
+ const CACHE_SCHEMA_VERSION = 1;
48
+
49
+ /** One validated per-source response with its bookkeeping timestamps. The
50
+ * format is private and versioned: a corrupt or old file is safely
51
+ * replaced, never migrated. */
52
+ interface SourceCache<T> {
53
+ schemaVersion: number;
54
+ data?: T;
55
+ lastSuccess: number;
56
+ lastAttempt: number;
57
+ }
58
+
59
+ const MAX_RESPONSE_BYTES = 64 * 1024;
60
+ const MAX_ANNOUNCEMENTS = 50;
61
+ const MAX_ID_LENGTH = 128;
62
+ const MAX_MESSAGE_LENGTH = 400;
63
+ const MAX_ACTION_LENGTH = 200;
64
+ /** A pending display claim older than this is considered abandoned (the
65
+ * presenting process died mid-display) and may be reclaimed. */
66
+ const CLAIM_STALE_MS = 10 * 60 * 1000;
67
+ const HISTORY_SCHEMA_VERSION = 1;
68
+
69
+ export interface NoticeSurface {
70
+ display(message: string, level: "info" | "warning"): void;
71
+ }
72
+
73
+ export interface StartupNotifierOptions {
74
+ /** The running package version, read from installed package metadata. */
75
+ installedVersion: string;
76
+ /** The global profile-switch dir; all state lives in its `notifications/`
77
+ * subdirectory, shared across profiles and projects. */
78
+ workspaceDir: string;
79
+ /** Explicit offline mode (PI_OFFLINE): no remote request is initiated. */
80
+ offline: boolean;
81
+ surface: NoticeSurface;
82
+ fetcher?: typeof fetch;
83
+ now?: () => Date;
84
+ signal?: AbortSignal;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // SemVer comparison (npm version semantics; no runtime dependency)
89
+ // ---------------------------------------------------------------------------
90
+
91
+ const SEMVER_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
92
+
93
+ interface ParsedVersion {
94
+ major: number;
95
+ minor: number;
96
+ patch: number;
97
+ prerelease: string[];
98
+ }
99
+
100
+ function parseVersion(version: string): ParsedVersion | undefined {
101
+ const match = SEMVER_PATTERN.exec(version);
102
+ if (match === null) return undefined;
103
+ return {
104
+ major: Number(match[1]),
105
+ minor: Number(match[2]),
106
+ patch: Number(match[3]),
107
+ prerelease: match[4] === undefined ? [] : match[4].split("."),
108
+ };
109
+ }
110
+
111
+ /** SemVer precedence after npm's `latest` tag. Returns undefined when
112
+ * either version is malformed — callers must reject the data rather than
113
+ * fall back to lexical comparison. */
114
+ function compareVersions(a: string, b: string): number | undefined {
115
+ const left = parseVersion(a);
116
+ const right = parseVersion(b);
117
+ if (left === undefined || right === undefined) return undefined;
118
+ for (const key of ["major", "minor", "patch"] as const) {
119
+ if (left[key] !== right[key]) return left[key] < right[key] ? -1 : 1;
120
+ }
121
+ const preA = left.prerelease;
122
+ const preB = right.prerelease;
123
+ if (preA.length === 0 && preB.length === 0) return 0;
124
+ // A release outranks any of its prereleases.
125
+ if (preA.length === 0) return 1;
126
+ if (preB.length === 0) return -1;
127
+ for (let i = 0; i < Math.max(preA.length, preB.length); i++) {
128
+ const x = preA[i];
129
+ const y = preB[i];
130
+ // A shorter prerelease list loses when it is a prefix of the longer.
131
+ if (x === undefined) return -1;
132
+ if (y === undefined) return 1;
133
+ const xNumeric = /^\d+$/.test(x);
134
+ const yNumeric = /^\d+$/.test(y);
135
+ if (xNumeric && yNumeric) {
136
+ if (x.length !== y.length) return x.length < y.length ? -1 : 1;
137
+ if (x !== y) return x < y ? -1 : 1;
138
+ } else if (xNumeric !== yNumeric) {
139
+ // Numeric identifiers sort below alphanumeric ones.
140
+ return xNumeric ? -1 : 1;
141
+ } else if (x !== y) {
142
+ return x < y ? -1 : 1;
143
+ }
144
+ }
145
+ return 0;
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Remote content validation (the validator is authoritative for the shape)
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /** Remote content was malformed or violates the feed limits. Distinct from
153
+ * network failures, which are expected offline and stay silent. */
154
+ class InvalidContentError extends Error {}
155
+
156
+ interface Announcement {
157
+ id: string;
158
+ message: string;
159
+ action: string;
160
+ expiresAt: string;
161
+ requiresUpgrade: boolean;
162
+ minInstalledVersion?: string;
163
+ maxInstalledVersionExclusive?: string;
164
+ }
165
+
166
+ const ANNOUNCEMENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
167
+ const EXPIRY_PATTERN = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
168
+
169
+ function assertCleanText(value: unknown, field: string, maxLength: number): asserts value is string {
170
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLength) {
171
+ throw new InvalidContentError(`${field} must be a non-empty string of at most ${maxLength} characters`);
172
+ }
173
+ for (const char of value) {
174
+ const code = char.codePointAt(0)!;
175
+ if (code < 0x20 || code === 0x7f) {
176
+ throw new InvalidContentError(`${field} must not contain control characters`);
177
+ }
178
+ }
179
+ }
180
+
181
+ function parseAnnouncement(raw: unknown): Announcement {
182
+ if (!isRecord(raw)) {
183
+ throw new InvalidContentError("announcement entries must be objects");
184
+ }
185
+ assertCleanText(raw.id, "id", MAX_ID_LENGTH);
186
+ if (!ANNOUNCEMENT_ID_PATTERN.test(raw.id)) {
187
+ throw new InvalidContentError(`id must match ${ANNOUNCEMENT_ID_PATTERN}`);
188
+ }
189
+ assertCleanText(raw.message, "message", MAX_MESSAGE_LENGTH);
190
+ assertCleanText(raw.action, "action", MAX_ACTION_LENGTH);
191
+ if (typeof raw.expiresAt !== "string" || !EXPIRY_PATTERN.test(raw.expiresAt)) {
192
+ throw new InvalidContentError("expiresAt must be an ISO 8601 date or timestamp");
193
+ }
194
+ if (!Number.isFinite(Date.parse(raw.expiresAt))) {
195
+ throw new InvalidContentError(`expiresAt is not a real date: ${JSON.stringify(raw.expiresAt)}`);
196
+ }
197
+ const requiresUpgrade = raw.requiresUpgrade ?? false;
198
+ if (typeof requiresUpgrade !== "boolean") {
199
+ throw new InvalidContentError("requiresUpgrade must be a boolean");
200
+ }
201
+ const announcement: Announcement = {
202
+ id: raw.id,
203
+ message: raw.message,
204
+ action: raw.action,
205
+ expiresAt: raw.expiresAt,
206
+ requiresUpgrade,
207
+ };
208
+ for (const field of ["minInstalledVersion", "maxInstalledVersionExclusive"] as const) {
209
+ const bound = raw[field];
210
+ if (bound === undefined) continue;
211
+ if (typeof bound !== "string" || parseVersion(bound) === undefined) {
212
+ throw new InvalidContentError(`${field} must be a valid SemVer version`);
213
+ }
214
+ announcement[field] = bound;
215
+ }
216
+ if (announcement.minInstalledVersion !== undefined && announcement.maxInstalledVersionExclusive !== undefined) {
217
+ const order = compareVersions(announcement.minInstalledVersion, announcement.maxInstalledVersionExclusive);
218
+ if (order === undefined || order >= 0) {
219
+ throw new InvalidContentError("minInstalledVersion must be less than maxInstalledVersionExclusive");
220
+ }
221
+ }
222
+ return announcement;
223
+ }
224
+
225
+ /** Validates the entire response before any of it is used; the whole feed is
226
+ * rejected on the first violation (duplicate ids, bad ranges/dates, control
227
+ * characters, oversized bodies, unknown schema versions). */
228
+ function parseFeed(text: string): Announcement[] {
229
+ if (text.length > MAX_RESPONSE_BYTES) {
230
+ throw new InvalidContentError(`response exceeds ${MAX_RESPONSE_BYTES} bytes`);
231
+ }
232
+ let raw: unknown;
233
+ try {
234
+ raw = JSON.parse(text);
235
+ } catch {
236
+ throw new InvalidContentError("response is not valid JSON");
237
+ }
238
+ if (!isRecord(raw)) {
239
+ throw new InvalidContentError("response must be a JSON object");
240
+ }
241
+ if (raw.schemaVersion !== 1) {
242
+ throw new InvalidContentError(`unsupported schemaVersion: ${String(raw.schemaVersion)}`);
243
+ }
244
+ if (!Array.isArray(raw.announcements) || raw.announcements.length > MAX_ANNOUNCEMENTS) {
245
+ throw new InvalidContentError(`announcements must be an array of at most ${MAX_ANNOUNCEMENTS} entries`);
246
+ }
247
+ const seen = new Set<string>();
248
+ return raw.announcements.map((entry) => {
249
+ const announcement = parseAnnouncement(entry);
250
+ if (seen.has(announcement.id)) {
251
+ throw new InvalidContentError(`duplicate announcement id: ${announcement.id}`);
252
+ }
253
+ seen.add(announcement.id);
254
+ return announcement;
255
+ });
256
+ }
257
+
258
+ function parseNpmLatest(text: string): string {
259
+ if (text.length > MAX_RESPONSE_BYTES) {
260
+ throw new InvalidContentError(`response exceeds ${MAX_RESPONSE_BYTES} bytes`);
261
+ }
262
+ let raw: unknown;
263
+ try {
264
+ raw = JSON.parse(text);
265
+ } catch {
266
+ throw new InvalidContentError("response is not valid JSON");
267
+ }
268
+ if (!isRecord(raw) || !isRecord(raw["dist-tags"]) || typeof raw["dist-tags"].latest !== "string") {
269
+ throw new InvalidContentError("missing dist-tags.latest");
270
+ }
271
+ const latest = raw["dist-tags"].latest;
272
+ if (parseVersion(latest) === undefined) {
273
+ throw new InvalidContentError(`malformed latest version: ${JSON.stringify(latest)}`);
274
+ }
275
+ return latest;
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Global history and exclusive display claims
280
+ // ---------------------------------------------------------------------------
281
+
282
+ function notificationsDir(workspaceDir: string): string {
283
+ return path.join(workspaceDir, "notifications");
284
+ }
285
+
286
+ async function readJsonQuiet(file: string): Promise<unknown> {
287
+ try {
288
+ return JSON.parse(await readFile(file, "utf8"));
289
+ } catch (error) {
290
+ if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) return undefined;
291
+ throw error;
292
+ }
293
+ }
294
+
295
+ async function atomicWriteJson(file: string, value: unknown): Promise<void> {
296
+ await mkdir(path.dirname(file), { recursive: true });
297
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
298
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`);
299
+ await rename(tmp, file);
300
+ }
301
+
302
+ /** Displayed-history keys (`upgrade:<version>` / `announcement:<id>`),
303
+ * shared across profiles and projects. A missing or corrupt file means
304
+ * "nothing shown yet". */
305
+ async function readDisplayedKeys(dir: string): Promise<Set<string>> {
306
+ const raw = await readJsonQuiet(path.join(dir, "displayed.json"));
307
+ if (isRecord(raw) && raw.schemaVersion === HISTORY_SCHEMA_VERSION && Array.isArray(raw.keys)) {
308
+ return new Set(raw.keys.filter((key): key is string => typeof key === "string"));
309
+ }
310
+ return new Set();
311
+ }
312
+
313
+ async function recordDisplayed(dir: string, key: string): Promise<void> {
314
+ const keys = await readDisplayedKeys(dir);
315
+ keys.add(key);
316
+ await atomicWriteJson(path.join(dir, "displayed.json"), { schemaVersion: HISTORY_SCHEMA_VERSION, keys: [...keys] });
317
+ }
318
+
319
+ function claimFile(dir: string, key: string): string {
320
+ const digest = createHash("sha256").update(key).digest("hex");
321
+ return path.join(dir, "claims", `${digest}.json`);
322
+ }
323
+
324
+ /** Exclusive per-key claim around synchronous presentation: concurrent
325
+ * launches (same process or separate ones) cannot both display the same
326
+ * notice. Abandoned claims go stale and become reclaimable. */
327
+ async function tryAcquireClaim(dir: string, key: string, nowMs: number): Promise<boolean> {
328
+ const file = claimFile(dir, key);
329
+ await mkdir(path.dirname(file), { recursive: true });
330
+ for (let attempt = 0; attempt < 2; attempt++) {
331
+ try {
332
+ const handle = await open(file, "wx");
333
+ try {
334
+ await handle.writeFile(JSON.stringify({ key, at: nowMs }));
335
+ } finally {
336
+ await handle.close();
337
+ }
338
+ return true;
339
+ } catch (error) {
340
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
341
+ const existing = await readJsonQuiet(file);
342
+ const at = isRecord(existing) && typeof existing.at === "number" ? existing.at : 0;
343
+ if (nowMs - at <= CLAIM_STALE_MS) return false;
344
+ await rm(file, { force: true });
345
+ }
346
+ }
347
+ return false;
348
+ }
349
+
350
+ async function releaseClaim(dir: string, key: string): Promise<void> {
351
+ await rm(claimFile(dir, key), { force: true });
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Candidate evaluation and presentation
356
+ // ---------------------------------------------------------------------------
357
+
358
+ interface Candidate {
359
+ key: string;
360
+ message: string;
361
+ level: "info" | "warning";
362
+ requiresUpgrade: boolean;
363
+ }
364
+
365
+ function appliesToInstalledVersion(announcement: Announcement, installedVersion: string): boolean {
366
+ // Missing bounds apply to all installed versions; a bound that cannot be
367
+ // evaluated against the installed version excludes the announcement.
368
+ if (announcement.minInstalledVersion !== undefined) {
369
+ const comparison = compareVersions(installedVersion, announcement.minInstalledVersion);
370
+ if (comparison === undefined || comparison < 0) return false;
371
+ }
372
+ if (announcement.maxInstalledVersionExclusive !== undefined) {
373
+ const comparison = compareVersions(installedVersion, announcement.maxInstalledVersionExclusive);
374
+ if (comparison === undefined || comparison >= 0) return false;
375
+ }
376
+ return true;
377
+ }
378
+
379
+ function collectCandidates(input: {
380
+ announcements: Announcement[] | undefined;
381
+ latest: string | undefined;
382
+ installedVersion: string;
383
+ nowMs: number;
384
+ displayed: Set<string>;
385
+ }): Candidate[] {
386
+ const candidates: Candidate[] = [];
387
+ for (const announcement of input.announcements ?? []) {
388
+ if (input.displayed.has(`announcement:${announcement.id}`)) continue;
389
+ if (Date.parse(announcement.expiresAt) <= input.nowMs) continue;
390
+ if (!appliesToInstalledVersion(announcement, input.installedVersion)) continue;
391
+ candidates.push({
392
+ key: `announcement:${announcement.id}`,
393
+ message: `${announcement.message} — ${announcement.action}`,
394
+ level: "info",
395
+ requiresUpgrade: announcement.requiresUpgrade,
396
+ });
397
+ }
398
+ let reminder: Candidate | undefined;
399
+ if (input.latest !== undefined) {
400
+ const comparison = compareVersions(input.latest, input.installedVersion);
401
+ if (comparison !== undefined && comparison > 0 && !input.displayed.has(`upgrade:${input.latest}`)) {
402
+ reminder = {
403
+ key: `upgrade:${input.latest}`,
404
+ message: `pi-profile-switch ${input.installedVersion} → ${input.latest}: upgrade with npm install -g pi-profile-switch`,
405
+ level: "info",
406
+ requiresUpgrade: false,
407
+ };
408
+ }
409
+ }
410
+ // An applicable upgrade-requiring announcement replaces the ordinary
411
+ // reminder on this launch — and the suppressed target is NOT marked as
412
+ // shown, so the reminder can fire on a later launch.
413
+ if (reminder !== undefined && candidates.some((candidate) => candidate.requiresUpgrade)) {
414
+ reminder = undefined;
415
+ }
416
+ return reminder === undefined ? candidates : [...candidates, reminder];
417
+ }
418
+
419
+ function report(surface: NoticeSurface, message: string): void {
420
+ try {
421
+ surface.display(message, "warning");
422
+ } catch {
423
+ // A broken surface must not break startup.
424
+ }
425
+ }
426
+
427
+ async function present(dir: string, candidate: Candidate, surface: NoticeSurface, nowMs: number): Promise<void> {
428
+ if (!(await tryAcquireClaim(dir, candidate.key, nowMs))) return;
429
+ try {
430
+ surface.display(candidate.message, candidate.level);
431
+ await recordDisplayed(dir, candidate.key);
432
+ } catch (error) {
433
+ // A failed presentation releases its claim so a later launch retries.
434
+ await releaseClaim(dir, candidate.key);
435
+ throw error;
436
+ }
437
+ await releaseClaim(dir, candidate.key);
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Remote checks (bounded, cancellable, silent on expected failures)
442
+ // ---------------------------------------------------------------------------
443
+
444
+ async function fetchText(url: string, fetcher: typeof fetch, externalSignal: AbortSignal | undefined): Promise<string> {
445
+ const controller = new AbortController();
446
+ const onAbort = () => controller.abort();
447
+ if (externalSignal !== undefined) {
448
+ if (externalSignal.aborted) controller.abort();
449
+ else externalSignal.addEventListener("abort", onAbort, { once: true });
450
+ }
451
+ // Unref'd: a pending check never keeps a short-lived Pi process alive.
452
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
453
+ (timer as unknown as { unref?: () => void }).unref?.();
454
+ try {
455
+ const response = await fetcher(url, { signal: controller.signal });
456
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
457
+ if (response.url) {
458
+ const finalOrigin = new URL(response.url).origin;
459
+ if (finalOrigin !== new URL(url).origin) {
460
+ throw new Error(`redirected to a different origin: ${finalOrigin}`);
461
+ }
462
+ }
463
+ return await response.text();
464
+ } finally {
465
+ clearTimeout(timer);
466
+ externalSignal?.removeEventListener("abort", onAbort);
467
+ }
468
+ }
469
+
470
+ /** Fetch one source when its daily refresh interval or failure backoff
471
+ * allows. Invalid content and expected network/abort failures keep the
472
+ * previously validated cache — invalid content additionally yields exactly
473
+ * one bounded source-specific diagnostic; unexpected errors propagate to
474
+ * the top-level catch. */
475
+ async function refreshSource<T>(input: {
476
+ dir: string;
477
+ name: string;
478
+ url: string;
479
+ label: string;
480
+ cache: SourceCache<T> | undefined;
481
+ parse: (text: string) => T;
482
+ fetcher: typeof fetch;
483
+ surface: NoticeSurface;
484
+ signal: AbortSignal | undefined;
485
+ nowMs: number;
486
+ }): Promise<SourceCache<T> | undefined> {
487
+ const { cache } = input;
488
+ if (cache?.data !== undefined && input.nowMs - cache.lastSuccess < DAILY_REFRESH_MS) return cache;
489
+ if (cache !== undefined && input.nowMs - cache.lastAttempt < FAILURE_BACKOFF_MS) return cache;
490
+ const next: SourceCache<T> = {
491
+ schemaVersion: CACHE_SCHEMA_VERSION,
492
+ data: cache?.data,
493
+ lastSuccess: cache?.lastSuccess ?? 0,
494
+ lastAttempt: input.nowMs,
495
+ };
496
+ const recordAttempt = async (): Promise<void> => {
497
+ await atomicWriteJson(path.join(input.dir, `${input.name}.json`), next);
498
+ };
499
+ let text: string;
500
+ try {
501
+ text = await fetchText(input.url, input.fetcher, input.signal);
502
+ } catch {
503
+ // Offline, timeouts, aborts: expected failures stay silent.
504
+ await recordAttempt();
505
+ return cache;
506
+ }
507
+ try {
508
+ next.data = input.parse(text);
509
+ next.lastSuccess = input.nowMs;
510
+ await recordAttempt();
511
+ return next;
512
+ } catch (error) {
513
+ if (error instanceof InvalidContentError) {
514
+ report(input.surface, `pi-profile: ${input.label} response invalid (${error.message}); keeping the last valid copy`);
515
+ await recordAttempt();
516
+ return cache;
517
+ }
518
+ throw error;
519
+ }
520
+ }
521
+
522
+ function validateFeedData(data: unknown): { announcements: Announcement[] } | undefined {
523
+ if (!isRecord(data) || !Array.isArray(data.announcements)) return undefined;
524
+ try {
525
+ return { announcements: data.announcements.map(parseAnnouncement) };
526
+ } catch {
527
+ return undefined;
528
+ }
529
+ }
530
+
531
+ function validateNpmData(data: unknown): { latest: string } | undefined {
532
+ if (!isRecord(data) || typeof data.latest !== "string" || parseVersion(data.latest) === undefined) return undefined;
533
+ return { latest: data.latest };
534
+ }
535
+
536
+ async function readSourceCache<T>(
537
+ dir: string,
538
+ name: string,
539
+ validate: (data: unknown) => T | undefined,
540
+ ): Promise<SourceCache<T> | undefined> {
541
+ const raw = await readJsonQuiet(path.join(dir, `${name}.json`));
542
+ if (!isRecord(raw) || raw.schemaVersion !== CACHE_SCHEMA_VERSION) return undefined;
543
+ if (typeof raw.lastSuccess !== "number" || typeof raw.lastAttempt !== "number") return undefined;
544
+ const cache: SourceCache<T> = {
545
+ schemaVersion: CACHE_SCHEMA_VERSION,
546
+ lastSuccess: raw.lastSuccess,
547
+ lastAttempt: raw.lastAttempt,
548
+ };
549
+ if (raw.data !== undefined) {
550
+ const data = validate(raw.data);
551
+ if (data === undefined) return undefined;
552
+ cache.data = data;
553
+ }
554
+ return cache;
555
+ }
556
+
557
+ // ---------------------------------------------------------------------------
558
+ // Entry point
559
+ // ---------------------------------------------------------------------------
560
+
561
+ /**
562
+ * Runs the startup notification check exactly as specified by the launcher
563
+ * delta: best-effort, never throwing, never blocking Pi's exit. Eligible
564
+ * cached information is evaluated before remote checks complete.
565
+ */
566
+ export async function runStartupNotifications(options: StartupNotifierOptions): Promise<void> {
567
+ try {
568
+ await run(options);
569
+ } catch (error) {
570
+ report(
571
+ options.surface,
572
+ `pi-profile: startup notification check failed (${error instanceof Error ? error.message : String(error)})`,
573
+ );
574
+ }
575
+ }
576
+
577
+ async function run(options: StartupNotifierOptions): Promise<void> {
578
+ const fetcher = options.fetcher ?? fetch;
579
+ const now = options.now ?? (() => new Date());
580
+ const dir = notificationsDir(options.workspaceDir);
581
+ const nowMs = now().getTime();
582
+ const displayed = await readDisplayedKeys(dir);
583
+ const feedCache = await readSourceCache<{ announcements: Announcement[] }>(dir, "announcements-feed", validateFeedData);
584
+ const npmCache = await readSourceCache<{ latest: string }>(dir, "npm-latest", validateNpmData);
585
+
586
+ let announcements = feedCache?.data;
587
+ let latest = npmCache?.data;
588
+ const evaluate = async (): Promise<void> => {
589
+ const candidates = collectCandidates({
590
+ announcements: announcements?.announcements,
591
+ latest: latest?.latest,
592
+ installedVersion: options.installedVersion,
593
+ nowMs,
594
+ displayed,
595
+ });
596
+ for (const candidate of candidates) {
597
+ await present(dir, candidate, options.surface, nowMs);
598
+ displayed.add(candidate.key);
599
+ }
600
+ };
601
+
602
+ // Previously validated cached information is evaluated first, so a
603
+ // short-lived process can display it without waiting for the network.
604
+ await evaluate();
605
+
606
+ if (!options.offline) {
607
+ const refreshedFeed = await refreshSource<{ announcements: Announcement[] }>({
608
+ dir,
609
+ name: "announcements-feed",
610
+ url: ANNOUNCEMENTS_URL,
611
+ label: "announcements",
612
+ cache: feedCache,
613
+ parse: (text) => ({ announcements: parseFeed(text) }),
614
+ fetcher,
615
+ surface: options.surface,
616
+ signal: options.signal,
617
+ nowMs,
618
+ });
619
+ announcements = refreshedFeed?.data;
620
+ await evaluate();
621
+ const refreshedNpm = await refreshSource<{ latest: string }>({
622
+ dir,
623
+ name: "npm-latest",
624
+ url: NPM_METADATA_URL,
625
+ label: "npm registry",
626
+ cache: npmCache,
627
+ parse: (text) => ({ latest: parseNpmLatest(text) }),
628
+ fetcher,
629
+ surface: options.surface,
630
+ signal: options.signal,
631
+ nowMs,
632
+ });
633
+ latest = refreshedNpm?.data;
634
+ await evaluate();
635
+ }
636
+ }