pi-profile-switch 0.10.0 → 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.
- package/README.md +0 -15
- package/README.zh-CN.md +0 -15
- package/extensions/pi-profile/index.ts +65 -1
- package/package.json +1 -1
- package/src/startup-notifier.ts +636 -0
package/README.md
CHANGED
|
@@ -97,14 +97,6 @@ How fields resolve:
|
|
|
97
97
|
|
|
98
98
|
The files in [`examples/`](examples/) mirror the two profiles above: `ask.json` is the seeded starter, `example.json` the full-field demo.
|
|
99
99
|
|
|
100
|
-
### Migrating from earlier versions
|
|
101
|
-
|
|
102
|
-
If you used an earlier version that stored all profiles in a single `profiles.json` (`schemaVersion: 1`), migrate manually by creating a file for each profile under the `profiles/` directory:
|
|
103
|
-
|
|
104
|
-
1. Create directory `~/.pi-profile-switch/profiles/` (or `<project>/.pi/profiles/`).
|
|
105
|
-
2. For each key `<name>` in your old `profiles.json`'s `profiles` object, save its value directly as `<name>.json`.
|
|
106
|
-
3. Drop the outer `schemaVersion` and `profiles` envelope.
|
|
107
|
-
|
|
108
100
|
## Commands
|
|
109
101
|
|
|
110
102
|
The `/profile` command family manages everything in-session:
|
|
@@ -119,13 +111,6 @@ The `/profile` command family manages everything in-session:
|
|
|
119
111
|
|
|
120
112
|
All forms work in every mode, including non-interactive ones (`--mode rpc|print|json`); the bare selector degrades to the profile list where no interactive UI exists. The overlay is a runtime-only narrowing: it is never written to a catalog file and never survives a restart. Tools follow the same disable/enable model as the other resource kinds: a tool `disable` entry narrows the profile's resolved tool references — or the runtime's full available tool set when the profile declares no `tools`.
|
|
121
113
|
|
|
122
|
-
Removed subcommands and their replacements:
|
|
123
|
-
|
|
124
|
-
| Removed | Replacement |
|
|
125
|
-
| --- | --- |
|
|
126
|
-
| `/profile create` / `/profile edit` / `/profile delete` / `/profile duplicate` | edit the catalog JSON files directly, or configure profiles conversationally with the [`profile-config`](skills/profile-config/SKILL.md) skill |
|
|
127
|
-
| the `tools` replace-form of `/profile overlay` | `/profile overlay disable tool <name-or-glob>` (disable the complement, with globs) or declare the fixed set in the profile's `tools` field |
|
|
128
|
-
|
|
129
114
|
## Docs
|
|
130
115
|
|
|
131
116
|
- [Architecture](docs/architecture/overview.md) · [ADRs](docs/adr/) · [Glossary](CONTEXT.md)
|
package/README.zh-CN.md
CHANGED
|
@@ -97,14 +97,6 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
|
|
|
97
97
|
|
|
98
98
|
[`examples/`](examples/) 中的两个文件与上面一一对应:`ask.json` 是播种的初始 profile,`example.json` 是全字段演示。
|
|
99
99
|
|
|
100
|
-
### 从旧版本迁移
|
|
101
|
-
|
|
102
|
-
如果你之前使用了把全部 profile 存在单个 `profiles.json`(含 `schemaVersion: 1`)的旧版本,请手动把每个 profile 拆分到 `profiles/` 目录:
|
|
103
|
-
|
|
104
|
-
1. 创建 `~/.pi-profile-switch/profiles/`(或 `<项目>/.pi/profiles/`)目录。
|
|
105
|
-
2. 将旧 `profiles.json` 中 `profiles` 下的每个 `<name>` 键值提取为独立的 `<name>.json` 文件。
|
|
106
|
-
3. 去除外层的 `schemaVersion` 与 `profiles` 信封,文件顶层即为裸 profile 定义。
|
|
107
|
-
|
|
108
100
|
## 命令
|
|
109
101
|
|
|
110
102
|
`/profile` 命令族完成所有会话内操作:
|
|
@@ -119,13 +111,6 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
|
|
|
119
111
|
|
|
120
112
|
所有命令在非交互模式(`--mode rpc|print|json`)下同样生效;裸 `/profile` 在无交互界面时降级为 profile 列表。overlay 是会话级收窄:绝不写入 catalog 文件,也不会跨重启保留。tool 与其他资源类别使用相同的 disable/enable 模型:tool `disable` 条目收窄 profile 解析出的工具引用;profile 未声明 `tools` 时,则收窄运行时的全部可用工具集。
|
|
121
113
|
|
|
122
|
-
已移除子命令及其替代:
|
|
123
|
-
|
|
124
|
-
| 移除 | 替代 |
|
|
125
|
-
| --- | --- |
|
|
126
|
-
| `/profile create` / `/profile edit` / `/profile delete` / `/profile duplicate` | 直接编辑 catalog JSON 文件,或通过 [`profile-config`](skills/profile-config/SKILL.md) skill 对话式配置 |
|
|
127
|
-
| `/profile overlay` 的 `tools` 替换式 | `/profile overlay disable tool <name-or-glob>`(用 glob 禁用其余工具),或在 profile 的 `tools` 字段中声明固定集合 |
|
|
128
|
-
|
|
129
114
|
## 文档
|
|
130
115
|
|
|
131
116
|
- [架构设计](docs/architecture/overview.md) · [ADR](docs/adr/) · [术语表](CONTEXT.md)
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
4
6
|
|
|
7
|
+
import { isRecord } from "../../src/json-file.ts";
|
|
5
8
|
import { readTrustInputs } from "../../src/launcher/initial-profile.ts";
|
|
6
9
|
import { discoverAdapterServerNames } from "../../src/mcp-config.ts";
|
|
7
10
|
import { RuntimeStateStore } from "../../src/runtime-state-store.ts";
|
|
11
|
+
import { runStartupNotifications, type NoticeSurface } from "../../src/startup-notifier.ts";
|
|
8
12
|
import { applyLaunchPlan, readLaunchPlanFile } from "../../src/switching/apply-plan.ts";
|
|
9
13
|
import { OVERLAY_USAGE, applyOverlayMutation, clearOverlay, parseOverlayArgs } from "../../src/switching/overlay.ts";
|
|
10
14
|
import { formatProfileList, listProfiles } from "../../src/switching/list-profiles.ts";
|
|
11
15
|
import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/status.ts";
|
|
12
16
|
import { switchProfile, type SwitchDeps } from "../../src/switching/switch-profile.ts";
|
|
13
|
-
import { getGlobalStateDir } from "../../src/workspace.ts";
|
|
17
|
+
import { getGlobalStateDir, getProfileSwitchDir } from "../../src/workspace.ts";
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
20
|
* pi-profile extension entry.
|
|
@@ -38,6 +42,59 @@ import { getGlobalStateDir } from "../../src/workspace.ts";
|
|
|
38
42
|
* exclusively through `session_start` — nothing stale survives.
|
|
39
43
|
*/
|
|
40
44
|
|
|
45
|
+
/** The bundled package's own metadata: the notifier compares the RUNNING
|
|
46
|
+
* package version (never Pi's version or a repository checkout). */
|
|
47
|
+
const OWN_PACKAGE_JSON = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
48
|
+
|
|
49
|
+
async function readOwnVersion(): Promise<string | undefined> {
|
|
50
|
+
try {
|
|
51
|
+
const raw: unknown = JSON.parse(await readFile(OWN_PACKAGE_JSON, "utf8"));
|
|
52
|
+
if (isRecord(raw) && typeof raw.version === "string") return raw.version;
|
|
53
|
+
} catch {
|
|
54
|
+
// Unresolvable package metadata: startup notices are skipped quietly.
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Notice presentation surface: the TUI notification channel when available,
|
|
60
|
+
* stderr in every other mode (never stdout, never the agent's prompts). */
|
|
61
|
+
function createNoticeSurface(ctx: unknown): NoticeSurface {
|
|
62
|
+
const context = ctx as {
|
|
63
|
+
hasUI?: boolean;
|
|
64
|
+
mode?: string;
|
|
65
|
+
ui?: { notify?: (message: string, level: "info" | "warning" | "error") => void };
|
|
66
|
+
};
|
|
67
|
+
if (context.hasUI === true && context.mode === "tui" && typeof context.ui?.notify === "function") {
|
|
68
|
+
return { display: (message, level) => context.ui!.notify!(message, level) };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
display: (message) => {
|
|
72
|
+
process.stderr.write(`${message}\n`);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Startup notices run once per Pi process on the initial session_start,
|
|
78
|
+
* without awaiting remote IO, in an isolated failure domain: a notifier
|
|
79
|
+
* problem can never block profile activation, change Pi's exit code, or
|
|
80
|
+
* reach the activation/switch error path. Pi re-executes this module on
|
|
81
|
+
* reload, and reload/new/resume/fork never pass reason "startup", so no
|
|
82
|
+
* second check is possible within one process. */
|
|
83
|
+
async function startStartupNotices(surface: NoticeSurface): Promise<void> {
|
|
84
|
+
try {
|
|
85
|
+
const version = await readOwnVersion();
|
|
86
|
+
if (version === undefined) return;
|
|
87
|
+
await runStartupNotifications({
|
|
88
|
+
installedVersion: version,
|
|
89
|
+
workspaceDir: getProfileSwitchDir(),
|
|
90
|
+
offline: process.env.PI_OFFLINE === "1",
|
|
91
|
+
surface,
|
|
92
|
+
});
|
|
93
|
+
} catch {
|
|
94
|
+
// Best-effort by contract: swallow everything the notifier missed.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
41
98
|
function setProfileStatus(ui: unknown, profile: string | undefined): void {
|
|
42
99
|
if (profile && typeof (ui as { setStatus?: (k: string, v: string) => void })?.setStatus === "function") {
|
|
43
100
|
(ui as { setStatus: (k: string, v: string) => void }).setStatus("profile", `profile: ${profile}`);
|
|
@@ -51,6 +108,10 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
51
108
|
let pendingSummary: string | undefined;
|
|
52
109
|
|
|
53
110
|
pi.on("session_start", async (event, ctx) => {
|
|
111
|
+
// Capture the notice surface synchronously: pi may replace the session
|
|
112
|
+
// right after this handler (one-shot modes), after which a captured
|
|
113
|
+
// ctx throws on access. The surface freezes the channel now.
|
|
114
|
+
const noticeSurface = createNoticeSurface(ctx);
|
|
54
115
|
const plan = await readLaunchPlanFile(runtimeDir);
|
|
55
116
|
setProfileStatus(ctx.ui, plan?.profile);
|
|
56
117
|
const result = await applyLaunchPlan({
|
|
@@ -64,6 +125,9 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
64
125
|
},
|
|
65
126
|
});
|
|
66
127
|
pendingSummary = result.summary;
|
|
128
|
+
if (event.reason === "startup") {
|
|
129
|
+
void startStartupNotices(noticeSurface);
|
|
130
|
+
}
|
|
67
131
|
});
|
|
68
132
|
|
|
69
133
|
pi.on("before_agent_start", async (event) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-profile-switch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Named profiles for Pi: reference skills, extensions, MCP servers, and tools per workflow, switched without restarting. Install: npm install -g pi-profile-switch (not pi install).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -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
|
+
}
|