pi-updater 0.3.3 → 0.4.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.
Files changed (4) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +88 -36
  3. package/index.ts +284 -346
  4. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,6 +1,24 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.4.0 - 2026-07-05
4
+
5
+ - Detect outdated extension packages (same check as pi's "Package Updates Available" banner) and offer to update them interactively.
6
+ - Combined prompt when both pi and extensions are outdated: "Update all" (`pi update --self --extensions`), pi only, or extensions only.
7
+ - Extensions-only prompt when pi is current but packages are outdated: runs `pi update --extensions`, then hot-reloads via `ctx.reload()` (from `/update`) or restarts into the same session (from the startup prompt, which cannot trigger a reload).
8
+ - Selecting an update option is the only interaction: no separate restart confirmation afterwards, and the restarted pi skips its startup check once so partial updates don't immediately re-prompt.
9
+ - Startup shows one consolidated prompt after both checks resolve (never a partial offer); the cached pi version is now only a fallback for failed fetches.
10
+ - `/update` now checks both pi and extension packages.
11
+ - Raise install timeout to 5 minutes to accommodate multi-package updates.
12
+ - Treat killed (timed-out) update processes as failures so a partial install never reloads or restarts as success.
13
+ - Use `pi update --self --extensions` instead of `--all` for compatibility with pi < 0.80.
14
+ - Version dismissal is now "Ignore <version>" and only offered in the pi-only prompt.
15
+
16
+ ## 0.3.4 - 2026-07-05 (not published to npm; included in 0.4.0)
17
+
18
+ - Delegate installs to pi's native `pi update --self` command so npm, pnpm, yarn, bun, and standalone installs use pi's own update logic.
19
+ - Remove the npm-only package migration installer now that the legacy package migration window is closed.
20
+ - Remove all legacy `@mariozechner` support: static `@earendil-works/pi-coding-agent` import, no owning-package detection, no package-name tracking in the cache. Requires pi 0.74.0+ (`@earendil-works` scope); older installs can pin `pi-updater@0.3.3`.
21
+ - Keep pi-updater focused on the interactive prompt, cache-first update checks, and auto-restart flow.
4
22
 
5
23
  ## 0.3.3 - 2026-05-21
6
24
 
package/README.md CHANGED
@@ -1,70 +1,122 @@
1
1
  # pi-updater
2
2
 
3
- A lightweight, Codex-style auto-updater for pi with fast, cache-first startup checks.
3
+ pi-updater is a [pi](https://pi.dev) extension that turns pi's update notices
4
+ into an interactive flow: it prompts you when a new pi version or extension
5
+ package updates are available, installs them without leaving your session,
6
+ and puts you right back where you were.
4
7
 
5
8
  - npm: https://www.npmjs.com/package/pi-updater
6
9
  - repo: https://github.com/tonze/pi-updater
7
10
 
8
- > **Note:** pi-updater installs the exact package/version returned by pi's update service with npm. This handles pi package-name migrations and avoids stale native self-update behavior while still keeping the interactive prompt/restart flow.
9
-
10
11
  <img width="800" height="482" alt="Screenshot 2026-02-28 at 09 01 37" src="https://github.com/user-attachments/assets/89df2dad-8d91-464b-b3cb-dfd15bce1c06" />
11
12
 
12
- ## What it does
13
-
14
- If a newer version is available, pi-updater shows a startup prompt:
15
- - **Update now** — install with npm, then auto-restart pi on the current session
16
- - **Skip** — dismiss until next session
17
- - **Skip this version** — don't ask again until a newer version appears
18
-
19
- After a successful update, pi-updater asks whether to restart immediately. If confirmed, pi relaunches seamlessly on the current session. In non-interactive modes or if auto-restart fails, it falls back to a manual restart message. Ephemeral `--no-session` runs stay ephemeral on restart.
13
+ ## Why does this exist?
20
14
 
21
- **`/update`:** manually check for updates (always fetches fresh from pi's update service, unless `PI_OFFLINE` is set). It installs the exact npm package/version advertised by pi's update service and respects npm engine requirements, so upgrade Node.js first if the new pi release requires it.
15
+ pi already checks for updates on startup for itself and for installed
16
+ extension packages — but all it does is print a notice telling you which
17
+ command to run. The built-in flow is: see the notice, finish what you're
18
+ doing, quit pi, run `pi update`, start pi again, run `pi -c` to get your
19
+ session back. That's five steps for something that should be one keypress.
22
20
 
23
- ## How version checks work
21
+ pi-updater collapses this into a prompt. Choose an update option and it
22
+ installs the new versions and puts you back in your current session. You can
23
+ also skip once, or skip a specific pi version so it stops asking until the
24
+ next release.
24
25
 
25
- pi-updater uses a cache-first approach to keep startup fast:
26
+ The actual installation is delegated to pi's native `pi update` command. pi
27
+ knows how it was installed (npm, pnpm, yarn, bun, or a standalone binary)
28
+ and what extension packages you have configured; pi-updater deliberately
29
+ does not reimplement any of that. This extension owns the interactive
30
+ experience, nothing more.
26
31
 
27
- 1. On startup, cached version data is checked instantly.
28
- 2. One background live fetch refreshes the cache from pi's update service.
29
- 3. If the background fetch finds a newer version, pi-updater can prompt in the same session.
30
- 4. Automatic checks are skipped when `PI_SKIP_VERSION_CHECK` or `PI_OFFLINE` is set.
31
-
32
- ## Install
32
+ ## Installation
33
33
 
34
34
  ```bash
35
35
  pi install npm:pi-updater
36
36
  ```
37
37
 
38
- Or from git:
39
-
40
- ```bash
41
- pi install git:github.com/tonze/pi-updater
42
- ```
38
+ Requires pi 0.74.0 or later (the `@earendil-works` package scope). On older
39
+ installs the extension fails to load harmlessly; if you need it there, pin
40
+ `pi-updater@0.3.3`.
43
41
 
44
42
  ## Usage
45
43
 
46
- Use `/update` inside pi to manually check for updates and install them.
44
+ There is nothing to configure. On startup, pi-updater checks both pi itself
45
+ and your installed extension packages (the same check behind pi's "Package
46
+ Updates Available" banner).
47
47
 
48
- Cache and dismissed-version state are stored in pi's configured agent directory and respect `PI_CODING_AGENT_DIR`.
48
+ If only pi is outdated:
49
49
 
50
- ## Environment flags
50
+ - **Update now** — run `pi update --self`, then restart pi on the current session
51
+ - **Skip** — ask again next session
52
+ - **Ignore \<version\>** — don't ask again until a newer version appears
51
53
 
52
- Disable automatic version checks:
54
+ If both pi and extensions are outdated, a combined prompt appears:
53
55
 
54
- ```bash
55
- export PI_SKIP_VERSION_CHECK=1
56
- ```
56
+ - **Update all** — run `pi update --self --extensions`, then restart
57
+ - **Skip** — ask again next session
58
+ - **Update pi only** — run `pi update --self`, then restart
59
+ - **Update extensions only** — run `pi update --extensions`, then reload
60
+
61
+ Version dismissal ("Ignore") is only offered in the pi-only prompt; a
62
+ dismissed pi version degrades the combined prompt to extensions-only.
63
+
64
+ If only extensions are outdated, you're offered `pi update --extensions`.
57
65
 
58
- Or run in offline mode (also disables automatic checks):
66
+ Choosing an update option is the only interaction: anything involving pi
67
+ core restarts straight back into your current session, and extension-only
68
+ updates are hot-reloaded in place (from the startup prompt, where extensions
69
+ cannot trigger a reload, pi restarts into the session instead — same
70
+ result). Either way you keep working where you left off.
71
+
72
+ You can also check manually at any time with `/update`.
73
+
74
+ Extension updates have no per-version skip; choosing Skip simply asks again
75
+ next session. Pinned (`@version` / `#ref`) and local packages are excluded,
76
+ matching pi's own update check.
77
+
78
+ In non-interactive modes, or if the restart fails, pi-updater falls back to
79
+ a message telling you how to restart yourself. Ephemeral `--no-session` runs
80
+ stay ephemeral across the restart.
81
+
82
+ ### How version checks work
83
+
84
+ Startup is never blocked. Both checks run in the background — pi's version
85
+ against pi's update service, extension packages against their npm/git
86
+ sources — and one consolidated prompt appears when they resolve, so you are
87
+ never offered a partial update. If the version fetch fails, a previously
88
+ cached result is used as fallback. After an update restarts pi, the startup
89
+ check is skipped once so you're not immediately re-prompted for anything you
90
+ just declined.
91
+
92
+ `/update` always fetches fresh. Cache and dismissed-version state live in pi's
93
+ agent directory and respect `PI_CODING_AGENT_DIR`.
94
+
95
+ ### Disabling checks
96
+
97
+ pi's standard environment variables are respected:
59
98
 
60
99
  ```bash
61
- export PI_OFFLINE=1
100
+ export PI_SKIP_VERSION_CHECK=1 # disable automatic checks
101
+ export PI_OFFLINE=1 # offline mode, also disables checks
62
102
  ```
63
103
 
64
- ## Updating this package
104
+ While pi-updater is active it suppresses pi's built-in update notice so you
105
+ don't get prompted twice for the same release. pi's "Package Updates
106
+ Available" banner cannot be suppressed the same way, so it may still appear
107
+ alongside pi-updater's extension prompt.
108
+
109
+ ## Caveats
110
+
111
+ Because installation is delegated to pi, pi's limitations apply: standalone
112
+ binary installs get download instructions instead of an automatic install, and
113
+ Windows self-update covers npm and pnpm installs only. In those cases you'll
114
+ see pi's own message explaining what to do.
115
+
116
+ ## Updating pi-updater itself
65
117
 
66
118
  ```bash
67
- pi update
119
+ pi update npm:pi-updater
68
120
  ```
69
121
 
70
122
  ## License
package/index.ts CHANGED
@@ -2,117 +2,53 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
+ import { VERSION, BorderedLoader, getAgentDir } from "@earendil-works/pi-coding-agent";
5
6
  import { spawnSync } from "node:child_process";
6
- import { readFileSync, realpathSync, writeFileSync, mkdirSync } from "node:fs";
7
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
7
8
  import { join, dirname } from "node:path";
8
9
 
9
- const PACKAGE_NAME = "@earendil-works/pi-coding-agent";
10
- const LEGACY_PACKAGE_NAME = "@mariozechner/pi-coding-agent";
11
10
  const LATEST_VERSION_URL = "https://pi.dev/api/latest-version";
12
- const NATIVE_VERSION_NOTICE_MIN_VERSION = "0.70.3";
11
+ const CACHE_FILE = join(getAgentDir(), "update-cache.json");
12
+ const UPDATE_COMMANDS = {
13
+ self: { args: ["update", "--self"], display: "pi update --self" },
14
+ extensions: { args: ["update", "--extensions"], display: "pi update --extensions" },
15
+ // --self --extensions rather than --all: pi < 0.80 doesn't parse --all,
16
+ // but the flag pair resolves to the "all" target on every supported version.
17
+ all: { args: ["update", "--self", "--extensions"], display: "pi update --self --extensions" },
18
+ } as const;
19
+
20
+ type UpdateTarget = keyof typeof UPDATE_COMMANDS;
21
+
22
+ interface UpdateOffer {
23
+ piLatest?: string;
24
+ extensions: string[];
25
+ }
13
26
 
14
27
  const ENV_SKIP_VERSION_CHECK = "PI_SKIP_VERSION_CHECK";
15
28
  const ENV_OFFLINE = "PI_OFFLINE";
16
29
  const ENV_INTERNAL_SKIP = "PI_UPDATER_SUPPRESSED_NATIVE_VERSION_CHECK";
17
-
18
- interface LatestRelease {
19
- version: string;
20
- packageName?: string;
21
- }
30
+ // One-shot: set on the pi process we restart into after an update, so the
31
+ // user is not immediately re-prompted for updates they just declined.
32
+ const ENV_SUPPRESS_STARTUP_CHECK = "PI_UPDATER_SUPPRESS_STARTUP_CHECK";
22
33
 
23
34
  interface VersionCache {
24
35
  latestVersion: string;
25
- latestPackageName?: string;
26
36
  dismissedVersion?: string;
27
- dismissedPackageName?: string;
28
37
  checkedAt?: string;
29
38
  }
30
39
 
31
- type BorderedLoaderConstructor = new (...args: any[]) => any;
32
-
33
- interface PiRuntime {
34
- VERSION: string;
35
- BorderedLoader: BorderedLoaderConstructor;
36
- getAgentDir: () => string;
37
- packageName: string;
38
- }
39
-
40
- let VERSION = "0.0.0";
41
- let BorderedLoader: BorderedLoaderConstructor;
42
- let getAgentDir: () => string;
43
-
44
- function packageNameFromNodeModulesPath(path: string): string | undefined {
45
- const normalized = path.replace(/\\/g, "/");
46
- const marker = "/node_modules/";
47
- const index = normalized.lastIndexOf(marker);
48
- if (index === -1) return undefined;
49
-
50
- const parts = normalized.slice(index + marker.length).split("/");
51
- if (!parts[0]) return undefined;
52
- if (parts[0].startsWith("@")) {
53
- if (!parts[1]) return undefined;
54
- return `${parts[0]}/${parts[1]}`;
55
- }
56
- return parts[0];
57
- }
58
-
59
- async function findOwningPiPackageName(pi: ExtensionAPI): Promise<string | undefined> {
40
+ function readCache(): VersionCache | undefined {
60
41
  try {
61
- const cmd = process.platform === "win32" ? "where" : "which";
62
- const result = await pi.exec(cmd, ["pi"]);
63
- const binary = result.code === 0 ? result.stdout?.trim().split(/\r?\n/)[0] : undefined;
64
- if (!binary) return undefined;
65
-
66
- try {
67
- return packageNameFromNodeModulesPath(realpathSync(binary));
68
- } catch {
69
- return packageNameFromNodeModulesPath(binary);
70
- }
42
+ return JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
71
43
  } catch {
72
44
  return undefined;
73
45
  }
74
46
  }
75
47
 
76
- async function loadPiRuntime(preferredPackageName?: string): Promise<PiRuntime> {
77
- const packageNames = [
78
- preferredPackageName,
79
- PACKAGE_NAME,
80
- LEGACY_PACKAGE_NAME,
81
- ].filter((packageName): packageName is string => !!packageName);
82
-
83
- for (const packageName of new Set(packageNames)) {
84
- try {
85
- const runtime = await import(packageName);
86
- if (
87
- typeof runtime.VERSION === "string" &&
88
- typeof runtime.BorderedLoader === "function" &&
89
- typeof runtime.getAgentDir === "function"
90
- ) {
91
- return {
92
- VERSION: runtime.VERSION,
93
- BorderedLoader: runtime.BorderedLoader,
94
- getAgentDir: runtime.getAgentDir,
95
- packageName,
96
- };
97
- }
98
- } catch {}
99
- }
100
-
101
- throw new Error(`Could not load ${PACKAGE_NAME} or ${LEGACY_PACKAGE_NAME}`);
102
- }
103
-
104
- function readCache(cacheFile: string): VersionCache | undefined {
105
- try {
106
- return JSON.parse(readFileSync(cacheFile, "utf-8"));
107
- } catch {
108
- return undefined;
109
- }
110
- }
111
-
112
- function writeCache(cacheFile: string, cache: VersionCache) {
48
+ function writeCache(cache: VersionCache) {
113
49
  try {
114
- mkdirSync(dirname(cacheFile), { recursive: true });
115
- writeFileSync(cacheFile, JSON.stringify(cache) + "\n");
50
+ mkdirSync(dirname(CACHE_FILE), { recursive: true });
51
+ writeFileSync(CACHE_FILE, JSON.stringify(cache) + "\n");
116
52
  } catch {}
117
53
  }
118
54
 
@@ -136,22 +72,17 @@ function parseVersion(version: string): ParsedVersion | undefined {
136
72
  };
137
73
  }
138
74
 
139
- function compareVersions(leftVersion: string, rightVersion: string): number | undefined {
140
- const left = parseVersion(leftVersion);
141
- const right = parseVersion(rightVersion);
142
- if (!left || !right) return undefined;
143
- if (left.major !== right.major) return left.major - right.major;
144
- if (left.minor !== right.minor) return left.minor - right.minor;
145
- if (left.patch !== right.patch) return left.patch - right.patch;
146
- if (left.prerelease === right.prerelease) return 0;
147
- if (!left.prerelease) return 1;
148
- if (!right.prerelease) return -1;
149
- return left.prerelease.localeCompare(right.prerelease);
150
- }
151
-
152
- function isAtLeast(version: string, minimum: string): boolean {
153
- const comparison = compareVersions(version, minimum);
154
- return comparison !== undefined && comparison >= 0;
75
+ function isNewer(candidate: string, current: string): boolean {
76
+ const left = parseVersion(candidate);
77
+ const right = parseVersion(current);
78
+ if (!left || !right) return false;
79
+ if (left.major !== right.major) return left.major > right.major;
80
+ if (left.minor !== right.minor) return left.minor > right.minor;
81
+ if (left.patch !== right.patch) return left.patch > right.patch;
82
+ if (left.prerelease === right.prerelease) return false;
83
+ if (!left.prerelease) return true;
84
+ if (!right.prerelease) return false;
85
+ return left.prerelease.localeCompare(right.prerelease) > 0;
155
86
  }
156
87
 
157
88
  function isEnvSet(name: string): boolean {
@@ -176,46 +107,7 @@ function piUserAgent(): string {
176
107
  return `pi/${VERSION} (${process.platform}; ${runtime}; ${process.arch})`;
177
108
  }
178
109
 
179
- function hasNativeVersionNotice(): boolean {
180
- return isAtLeast(VERSION, NATIVE_VERSION_NOTICE_MIN_VERSION);
181
- }
182
-
183
- function targetPackageName(release: LatestRelease, currentPackageName: string): string {
184
- return release.packageName ?? currentPackageName;
185
- }
186
-
187
- function releaseKey(release: LatestRelease, currentPackageName: string): string {
188
- return `${targetPackageName(release, currentPackageName)}@${release.version}`;
189
- }
190
-
191
- function isUpdateAvailable(release: LatestRelease, currentPackageName: string): boolean {
192
- const comparison = compareVersions(release.version, VERSION);
193
- if (comparison === undefined) return false;
194
- return comparison > 0 || (comparison === 0 && targetPackageName(release, currentPackageName) !== currentPackageName);
195
- }
196
-
197
- function isDismissed(
198
- cache: VersionCache,
199
- release: LatestRelease,
200
- currentPackageName: string,
201
- ): boolean {
202
- if (cache.dismissedVersion !== release.version) return false;
203
- if (!cache.dismissedPackageName) return !release.packageName;
204
- return cache.dismissedPackageName === targetPackageName(release, currentPackageName);
205
- }
206
-
207
- function saveLatestToCache(cacheFile: string, latest: LatestRelease) {
208
- const prev = readCache(cacheFile);
209
- writeCache(cacheFile, {
210
- latestVersion: latest.version,
211
- latestPackageName: latest.packageName,
212
- dismissedVersion: prev?.dismissedVersion,
213
- dismissedPackageName: prev?.dismissedPackageName,
214
- checkedAt: new Date().toISOString(),
215
- });
216
- }
217
-
218
- async function fetchLatestRelease(): Promise<LatestRelease | undefined> {
110
+ async function fetchLatestVersion(): Promise<string | undefined> {
219
111
  try {
220
112
  const res = await fetch(LATEST_VERSION_URL, {
221
113
  headers: {
@@ -225,158 +117,139 @@ async function fetchLatestRelease(): Promise<LatestRelease | undefined> {
225
117
  signal: AbortSignal.timeout(10_000),
226
118
  });
227
119
  if (!res.ok) return undefined;
228
- const data = (await res.json()) as { version?: string; packageName?: string };
120
+ const data = (await res.json()) as { version?: string };
229
121
  if (typeof data.version !== "string" || !data.version.trim()) return undefined;
230
- const packageName =
231
- typeof data.packageName === "string" && data.packageName.trim()
232
- ? data.packageName.trim()
233
- : undefined;
234
- return { version: data.version.trim(), packageName };
122
+ return data.version.trim();
235
123
  } catch {
236
124
  return undefined;
237
125
  }
238
126
  }
239
127
 
240
- /** Returns a cached upgrade if available and not dismissed. */
241
- function getCachedUpgradeRelease(
242
- cacheFile: string,
243
- currentPackageName: string,
244
- ): LatestRelease | undefined {
245
- const cache = readCache(cacheFile);
128
+ /**
129
+ * Check for outdated extension packages using pi's own package manager —
130
+ * the same code path behind pi's startup "Package Updates Available" banner.
131
+ * These exports are not documented extension API, so degrade gracefully.
132
+ */
133
+ async function checkForExtensionUpdates(cwd: string): Promise<string[]> {
134
+ try {
135
+ const mod = (await import("@earendil-works/pi-coding-agent")) as Record<string, any>;
136
+ const { DefaultPackageManager, SettingsManager } = mod;
137
+ if (
138
+ typeof DefaultPackageManager !== "function" ||
139
+ typeof SettingsManager?.create !== "function"
140
+ ) {
141
+ return [];
142
+ }
143
+ const agentDir = getAgentDir();
144
+ const packageManager = new DefaultPackageManager({
145
+ cwd,
146
+ agentDir,
147
+ settingsManager: SettingsManager.create(cwd, agentDir),
148
+ });
149
+ const updates = await packageManager.checkForAvailableUpdates();
150
+ if (!Array.isArray(updates)) return [];
151
+ return updates
152
+ .map((update) => String(update?.displayName ?? update?.source ?? ""))
153
+ .filter(Boolean);
154
+ } catch {
155
+ return [];
156
+ }
157
+ }
158
+
159
+ /** Returns a cached upgrade version if available and not dismissed. */
160
+ function getCachedUpgradeVersion(): string | undefined {
161
+ const cache = readCache();
246
162
  if (!cache) return undefined;
247
- const release = {
248
- version: cache.latestVersion,
249
- packageName: cache.latestPackageName,
250
- };
251
- if (!isUpdateAvailable(release, currentPackageName)) return undefined;
252
- if (isDismissed(cache, release, currentPackageName)) return undefined;
253
- return release;
163
+ if (!isNewer(cache.latestVersion, VERSION)) return undefined;
164
+ if (cache.dismissedVersion === cache.latestVersion) return undefined;
165
+ return cache.latestVersion;
166
+ }
167
+
168
+ function saveLatestToCache(latest: string) {
169
+ const prev = readCache();
170
+ writeCache({
171
+ latestVersion: latest,
172
+ dismissedVersion: prev?.dismissedVersion,
173
+ checkedAt: new Date().toISOString(),
174
+ });
254
175
  }
255
176
 
256
177
  /** Fetch latest from Pi's update endpoint and refresh cache. */
257
- async function refreshLatestReleaseInCache(cacheFile: string): Promise<LatestRelease | undefined> {
258
- const latest = await fetchLatestRelease();
178
+ async function refreshLatestVersionInCache(): Promise<string | undefined> {
179
+ const latest = await fetchLatestVersion();
259
180
  if (!latest) return undefined;
260
- saveLatestToCache(cacheFile, latest);
181
+ saveLatestToCache(latest);
261
182
  return latest;
262
183
  }
263
184
 
264
- function dismissRelease(
265
- cacheFile: string,
266
- release: LatestRelease,
267
- currentPackageName: string,
268
- ) {
269
- const cache = readCache(cacheFile);
270
- writeCache(cacheFile, {
271
- latestVersion: cache?.latestVersion ?? release.version,
272
- latestPackageName: cache?.latestPackageName ?? release.packageName,
273
- dismissedVersion: release.version,
274
- dismissedPackageName: targetPackageName(release, currentPackageName),
185
+ function dismissVersion(version: string) {
186
+ const cache = readCache();
187
+ writeCache({
188
+ latestVersion: cache?.latestVersion ?? version,
189
+ dismissedVersion: version,
275
190
  checkedAt: cache?.checkedAt,
276
191
  });
277
192
  }
278
193
 
279
- interface InstallStep {
280
- program: string;
281
- args: string[];
282
- display: string;
194
+ function isBunFsPath(path: string): boolean {
195
+ return path.includes("$bunfs") || path.includes("~BUN") || path.includes("%7EBUN");
283
196
  }
284
197
 
285
- interface InstallCommand {
286
- steps: InstallStep[];
287
- display: string;
288
- targetVersion: string;
289
- targetPackageName: string;
198
+ /**
199
+ * Command that re-invokes the currently running pi, regardless of what
200
+ * `pi` on PATH points to. For Node installs this is `node <entrypoint>`;
201
+ * for Bun standalone binaries the executable itself is pi.
202
+ */
203
+ function currentPiCommand(args: string[]): { program: string; args: string[] } {
204
+ const entry = process.argv[1];
205
+ if (entry && !isBunFsPath(entry)) {
206
+ return { program: process.execPath, args: [entry, ...args] };
207
+ }
208
+ return { program: process.execPath, args };
290
209
  }
291
210
 
292
211
  interface InstallFailure {
293
- step: InstallStep;
294
212
  code: number;
295
213
  output: string;
296
214
  }
297
215
 
298
- function npmInstallStep(packageSpec: string, args: string[] = []): InstallStep {
299
- const stepArgs = ["install", "-g", packageSpec, ...args];
300
- return {
301
- program: "npm",
302
- args: stepArgs,
303
- display: ["npm", ...stepArgs].join(" "),
304
- };
305
- }
306
-
307
- function getInstallCommand(
308
- release: LatestRelease,
309
- currentPackageName: string,
310
- ): InstallCommand {
311
- const updatePackageName = targetPackageName(release, currentPackageName);
312
- const targetVersion = release.version;
313
- const packageSpec = `${updatePackageName}@${targetVersion}`;
314
- const packageChanged = updatePackageName !== currentPackageName;
315
- const installStep = npmInstallStep(packageSpec, ["--engine-strict=true"]);
316
-
317
- if (!packageChanged) {
318
- return {
319
- steps: [installStep],
320
- display: installStep.display,
321
- targetVersion,
322
- targetPackageName: updatePackageName,
323
- };
324
- }
325
-
326
- return {
327
- steps: [
328
- npmInstallStep(packageSpec, ["--dry-run", "--engine-strict=true"]),
329
- npmInstallStep(packageSpec, ["--force"]),
330
- ],
331
- display: `migrate ${currentPackageName} → ${packageSpec}`,
332
- targetVersion,
333
- targetPackageName: updatePackageName,
334
- };
216
+ function formatInstallFailure(failure: InstallFailure, display: string): string {
217
+ return `Update failed while running \`${display}\` (exit ${failure.code})${failure.output ? `: ${failure.output}` : ""}`;
335
218
  }
336
219
 
337
- function extractRequiredNodeVersion(output: string): string | undefined {
338
- return (
339
- output.match(/required:\s*\{\s*node:\s*['"]([^'"]+)['"]/i)?.[1] ??
340
- output.match(/Required:\s*\{[^}]*"node":"([^"]+)"/i)?.[1]
341
- );
342
- }
343
-
344
- function formatInstallFailure(failure: InstallFailure, cmd: InstallCommand): string {
345
- if (/EBADENGINE|Unsupported engine|not compatible with your version of node/i.test(failure.output)) {
346
- const requiredNode = extractRequiredNodeVersion(failure.output);
347
- const requirement = requiredNode ? ` Requires Node.js ${requiredNode}.` : "";
348
- return `Update blocked: pi ${cmd.targetVersion} is incompatible with current Node.js ${process.version}.${requirement} Upgrade Node.js, restart pi, then run /update again.`;
349
- }
350
-
351
- return `Update failed while running \`${failure.step.display}\` (exit ${failure.code})${failure.output ? `: ${failure.output}` : ""}`;
352
- }
353
-
354
- async function runInstallCommand(
220
+ async function runNativeUpdate(
355
221
  pi: ExtensionAPI,
356
- cmd: InstallCommand,
222
+ target: UpdateTarget,
357
223
  ): Promise<InstallFailure | undefined> {
358
- for (const step of cmd.steps) {
359
- const result = await pi.exec(step.program, step.args, { timeout: 120_000 });
360
- if (result.code !== 0) {
224
+ const previousSkip = process.env[ENV_SKIP_VERSION_CHECK];
225
+ const previousInternalSkip = process.env[ENV_INTERNAL_SKIP];
226
+ delete process.env[ENV_SKIP_VERSION_CHECK];
227
+ delete process.env[ENV_INTERNAL_SKIP];
228
+
229
+ try {
230
+ const cmd = currentPiCommand([...UPDATE_COMMANDS[target].args]);
231
+ const result = await pi.exec(cmd.program, cmd.args, { timeout: 300_000 });
232
+ // A timed-out process is killed and can report exit code 0; treat it as failure.
233
+ if (result.killed || result.code !== 0) {
234
+ const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
361
235
  return {
362
- step,
363
236
  code: result.code,
364
- output: [result.stderr, result.stdout].filter(Boolean).join("\n").trim(),
237
+ output: result.killed ? ["Update timed out after 5 minutes.", output].filter(Boolean).join("\n") : output,
365
238
  };
366
239
  }
240
+ } finally {
241
+ if (previousSkip === undefined) delete process.env[ENV_SKIP_VERSION_CHECK];
242
+ else process.env[ENV_SKIP_VERSION_CHECK] = previousSkip;
243
+
244
+ if (previousInternalSkip === undefined) delete process.env[ENV_INTERNAL_SKIP];
245
+ else process.env[ENV_INTERNAL_SKIP] = previousInternalSkip;
367
246
  }
368
247
  }
369
248
 
370
- export default async function (pi: ExtensionAPI) {
371
- const owningPackageName = await findOwningPiPackageName(pi);
372
- const runtime = await loadPiRuntime(owningPackageName);
373
- VERSION = runtime.VERSION;
374
- BorderedLoader = runtime.BorderedLoader;
375
- getAgentDir = runtime.getAgentDir;
376
- const currentPackageName = owningPackageName ?? runtime.packageName;
377
-
378
- const cacheFile = join(getAgentDir(), "update-cache.json");
379
- const suppressNativeCheck = hasNativeVersionNotice() && !userSkippedVersionCheck;
249
+ export default function (pi: ExtensionAPI) {
250
+ // Take over pi's built-in version notice with our interactive prompt,
251
+ // unless the user disabled version checks themselves.
252
+ const suppressNativeCheck = !userSkippedVersionCheck;
380
253
  if (suppressNativeCheck) {
381
254
  process.env[ENV_SKIP_VERSION_CHECK] = "1";
382
255
  process.env[ENV_INTERNAL_SKIP] = "1";
@@ -386,36 +259,33 @@ export default async function (pi: ExtensionAPI) {
386
259
  const promptedVersions = new Set<string>();
387
260
  let liveCheckStarted = false;
388
261
 
389
- async function findPiBinary(): Promise<string> {
390
- const cmd = process.platform === "win32" ? "where" : "which";
391
- const result = await pi.exec(cmd, ["pi"]);
392
- if (result.code === 0 && result.stdout?.trim()) {
393
- return result.stdout.trim().split(/\r?\n/)[0];
394
- }
395
- return "pi";
396
- }
262
+ // Consume the one-shot suppression from a post-update restart. Deleting it
263
+ // keeps it from propagating into any further restarts from this session.
264
+ const suppressStartupCheck = isEnvSet(ENV_SUPPRESS_STARTUP_CHECK);
265
+ delete process.env[ENV_SUPPRESS_STARTUP_CHECK];
397
266
 
398
267
  function canAutoRestart(ctx: ExtensionContext): boolean {
399
268
  return ctx.hasUI && !!process.stdin.isTTY && !!process.stdout.isTTY;
400
269
  }
401
270
 
402
271
  async function restartPi(ctx: ExtensionContext): Promise<boolean> {
403
- const piBinary = await findPiBinary();
404
272
  const sessionFile = ctx.sessionManager.getSessionFile();
405
273
  const restartArgs = sessionFile ? ["--session", sessionFile] : ["--no-session"];
274
+ const cmd = currentPiCommand(restartArgs);
406
275
  const env = { ...process.env };
407
276
  if (suppressNativeCheck) {
408
277
  delete env[ENV_SKIP_VERSION_CHECK];
409
278
  delete env[ENV_INTERNAL_SKIP];
410
279
  }
280
+ // The user just acted on an update prompt; don't greet them with another.
281
+ env[ENV_SUPPRESS_STARTUP_CHECK] = "1";
411
282
 
412
283
  return ctx.ui.custom<boolean>((tui, _theme, _kb, done) => {
413
284
  tui.stop();
414
- const result = spawnSync(piBinary, restartArgs, {
285
+ const result = spawnSync(cmd.program, cmd.args, {
415
286
  cwd: ctx.cwd,
416
287
  env,
417
288
  stdio: "inherit",
418
- shell: process.platform === "win32",
419
289
  windowsHide: false,
420
290
  });
421
291
  tui.start();
@@ -425,19 +295,37 @@ export default async function (pi: ExtensionAPI) {
425
295
  });
426
296
  }
427
297
 
428
- async function doInstall(
429
- ctx: ExtensionContext,
430
- latest: string,
431
- cmd: InstallCommand,
432
- ) {
298
+ async function reloadExtensions(ctx: ExtensionContext) {
299
+ // Command contexts (/update) can hot-reload in place — the best path.
300
+ const maybeReload = (ctx as { reload?: () => Promise<void> }).reload;
301
+ if (typeof maybeReload === "function") {
302
+ ctx.ui.notify("Extensions updated. Reloading...", "info");
303
+ await maybeReload.call(ctx);
304
+ return;
305
+ }
306
+ // Event contexts (startup prompt) can't reload programmatically.
307
+ // Restart into the same session instead — at startup this is cheap
308
+ // and the user already consented by choosing "Update now".
309
+ if (canAutoRestart(ctx)) {
310
+ const ok = await restartPi(ctx);
311
+ if (ok) {
312
+ ctx.shutdown();
313
+ return;
314
+ }
315
+ }
316
+ ctx.ui.notify("Extensions updated. Run /reload to load them.", "info");
317
+ }
318
+
319
+ async function doInstall(ctx: ExtensionContext, target: UpdateTarget, piLatest?: string) {
320
+ const command = UPDATE_COMMANDS[target];
433
321
  const success = await ctx.ui.custom<boolean>((tui, theme, _kb, done) => {
434
- const loader = new BorderedLoader(tui, theme, `Running ${cmd.display}...`);
322
+ const loader = new BorderedLoader(tui, theme, `Running ${command.display}...`);
435
323
  loader.onAbort = () => done(false);
436
324
 
437
- runInstallCommand(pi, cmd)
325
+ runNativeUpdate(pi, target)
438
326
  .then((failure) => {
439
327
  if (failure) {
440
- ctx.ui.notify(formatInstallFailure(failure, cmd), "error");
328
+ ctx.ui.notify(formatInstallFailure(failure, command.display), "error");
441
329
  done(false);
442
330
  } else {
443
331
  done(true);
@@ -456,6 +344,12 @@ export default async function (pi: ExtensionAPI) {
456
344
 
457
345
  if (!success) return;
458
346
 
347
+ if (target === "extensions") {
348
+ await reloadExtensions(ctx);
349
+ return;
350
+ }
351
+
352
+ const latest = piLatest ?? "latest";
459
353
  const restartTip = ctx.sessionManager.getSessionFile()
460
354
  ? "Tip: run `pi -c` to continue this session."
461
355
  : "Tip: run `pi --no-session` to continue without a saved session.";
@@ -468,13 +362,8 @@ export default async function (pi: ExtensionAPI) {
468
362
  return;
469
363
  }
470
364
 
471
- const restart = await ctx.ui.confirm(
472
- `Updated to ${latest}!`,
473
- "Restart now?",
474
- );
475
-
476
- if (!restart) return;
477
-
365
+ // No confirmation: choosing the update option was the consent, and the
366
+ // restart relaunches straight into the current session.
478
367
  const ok = await restartPi(ctx);
479
368
  if (ok) {
480
369
  ctx.shutdown();
@@ -487,41 +376,79 @@ export default async function (pi: ExtensionAPI) {
487
376
  );
488
377
  }
489
378
 
490
- async function showUpdatePrompt(ctx: ExtensionContext, latest: LatestRelease) {
491
- const cmd = getInstallCommand(latest, currentPackageName);
492
- const currentLabel = `${currentPackageName}@${VERSION}`;
493
- const targetLabel = `${cmd.targetPackageName}@${cmd.targetVersion}`;
494
- const choice = await ctx.ui.select(`Update ${currentLabel} ${targetLabel}`, [
495
- `Update now (${cmd.display})`,
496
- "Skip",
497
- "Skip this version",
498
- ]);
499
-
500
- if (!choice || choice === "Skip") return;
501
- if (choice === "Skip this version") {
502
- dismissRelease(cacheFile, latest, currentPackageName);
379
+ async function showUpdatePrompt(ctx: ExtensionContext, offer: UpdateOffer) {
380
+ const { piLatest, extensions } = offer;
381
+ const extList = extensions.join(", ");
382
+
383
+ if (piLatest && extensions.length > 0) {
384
+ const updateAll = "Update all";
385
+ const updatePi = "Update pi only";
386
+ const updateExtensions = "Update extensions only";
387
+ // No "Ignore pi X" here: dismissing a version while also deciding about
388
+ // extensions is ambiguous. Version dismissal lives in the pi-only prompt.
389
+ const choice = await ctx.ui.select(
390
+ `Update pi ${VERSION} ${piLatest} · extensions: ${extList}`,
391
+ [updateAll, "Skip", updatePi, updateExtensions],
392
+ );
393
+
394
+ if (!choice || choice === "Skip") return;
395
+ if (choice === updateAll) return doInstall(ctx, "all", piLatest);
396
+ if (choice === updatePi) return doInstall(ctx, "self", piLatest);
397
+ if (choice === updateExtensions) return doInstall(ctx, "extensions");
503
398
  return;
504
399
  }
505
- await doInstall(ctx, targetLabel, cmd);
400
+
401
+ if (piLatest) {
402
+ const updateAction = "Update now";
403
+ const ignorePiVersion = `Ignore ${piLatest}`;
404
+ const choice = await ctx.ui.select(`Update ${VERSION} → ${piLatest}`, [
405
+ updateAction,
406
+ "Skip",
407
+ ignorePiVersion,
408
+ ]);
409
+
410
+ if (!choice || choice === "Skip") return;
411
+ if (choice === ignorePiVersion) {
412
+ dismissVersion(piLatest);
413
+ return;
414
+ }
415
+ if (choice !== updateAction) return;
416
+ await doInstall(ctx, "self", piLatest);
417
+ return;
418
+ }
419
+
420
+ if (extensions.length > 0) {
421
+ const updateAction = "Update now";
422
+ const choice = await ctx.ui.select(`Extension updates available: ${extList}`, [
423
+ updateAction,
424
+ "Skip",
425
+ ]);
426
+ if (choice === updateAction) await doInstall(ctx, "extensions");
427
+ }
506
428
  }
507
429
 
508
- function canAutoPromptVersion(latest: LatestRelease): boolean {
509
- if (!isUpdateAvailable(latest, currentPackageName)) return false;
510
- if (promptedVersions.has(releaseKey(latest, currentPackageName))) return false;
511
- const cache = readCache(cacheFile);
512
- if (cache && isDismissed(cache, latest, currentPackageName)) return false;
430
+ function canAutoPromptVersion(latest: string): boolean {
431
+ if (!isNewer(latest, VERSION)) return false;
432
+ if (promptedVersions.has(latest)) return false;
433
+ if (readCache()?.dismissedVersion === latest) return false;
513
434
  return true;
514
435
  }
515
436
 
516
- async function maybeShowAutoPrompt(ctx: ExtensionContext, latest: LatestRelease) {
437
+ async function maybeShowAutoPrompt(
438
+ ctx: ExtensionContext,
439
+ latest: string | undefined,
440
+ extensions: string[],
441
+ ) {
517
442
  if (!ctx.hasUI) return;
518
443
  if (promptOpen) return;
519
- if (!canAutoPromptVersion(latest)) return;
444
+
445
+ const piLatest = latest && canAutoPromptVersion(latest) ? latest : undefined;
446
+ if (!piLatest && extensions.length === 0) return;
520
447
 
521
448
  promptOpen = true;
522
- promptedVersions.add(releaseKey(latest, currentPackageName));
449
+ if (piLatest) promptedVersions.add(piLatest);
523
450
  try {
524
- await showUpdatePrompt(ctx, latest);
451
+ await showUpdatePrompt(ctx, { piLatest, extensions });
525
452
  } finally {
526
453
  promptOpen = false;
527
454
  }
@@ -530,42 +457,46 @@ export default async function (pi: ExtensionAPI) {
530
457
  function runAutoChecks(ctx: ExtensionContext) {
531
458
  if (!ctx.hasUI) return;
532
459
  if (shouldSkipAutoChecks()) return;
533
-
534
- const cached = getCachedUpgradeRelease(cacheFile, currentPackageName);
535
- if (cached) void maybeShowAutoPrompt(ctx, cached);
536
-
537
460
  if (liveCheckStarted) return;
538
461
  liveCheckStarted = true;
539
462
 
540
- void refreshLatestReleaseInCache(cacheFile)
541
- .then((latest) => {
542
- if (!latest) return;
543
- void maybeShowAutoPrompt(ctx, latest);
463
+ // Wait for both checks and show a single consolidated prompt. Startup is
464
+ // never blocked; the prompt simply appears when the checks resolve.
465
+ void Promise.all([
466
+ refreshLatestVersionInCache().catch(() => undefined),
467
+ checkForExtensionUpdates(ctx.cwd).catch(() => [] as string[]),
468
+ ])
469
+ .then(([latest, extensions]) => {
470
+ // Fall back to a previously cached pi version if the live fetch failed.
471
+ void maybeShowAutoPrompt(ctx, latest ?? getCachedUpgradeVersion(), extensions);
544
472
  })
545
473
  .catch(() => {});
546
474
  }
547
475
 
548
476
  pi.on("session_start", async (event, ctx) => {
549
477
  if (event.reason === "reload" || event.reason === "fork") return;
478
+ // One-shot suppression applies only to the startup right after a
479
+ // post-update restart, never to later new/resume session starts.
480
+ if (suppressStartupCheck && event.reason === "startup") return;
550
481
  runAutoChecks(ctx);
551
482
  });
552
483
 
553
484
  pi.registerCommand("update", {
554
- description: "Check for pi updates and install with npm",
485
+ description: "Check for pi updates and install with pi's native updater",
555
486
  handler: async (rawArgs, ctx) => {
556
487
  // /update --test — simulate the full UI flow without a real install
557
488
  if (rawArgs?.trim() === "--test") {
558
489
  const fakeLatest = "99.0.0";
559
- const cmd = getInstallCommand({ version: fakeLatest }, currentPackageName);
560
- const choice = await ctx.ui.select(`Update ${currentPackageName}@${VERSION} → ${cmd.targetPackageName}@${fakeLatest}`, [
561
- `Update now (${cmd.display})`,
490
+ const updateAction = "Update now";
491
+ const choice = await ctx.ui.select(`Update ${VERSION} → ${fakeLatest}`, [
492
+ updateAction,
562
493
  "Skip",
563
- "Skip this version",
494
+ `Ignore ${fakeLatest}`,
564
495
  ]);
565
- if (!choice || choice === "Skip" || choice === "Skip this version") return;
496
+ if (choice !== updateAction) return;
566
497
 
567
498
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
568
- const loader = new BorderedLoader(tui, theme, `Running ${cmd.display}...`);
499
+ const loader = new BorderedLoader(tui, theme, `Running ${UPDATE_COMMANDS.self.display}...`);
569
500
  loader.onAbort = () => done();
570
501
  setTimeout(() => done(), 1500);
571
502
  return loader;
@@ -576,9 +507,6 @@ export default async function (pi: ExtensionAPI) {
576
507
  return;
577
508
  }
578
509
 
579
- const restart = await ctx.ui.confirm(`Updated to ${fakeLatest}!`, "Restart now?");
580
- if (!restart) return;
581
-
582
510
  const ok = await restartPi(ctx);
583
511
  if (ok) { ctx.shutdown(); return; }
584
512
  ctx.ui.notify("Test restart failed.", "error");
@@ -593,35 +521,45 @@ export default async function (pi: ExtensionAPI) {
593
521
  return;
594
522
  }
595
523
 
596
- const latest = await ctx.ui.custom<LatestRelease | null>(
597
- (tui, theme, _kb, done) => {
598
- const loader = new BorderedLoader(
599
- tui,
600
- theme,
601
- "Checking for updates...",
602
- );
603
- loader.onAbort = () => done(null);
604
- fetchLatestRelease()
605
- .then((v) => done(v ?? null))
606
- .catch(() => done(null));
607
- return loader;
608
- },
609
- );
524
+ const result = await ctx.ui.custom<{
525
+ latest: string | undefined;
526
+ extensions: string[];
527
+ } | null>((tui, theme, _kb, done) => {
528
+ const loader = new BorderedLoader(
529
+ tui,
530
+ theme,
531
+ "Checking for updates...",
532
+ );
533
+ loader.onAbort = () => done(null);
534
+ Promise.all([
535
+ fetchLatestVersion().catch(() => undefined),
536
+ checkForExtensionUpdates(ctx.cwd).catch(() => [] as string[]),
537
+ ])
538
+ .then(([latest, extensions]) => done({ latest, extensions }))
539
+ .catch(() => done(null));
540
+ return loader;
541
+ });
610
542
 
611
- if (!latest) {
543
+ if (!result || (!result.latest && result.extensions.length === 0)) {
612
544
  ctx.ui.notify("Could not reach Pi update service.", "error");
613
545
  return;
614
546
  }
615
547
 
616
- saveLatestToCache(cacheFile, latest);
548
+ const { latest, extensions } = result;
549
+ if (latest) saveLatestToCache(latest);
617
550
 
618
- if (!isUpdateAvailable(latest, currentPackageName)) {
619
- ctx.ui.notify(`Already on latest version (${currentPackageName}@${VERSION}).`, "info");
551
+ const piLatest = latest && isNewer(latest, VERSION) ? latest : undefined;
552
+
553
+ if (!piLatest && extensions.length === 0) {
554
+ ctx.ui.notify(
555
+ `Already on latest version (${VERSION}). Extensions are up to date.`,
556
+ "info",
557
+ );
620
558
  return;
621
559
  }
622
560
 
623
- promptedVersions.add(releaseKey(latest, currentPackageName));
624
- await showUpdatePrompt(ctx, latest);
561
+ if (piLatest) promptedVersions.add(piLatest);
562
+ await showUpdatePrompt(ctx, { piLatest, extensions });
625
563
  },
626
564
  });
627
565
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-updater",
3
- "version": "0.3.3",
4
- "description": "Codex-style auto-updater for pi. Checks for new versions on startup and prompts to install.",
3
+ "version": "0.4.0",
4
+ "description": "Codex-style auto-updater for pi. Checks pi and extension packages on startup and prompts to install updates.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Toms clanker",