pi-updater 0.3.2 → 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.
- package/CHANGELOG.md +30 -0
- package/README.md +87 -37
- package/index.ts +273 -164
- package/package.json +9 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
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.
|
|
22
|
+
|
|
23
|
+
## 0.3.3 - 2026-05-21
|
|
24
|
+
|
|
25
|
+
- Honor the update service `packageName` and install the explicit advertised npm package/version.
|
|
26
|
+
- Avoid native `pi update --self` so pi-updater can update through stale native self-update behavior.
|
|
27
|
+
- Keep loading under the legacy `@mariozechner/pi-coding-agent` runtime so package-name migrations can run.
|
|
28
|
+
- Migrate the global pi binary when the update service advertises a new package name by force-installing the advertised package after an engine-strict dry run.
|
|
29
|
+
- Treat package-name-only migrations as updates only when the advertised version is unchanged or newer, while staying on the current package if `packageName` is absent.
|
|
30
|
+
- Respect npm engine requirements during pi installs so updates fail safely when Node.js is too old.
|
|
31
|
+
- Switch extension imports and optional peer dependency to `@earendil-works/pi-coding-agent` so installing pi-updater no longer pulls the old `@mariozechner` pi package.
|
|
32
|
+
|
|
3
33
|
## 0.3.2 - 2026-05-02
|
|
4
34
|
|
|
5
35
|
- Use pi's native `pi update --self` installer on pi 0.70.3+ and keep npm install as the fallback for older pi versions.
|
package/README.md
CHANGED
|
@@ -1,72 +1,122 @@
|
|
|
1
1
|
# pi-updater
|
|
2
2
|
|
|
3
|
-
|
|
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:** On pi 0.70.3+, pi-updater delegates installation to pi's native `pi update --self` command. Older pi versions fall back to npm-based installation.
|
|
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
|
-
##
|
|
13
|
+
## Why does this exist?
|
|
13
14
|
|
|
14
|
-
|
|
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.
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
20
25
|
|
|
21
|
-
|
|
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.
|
|
22
31
|
|
|
23
|
-
|
|
32
|
+
## Installation
|
|
24
33
|
|
|
25
|
-
|
|
34
|
+
```bash
|
|
35
|
+
pi install npm:pi-updater
|
|
36
|
+
```
|
|
26
37
|
|
|
27
|
-
pi
|
|
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`.
|
|
28
41
|
|
|
29
|
-
|
|
30
|
-
2. One background live fetch refreshes the cache from pi's update service.
|
|
31
|
-
3. If the background fetch finds a newer version, pi-updater can prompt in the same session.
|
|
32
|
-
4. Automatic checks are skipped when `PI_SKIP_VERSION_CHECK` or `PI_OFFLINE` is set.
|
|
42
|
+
## Usage
|
|
33
43
|
|
|
34
|
-
|
|
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).
|
|
35
47
|
|
|
36
|
-
|
|
37
|
-
pi install npm:pi-updater
|
|
38
|
-
```
|
|
48
|
+
If only pi is outdated:
|
|
39
49
|
|
|
40
|
-
|
|
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
|
|
41
53
|
|
|
42
|
-
|
|
43
|
-
pi install git:github.com/tonze/pi-updater
|
|
44
|
-
```
|
|
54
|
+
If both pi and extensions are outdated, a combined prompt appears:
|
|
45
55
|
|
|
46
|
-
|
|
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
|
|
47
60
|
|
|
48
|
-
|
|
61
|
+
Version dismissal ("Ignore") is only offered in the pi-only prompt; a
|
|
62
|
+
dismissed pi version degrades the combined prompt to extensions-only.
|
|
49
63
|
|
|
50
|
-
|
|
64
|
+
If only extensions are outdated, you're offered `pi update --extensions`.
|
|
51
65
|
|
|
52
|
-
|
|
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.
|
|
53
71
|
|
|
54
|
-
|
|
72
|
+
You can also check manually at any time with `/update`.
|
|
55
73
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
59
91
|
|
|
60
|
-
|
|
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:
|
|
61
98
|
|
|
62
99
|
```bash
|
|
63
|
-
export
|
|
100
|
+
export PI_SKIP_VERSION_CHECK=1 # disable automatic checks
|
|
101
|
+
export PI_OFFLINE=1 # offline mode, also disables checks
|
|
64
102
|
```
|
|
65
103
|
|
|
66
|
-
|
|
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
|
|
67
117
|
|
|
68
118
|
```bash
|
|
69
|
-
pi update
|
|
119
|
+
pi update npm:pi-updater
|
|
70
120
|
```
|
|
71
121
|
|
|
72
122
|
## License
|
package/index.ts
CHANGED
|
@@ -1,20 +1,35 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionContext,
|
|
4
|
-
} from "@
|
|
5
|
-
import { VERSION, BorderedLoader, getAgentDir } from "@
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { VERSION, BorderedLoader, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
7
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
8
8
|
import { join, dirname } from "node:path";
|
|
9
9
|
|
|
10
|
-
const PACKAGE_NAME = "@mariozechner/pi-coding-agent";
|
|
11
10
|
const LATEST_VERSION_URL = "https://pi.dev/api/latest-version";
|
|
12
|
-
const NATIVE_SELF_UPDATE_MIN_VERSION = "0.70.3";
|
|
13
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
|
+
}
|
|
14
26
|
|
|
15
27
|
const ENV_SKIP_VERSION_CHECK = "PI_SKIP_VERSION_CHECK";
|
|
16
28
|
const ENV_OFFLINE = "PI_OFFLINE";
|
|
17
29
|
const ENV_INTERNAL_SKIP = "PI_UPDATER_SUPPRESSED_NATIVE_VERSION_CHECK";
|
|
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";
|
|
18
33
|
|
|
19
34
|
interface VersionCache {
|
|
20
35
|
latestVersion: string;
|
|
@@ -57,27 +72,17 @@ function parseVersion(version: string): ParsedVersion | undefined {
|
|
|
57
72
|
};
|
|
58
73
|
}
|
|
59
74
|
|
|
60
|
-
function
|
|
61
|
-
const left = parseVersion(
|
|
62
|
-
const right = parseVersion(
|
|
63
|
-
if (!left || !right) return
|
|
64
|
-
if (left.major !== right.major) return left.major
|
|
65
|
-
if (left.minor !== right.minor) return left.minor
|
|
66
|
-
if (left.patch !== right.patch) return left.patch
|
|
67
|
-
if (left.prerelease === right.prerelease) return
|
|
68
|
-
if (!left.prerelease) return
|
|
69
|
-
if (!right.prerelease) return
|
|
70
|
-
return left.prerelease.localeCompare(right.prerelease);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function isNewer(latest: string, current: string): boolean {
|
|
74
|
-
const comparison = compareVersions(latest, current);
|
|
75
|
-
return comparison !== undefined && comparison > 0;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function isAtLeast(version: string, minimum: string): boolean {
|
|
79
|
-
const comparison = compareVersions(version, minimum);
|
|
80
|
-
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;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
function isEnvSet(name: string): boolean {
|
|
@@ -102,19 +107,6 @@ function piUserAgent(): string {
|
|
|
102
107
|
return `pi/${VERSION} (${process.platform}; ${runtime}; ${process.arch})`;
|
|
103
108
|
}
|
|
104
109
|
|
|
105
|
-
function hasNativeSelfUpdate(): boolean {
|
|
106
|
-
return isAtLeast(VERSION, NATIVE_SELF_UPDATE_MIN_VERSION);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function saveLatestToCache(latest: string) {
|
|
110
|
-
const prev = readCache();
|
|
111
|
-
writeCache({
|
|
112
|
-
latestVersion: latest,
|
|
113
|
-
dismissedVersion: prev?.dismissedVersion,
|
|
114
|
-
checkedAt: new Date().toISOString(),
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
110
|
async function fetchLatestVersion(): Promise<string | undefined> {
|
|
119
111
|
try {
|
|
120
112
|
const res = await fetch(LATEST_VERSION_URL, {
|
|
@@ -125,16 +117,46 @@ async function fetchLatestVersion(): Promise<string | undefined> {
|
|
|
125
117
|
signal: AbortSignal.timeout(10_000),
|
|
126
118
|
});
|
|
127
119
|
if (!res.ok) return undefined;
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
: undefined;
|
|
120
|
+
const data = (await res.json()) as { version?: string };
|
|
121
|
+
if (typeof data.version !== "string" || !data.version.trim()) return undefined;
|
|
122
|
+
return data.version.trim();
|
|
132
123
|
} catch {
|
|
133
124
|
return undefined;
|
|
134
125
|
}
|
|
135
126
|
}
|
|
136
127
|
|
|
137
|
-
/**
|
|
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. */
|
|
138
160
|
function getCachedUpgradeVersion(): string | undefined {
|
|
139
161
|
const cache = readCache();
|
|
140
162
|
if (!cache) return undefined;
|
|
@@ -143,6 +165,15 @@ function getCachedUpgradeVersion(): string | undefined {
|
|
|
143
165
|
return cache.latestVersion;
|
|
144
166
|
}
|
|
145
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
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
146
177
|
/** Fetch latest from Pi's update endpoint and refresh cache. */
|
|
147
178
|
async function refreshLatestVersionInCache(): Promise<string | undefined> {
|
|
148
179
|
const latest = await fetchLatestVersion();
|
|
@@ -160,34 +191,65 @@ function dismissVersion(version: string) {
|
|
|
160
191
|
});
|
|
161
192
|
}
|
|
162
193
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
args: string[];
|
|
166
|
-
display: string;
|
|
194
|
+
function isBunFsPath(path: string): boolean {
|
|
195
|
+
return path.includes("$bunfs") || path.includes("~BUN") || path.includes("%7EBUN");
|
|
167
196
|
}
|
|
168
197
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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] };
|
|
176
207
|
}
|
|
208
|
+
return { program: process.execPath, args };
|
|
209
|
+
}
|
|
177
210
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
display: `npm install -g ${PACKAGE_NAME}@${version}`,
|
|
182
|
-
};
|
|
211
|
+
interface InstallFailure {
|
|
212
|
+
code: number;
|
|
213
|
+
output: string;
|
|
183
214
|
}
|
|
184
215
|
|
|
185
|
-
function
|
|
186
|
-
return
|
|
216
|
+
function formatInstallFailure(failure: InstallFailure, display: string): string {
|
|
217
|
+
return `Update failed while running \`${display}\` (exit ${failure.code})${failure.output ? `: ${failure.output}` : ""}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function runNativeUpdate(
|
|
221
|
+
pi: ExtensionAPI,
|
|
222
|
+
target: UpdateTarget,
|
|
223
|
+
): Promise<InstallFailure | undefined> {
|
|
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();
|
|
235
|
+
return {
|
|
236
|
+
code: result.code,
|
|
237
|
+
output: result.killed ? ["Update timed out after 5 minutes.", output].filter(Boolean).join("\n") : output,
|
|
238
|
+
};
|
|
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;
|
|
246
|
+
}
|
|
187
247
|
}
|
|
188
248
|
|
|
189
249
|
export default function (pi: ExtensionAPI) {
|
|
190
|
-
|
|
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;
|
|
191
253
|
if (suppressNativeCheck) {
|
|
192
254
|
process.env[ENV_SKIP_VERSION_CHECK] = "1";
|
|
193
255
|
process.env[ENV_INTERNAL_SKIP] = "1";
|
|
@@ -197,36 +259,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
197
259
|
const promptedVersions = new Set<string>();
|
|
198
260
|
let liveCheckStarted = false;
|
|
199
261
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return result.stdout.trim().split(/\r?\n/)[0];
|
|
205
|
-
}
|
|
206
|
-
return "pi";
|
|
207
|
-
}
|
|
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];
|
|
208
266
|
|
|
209
267
|
function canAutoRestart(ctx: ExtensionContext): boolean {
|
|
210
268
|
return ctx.hasUI && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
211
269
|
}
|
|
212
270
|
|
|
213
271
|
async function restartPi(ctx: ExtensionContext): Promise<boolean> {
|
|
214
|
-
const piBinary = await findPiBinary();
|
|
215
272
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
216
273
|
const restartArgs = sessionFile ? ["--session", sessionFile] : ["--no-session"];
|
|
274
|
+
const cmd = currentPiCommand(restartArgs);
|
|
217
275
|
const env = { ...process.env };
|
|
218
276
|
if (suppressNativeCheck) {
|
|
219
277
|
delete env[ENV_SKIP_VERSION_CHECK];
|
|
220
278
|
delete env[ENV_INTERNAL_SKIP];
|
|
221
279
|
}
|
|
280
|
+
// The user just acted on an update prompt; don't greet them with another.
|
|
281
|
+
env[ENV_SUPPRESS_STARTUP_CHECK] = "1";
|
|
222
282
|
|
|
223
283
|
return ctx.ui.custom<boolean>((tui, _theme, _kb, done) => {
|
|
224
284
|
tui.stop();
|
|
225
|
-
const result = spawnSync(
|
|
285
|
+
const result = spawnSync(cmd.program, cmd.args, {
|
|
226
286
|
cwd: ctx.cwd,
|
|
227
287
|
env,
|
|
228
288
|
stdio: "inherit",
|
|
229
|
-
shell: process.platform === "win32",
|
|
230
289
|
windowsHide: false,
|
|
231
290
|
});
|
|
232
291
|
tui.start();
|
|
@@ -236,40 +295,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
236
295
|
});
|
|
237
296
|
}
|
|
238
297
|
|
|
239
|
-
async function
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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];
|
|
244
321
|
const success = await ctx.ui.custom<boolean>((tui, theme, _kb, done) => {
|
|
245
|
-
const loader = new BorderedLoader(tui, theme, `Running ${
|
|
322
|
+
const loader = new BorderedLoader(tui, theme, `Running ${command.display}...`);
|
|
246
323
|
loader.onAbort = () => done(false);
|
|
247
324
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
try {
|
|
253
|
-
return await pi.exec(cmd.program, cmd.args, { timeout: 120_000 });
|
|
254
|
-
} finally {
|
|
255
|
-
process.env[ENV_SKIP_VERSION_CHECK] = "1";
|
|
256
|
-
process.env[ENV_INTERNAL_SKIP] = "1";
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return pi.exec(cmd.program, cmd.args, { timeout: 120_000 });
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
runUpdateCommand()
|
|
263
|
-
.then((result) => {
|
|
264
|
-
if (result.code !== 0) {
|
|
265
|
-
const output = [result.stderr, result.stdout]
|
|
266
|
-
.filter(Boolean)
|
|
267
|
-
.join("\n")
|
|
268
|
-
.trim();
|
|
269
|
-
ctx.ui.notify(
|
|
270
|
-
`Update failed (exit ${result.code})${output ? `: ${output}` : ""}`,
|
|
271
|
-
"error",
|
|
272
|
-
);
|
|
325
|
+
runNativeUpdate(pi, target)
|
|
326
|
+
.then((failure) => {
|
|
327
|
+
if (failure) {
|
|
328
|
+
ctx.ui.notify(formatInstallFailure(failure, command.display), "error");
|
|
273
329
|
done(false);
|
|
274
330
|
} else {
|
|
275
331
|
done(true);
|
|
@@ -288,6 +344,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
288
344
|
|
|
289
345
|
if (!success) return;
|
|
290
346
|
|
|
347
|
+
if (target === "extensions") {
|
|
348
|
+
await reloadExtensions(ctx);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const latest = piLatest ?? "latest";
|
|
291
353
|
const restartTip = ctx.sessionManager.getSessionFile()
|
|
292
354
|
? "Tip: run `pi -c` to continue this session."
|
|
293
355
|
: "Tip: run `pi --no-session` to continue without a saved session.";
|
|
@@ -300,13 +362,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
300
362
|
return;
|
|
301
363
|
}
|
|
302
364
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
"Restart now?",
|
|
306
|
-
);
|
|
307
|
-
|
|
308
|
-
if (!restart) return;
|
|
309
|
-
|
|
365
|
+
// No confirmation: choosing the update option was the consent, and the
|
|
366
|
+
// restart relaunches straight into the current session.
|
|
310
367
|
const ok = await restartPi(ctx);
|
|
311
368
|
if (ok) {
|
|
312
369
|
ctx.shutdown();
|
|
@@ -319,20 +376,55 @@ export default function (pi: ExtensionAPI) {
|
|
|
319
376
|
);
|
|
320
377
|
}
|
|
321
378
|
|
|
322
|
-
async function showUpdatePrompt(ctx: ExtensionContext,
|
|
323
|
-
const
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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");
|
|
333
398
|
return;
|
|
334
399
|
}
|
|
335
|
-
|
|
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
|
+
}
|
|
336
428
|
}
|
|
337
429
|
|
|
338
430
|
function canAutoPromptVersion(latest: string): boolean {
|
|
@@ -342,15 +434,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
342
434
|
return true;
|
|
343
435
|
}
|
|
344
436
|
|
|
345
|
-
async function maybeShowAutoPrompt(
|
|
437
|
+
async function maybeShowAutoPrompt(
|
|
438
|
+
ctx: ExtensionContext,
|
|
439
|
+
latest: string | undefined,
|
|
440
|
+
extensions: string[],
|
|
441
|
+
) {
|
|
346
442
|
if (!ctx.hasUI) return;
|
|
347
443
|
if (promptOpen) return;
|
|
348
|
-
|
|
444
|
+
|
|
445
|
+
const piLatest = latest && canAutoPromptVersion(latest) ? latest : undefined;
|
|
446
|
+
if (!piLatest && extensions.length === 0) return;
|
|
349
447
|
|
|
350
448
|
promptOpen = true;
|
|
351
|
-
promptedVersions.add(
|
|
449
|
+
if (piLatest) promptedVersions.add(piLatest);
|
|
352
450
|
try {
|
|
353
|
-
await showUpdatePrompt(ctx,
|
|
451
|
+
await showUpdatePrompt(ctx, { piLatest, extensions });
|
|
354
452
|
} finally {
|
|
355
453
|
promptOpen = false;
|
|
356
454
|
}
|
|
@@ -359,42 +457,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
359
457
|
function runAutoChecks(ctx: ExtensionContext) {
|
|
360
458
|
if (!ctx.hasUI) return;
|
|
361
459
|
if (shouldSkipAutoChecks()) return;
|
|
362
|
-
|
|
363
|
-
const cached = getCachedUpgradeVersion();
|
|
364
|
-
if (cached) void maybeShowAutoPrompt(ctx, cached);
|
|
365
|
-
|
|
366
460
|
if (liveCheckStarted) return;
|
|
367
461
|
liveCheckStarted = true;
|
|
368
462
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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);
|
|
373
472
|
})
|
|
374
473
|
.catch(() => {});
|
|
375
474
|
}
|
|
376
475
|
|
|
377
476
|
pi.on("session_start", async (event, ctx) => {
|
|
378
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;
|
|
379
481
|
runAutoChecks(ctx);
|
|
380
482
|
});
|
|
381
483
|
|
|
382
484
|
pi.registerCommand("update", {
|
|
383
|
-
description: "Check for pi updates and install
|
|
485
|
+
description: "Check for pi updates and install with pi's native updater",
|
|
384
486
|
handler: async (rawArgs, ctx) => {
|
|
385
487
|
// /update --test — simulate the full UI flow without a real install
|
|
386
488
|
if (rawArgs?.trim() === "--test") {
|
|
387
489
|
const fakeLatest = "99.0.0";
|
|
388
|
-
const
|
|
490
|
+
const updateAction = "Update now";
|
|
389
491
|
const choice = await ctx.ui.select(`Update ${VERSION} → ${fakeLatest}`, [
|
|
390
|
-
|
|
492
|
+
updateAction,
|
|
391
493
|
"Skip",
|
|
392
|
-
|
|
494
|
+
`Ignore ${fakeLatest}`,
|
|
393
495
|
]);
|
|
394
|
-
if (
|
|
496
|
+
if (choice !== updateAction) return;
|
|
395
497
|
|
|
396
498
|
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
397
|
-
const loader = new BorderedLoader(tui, theme, `Running ${
|
|
499
|
+
const loader = new BorderedLoader(tui, theme, `Running ${UPDATE_COMMANDS.self.display}...`);
|
|
398
500
|
loader.onAbort = () => done();
|
|
399
501
|
setTimeout(() => done(), 1500);
|
|
400
502
|
return loader;
|
|
@@ -405,9 +507,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
405
507
|
return;
|
|
406
508
|
}
|
|
407
509
|
|
|
408
|
-
const restart = await ctx.ui.confirm(`Updated to ${fakeLatest}!`, "Restart now?");
|
|
409
|
-
if (!restart) return;
|
|
410
|
-
|
|
411
510
|
const ok = await restartPi(ctx);
|
|
412
511
|
if (ok) { ctx.shutdown(); return; }
|
|
413
512
|
ctx.ui.notify("Test restart failed.", "error");
|
|
@@ -422,35 +521,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
422
521
|
return;
|
|
423
522
|
}
|
|
424
523
|
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
+
});
|
|
439
542
|
|
|
440
|
-
if (!latest) {
|
|
543
|
+
if (!result || (!result.latest && result.extensions.length === 0)) {
|
|
441
544
|
ctx.ui.notify("Could not reach Pi update service.", "error");
|
|
442
545
|
return;
|
|
443
546
|
}
|
|
444
547
|
|
|
445
|
-
|
|
548
|
+
const { latest, extensions } = result;
|
|
549
|
+
if (latest) saveLatestToCache(latest);
|
|
550
|
+
|
|
551
|
+
const piLatest = latest && isNewer(latest, VERSION) ? latest : undefined;
|
|
446
552
|
|
|
447
|
-
if (!
|
|
448
|
-
ctx.ui.notify(
|
|
553
|
+
if (!piLatest && extensions.length === 0) {
|
|
554
|
+
ctx.ui.notify(
|
|
555
|
+
`Already on latest version (${VERSION}). Extensions are up to date.`,
|
|
556
|
+
"info",
|
|
557
|
+
);
|
|
449
558
|
return;
|
|
450
559
|
}
|
|
451
560
|
|
|
452
|
-
promptedVersions.add(
|
|
453
|
-
await showUpdatePrompt(ctx,
|
|
561
|
+
if (piLatest) promptedVersions.add(piLatest);
|
|
562
|
+
await showUpdatePrompt(ctx, { piLatest, extensions });
|
|
454
563
|
},
|
|
455
564
|
});
|
|
456
565
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-updater",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Codex-style auto-updater for pi. Checks
|
|
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",
|
|
@@ -26,10 +26,15 @@
|
|
|
26
26
|
"CHANGELOG.md"
|
|
27
27
|
],
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"@
|
|
29
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"@earendil-works/pi-coding-agent": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
30
35
|
},
|
|
31
36
|
"devDependencies": {
|
|
32
|
-
"@
|
|
37
|
+
"@earendil-works/pi-coding-agent": "^0.74.1",
|
|
33
38
|
"@types/node": "^25.3.2",
|
|
34
39
|
"typescript": "^5.9.3"
|
|
35
40
|
}
|