@pi-unipi/updater 3.0.0-alpha.36 → 3.0.0-alpha.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/updater",
3
- "version": "3.0.0-alpha.36",
3
+ "version": "3.0.0-alpha.38",
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.36"
37
+ "@pi-unipi/core": "3.0.0-alpha.38"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@earendil-works/pi-coding-agent": "^0.87.1",
package/src/channel.ts ADDED
@@ -0,0 +1,87 @@
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
+ /** The `pi install` source for this channel. */
85
+ export function installSpec(channel: string): string {
86
+ return channel === "latest" ? `npm:${UNIPI_PACKAGE}` : `npm:${UNIPI_PACKAGE}@${channel}`;
87
+ }
package/src/checker.ts CHANGED
@@ -1,90 +1,115 @@
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, 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): UpdateCheckResult {
27
38
  return {
28
39
  updateAvailable: isNewerVersion(latestVersion, currentVersion),
29
40
  latestVersion,
30
41
  currentVersion,
42
+ channel,
31
43
  };
32
44
  }
33
45
 
46
+ export interface CheckOptions {
47
+ fetchImpl?: typeof fetch;
48
+ currentVersion?: string;
49
+ channel?: string;
50
+ /** Ignore the interval cache. */
51
+ force?: boolean;
52
+ }
53
+
34
54
  /**
35
- * Check for updates from npm registry.
55
+ * Check for updates on this install's channel.
36
56
  * Respects check interval — skips if last check was recent.
37
- * Returns update status and version info.
38
57
  */
39
- export async function checkForUpdates(): Promise<UpdateCheckResult> {
40
- const currentVersion = getInstalledVersion();
58
+ export async function checkForUpdates(opts: CheckOptions = {}): Promise<UpdateCheckResult> {
59
+ const currentVersion = opts.currentVersion ?? getInstalledVersion();
60
+ const channel = opts.channel ?? currentChannel(currentVersion);
41
61
 
42
62
  try {
43
63
  const config = loadConfig();
44
64
 
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.
65
+ // A cache is only reused for the same channel (an old cache written by
66
+ // the pre-UNI-262 checker holds `latest`). If the cached version is older
67
+ // than the installed one, refresh: right after a release the cache has
68
+ // not seen the new dist-tag yet.
49
69
  const cache = readLastCheck();
50
- if (cache && !isCheckDue(config.checkIntervalMs)) {
70
+ if (!opts.force && cache && (cache.channel ?? "latest") === channel && !isCheckDue(config.checkIntervalMs)) {
51
71
  if (compareVersions(cache.latestVersion, currentVersion) >= 0) {
52
- return toUpdateResult(cache.latestVersion, currentVersion);
72
+ return toUpdateResult(cache.latestVersion, currentVersion, channel);
53
73
  }
54
74
  }
55
75
 
56
- // Fetch from npm registry
57
- const response = await fetch(NPM_REGISTRY_URL, {
76
+ const fetchImpl = opts.fetchImpl ?? fetch;
77
+ const response = await fetchImpl(registryUrl(), {
58
78
  headers: { Accept: "application/json" },
59
- signal: AbortSignal.timeout(10000), // 10s timeout
79
+ signal: AbortSignal.timeout(10000),
60
80
  });
61
81
 
62
82
  if (!response.ok) {
63
83
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
64
84
  }
65
85
 
66
- const data = await response.json() as { "dist-tags": { latest: string } };
67
- const latestVersion = data["dist-tags"]?.latest;
86
+ const body = await response.json() as Record<string, unknown>;
87
+ // The dist-tags endpoint returns the map itself; the full packument nests it.
88
+ const tags = (body["dist-tags"] ?? body) as Record<string, string>;
89
+ const latestVersion = pickChannelVersion(tags, channel);
68
90
 
69
91
  if (!latestVersion) {
70
- throw new Error("No dist-tags.latest in npm response");
92
+ throw new Error(`No dist-tag "${channel}" in npm response`);
71
93
  }
72
94
 
73
- // Write cache
74
95
  writeLastCheck({
75
96
  lastCheck: new Date().toISOString(),
76
97
  latestVersion,
98
+ channel,
99
+ skippedVersion: cache?.skippedVersion,
77
100
  });
78
101
 
79
- return toUpdateResult(latestVersion, currentVersion);
102
+ return toUpdateResult(latestVersion, currentVersion, channel);
80
103
  } catch (err: unknown) {
81
- // Network error — return cached info if available, but never suggest a
82
- // downgrade from a stale cache.
104
+ // Network error — reuse cached info for this channel only, and never
105
+ // suggest a downgrade from a stale cache.
83
106
  const cache = readLastCheck();
107
+ const usable = cache && (cache.channel ?? "latest") === channel ? cache : null;
84
108
  return {
85
- updateAvailable: cache ? isNewerVersion(cache.latestVersion, currentVersion) : false,
86
- latestVersion: cache?.latestVersion ?? "",
109
+ updateAvailable: usable ? isNewerVersion(usable.latestVersion, currentVersion) : false,
110
+ latestVersion: usable?.latestVersion ?? "",
87
111
  currentVersion,
112
+ channel,
88
113
  error: err instanceof Error ? err.message : String(err) || "Unknown error",
89
114
  };
90
115
  }
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@
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 {
13
14
  bus,
@@ -25,7 +26,7 @@ import { renderUpdateOverlay } from "./tui/update-overlay.js";
25
26
  import { loadUpdateChangelog } from "./remote-changelog.js";
26
27
 
27
28
  /** Package version */
28
- const VERSION = getPackageVersion(new URL("..", import.meta.url).pathname);
29
+ const VERSION = getPackageVersion(fileURLToPath(new URL("..", import.meta.url)));
29
30
 
30
31
  export default function updaterExtension(pi: ExtensionAPI): void {
31
32
 
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
  }
@@ -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,7 @@ export function renderUpdateOverlay(checkResult: UpdateCheckResult, providedNewe
93
93
  state.installing = true;
94
94
  tui.requestRender();
95
95
 
96
- const result = await installUpdate();
96
+ const result = await installUpdate(state.result.channel ?? "latest");
97
97
  if (result.success) {
98
98
  done({ updated: true });
99
99
  } else {
package/types.ts CHANGED
@@ -20,6 +20,8 @@ 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;
23
25
  }
24
26
 
25
27
  /** A parsed changelog version entry */
@@ -54,6 +56,8 @@ export interface UpdateCheckResult {
54
56
  latestVersion: string;
55
57
  /** Currently installed version */
56
58
  currentVersion: string;
59
+ /** npm dist-tag this install follows ("alpha", "latest", …) */
60
+ channel?: string;
57
61
  /** Error message if check failed */
58
62
  error?: string;
59
63
  }