@pi-unipi/updater 3.0.0-alpha.4 → 3.0.0-alpha.40

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/README.md CHANGED
@@ -1,77 +1,75 @@
1
- # @pi-unipi/updater
1
+ # Updater
2
2
 
3
- Checks npm for new versions on session start, shows a changelog diff, and lets you update with one keypress. Also provides TUI browsers for package READMEs and the changelog.
3
+ Updater tells you when a new UniPi release is on npm and installs it with one key.
4
4
 
5
- The update overlay appears automatically when a newer version is found. Press `Y` to update, `n` to skip. Skipped versions are cached — you only get re-prompted when an even newer version appears.
5
+ `@pi-unipi/updater` · part of [UniPi](../../README.md)
6
6
 
7
- ## Commands
7
+ ## What it does
8
8
 
9
- | Command | Description |
10
- |---------|-------------|
11
- | `/unipi:readme [package]` | Browse package README files in TUI overlay |
12
- | `/unipi:changelog` | Browse CHANGELOG.md with version list and detail view |
13
- | `/unipi:settings` | Configure check interval and auto-update mode (Updater group) |
9
+ - Checks the npm registry for `@pi-unipi/unipi` at session start.
10
+ - Shows an update overlay with the changelog entries between your version and the new version.
11
+ - Runs `pi install npm:@pi-unipi/unipi` when you press `Y`.
12
+ - Remembers a version that you skip. It asks again only for a newer version.
13
+ - Opens package READMEs and the changelog in TUI overlays.
14
+ - Adds an Updater group to the info screen: installed version, latest version, status and last check.
14
15
 
15
- ### TUI Controls
16
+ ## Quick start
16
17
 
17
- | Key | Action |
18
- |-----|--------|
19
- | `j/k` or Up/Down | Navigate |
20
- | `Enter` | Select/open |
21
- | `q/Esc` | Back/close |
22
- | `g/G` | Jump to top/bottom |
23
- | `Space` | Cycle options (settings) |
24
- | `h/l` or Left/Right | Cycle options (settings) |
18
+ Updater ships in `@pi-unipi/unipi`. It checks the version of `@pi-unipi/unipi`, so it is useful only with the full suite.
25
19
 
26
- ## Special Triggers
20
+ 1. Start a pi session. Updater checks npm in the background.
21
+ 2. If a newer version is on npm, the update overlay opens.
22
+ 3. Press `Y` to install. Press `n` to skip this version.
23
+ 4. Restart pi to load the new version.
27
24
 
28
- On session start, updater checks the npm registry for `@pi-unipi/unipi`. If a newer version exists and wasn't previously skipped, it shows the update overlay with changelog diff. This runs once per session, respecting the check interval config.
25
+ ## Commands
29
26
 
30
- Updater registers with the info-screen dashboard, showing installed version, latest version, update status, and last check time.
27
+ | Command | What it does |
28
+ |---|---|
29
+ | `/unipi:readme [package]` | Opens the README browser. A package name, for example `utility`, opens that README. |
30
+ | `/unipi:changelog` | Opens the changelog browser with a version list and a detail view. |
31
31
 
32
- ## How Updates Work
32
+ ## Keys
33
33
 
34
- 1. Session start triggers npm registry check
35
- 2. Compare latest version with installed version
36
- 3. If newer and not skipped, show update overlay
37
- 4. User views changelog diff, presses `Y` to update or `n` to skip
38
- 5. Update runs `pi install npm:@pi-unipi/unipi`
39
- 6. Skipped version cached — re-prompted only for newer versions
34
+ Update overlay:
40
35
 
41
- ## Configurables
36
+ | Key | What it does |
37
+ |---|---|
38
+ | `Y` or `y` | Installs the new version. |
39
+ | `n`, `q` or `Esc` | Skips this version. |
40
+ | `j` / `k` or arrows | Scrolls the changelog. |
41
+ | `g` / `G` | Goes to the top or the bottom. |
42
42
 
43
- Config stored at `~/.unipi/config/updater/config.json`:
43
+ README and changelog browsers:
44
44
 
45
- ```json
46
- {
47
- "checkIntervalMs": 3600000,
48
- "autoUpdate": "notify"
49
- }
50
- ```
45
+ | Key | What it does |
46
+ |---|---|
47
+ | `j` / `k` or arrows | Moves in the list, or scrolls the detail view. |
48
+ | `Enter` | Opens the selected item. |
49
+ | `g` / `G` | Goes to the top or the bottom. |
50
+ | `q` or `Esc` | Goes back to the list, or closes the list. |
51
51
 
