pi-updater 0.3.1 → 0.3.3
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 +18 -2
- package/README.md +4 -6
- package/index.ts +358 -88
- package/package.json +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.3.3 - 2026-05-21
|
|
6
|
+
|
|
7
|
+
- Honor the update service `packageName` and install the explicit advertised npm package/version.
|
|
8
|
+
- Avoid native `pi update --self` so pi-updater can update through stale native self-update behavior.
|
|
9
|
+
- Keep loading under the legacy `@mariozechner/pi-coding-agent` runtime so package-name migrations can run.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
- Respect npm engine requirements during pi installs so updates fail safely when Node.js is too old.
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
## 0.3.2 - 2026-05-02
|
|
16
|
+
|
|
17
|
+
- Use pi's native `pi update --self` installer on pi 0.70.3+ and keep npm install as the fallback for older pi versions.
|
|
18
|
+
- Use pi's `https://pi.dev/api/latest-version` update endpoint with a `pi/<version>` user agent.
|
|
19
|
+
- Keep pi-updater's interactive startup prompt on pi 0.70.3+ while avoiding pi's duplicate built-in version notice.
|
|
20
|
+
|
|
3
21
|
## 0.3.1 - 2026-04-04
|
|
4
22
|
|
|
5
23
|
- Compatibility with pi 0.65+: use `session_start` instead of legacy `session_switch` for automatic checks. See [pi-mono v0.65.0](https://github.com/badlogic/pi-mono/releases/tag/v0.65.0).
|
|
6
24
|
- Store cache and dismissed-version state in pi's configured agent directory.
|
|
7
25
|
- Preserve `--no-session` mode when restarting after an update and show the correct manual restart hint.
|
|
8
26
|
|
|
9
|
-
## Unreleased
|
|
10
|
-
|
|
11
27
|
## 0.3.0 - 2026-03-23
|
|
12
28
|
|
|
13
29
|
- Auto-restart pi after a successful update. Asks to restart, then seamlessly relaunches on the current session.
|
package/README.md
CHANGED
|
@@ -5,29 +5,27 @@ A lightweight, Codex-style auto-updater for pi with fast, cache-first startup ch
|
|
|
5
5
|
- npm: https://www.npmjs.com/package/pi-updater
|
|
6
6
|
- repo: https://github.com/tonze/pi-updater
|
|
7
7
|
|
|
8
|
-
> **Note:**
|
|
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
9
|
|
|
10
10
|
<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
11
|
|
|
12
12
|
## What it does
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
If a newer version is available, pi-updater shows a startup prompt:
|
|
15
15
|
- **Update now** — install with npm, then auto-restart pi on the current session
|
|
16
16
|
- **Skip** — dismiss until next session
|
|
17
17
|
- **Skip this version** — don't ask again until a newer version appears
|
|
18
18
|
|
|
19
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.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
**`/update`:** manually check for updates (always fetches fresh from npm, unless `PI_OFFLINE` is set).
|
|
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.
|
|
24
22
|
|
|
25
23
|
## How version checks work
|
|
26
24
|
|
|
27
25
|
pi-updater uses a cache-first approach to keep startup fast:
|
|
28
26
|
|
|
29
27
|
1. On startup, cached version data is checked instantly.
|
|
30
|
-
2. One background live fetch refreshes the cache.
|
|
28
|
+
2. One background live fetch refreshes the cache from pi's update service.
|
|
31
29
|
3. If the background fetch finds a newer version, pi-updater can prompt in the same session.
|
|
32
30
|
4. Automatic checks are skipped when `PI_SKIP_VERSION_CHECK` or `PI_OFFLINE` is set.
|
|
33
31
|
|
package/index.ts
CHANGED
|
@@ -1,128 +1,387 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionContext,
|
|
4
|
-
} from "@
|
|
5
|
-
import { VERSION, BorderedLoader, getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
5
|
import { spawnSync } from "node:child_process";
|
|
7
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { readFileSync, realpathSync, writeFileSync, mkdirSync } from "node:fs";
|
|
8
7
|
import { join, dirname } from "node:path";
|
|
9
8
|
|
|
10
|
-
const PACKAGE_NAME = "@
|
|
11
|
-
const
|
|
12
|
-
const
|
|
9
|
+
const PACKAGE_NAME = "@earendil-works/pi-coding-agent";
|
|
10
|
+
const LEGACY_PACKAGE_NAME = "@mariozechner/pi-coding-agent";
|
|
11
|
+
const LATEST_VERSION_URL = "https://pi.dev/api/latest-version";
|
|
12
|
+
const NATIVE_VERSION_NOTICE_MIN_VERSION = "0.70.3";
|
|
13
13
|
|
|
14
14
|
const ENV_SKIP_VERSION_CHECK = "PI_SKIP_VERSION_CHECK";
|
|
15
15
|
const ENV_OFFLINE = "PI_OFFLINE";
|
|
16
|
+
const ENV_INTERNAL_SKIP = "PI_UPDATER_SUPPRESSED_NATIVE_VERSION_CHECK";
|
|
17
|
+
|
|
18
|
+
interface LatestRelease {
|
|
19
|
+
version: string;
|
|
20
|
+
packageName?: string;
|
|
21
|
+
}
|
|
16
22
|
|
|
17
23
|
interface VersionCache {
|
|
18
24
|
latestVersion: string;
|
|
25
|
+
latestPackageName?: string;
|
|
19
26
|
dismissedVersion?: string;
|
|
27
|
+
dismissedPackageName?: string;
|
|
20
28
|
checkedAt?: string;
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
|
|
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> {
|
|
60
|
+
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
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
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 {
|
|
24
105
|
try {
|
|
25
|
-
return JSON.parse(readFileSync(
|
|
106
|
+
return JSON.parse(readFileSync(cacheFile, "utf-8"));
|
|
26
107
|
} catch {
|
|
27
108
|
return undefined;
|
|
28
109
|
}
|
|
29
110
|
}
|
|
30
111
|
|
|
31
|
-
function writeCache(cache: VersionCache) {
|
|
112
|
+
function writeCache(cacheFile: string, cache: VersionCache) {
|
|
32
113
|
try {
|
|
33
|
-
mkdirSync(dirname(
|
|
34
|
-
writeFileSync(
|
|
114
|
+
mkdirSync(dirname(cacheFile), { recursive: true });
|
|
115
|
+
writeFileSync(cacheFile, JSON.stringify(cache) + "\n");
|
|
35
116
|
} catch {}
|
|
36
117
|
}
|
|
37
118
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
119
|
+
interface ParsedVersion {
|
|
120
|
+
major: number;
|
|
121
|
+
minor: number;
|
|
122
|
+
patch: number;
|
|
123
|
+
prerelease?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseVersion(version: string): ParsedVersion | undefined {
|
|
127
|
+
const match = version
|
|
128
|
+
.trim()
|
|
129
|
+
.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/);
|
|
130
|
+
if (!match) return undefined;
|
|
131
|
+
return {
|
|
132
|
+
major: Number.parseInt(match[1], 10),
|
|
133
|
+
minor: Number.parseInt(match[2], 10),
|
|
134
|
+
patch: Number.parseInt(match[3], 10),
|
|
135
|
+
prerelease: match[4],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
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);
|
|
44
150
|
}
|
|
45
151
|
|
|
46
|
-
function
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
if (!l || !c) return false;
|
|
50
|
-
if (l[0] !== c[0]) return l[0] > c[0];
|
|
51
|
-
if (l[1] !== c[1]) return l[1] > c[1];
|
|
52
|
-
return l[2] > c[2];
|
|
152
|
+
function isAtLeast(version: string, minimum: string): boolean {
|
|
153
|
+
const comparison = compareVersions(version, minimum);
|
|
154
|
+
return comparison !== undefined && comparison >= 0;
|
|
53
155
|
}
|
|
54
156
|
|
|
55
157
|
function isEnvSet(name: string): boolean {
|
|
56
158
|
return Boolean(process.env[name]);
|
|
57
159
|
}
|
|
58
160
|
|
|
161
|
+
const userSkippedVersionCheck =
|
|
162
|
+
isEnvSet(ENV_SKIP_VERSION_CHECK) && !isEnvSet(ENV_INTERNAL_SKIP);
|
|
163
|
+
|
|
59
164
|
function shouldSkipAutoChecks(): boolean {
|
|
60
|
-
return
|
|
165
|
+
return userSkippedVersionCheck || isEnvSet(ENV_OFFLINE);
|
|
61
166
|
}
|
|
62
167
|
|
|
63
168
|
function isOffline(): boolean {
|
|
64
169
|
return isEnvSet(ENV_OFFLINE);
|
|
65
170
|
}
|
|
66
171
|
|
|
67
|
-
function
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
172
|
+
function piUserAgent(): string {
|
|
173
|
+
const runtime = process.versions.bun
|
|
174
|
+
? `bun/${process.versions.bun}`
|
|
175
|
+
: `node/${process.version}`;
|
|
176
|
+
return `pi/${VERSION} (${process.platform}; ${runtime}; ${process.arch})`;
|
|
177
|
+
}
|
|
178
|
+
|
|
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,
|
|
71
212
|
dismissedVersion: prev?.dismissedVersion,
|
|
213
|
+
dismissedPackageName: prev?.dismissedPackageName,
|
|
72
214
|
checkedAt: new Date().toISOString(),
|
|
73
215
|
});
|
|
74
216
|
}
|
|
75
217
|
|
|
76
|
-
async function
|
|
218
|
+
async function fetchLatestRelease(): Promise<LatestRelease | undefined> {
|
|
77
219
|
try {
|
|
78
|
-
const res = await fetch(
|
|
220
|
+
const res = await fetch(LATEST_VERSION_URL, {
|
|
221
|
+
headers: {
|
|
222
|
+
"User-Agent": piUserAgent(),
|
|
223
|
+
accept: "application/json",
|
|
224
|
+
},
|
|
79
225
|
signal: AbortSignal.timeout(10_000),
|
|
80
226
|
});
|
|
81
227
|
if (!res.ok) return undefined;
|
|
82
|
-
|
|
228
|
+
const data = (await res.json()) as { version?: string; packageName?: string };
|
|
229
|
+
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 };
|
|
83
235
|
} catch {
|
|
84
236
|
return undefined;
|
|
85
237
|
}
|
|
86
238
|
}
|
|
87
239
|
|
|
88
240
|
/** Returns a cached upgrade if available and not dismissed. */
|
|
89
|
-
function
|
|
90
|
-
|
|
241
|
+
function getCachedUpgradeRelease(
|
|
242
|
+
cacheFile: string,
|
|
243
|
+
currentPackageName: string,
|
|
244
|
+
): LatestRelease | undefined {
|
|
245
|
+
const cache = readCache(cacheFile);
|
|
91
246
|
if (!cache) return undefined;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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;
|
|
95
254
|
}
|
|
96
255
|
|
|
97
|
-
/** Fetch latest from
|
|
98
|
-
async function
|
|
99
|
-
const latest = await
|
|
256
|
+
/** Fetch latest from Pi's update endpoint and refresh cache. */
|
|
257
|
+
async function refreshLatestReleaseInCache(cacheFile: string): Promise<LatestRelease | undefined> {
|
|
258
|
+
const latest = await fetchLatestRelease();
|
|
100
259
|
if (!latest) return undefined;
|
|
101
|
-
saveLatestToCache(latest);
|
|
260
|
+
saveLatestToCache(cacheFile, latest);
|
|
102
261
|
return latest;
|
|
103
262
|
}
|
|
104
263
|
|
|
105
|
-
function
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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),
|
|
110
275
|
checkedAt: cache?.checkedAt,
|
|
111
276
|
});
|
|
112
277
|
}
|
|
113
278
|
|
|
114
|
-
|
|
279
|
+
interface InstallStep {
|
|
280
|
+
program: string;
|
|
281
|
+
args: string[];
|
|
282
|
+
display: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface InstallCommand {
|
|
286
|
+
steps: InstallStep[];
|
|
287
|
+
display: string;
|
|
288
|
+
targetVersion: string;
|
|
289
|
+
targetPackageName: string;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
interface InstallFailure {
|
|
293
|
+
step: InstallStep;
|
|
294
|
+
code: number;
|
|
295
|
+
output: string;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function npmInstallStep(packageSpec: string, args: string[] = []): InstallStep {
|
|
299
|
+
const stepArgs = ["install", "-g", packageSpec, ...args];
|
|
115
300
|
return {
|
|
116
301
|
program: "npm",
|
|
117
|
-
args:
|
|
302
|
+
args: stepArgs,
|
|
303
|
+
display: ["npm", ...stepArgs].join(" "),
|
|
118
304
|
};
|
|
119
305
|
}
|
|
120
306
|
|
|
121
|
-
function
|
|
122
|
-
|
|
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
|
+
};
|
|
123
335
|
}
|
|
124
336
|
|
|
125
|
-
|
|
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(
|
|
355
|
+
pi: ExtensionAPI,
|
|
356
|
+
cmd: InstallCommand,
|
|
357
|
+
): 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) {
|
|
361
|
+
return {
|
|
362
|
+
step,
|
|
363
|
+
code: result.code,
|
|
364
|
+
output: [result.stderr, result.stdout].filter(Boolean).join("\n").trim(),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
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;
|
|
380
|
+
if (suppressNativeCheck) {
|
|
381
|
+
process.env[ENV_SKIP_VERSION_CHECK] = "1";
|
|
382
|
+
process.env[ENV_INTERNAL_SKIP] = "1";
|
|
383
|
+
}
|
|
384
|
+
|
|
126
385
|
let promptOpen = false;
|
|
127
386
|
const promptedVersions = new Set<string>();
|
|
128
387
|
let liveCheckStarted = false;
|
|
@@ -144,12 +403,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
144
403
|
const piBinary = await findPiBinary();
|
|
145
404
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
146
405
|
const restartArgs = sessionFile ? ["--session", sessionFile] : ["--no-session"];
|
|
406
|
+
const env = { ...process.env };
|
|
407
|
+
if (suppressNativeCheck) {
|
|
408
|
+
delete env[ENV_SKIP_VERSION_CHECK];
|
|
409
|
+
delete env[ENV_INTERNAL_SKIP];
|
|
410
|
+
}
|
|
147
411
|
|
|
148
412
|
return ctx.ui.custom<boolean>((tui, _theme, _kb, done) => {
|
|
149
413
|
tui.stop();
|
|
150
414
|
const result = spawnSync(piBinary, restartArgs, {
|
|
151
415
|
cwd: ctx.cwd,
|
|
152
|
-
env
|
|
416
|
+
env,
|
|
153
417
|
stdio: "inherit",
|
|
154
418
|
shell: process.platform === "win32",
|
|
155
419
|
windowsHide: false,
|
|
@@ -164,25 +428,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
164
428
|
async function doInstall(
|
|
165
429
|
ctx: ExtensionContext,
|
|
166
430
|
latest: string,
|
|
167
|
-
cmd:
|
|
431
|
+
cmd: InstallCommand,
|
|
168
432
|
) {
|
|
169
433
|
const success = await ctx.ui.custom<boolean>((tui, theme, _kb, done) => {
|
|
170
|
-
const loader = new BorderedLoader(tui, theme, `
|
|
434
|
+
const loader = new BorderedLoader(tui, theme, `Running ${cmd.display}...`);
|
|
171
435
|
loader.onAbort = () => done(false);
|
|
172
436
|
|
|
173
|
-
pi
|
|
174
|
-
.then((
|
|
175
|
-
if (
|
|
176
|
-
ctx.ui.notify(
|
|
177
|
-
`Update failed (exit ${result.code}): ${result.stderr || result.stdout}`,
|
|
178
|
-
"error",
|
|
179
|
-
);
|
|
437
|
+
runInstallCommand(pi, cmd)
|
|
438
|
+
.then((failure) => {
|
|
439
|
+
if (failure) {
|
|
440
|
+
ctx.ui.notify(formatInstallFailure(failure, cmd), "error");
|
|
180
441
|
done(false);
|
|
181
442
|
} else {
|
|
182
443
|
done(true);
|
|
183
444
|
}
|
|
184
445
|
})
|
|
185
|
-
.catch(() =>
|
|
446
|
+
.catch((error) => {
|
|
447
|
+
ctx.ui.notify(
|
|
448
|
+
`Update failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
449
|
+
"error",
|
|
450
|
+
);
|
|
451
|
+
done(false);
|
|
452
|
+
});
|
|
186
453
|
|
|
187
454
|
return loader;
|
|
188
455
|
});
|
|
@@ -220,36 +487,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
220
487
|
);
|
|
221
488
|
}
|
|
222
489
|
|
|
223
|
-
async function showUpdatePrompt(ctx: ExtensionContext, latest:
|
|
224
|
-
const cmd = getInstallCommand(latest);
|
|
225
|
-
const
|
|
226
|
-
|
|
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})`,
|
|
227
496
|
"Skip",
|
|
228
497
|
"Skip this version",
|
|
229
498
|
]);
|
|
230
499
|
|
|
231
500
|
if (!choice || choice === "Skip") return;
|
|
232
501
|
if (choice === "Skip this version") {
|
|
233
|
-
|
|
502
|
+
dismissRelease(cacheFile, latest, currentPackageName);
|
|
234
503
|
return;
|
|
235
504
|
}
|
|
236
|
-
await doInstall(ctx,
|
|
505
|
+
await doInstall(ctx, targetLabel, cmd);
|
|
237
506
|
}
|
|
238
507
|
|
|
239
|
-
function canAutoPromptVersion(latest:
|
|
240
|
-
if (!
|
|
241
|
-
if (promptedVersions.has(latest)) return false;
|
|
242
|
-
|
|
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;
|
|
243
513
|
return true;
|
|
244
514
|
}
|
|
245
515
|
|
|
246
|
-
async function maybeShowAutoPrompt(ctx: ExtensionContext, latest:
|
|
516
|
+
async function maybeShowAutoPrompt(ctx: ExtensionContext, latest: LatestRelease) {
|
|
247
517
|
if (!ctx.hasUI) return;
|
|
248
518
|
if (promptOpen) return;
|
|
249
519
|
if (!canAutoPromptVersion(latest)) return;
|
|
250
520
|
|
|
251
521
|
promptOpen = true;
|
|
252
|
-
promptedVersions.add(latest);
|
|
522
|
+
promptedVersions.add(releaseKey(latest, currentPackageName));
|
|
253
523
|
try {
|
|
254
524
|
await showUpdatePrompt(ctx, latest);
|
|
255
525
|
} finally {
|
|
@@ -261,13 +531,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
261
531
|
if (!ctx.hasUI) return;
|
|
262
532
|
if (shouldSkipAutoChecks()) return;
|
|
263
533
|
|
|
264
|
-
const cached =
|
|
534
|
+
const cached = getCachedUpgradeRelease(cacheFile, currentPackageName);
|
|
265
535
|
if (cached) void maybeShowAutoPrompt(ctx, cached);
|
|
266
536
|
|
|
267
537
|
if (liveCheckStarted) return;
|
|
268
538
|
liveCheckStarted = true;
|
|
269
539
|
|
|
270
|
-
void
|
|
540
|
+
void refreshLatestReleaseInCache(cacheFile)
|
|
271
541
|
.then((latest) => {
|
|
272
542
|
if (!latest) return;
|
|
273
543
|
void maybeShowAutoPrompt(ctx, latest);
|
|
@@ -281,21 +551,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
281
551
|
});
|
|
282
552
|
|
|
283
553
|
pi.registerCommand("update", {
|
|
284
|
-
description: "Check for pi updates and install",
|
|
554
|
+
description: "Check for pi updates and install with npm",
|
|
285
555
|
handler: async (rawArgs, ctx) => {
|
|
286
556
|
// /update --test — simulate the full UI flow without a real install
|
|
287
557
|
if (rawArgs?.trim() === "--test") {
|
|
288
558
|
const fakeLatest = "99.0.0";
|
|
289
|
-
const cmd = getInstallCommand(fakeLatest);
|
|
290
|
-
const choice = await ctx.ui.select(`Update ${VERSION} → ${fakeLatest}`, [
|
|
291
|
-
`Update now (${
|
|
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})`,
|
|
292
562
|
"Skip",
|
|
293
563
|
"Skip this version",
|
|
294
564
|
]);
|
|
295
565
|
if (!choice || choice === "Skip" || choice === "Skip this version") return;
|
|
296
566
|
|
|
297
567
|
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
298
|
-
const loader = new BorderedLoader(tui, theme, `
|
|
568
|
+
const loader = new BorderedLoader(tui, theme, `Running ${cmd.display}...`);
|
|
299
569
|
loader.onAbort = () => done();
|
|
300
570
|
setTimeout(() => done(), 1500);
|
|
301
571
|
return loader;
|
|
@@ -323,7 +593,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
323
593
|
return;
|
|
324
594
|
}
|
|
325
595
|
|
|
326
|
-
const latest = await ctx.ui.custom<
|
|
596
|
+
const latest = await ctx.ui.custom<LatestRelease | null>(
|
|
327
597
|
(tui, theme, _kb, done) => {
|
|
328
598
|
const loader = new BorderedLoader(
|
|
329
599
|
tui,
|
|
@@ -331,7 +601,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
331
601
|
"Checking for updates...",
|
|
332
602
|
);
|
|
333
603
|
loader.onAbort = () => done(null);
|
|
334
|
-
|
|
604
|
+
fetchLatestRelease()
|
|
335
605
|
.then((v) => done(v ?? null))
|
|
336
606
|
.catch(() => done(null));
|
|
337
607
|
return loader;
|
|
@@ -339,18 +609,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
339
609
|
);
|
|
340
610
|
|
|
341
611
|
if (!latest) {
|
|
342
|
-
ctx.ui.notify("Could not reach
|
|
612
|
+
ctx.ui.notify("Could not reach Pi update service.", "error");
|
|
343
613
|
return;
|
|
344
614
|
}
|
|
345
615
|
|
|
346
|
-
saveLatestToCache(latest);
|
|
616
|
+
saveLatestToCache(cacheFile, latest);
|
|
347
617
|
|
|
348
|
-
if (!
|
|
349
|
-
ctx.ui.notify(`Already on latest version (${VERSION}).`, "info");
|
|
618
|
+
if (!isUpdateAvailable(latest, currentPackageName)) {
|
|
619
|
+
ctx.ui.notify(`Already on latest version (${currentPackageName}@${VERSION}).`, "info");
|
|
350
620
|
return;
|
|
351
621
|
}
|
|
352
622
|
|
|
353
|
-
promptedVersions.add(latest);
|
|
623
|
+
promptedVersions.add(releaseKey(latest, currentPackageName));
|
|
354
624
|
await showUpdatePrompt(ctx, latest);
|
|
355
625
|
},
|
|
356
626
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-updater",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Codex-style auto-updater for pi. Checks for new versions on startup and prompts to install.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -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
|
}
|