@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 +53 -55
- package/package.json +2 -2
- package/src/channel.ts +100 -0
- package/src/checker.ts +60 -31
- package/src/index.ts +31 -31
- package/src/installer.ts +10 -5
- package/src/remote-changelog.ts +19 -6
- package/src/settings.ts +8 -2
- package/src/tui/changelog-overlay.ts +4 -8
- package/src/tui/update-overlay.ts +2 -1
- package/types.ts +9 -0
package/README.md
CHANGED
|
@@ -1,77 +1,75 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Updater
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Updater tells you when a new UniPi release is on npm and installs it with one key.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`@pi-unipi/updater` · part of [UniPi](../../README.md)
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## What it does
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
+
## Quick start
|
|
16
17
|
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
+
## Commands
|
|
29
26
|
|
|
30
|
-
|
|
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
|
-
##
|
|
32
|
+
## Keys
|
|
33
33
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
+
README and changelog browsers:
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|--------|--------|---------|
|
|
54
|
-
| `checkIntervalMs` | 1800000 (30min), 3600000 (1h), 21600000 (6h), 86400000 (1d) | 3600000 (1h) |
|
|
55
|
-
| `autoUpdate` | disabled, notify, auto | notify |
|
|
52
|
+
## Settings
|
|
56
53
|
|
|
57
|
-
|
|
54
|
+
Open `/unipi:settings` → Updater. The namespace is `updater`.
|
|
58
55
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
61
|
+
## How it works
|
|
64
62
|
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
71
|
+
## See also
|
|
76
72
|
|
|
77
|
-
|
|
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.
|
|
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.
|
|
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
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
15
|
-
|
|
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
|
-
//
|
|
20
|
-
const dir =
|
|
21
|
-
return getInstalledPackageVersion(dir,
|
|
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
|
|
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
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// refresh:
|
|
48
|
-
//
|
|
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
|
-
|
|
57
|
-
const response = await
|
|
77
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
78
|
+
const response = await fetchImpl(registryUrl(), {
|
|
58
79
|
headers: { Accept: "application/json" },
|
|
59
|
-
signal: AbortSignal.timeout(10000),
|
|
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
|
|
67
|
-
|
|
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(
|
|
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 —
|
|
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:
|
|
86
|
-
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)
|
|
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
|
-
|
|
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: "
|
|
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 ? "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
32
|
+
`pi install ${installSpec(channel)}`,
|
|
28
33
|
{
|
|
29
34
|
timeout: INSTALL_TIMEOUT_MS,
|
|
30
35
|
env: { ...process.env },
|
package/src/remote-changelog.ts
CHANGED
|
@@ -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
|
|
44
|
+
// The branch fallbacks (tag not pushed yet) are not: never cache them.
|
|
35
45
|
const cacheable = response.ok;
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
}
|