52
- | Option | Values | Default |
53
- |--------|--------|---------|
54
- | `checkIntervalMs` | 1800000 (30min), 3600000 (1h), 21600000 (6h), 86400000 (1d) | 3600000 (1h) |
55
- | `autoUpdate` | disabled, notify, auto | notify |
52
+ ## Settings
56
53
 
57
- ### Auto-update Modes
54
+ Open `/unipi:settings` → Updater. The namespace is `updater`.
58
55
 
59
- - **disabled** — No update checks on session start
60
- - **notify** — Show overlay with changelog, user chooses Y/n
61
- - **auto** — Show countdown, auto-install after 5 seconds unless cancelled
56
+ | Key | Default | What it does |
57
+ |---|---|---|
58
+ | `checkIntervalMs` | `3600000` (1 hour) | Minimum time between two npm checks. Options: 30 min, 1 hour, 6 hours, 1 day. |
59
+ | `autoUpdate` | `notify` | `disabled`: no check. `notify`: the overlay asks you. `auto`: the overlay counts down 5 seconds, then installs. Press `n` to cancel and skip this version. |
62
60
 
63
- ### Cache
61
+ ## How it works
64
62
 
65
- Last-check cache at `~/.unipi/cache/updater/last-check.json`:
63
+ 1. At session start, Updater reads `~/.unipi/cache/updater/last-check.json`.
64
+ 2. If the last check is older than `checkIntervalMs`, Updater gets the `latest` dist-tag from npm. The request stops after 10 seconds.
65
+ 3. Updater compares the versions. It never offers a lower version.
66
+ 4. If the new version is not skipped, the overlay opens. Updater gets the changelog for the new version from GitHub. If that fails, it uses the local `CHANGELOG.md`.
67
+ 5. The install command stops after 60 seconds. If it fails, the overlay shows the error. Press any key to close it.
66
68
 
67
- ```json
68
- {
69
- "lastCheck": "2026-05-01T12:00:00.000Z",
70
- "latestVersion": "0.1.16",
71
- "skippedVersion": "0.1.16"
72
- }
73
- ```
69
+ A failed npm check is silent. Updater then uses the cached result.
74
70
 
75
- ## License
71
+ ## See also
76
72
 
