pi-updater 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +33 -5
  3. package/index.ts +199 -52
  4. package/package.json +3 -2
package/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0 - 2026-03-23
4
+
5
+ - Auto-restart pi after a successful update. Asks to restart, then seamlessly relaunches on the current session.
6
+ - Falls back to manual restart message in non-interactive modes or if restart fails.
7
+ - Cross-platform: uses `shell: true` on Windows to handle `.cmd` shims.
8
+ - `/update --test` to simulate the full update flow without a real install.
9
+
10
+ ## 0.2.9 - 2026-03-16
11
+
12
+ - Keep startup checks cache-first and non-blocking.
13
+ - Add a one-time background live check per run.
14
+ - Show update prompt in the same session when the background check finds a newer version.
15
+ - Respect `PI_SKIP_VERSION_CHECK` and `PI_OFFLINE` for automatic checks.
16
+ - Avoid duplicate automatic prompts for the same version in one run.
17
+ - `/update` now warns and exits early when `PI_OFFLINE` is set.
package/README.md CHANGED
@@ -1,21 +1,35 @@
1
1
  # pi-updater
2
2
 
3
- A Codex-style auto-updater for Pi.
3
+ A lightweight, Codex-style auto-updater for pi with fast, cache-first startup checks.
4
4
 
5
- > **Note:** Currently supports npm installations only.
5
+ - npm: https://www.npmjs.com/package/pi-updater
6
+ - repo: https://github.com/tonze/pi-updater
7
+
8
+ > **Note:** Automatic installation currently supports npm-based pi installs only.
6
9
 
7
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" />
8
11
 
9
12
  ## What it does
10
13
 
11
14
  **On startup:** if a newer version is available, shows a prompt:
12
- - **Update now** — install with npm, then restart pi
15
+ - **Update now** — install with npm, then auto-restart pi on the current session
13
16
  - **Skip** — dismiss until next session
14
17
  - **Skip this version** — don't ask again until a newer version appears
15
18
 
16
- **`/update`:** manually check for updates (always fetches fresh from npm)
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.
20
+
21
+ **In the background (once per run):** performs one live npm check and can show the prompt in the same session when a new release is detected.
22
+
23
+ **`/update`:** manually check for updates (always fetches fresh from npm, unless `PI_OFFLINE` is set).
24
+
25
+ ## How version checks work
17
26
 
18
- Version checks are cached. Latest version is fetched in the background on startup.
27
+ pi-updater uses a cache-first approach to keep startup fast:
28
+
29
+ 1. On startup, cached version data is checked instantly.
30
+ 2. One background live fetch refreshes the cache.
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.
19
33
 
20
34
  ## Install
21
35
 
@@ -33,6 +47,20 @@ pi install git:github.com/tonze/pi-updater
33
47
 
34
48
  Use `/update` inside pi to manually check for updates and install them.
35
49
 
50
+ ## Environment flags
51
+
52
+ Disable automatic version checks:
53
+
54
+ ```bash
55
+ export PI_SKIP_VERSION_CHECK=1
56
+ ```
57
+
58
+ Or run in offline mode (also disables automatic checks):
59
+
60
+ ```bash
61
+ export PI_OFFLINE=1
62
+ ```
63
+
36
64
  ## Updating this package
37
65
 
