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.
- package/CHANGELOG.md +19 -1
- package/README.md +88 -36
- package/index.ts +284 -346
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
+
If only pi is outdated:
|
|
49
49
|
|
|
50
|
-
|
|
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
|
-
|
|
54
|
+
If both pi and extensions are outdated, a combined prompt appears:
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
115
|
-
writeFileSync(
|
|
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
|
|
140
|
-
const left = parseVersion(
|
|
141
|
-
const right = parseVersion(
|
|
142
|
-
if (!left || !right) return
|
|
143
|
-
if (left.major !== right.major) return left.major
|
|
144
|
-
if (left.minor !== right.minor) return left.minor
|
|
145
|
-
if (left.patch !== right.patch) return left.patch
|
|
146
|
-
if (left.prerelease === right.prerelease) return
|
|
147
|
-
if (!left.prerelease) return
|
|
148
|
-
if (!right.prerelease) return
|
|
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
|
|
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
|
|
120
|
+
const data = (await res.json()) as { version?: string };
|
|
229
121
|
if (typeof data.version !== "string" || !data.version.trim()) return undefined;
|
|
230
|
-
|
|
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
|
-
/**
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
258
|
-
const latest = await
|
|
178
|
+
async function refreshLatestVersionInCache(): Promise<string | undefined> {
|
|
179
|
+
const latest = await fetchLatestVersion();
|
|
259
180
|
if (!latest) return undefined;
|
|
260
|
-
saveLatestToCache(
|
|
181
|
+
saveLatestToCache(latest);
|
|
261
182
|
return latest;
|
|
262
183
|
}
|
|
263
184
|
|
|
264
|
-
function
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
299
|
-
|
|
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
|
|
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
|
-
|
|
222
|
+
target: UpdateTarget,
|
|
357
223
|
): Promise<InstallFailure | undefined> {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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:
|
|
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
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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(
|
|
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
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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 ${
|
|
322
|
+
const loader = new BorderedLoader(tui, theme, `Running ${command.display}...`);
|
|
435
323
|
loader.onAbort = () => done(false);
|
|
436
324
|
|
|
437
|
-
|
|
325
|
+
runNativeUpdate(pi, target)
|
|
438
326
|
.then((failure) => {
|
|
439
327
|
if (failure) {
|
|
440
|
-
ctx.ui.notify(formatInstallFailure(failure,
|
|
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
|
-
|
|
472
|
-
|
|
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,
|
|
491
|
-
const
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
"
|
|
497
|
-
"
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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:
|
|
509
|
-
if (!
|
|
510
|
-
if (promptedVersions.has(
|
|
511
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
449
|
+
if (piLatest) promptedVersions.add(piLatest);
|
|
523
450
|
try {
|
|
524
|
-
await showUpdatePrompt(ctx,
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
|
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
|
|
560
|
-
const choice = await ctx.ui.select(`Update ${
|
|
561
|
-
|
|
490
|
+
const updateAction = "Update now";
|
|
491
|
+
const choice = await ctx.ui.select(`Update ${VERSION} → ${fakeLatest}`, [
|
|
492
|
+
updateAction,
|
|
562
493
|
"Skip",
|
|
563
|
-
|
|
494
|
+
`Ignore ${fakeLatest}`,
|
|
564
495
|
]);
|
|
565
|
-
if (
|
|
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 ${
|
|
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
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
548
|
+
const { latest, extensions } = result;
|
|
549
|
+
if (latest) saveLatestToCache(latest);
|
|
617
550
|
|
|
618
|
-
|
|
619
|
-
|
|
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(
|
|
624
|
-
await showUpdatePrompt(ctx,
|
|
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.
|
|
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",
|