77
- MIT
73
+ - [Commands reference](../../docs/reference/commands.md)
74
+ - [Settings reference](../../docs/reference/settings.md)
75
+ - [Info Screen](../info-screen/README.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/updater",
3
- "version": "3.0.0-alpha.4",
3
+ "version": "3.0.0-alpha.40",
4
4
  "description": "Auto-updater, changelog browser, and readme browser for Unipi — checks npm registry, renders CHANGELOG.md and README.md files in TUI overlays",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "3.0.0-alpha.4"
37
+ "@pi-unipi/core": "3.0.0-alpha.40"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@earendil-works/pi-coding-agent": "^0.87.1",
package/src/channel.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @pi-unipi/updater — release channel resolution (UNI-262)
3
+ *
4
+ * UniPi 3 ships on the npm `alpha` dist-tag while `latest` still points at the
5
+ * 2.x line. Comparing an alpha install against `latest` offered a downgrade.
6
+ * The channel to follow comes from, in order:
7
+ * 1. the pi settings package spec (`npm:@pi-unipi/unipi@alpha`) when its
8
+ * version part is a dist-tag (not a pinned semver),
9
+ * 2. the installed version's prerelease id (`3.0.0-alpha.36` → `alpha`),
10
+ * 3. `latest`.
11
+ */
12
+
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { compareVersions } from "@pi-unipi/core";
17
+
18
+ export const UNIPI_PACKAGE = "@pi-unipi/unipi";
19
+
20
+ /** `3.0.0-alpha.36` → `alpha`; stable → null. */
21
+ export function prereleaseChannel(version: string): string | null {
22
+ const m = /^v?\d+\.\d+\.\d+-([0-9A-Za-z-]+)/.exec(version.trim());
23
+ if (!m) return null;
24
+ const id = m[1]!;
25
+ return /^\d+$/.test(id) ? null : id;
26
+ }
27
+
28
+ /** `npm:@pi-unipi/unipi@alpha` → `alpha`; pinned semver / no tag / other package → null. */
29
+ export function channelFromSpec(spec: string): string | null {
30
+ const s = spec.trim().replace(/^npm:/, "");
31
+ if (!s.startsWith(`${UNIPI_PACKAGE}@`)) return null;
32
+ const tag = s.slice(UNIPI_PACKAGE.length + 1).trim();
33
+ if (!tag) return null;
34
+ // A pinned or ranged version is not a channel.
35
+ if (/^[\^~<>=v]?\d/.test(tag) || tag === "*") return null;
36
+ return tag;
37
+ }
38
+
39
+ /** pi's agent dir (honours PI_CODING_AGENT_DIR like pi itself). */
40
+ export function piAgentDir(env: NodeJS.ProcessEnv = process.env): string {
41
+ const dir = env.PI_CODING_AGENT_DIR;
42
+ if (dir) return dir.startsWith("~") ? join(homedir(), dir.slice(1)) : dir;
43
+ return join(homedir(), ".pi", "agent");
44
+ }
45
+
46
+ /** The UniPi package spec from pi settings (project first, then global), if any. */
47
+ export function readUnipiPackageSpec(cwd = process.cwd(), env: NodeJS.ProcessEnv = process.env): string | null {
48
+ const files = [join(cwd, ".pi", "settings.json"), join(piAgentDir(env), "settings.json")];
49
+ for (const file of files) {
50
+ try {
51
+ if (!existsSync(file)) continue;
52
+ const settings = JSON.parse(readFileSync(file, "utf8")) as { packages?: Array<string | { source?: string }> };
53
+ for (const pkg of settings.packages ?? []) {
54
+ const source = typeof pkg === "string" ? pkg : pkg?.source;
55
+ if (typeof source !== "string") continue;
56
+ const bare = source.trim().replace(/^npm:/, "");
57
+ if (bare === UNIPI_PACKAGE || bare.startsWith(`${UNIPI_PACKAGE}@`)) return source.trim();
58
+ }
59
+ } catch {
60
+ // Unreadable settings — try the next file.
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /** The dist-tag this install follows. */
67
+ export function resolveChannel(installedVersion: string, spec: string | null): string {
68
+ return (spec ? channelFromSpec(spec) : null) ?? prereleaseChannel(installedVersion) ?? "latest";
69
+ }
70
+
71
+ /**
72
+ * Pick the version to offer from a dist-tags map. A prerelease channel also
73
+ * considers `latest` so a graduation (3.0.0 stable) is still offered; a stable
74
+ * install never sees prerelease tags. Returns null if nothing usable.
75
+ */
76
+ export function pickChannelVersion(distTags: Record<string, string> | undefined, channel: string): string | null {
77
+ if (!distTags) return null;
78
+ const candidates = [distTags[channel], channel !== "latest" ? distTags.latest : undefined]
79
+ .filter((v): v is string => typeof v === "string" && v.length > 0);
80
+ if (candidates.length === 0) return null;
81
+ return candidates.reduce((best, v) => (compareVersions(v, best) > 0 ? v : best));
82
+ }
83
+
84
+ /**
85
+ * UNI-272: which dist-tag the picked version came from. An alpha install
86
+ * offered 3.0.0 from `latest` (graduation) must install `latest` — installing
87
+ * `@alpha` again would reinstall the alpha it already has, and keep the
88
+ * install on the alpha channel forever. Ties go to the install's own channel.
89
+ */
90
+ export function pickInstallTag(distTags: Record<string, string> | undefined, channel: string): string {
91
+ const picked = pickChannelVersion(distTags, channel);
92
+ if (!picked || !distTags) return channel;
93
+ if (distTags[channel] === picked) return channel;
94
+ return distTags.latest === picked ? "latest" : channel;
95
+ }
96
+
97
+ /** The `pi install` source for this channel. */
98
+ export function installSpec(channel: string): string {
99
+ return channel === "latest" ? `npm:${UNIPI_PACKAGE}` : `npm:${UNIPI_PACKAGE}@${channel}`;
100
+ }
package/src/checker.ts CHANGED
@@ -1,90 +1,119 @@
1
1
  /**
2
2
  * @pi-unipi/updater — NPM registry checker
3
3
  *
4
- * Fetches latest version from npm registry, compares with installed version,
5
- * respects check interval from config/cache.
4
+ * Fetches the dist-tag of the install's release channel (UNI-262: an alpha
5
+ * install follows `alpha`, never `latest`), compares with the installed
6
+ * version, never offers a lower version, and respects the check interval.
6
7
  */
7
8
 
9
+ import { dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
8
11
  import { getInstalledPackageVersion } from "@pi-unipi/core";
9
12
  import { loadConfig } from "./settings.js";
10
13
  import { readLastCheck, writeLastCheck, isCheckDue } from "./cache.js";
11
14
  import { compareVersions, isNewerVersion } from "./version.js";
15
+ import { pickChannelVersion, pickInstallTag, readUnipiPackageSpec, resolveChannel, UNIPI_PACKAGE } from "./channel.js";
12
16
  import type { UpdateCheckResult } from "../types.js";
13
17
 
14
- /** NPM registry URL for the unipi umbrella package */
15
- const NPM_REGISTRY_URL = "https://registry.npmjs.org/@pi-unipi/unipi";
18
+ /** npm registry base; `UNIPI_UPDATER_REGISTRY` overrides it (tests / mirrors). */
19
+ export function registryUrl(env: NodeJS.ProcessEnv = process.env): string {
20
+ const base = (env.UNIPI_UPDATER_REGISTRY || "https://registry.npmjs.org").replace(/\/+$/, "");
21
+ return `${base}/-/package/${UNIPI_PACKAGE}/dist-tags`;
22
+ }
16
23
 
17
24
  /** Resolve the installed version of @pi-unipi/unipi */
18
- function getInstalledVersion(): string {
19
- // Walk up from this file to find the @pi-unipi/unipi package by name
20
- const dir = new URL("..", import.meta.url).pathname;
21
- return getInstalledPackageVersion(dir, "@pi-unipi/unipi");
25
+ export function getInstalledVersion(): string {
26
+ // fileURLToPath, not URL.pathname: on Windows the latter is "/C:/…".
27
+ const dir = dirname(dirname(fileURLToPath(import.meta.url)));
28
+ return getInstalledPackageVersion(dir, UNIPI_PACKAGE);
22
29
  }
23
30
 
31
+ /** The channel this install follows. */
32
+ export function currentChannel(currentVersion = getInstalledVersion()): string {
33
+ return resolveChannel(currentVersion, readUnipiPackageSpec());
34
+ }
24
35
 
25
36
  /** Build an update result without ever reporting downgrades as updates. */
26
- function toUpdateResult(latestVersion: string, currentVersion: string): UpdateCheckResult {
37
+ function toUpdateResult(latestVersion: string, currentVersion: string, channel: string, installTag?: string): UpdateCheckResult {
27
38
  return {
28
39
  updateAvailable: isNewerVersion(latestVersion, currentVersion),
29
40
  latestVersion,
30
41
  currentVersion,
42
+ channel,
43
+ installTag: installTag ?? channel,
31
44
  };
32
45
  }
33
46
 
47
+ export interface CheckOptions {
48
+ fetchImpl?: typeof fetch;
49
+ currentVersion?: string;
50
+ channel?: string;
51
+ /** Ignore the interval cache. */
52
+ force?: boolean;
53
+ }
54
+
34
55
  /**
35
- * Check for updates from npm registry.
56
+ * Check for updates on this install's channel.
36
57
  * Respects check interval — skips if last check was recent.
37
- * Returns update status and version info.
38
58
  */
39
- export async function checkForUpdates(): Promise<UpdateCheckResult> {
40
- const currentVersion = getInstalledVersion();
59
+ export async function checkForUpdates(opts: CheckOptions = {}): Promise<UpdateCheckResult> {
60
+ const currentVersion = opts.currentVersion ?? getInstalledVersion();
61
+ const channel = opts.channel ?? currentChannel(currentVersion);
41
62
 
42
63
  try {
43
64
  const config = loadConfig();
44
65
 
45
- // Check if we need to fetch (interval not elapsed). If the cached npm
46
- // version is older than the installed version, ignore the interval and
47
- // refresh: this happens immediately after a local/source release before
48
- // the updater cache has seen the new npm dist-tag.
66
+ // A cache is only reused for the same channel (an old cache written by
67
+ // the pre-UNI-262 checker holds `latest`). If the cached version is older
68
+ // than the installed one, refresh: right after a release the cache has
69
+ // not seen the new dist-tag yet.
49
70
  const cache = readLastCheck();
50
- if (cache && !isCheckDue(config.checkIntervalMs)) {
71
+ if (!opts.force && cache && (cache.channel ?? "latest") === channel && !isCheckDue(config.checkIntervalMs)) {
51
72
  if (compareVersions(cache.latestVersion, currentVersion) >= 0) {
52
- return toUpdateResult(cache.latestVersion, currentVersion);
73
+ return toUpdateResult(cache.latestVersion, currentVersion, channel, cache.installTag);
53
74
  }
54
75
  }
55
76
 
56
- // Fetch from npm registry
57
- const response = await fetch(NPM_REGISTRY_URL, {
77
+ const fetchImpl = opts.fetchImpl ?? fetch;
78
+ const response = await fetchImpl(registryUrl(), {
58
79
  headers: { Accept: "application/json" },
59
- signal: AbortSignal.timeout(10000), // 10s timeout
80
+ signal: AbortSignal.timeout(10000),
60
81
  });
61
82
 
62
83
  if (!response.ok) {
63
84
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
64
85
  }
65
86
 
66
- const data = await response.json() as { "dist-tags": { latest: string } };
67
- const latestVersion = data["dist-tags"]?.latest;
87
+ const body = await response.json() as Record<string, unknown>;
88
+ // The dist-tags endpoint returns the map itself; the full packument nests it.
89
+ const tags = (body["dist-tags"] ?? body) as Record<string, string>;
90
+ const latestVersion = pickChannelVersion(tags, channel);
91
+ const installTag = pickInstallTag(tags, channel);
68
92
 
69
93
  if (!latestVersion) {
70
- throw new Error("No dist-tags.latest in npm response");
94
+ throw new Error(`No dist-tag "${channel}" in npm response`);
71
95
  }
72
96
 
73
- // Write cache
74
97
  writeLastCheck({
75
98
  lastCheck: new Date().toISOString(),
76
99
  latestVersion,
100
+ channel,
101
+ installTag,
102
+ skippedVersion: cache?.skippedVersion,
77
103
  });
78
104
 
79
- return toUpdateResult(latestVersion, currentVersion);
105
+ return toUpdateResult(latestVersion, currentVersion, channel, installTag);
80
106
  } catch (err: unknown) {
81
- // Network error — return cached info if available, but never suggest a
82
- // downgrade from a stale cache.
107
+ // Network error — reuse cached info for this channel only, and never
108
+ // suggest a downgrade from a stale cache.
83
109
  const cache = readLastCheck();
110
+ const usable = cache && (cache.channel ?? "latest") === channel ? cache : null;
84
111
  return {
85
- updateAvailable: cache ? isNewerVersion(cache.latestVersion, currentVersion) : false,
86
- latestVersion: cache?.latestVersion ?? "",
112
+ updateAvailable: usable ? isNewerVersion(usable.latestVersion, currentVersion) : false,
113
+ latestVersion: usable?.latestVersion ?? "",
87
114
  currentVersion,
115
+ channel,
116
+ installTag: usable?.installTag ?? channel,
88
117
  error: err instanceof Error ? err.message : String(err) || "Unknown error",
89
118
  };
90
119
  }
package/src/index.ts CHANGED
@@ -8,15 +8,15 @@
8
8
  * /unipi:readme and /unipi:changelog.
9
9
  */
10
10
 
11
+ import { fileURLToPath } from "node:url";
11
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
13
  import {
14
+ bus,
13
15
  UNIPI_EVENTS,
14
16
  MODULES,
15
17
  UPDATER_COMMANDS,
16
18
  UNIPI_PREFIX,
17
- emitEvent,
18
19
  getPackageVersion,
19
- type UnipiUpdateCheckEvent,
20
20
  } from "@pi-unipi/core";
21
21
  import { registerCommands } from "./commands.js";
22
22
  import { loadConfig } from "./settings.js";
@@ -26,7 +26,7 @@ import { renderUpdateOverlay } from "./tui/update-overlay.js";
26
26
  import { loadUpdateChangelog } from "./remote-changelog.js";
27
27
 
28
28
  /** Package version */
29
- const VERSION = getPackageVersion(new URL("..", import.meta.url).pathname);
29
+ const VERSION = getPackageVersion(fileURLToPath(new URL("..", import.meta.url)));
30
30
 
31
31
  export default function updaterExtension(pi: ExtensionAPI): void {
32
32
 
@@ -36,7 +36,7 @@ export default function updaterExtension(pi: ExtensionAPI): void {
36
36
  // Session lifecycle — check for updates and announce module
37
37
  pi.on("session_start", async (_event, ctx) => {
38
38
  // Emit MODULE_READY
39
- emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
39
+ bus.emit(UNIPI_EVENTS.MODULE_READY, {
40
40
  name: MODULES.UPDATER,
41
41
  version: VERSION,
42
42
  commands: [
@@ -49,7 +49,7 @@ export default function updaterExtension(pi: ExtensionAPI): void {
49
49
  // Register info-screen group
50
50
  const infoRegistry = globalThis.__unipi_info_registry;
51
51
  if (infoRegistry) {
52
- let cachedResult: { currentVersion: string; latestVersion: string; updateAvailable: boolean; lastCheck: string } | null = null;
52
+ let cachedResult: { currentVersion: string; latestVersion: string; updateAvailable: boolean; lastCheck: string; checkedAt: number } | null = null;
53
53
 
54
54
  infoRegistry.registerGroup({
55
55
  id: "updater",
@@ -66,56 +66,56 @@ export default function updaterExtension(pi: ExtensionAPI): void {
66
66
  ],
67
67
  },
68
68
  dataProvider: async () => {
69
+ const cfg = loadConfig();
69
70
  if (!cachedResult) {
70
71
  return {
71
- current: VERSION,
72
- latest: "checking...",
73
- status: "⏳ Checking",
74
- lastCheck: "never",
72
+ current: { value: VERSION },
73
+ latest: { value: "checking…" },
74
+ status: { value: "checking" },
75
+ lastCheck: { value: "never" },
76
+ raw: { value: "", raw: { current: VERSION, latest: null, available: false, checkedAt: 0, mode: cfg.autoUpdate } },
75
77
  };
76
78
  }
77
79
  return {
78
- current: cachedResult.currentVersion,
79
- latest: cachedResult.latestVersion,
80
- status: cachedResult.updateAvailable ? "↑ Update available" : "✓ Up to date",
81
- lastCheck: cachedResult.lastCheck || "never",
80
+ current: { value: cachedResult.currentVersion },
81
+ latest: { value: cachedResult.latestVersion },
82
+ status: { value: cachedResult.updateAvailable ? "update available" : "up to date" },
83
+ lastCheck: { value: cachedResult.lastCheck || "never" },
84
+ raw: {
85
+ value: "",
86
+ raw: {
87
+ current: cachedResult.currentVersion,
88
+ latest: cachedResult.latestVersion,
89
+ available: cachedResult.updateAvailable,
90
+ checkedAt: cachedResult.checkedAt,
91
+ mode: cfg.autoUpdate,
92
+ },
93
+ },
82
94
  };
83
95
  },
84
96
  });
85
97
 
86
98
  // Subscribe to events to update cached data
87
- pi.events.on(UNIPI_EVENTS.UPDATE_CHECK, (data) => {
88
- const payload = data as UnipiUpdateCheckEvent;
99
+ bus.on(pi, UNIPI_EVENTS.UPDATE_CHECK, (payload) => {
89
100
  cachedResult = {
90
101
  currentVersion: payload.currentVersion,
91
102
  latestVersion: payload.latestVersion,
92
103
  updateAvailable: payload.updateAvailable,
93
104
  lastCheck: new Date().toLocaleTimeString(),
105
+ checkedAt: Date.now(),
94
106
  };
95
- emitEvent(pi, UNIPI_EVENTS.INFO_DATA_UPDATED, {
96
- groupId: "updater",
97
- keys: ["current", "latest", "status", "lastCheck"],
98
- });
99
107
  });
100
108
 
101
- pi.events.on(UNIPI_EVENTS.UPDATE_AVAILABLE, (_data: unknown) => {
109
+ bus.on(pi, UNIPI_EVENTS.UPDATE_AVAILABLE, () => {
102
110
  if (cachedResult) {
103
111
  cachedResult.updateAvailable = true;
104
112
  }
105
- emitEvent(pi, UNIPI_EVENTS.INFO_DATA_UPDATED, {
106
- groupId: "updater",
107
- keys: ["status"],
108
- });
109
113
  });
110
114
 
111
- pi.events.on(UNIPI_EVENTS.UPDATE_APPLIED, (_data: unknown) => {
115
+ bus.on(pi, UNIPI_EVENTS.UPDATE_APPLIED, () => {
112
116
  if (cachedResult) {
113
117
  cachedResult.updateAvailable = false;
114
118
  }
115
- emitEvent(pi, UNIPI_EVENTS.INFO_DATA_UPDATED, {
116
- groupId: "updater",
117
- keys: ["status"],
118
- });
119
119
  });
120
120
  }
121
121
 
@@ -127,7 +127,7 @@ export default function updaterExtension(pi: ExtensionAPI): void {
127
127
  const result = await checkForUpdates();
128
128
 
129
129
  // Emit check event
130
- emitEvent(pi, UNIPI_EVENTS.UPDATE_CHECK, result);
130
+ bus.emit(UNIPI_EVENTS.UPDATE_CHECK, result);
131
131
 
132
132
  if (!result.updateAvailable || result.error) return;
133
133
 
@@ -135,7 +135,7 @@ export default function updaterExtension(pi: ExtensionAPI): void {
135
135
  if (isVersionSkipped(result.latestVersion)) return;
136
136
 
137
137
  // Emit available event
138
- emitEvent(pi, UNIPI_EVENTS.UPDATE_AVAILABLE, {
138
+ bus.emit(UNIPI_EVENTS.UPDATE_AVAILABLE, {
139
139
  currentVersion: result.currentVersion,
140
140
  latestVersion: result.latestVersion,
141
141
  });
package/src/installer.ts CHANGED
@@ -6,7 +6,10 @@
6
6
 
7
7
  import { exec } from "child_process";
8
8
  import { promisify } from "util";
9
+ import { dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
9
11
  import { getInstalledPackageVersion } from "@pi-unipi/core";
12
+ import { installSpec } from "./channel.js";
10
13
  import type { InstallResult } from "../types.js";
11
14
 
12
15
  const execAsync = promisify(exec);
@@ -15,16 +18,18 @@ const execAsync = promisify(exec);
15
18
  const INSTALL_TIMEOUT_MS = 60000;
16
19
 
17
20
  /**
18
- * Install the latest version of @pi-unipi/unipi.
19
- * Uses pi CLI: `pi install npm:@pi-unipi/unipi`
21
+ * Install the newest @pi-unipi/unipi on `channel`.
22
+ * Uses pi CLI: `pi install npm:@pi-unipi/unipi[@<channel>]`
20
23
  * Returns structured result with success/failure info.
21
24
  */
22
- export async function installUpdate(): Promise<InstallResult> {
23
- const thisDir = new URL("..", import.meta.url).pathname;
25
+ export async function installUpdate(channel = "latest"): Promise<InstallResult> {
26
+ const thisDir = dirname(dirname(fileURLToPath(import.meta.url)));
24
27
 
25
28
  try {
29
+ // Install from the same channel the check used — a bare spec would pull
30
+ // `latest` and downgrade an alpha install (UNI-262).
26
31
  await execAsync(
27
- "pi install npm:@pi-unipi/unipi",
32
+ `pi install ${installSpec(channel)}`,
28
33
  {
29
34
  timeout: INSTALL_TIMEOUT_MS,
30
35
  env: { ...process.env },
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { UPDATER_DIRS } from "@pi-unipi/core";
4
+ import { UPDATER_DIRS, compareVersions } from "@pi-unipi/core";
5
5
  import type { ChangelogEntry } from "../types.js";
6
6
  import { getNewerVersions, parseChangelogContent, parseChangelog, resolveChangelogPath } from "./changelog.js";
7
7
 
@@ -17,6 +17,16 @@ function cacheDirectory(opts?: RemoteChangelogOptions): string {
17
17
  return (opts?.cacheDir ?? UPDATER_DIRS.CACHE).replace("~", homedir());
18
18
  }
19
19
 
20
+ /**
21
+ * Branches to try when the release tag is missing. Prereleases are pushed to
22
+ * a channel branch (`3.0.0-alpha.36` → `v3.0.0-alpha`); `main` holds the
23
+ * stable line only, so it would show the wrong changelog to an alpha install.
24
+ */
25
+ export function fallbackRefs(version: string): string[] {
26
+ const m = /^v?(\d+\.\d+\.\d+-[0-9A-Za-z-]+)\.[0-9A-Za-z.-]+$/.exec(version);
27
+ return m ? [`v${m[1]}`, "main"] : ["main"];
28
+ }
29
+
20
30
  export async function fetchRemoteChangelog(version: string, opts: RemoteChangelogOptions = {}): Promise<string | null> {
21
31
  const cacheDir = cacheDirectory(opts);
22
32
  const cachePath = join(cacheDir, `changelog-${version}.md`);
@@ -31,10 +41,11 @@ export async function fetchRemoteChangelog(version: string, opts: RemoteChangelo
31
41
  try {
32
42
  let response = await fetchImpl(`${CHANGELOG_RAW_BASE}/v${version}/CHANGELOG.md`, { signal });
33
43
  // The release tag is immutable, so its changelog is safe to cache forever.
34
- // The `main` fallback (tag not pushed yet) is not: never cache it.
44
+ // The branch fallbacks (tag not pushed yet) are not: never cache them.
35
45
  const cacheable = response.ok;
36
- if (response.status === 404) {
37
- response = await fetchImpl(`${CHANGELOG_RAW_BASE}/main/CHANGELOG.md`, { signal });
46
+ for (const ref of fallbackRefs(version)) {
47
+ if (response.status !== 404) break;
48
+ response = await fetchImpl(`${CHANGELOG_RAW_BASE}/${ref}/CHANGELOG.md`, { signal });
38
49
  }
39
50
  if (!response.ok) return null;
40
51
  const content = await response.text();
@@ -55,7 +66,9 @@ export async function loadUpdateChangelog(
55
66
  ): Promise<ChangelogEntry[]> {
56
67
  const remote = await fetchRemoteChangelog(latestVersion, opts);
57
68
  const entries = remote === null ? [] : parseChangelogContent(remote);
58
- const newer = remote === null ? [] : getNewerVersions(entries, currentVersion);
69
+ const upTo = (list: ChangelogEntry[]) =>
70
+ list.filter((e) => e.version === "Unreleased" || compareVersions(e.version, latestVersion) <= 0);
71
+ const newer = remote === null ? [] : upTo(getNewerVersions(entries, currentVersion));
59
72
  if (newer.length > 0) return newer;
60
- return getNewerVersions(parseChangelog(resolveChangelogPath()), currentVersion);
73
+ return upTo(getNewerVersions(parseChangelog(resolveChangelogPath()), currentVersion));
61
74
  }
package/src/settings.ts CHANGED
@@ -46,19 +46,25 @@ registerSettings({
46
46
  key: "checkIntervalMs",
47
47
  type: "enum",
48
48
  label: "Check interval",
49
+ plainOptions: true,
50
+ description: "How often to check npm for updates.",
49
51
  options: [
50
52
  { value: "1800000", label: "30 min" },
51
53
  { value: "3600000", label: "1 hour" },
52
54
  { value: "21600000", label: "6 hours" },
53
55
  { value: "86400000", label: "daily" },
54
56
  ],
55
- description: "How often to check npm for updates",
56
57
  },
57
58
  {
58
59
  key: "autoUpdate",
59
60
  type: "enum",
60
61
  label: "Auto update",
61
- options: ["disabled", "notify", "auto"],
62
+ description: "What happens when an update is found.",
63
+ options: [
64
+ { value: "disabled", label: "disabled", description: "check nothing; update manually" },
65
+ { value: "notify", label: "notify", description: "tell you; you install" },
66
+ { value: "auto", label: "auto", description: "install the update itself" },
67
+ ],
62
68
  },
63
69
  ],
64
70
  },
@@ -5,10 +5,11 @@
5
5
  */
6
6
 
7
7
  import { existsSync } from "fs";
8
+ import { fileURLToPath } from "node:url";
8
9
  import type { Theme } from "@earendil-works/pi-coding-agent";
9
10
  import { parseChangelog, resolveChangelogPath } from "../changelog.js";
10
11
  import { renderMarkdown } from "../markdown.js";
11
- import { getInstalledPackageVersion } from "@pi-unipi/core";
12
+ import { getInstalledPackageVersion, compareVersions } from "@pi-unipi/core";
12
13
  import { createListDetailOverlay } from "./list-detail-overlay.js";
13
14
  import type { ChangelogEntry } from "../../types.js";
14
15
 
@@ -17,7 +18,7 @@ import type { ChangelogEntry } from "../../types.js";
17
18
  */
18
19
  export function renderChangelogOverlay() {
19
20
  const installedVersion = getInstalledPackageVersion(
20
- new URL("..", import.meta.url).pathname,
21
+ fileURLToPath(new URL("..", import.meta.url)),
21
22
  "@pi-unipi/unipi",
22
23
  );
23
24
 
@@ -39,12 +40,7 @@ export function renderChangelogOverlay() {
39
40
  } else if (entry.version === installedVersion) {
40
41
  label = theme.fg("success", "✓ Current");
41
42
  } else {
42
- const pa = entry.version.split(".").map(Number);
43
- const pb = installedVersion.split(".").map(Number);
44
- const isNewer =
45
- pa[0]! > pb[0]! ||
46
- (pa[0] === pb[0] && pa[1]! > pb[1]!) ||
47
- (pa[0] === pb[0] && pa[1] === pb[1] && pa[2]! > pb[2]!);
43
+ const isNewer = compareVersions(entry.version, installedVersion) > 0;
48
44
  label = isNewer ? theme.fg("warning", "↑ New") : "";
49
45
  }
50
46
 
@@ -93,7 +93,8 @@ export function renderUpdateOverlay(checkResult: UpdateCheckResult, providedNewe
93
93
  state.installing = true;
94
94
  tui.requestRender();
95
95
 
96
- const result = await installUpdate();
96
+ // UNI-272: the tag the offered version came from (alpha → 3.0.0 stable installs `latest`).
97
+ const result = await installUpdate(state.result.installTag ?? state.result.channel ?? "latest");
97
98
  if (result.success) {
98
99
  done({ updated: true });
99
100
  } else {
package/types.ts CHANGED
@@ -20,6 +20,10 @@ export interface LastCheckCache {
20
20
  latestVersion: string;
21
21
  /** Version the user chose to skip */
22
22
  skippedVersion?: string;
23
+ /** npm dist-tag the check followed (absent in pre-UNI-262 caches = "latest") */
24
+ channel?: string;
25
+ /** UNI-272: dist-tag to install the offered version from (≠ channel on graduation). */
26
+ installTag?: string;
23
27
  }
24
28
 
25
29
  /** A parsed changelog version entry */
@@ -54,6 +58,11 @@ export interface UpdateCheckResult {
54
58
  latestVersion: string;
55
59
  /** Currently installed version */
56
60
  currentVersion: string;
61
+ /** npm dist-tag this install follows ("alpha", "latest", …) */
62
+ channel?: string;
63
+ /** UNI-272: the dist-tag the offered version comes from — install from this
64
+ * (an alpha install offered 3.0.0 stable installs `latest`, leaving alpha). */
65
+ installTag?: string;
57
66
  /** Error message if check failed */
58
67
  error?: string;
59
68
  }