38
66
  ```bash
package/index.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  ExtensionContext,
4
4
  } from "@mariozechner/pi-coding-agent";
5
5
  import { VERSION, BorderedLoader } from "@mariozechner/pi-coding-agent";
6
+ import { spawnSync } from "node:child_process";
6
7
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
7
8
  import { join, dirname } from "node:path";
8
9
  import { homedir } from "node:os";
@@ -11,9 +12,13 @@ const PACKAGE_NAME = "@mariozechner/pi-coding-agent";
11
12
  const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
12
13
  const CACHE_FILE = join(homedir(), ".pi", "agent", "update-cache.json");
13
14
 
15
+ const ENV_SKIP_VERSION_CHECK = "PI_SKIP_VERSION_CHECK";
16
+ const ENV_OFFLINE = "PI_OFFLINE";
17
+
14
18
  interface VersionCache {
15
19
  latestVersion: string;
16
20
  dismissedVersion?: string;
21
+ checkedAt?: string;
17
22
  }
18
23
 
19
24
  function readCache(): VersionCache | undefined {
@@ -48,6 +53,27 @@ function isNewer(latest: string, current: string): boolean {
48
53
  return l[2] > c[2];
49
54
  }
50
55
 
56
+ function isEnvSet(name: string): boolean {
57
+ return Boolean(process.env[name]);
58
+ }
59
+
60
+ function shouldSkipAutoChecks(): boolean {
61
+ return isEnvSet(ENV_SKIP_VERSION_CHECK) || isEnvSet(ENV_OFFLINE);
62
+ }
63
+
64
+ function isOffline(): boolean {
65
+ return isEnvSet(ENV_OFFLINE);
66
+ }
67
+
68
+ function saveLatestToCache(latest: string) {
69
+ const prev = readCache();
70
+ writeCache({
71
+ latestVersion: latest,
72
+ dismissedVersion: prev?.dismissedVersion,
73
+ checkedAt: new Date().toISOString(),
74
+ });
75
+ }
76
+
51
77
  async function fetchLatestVersion(): Promise<string | undefined> {
52
78
  try {
53
79
  const res = await fetch(REGISTRY_URL, {
@@ -60,33 +86,30 @@ async function fetchLatestVersion(): Promise<string | undefined> {
60
86
  }
61
87
  }
62
88
 
63
- /**
64
- * Returns the cached latest version if an upgrade is available and not dismissed.
65
- * Always kicks off a background fetch to refresh the cache for the next run.
66
- */
67
- function getUpgradeVersion(): string | undefined {
89
+ /** Returns a cached upgrade if available and not dismissed. */
90
+ function getCachedUpgradeVersion(): string | undefined {
68
91
  const cache = readCache();
69
-
70
- void fetchLatestVersion().then((latest) => {
71
- if (!latest) return;
72
- // Re-read cache to avoid overwriting a dismissal that happened during the fetch
73
- writeCache({
74
- latestVersion: latest,
75
- dismissedVersion: readCache()?.dismissedVersion,
76
- });
77
- });
78
-
79
92
  if (!cache) return undefined;
80
93
  if (!isNewer(cache.latestVersion, VERSION)) return undefined;
81
94
  if (cache.dismissedVersion === cache.latestVersion) return undefined;
82
95
  return cache.latestVersion;
83
96
  }
84
97
 
98
+ /** Fetch latest from npm and refresh cache. */
99
+ async function refreshLatestVersionInCache(): Promise<string | undefined> {
100
+ const latest = await fetchLatestVersion();
101
+ if (!latest) return undefined;
102
+ saveLatestToCache(latest);
103
+ return latest;
104
+ }
105
+
85
106
  function dismissVersion(version: string) {
86
107
  const cache = readCache();
87
- if (!cache) return;
88
- cache.dismissedVersion = version;
89
- writeCache(cache);
108
+ writeCache({
109
+ latestVersion: cache?.latestVersion ?? version,
110
+ dismissedVersion: version,
111
+ checkedAt: cache?.checkedAt,
112
+ });
90
113
  }
91
114
 
92
115
  function getInstallCommand(version: string): { program: string; args: string[] } {
@@ -101,6 +124,44 @@ function fmtCmd(cmd: { program: string; args: string[] }): string {
101
124
  }
102
125
 
103
126
  export default function (pi: ExtensionAPI) {
127
+ let promptOpen = false;
128
+ const promptedVersions = new Set<string>();
129
+ let liveCheckStarted = false;
130
+
131
+ async function findPiBinary(): Promise<string> {
132
+ const cmd = process.platform === "win32" ? "where" : "which";
133
+ const result = await pi.exec(cmd, ["pi"]);
134
+ if (result.code === 0 && result.stdout?.trim()) {
135
+ return result.stdout.trim().split(/\r?\n/)[0];
136
+ }
137
+ return "pi";
138
+ }
139
+
140
+ function canAutoRestart(ctx: ExtensionContext): boolean {
141
+ return ctx.hasUI && !!process.stdin.isTTY && !!process.stdout.isTTY;
142
+ }
143
+
144
+ async function restartPi(ctx: ExtensionContext): Promise<boolean> {
145
+ const piBinary = await findPiBinary();
146
+ const sessionFile = ctx.sessionManager.getSessionFile();
147
+ const restartArgs = sessionFile ? ["--session", sessionFile] : ["-c"];
148
+
149
+ return ctx.ui.custom<boolean>((tui, _theme, _kb, done) => {
150
+ tui.stop();
151
+ const result = spawnSync(piBinary, restartArgs, {
152
+ cwd: ctx.cwd,
153
+ env: process.env,
154
+ stdio: "inherit",
155
+ shell: process.platform === "win32",
156
+ windowsHide: false,
157
+ });
158
+ tui.start();
159
+ tui.requestRender(true);
160
+ done(!result.error && (result.status === null || result.status === 0));
161
+ return { render: () => [], invalidate: () => {} };
162
+ });
163
+ }
164
+
104
165
  async function doInstall(
105
166
  ctx: ExtensionContext,
106
167
  latest: string,
@@ -110,37 +171,50 @@ export default function (pi: ExtensionAPI) {
110
171
  const loader = new BorderedLoader(tui, theme, `Installing ${latest}...`);
111
172
  loader.onAbort = () => done(false);
112
173
 
113
- const run = async () => {
114
- if (cmd.program === "echo") {
115
- ctx.ui.notify(cmd.args[0], "info");
116
- return false;
117
- }
118
- const result = await pi.exec(cmd.program, cmd.args, {
119
- timeout: 120_000,
120
- });
121
- if (result.code !== 0) {
122
- ctx.ui.notify(
123
- `Update failed (exit ${result.code}): ${result.stderr || result.stdout}`,
124
- "error",
125
- );
126
- return false;
127
- }
128
- return true;
129
- };
130
-
131
- run()
132
- .then(done)
174
+ pi.exec(cmd.program, cmd.args, { timeout: 120_000 })
175
+ .then((result) => {
176
+ if (result.code !== 0) {
177
+ ctx.ui.notify(
178
+ `Update failed (exit ${result.code}): ${result.stderr || result.stdout}`,
179
+ "error",
180
+ );
181
+ done(false);
182
+ } else {
183
+ done(true);
184
+ }
185
+ })
133
186
  .catch(() => done(false));
187
+
134
188
  return loader;
135
189
  });
136
190
 
137
191
  if (!success) return;
138
192
 
139
- const ok = await ctx.ui.confirm(
193
+ if (!canAutoRestart(ctx)) {
194
+ ctx.ui.notify(
195
+ `Updated to ${latest}! Please restart pi.\nTip: run \`pi -c\` to continue this session.`,
196
+ "info",
197
+ );
198
+ return;
199
+ }
200
+
201
+ const restart = await ctx.ui.confirm(
140
202
  `Updated to ${latest}!`,
141
- "Shut down pi? (Use pi -c to continue this session)",
203
+ "Restart now?",
204
+ );
205
+
206
+ if (!restart) return;
207
+
208
+ const ok = await restartPi(ctx);
209
+ if (ok) {
210
+ ctx.shutdown();
211
+ return;
212
+ }
213
+
214
+ ctx.ui.notify(
215
+ `Updated to ${latest}! Auto-restart failed. Please restart pi manually.\nTip: run \`pi -c\` to continue this session.`,
216
+ "error",
142
217
  );
143
- if (ok) ctx.shutdown();
144
218
  }
145
219
 
146
220
  async function showUpdatePrompt(ctx: ExtensionContext, latest: string) {
@@ -159,21 +233,96 @@ export default function (pi: ExtensionAPI) {
159
233
  await doInstall(ctx, latest, cmd);
160
234
  }
161
235
 
162
- pi.on("session_start", async (_event, ctx) => {
236
+ function canAutoPromptVersion(latest: string): boolean {
237
+ if (!isNewer(latest, VERSION)) return false;
238
+ if (promptedVersions.has(latest)) return false;
239
+ if (readCache()?.dismissedVersion === latest) return false;
240
+ return true;
241
+ }
242
+
243
+ async function maybeShowAutoPrompt(ctx: ExtensionContext, latest: string) {
163
244
  if (!ctx.hasUI) return;
164
- const latest = getUpgradeVersion();
165
- if (latest) void showUpdatePrompt(ctx, latest);
245
+ if (promptOpen) return;
246
+ if (!canAutoPromptVersion(latest)) return;
247
+
248
+ promptOpen = true;
249
+ promptedVersions.add(latest);
250
+ try {
251
+ await showUpdatePrompt(ctx, latest);
252
+ } finally {
253
+ promptOpen = false;
254
+ }
255
+ }
256
+
257
+ function runAutoChecks(ctx: ExtensionContext) {
258
+ if (!ctx.hasUI) return;
259
+ if (shouldSkipAutoChecks()) return;
260
+
261
+ const cached = getCachedUpgradeVersion();
262
+ if (cached) void maybeShowAutoPrompt(ctx, cached);
263
+
264
+ if (liveCheckStarted) return;
265
+ liveCheckStarted = true;
266
+
267
+ void refreshLatestVersionInCache()
268
+ .then((latest) => {
269
+ if (!latest) return;
270
+ void maybeShowAutoPrompt(ctx, latest);
271
+ })
272
+ .catch(() => {});
273
+ }
274
+
275
+ pi.on("session_start", async (_event, ctx) => {
276
+ runAutoChecks(ctx);
166
277
  });
167
278
 
168
279
  pi.on("session_switch", async (_event, ctx) => {
169
- if (!ctx.hasUI) return;
170
- const latest = getUpgradeVersion();
171
- if (latest) void showUpdatePrompt(ctx, latest);
280
+ runAutoChecks(ctx);
172
281
  });
173
282
 
174
283
  pi.registerCommand("update", {
175
284
  description: "Check for pi updates and install",
176
- handler: async (_args, ctx) => {
285
+ handler: async (rawArgs, ctx) => {
286
+ // /update --test — simulate the full UI flow without a real install
287
+ if (rawArgs?.trim() === "--test") {
288
+ const fakeLatest = "99.0.0";
289
+ const cmd = getInstallCommand(fakeLatest);
290
+ const choice = await ctx.ui.select(`Update ${VERSION} → ${fakeLatest}`, [
291
+ `Update now (${fmtCmd(cmd)})`,
292
+ "Skip",
293
+ "Skip this version",
294
+ ]);
295
+ if (!choice || choice === "Skip" || choice === "Skip this version") return;
296
+
297
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
298
+ const loader = new BorderedLoader(tui, theme, `Installing ${fakeLatest}...`);
299
+ loader.onAbort = () => done();
300
+ setTimeout(() => done(), 1500);
301
+ return loader;
302
+ });
303
+
304
+ if (!canAutoRestart(ctx)) {
305
+ ctx.ui.notify(`Updated to ${fakeLatest}! Please restart pi.`, "info");
306
+ return;
307
+ }
308
+
309
+ const restart = await ctx.ui.confirm(`Updated to ${fakeLatest}!`, "Restart now?");
310
+ if (!restart) return;
311
+
312
+ const ok = await restartPi(ctx);
313
+ if (ok) { ctx.shutdown(); return; }
314
+ ctx.ui.notify("Test restart failed.", "error");
315
+ return;
316
+ }
317
+
318
+ if (isOffline()) {
319
+ ctx.ui.notify(
320
+ "PI_OFFLINE is set. Disable it to check for updates.",
321
+ "warning",
322
+ );
323
+ return;
324
+ }
325
+
177
326
  const latest = await ctx.ui.custom<string | null>(
178
327
  (tui, theme, _kb, done) => {
179
328
  const loader = new BorderedLoader(
@@ -194,16 +343,14 @@ export default function (pi: ExtensionAPI) {
194
343
  return;
195
344
  }
196
345
 
197
- writeCache({
198
- latestVersion: latest,
199
- dismissedVersion: readCache()?.dismissedVersion,
200
- });
346
+ saveLatestToCache(latest);
201
347
 
202
348
  if (!isNewer(latest, VERSION)) {
203
349
  ctx.ui.notify(`Already on latest version (${VERSION}).`, "info");
204
350
  return;
205
351
  }
206
352
 
353
+ promptedVersions.add(latest);
207
354
  await showUpdatePrompt(ctx, latest);
208
355
  },
209
356
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-updater",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
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",
@@ -22,7 +22,8 @@
22
22
  },
23
23
  "files": [
24
24
  "index.ts",
25
- "README.md"
25
+ "README.md",
26
+ "CHANGELOG.md"
26
27
  ],
27
28
  "peerDependencies": {
28
29
  "@mariozechner/pi-coding-agent": "*"