pi-ui-extend 2.0.0 → 2.0.1

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
@@ -134,6 +134,11 @@ These packages contain Node.js and the Pix/ACP runtime; no separate Node/npm
134
134
  installation is required to launch them. For TUI, extract the whole archive and
135
135
  run `pix` (macOS/Linux) or `pix.cmd` (Windows) from your terminal. For Desktop,
136
136
  use the `.dmg`, Windows installer, `.AppImage`, or `.deb` for your system.
137
+ Portable TUI installs can later update themselves with `pix update`: the command
138
+ downloads the matching GitHub Release archive, verifies `SHA256SUMS`, smoke-tests
139
+ the staged runtime, and replaces the installation after the updater exits.
140
+ Packaged Desktop checks the signed Tauri update feed on startup and offers an
141
+ Update/Restart flow in the app; development builds do not run that updater.
137
142
  Git, tools needed by your projects, provider credentials and optional voice/
138
143
  clipboard helpers are separate. Unsigned/ad-hoc builds may show OS warnings;
139
144
  check the release notes before installing. See the [release guide](docs/release.md)
@@ -0,0 +1,11 @@
1
+ export type PortableUpdateSwap = {
2
+ parentPid: number;
3
+ installRoot: string;
4
+ stagedRoot: string;
5
+ backupRoot: string;
6
+ helperRoot: string;
7
+ nodeRelativePath: string;
8
+ };
9
+ export declare function waitForProcessExit(pid: number, timeoutMs?: number): Promise<void>;
10
+ export declare function swapPortableInstall(swap: PortableUpdateSwap): Promise<void>;
11
+ export declare function runPortableUpdateHelper(configPath: string): Promise<void>;
@@ -0,0 +1,94 @@
1
+ import { readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ function processExists(pid) {
6
+ try {
7
+ process.kill(pid, 0);
8
+ return true;
9
+ }
10
+ catch (error) {
11
+ return error instanceof Error && "code" in error && error.code === "EPERM";
12
+ }
13
+ }
14
+ export async function waitForProcessExit(pid, timeoutMs = 120_000) {
15
+ const deadline = Date.now() + timeoutMs;
16
+ while (processExists(pid)) {
17
+ if (Date.now() >= deadline)
18
+ throw new Error(`Timed out waiting for Pix process ${pid} to exit`);
19
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
20
+ }
21
+ }
22
+ function validateSwapPaths(swap) {
23
+ if (!Number.isSafeInteger(swap.parentPid) || swap.parentPid <= 0)
24
+ throw new Error("Invalid updater parent PID");
25
+ const installRoot = resolve(swap.installRoot);
26
+ const stagedRoot = resolve(swap.stagedRoot);
27
+ const backupRoot = resolve(swap.backupRoot);
28
+ if (dirname(stagedRoot) !== dirname(installRoot) || dirname(backupRoot) !== dirname(installRoot)) {
29
+ throw new Error("Updater staging and backup directories must be siblings of the installation");
30
+ }
31
+ if (installRoot === stagedRoot || installRoot === backupRoot || stagedRoot === backupRoot) {
32
+ throw new Error("Updater paths must be distinct");
33
+ }
34
+ if (!/^runtime[\\/]node(?:\.exe)?$/u.test(swap.nodeRelativePath))
35
+ throw new Error("Invalid bundled Node path");
36
+ }
37
+ export async function swapPortableInstall(swap) {
38
+ validateSwapPaths(swap);
39
+ await rm(swap.backupRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
40
+ await rename(swap.installRoot, swap.backupRoot);
41
+ try {
42
+ await rename(swap.stagedRoot, swap.installRoot);
43
+ }
44
+ catch (error) {
45
+ try {
46
+ await rename(swap.backupRoot, swap.installRoot);
47
+ }
48
+ catch (rollbackError) {
49
+ throw new AggregateError([error, rollbackError], "Pix update failed and rollback could not restore the previous installation");
50
+ }
51
+ throw error;
52
+ }
53
+ await rm(swap.backupRoot, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
54
+ }
55
+ function scheduleHelperCleanup(swap) {
56
+ const node = join(swap.installRoot, swap.nodeRelativePath);
57
+ const code = [
58
+ "const { rmSync } = require('node:fs');",
59
+ `setTimeout(() => { try { rmSync(${JSON.stringify(swap.helperRoot)}, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); } catch {} }, 250);`,
60
+ ].join("\n");
61
+ try {
62
+ spawn(node, ["-e", code], {
63
+ cwd: dirname(swap.installRoot),
64
+ detached: true,
65
+ stdio: "ignore",
66
+ windowsHide: true,
67
+ }).unref();
68
+ }
69
+ catch {
70
+ // A successful install is more important than cleaning the temporary helper.
71
+ }
72
+ }
73
+ export async function runPortableUpdateHelper(configPath) {
74
+ const swap = JSON.parse(await readFile(configPath, "utf8"));
75
+ validateSwapPaths(swap);
76
+ await waitForProcessExit(swap.parentPid);
77
+ // Give a Windows .cmd launcher that was waiting on the Node child a chance to
78
+ // return before renaming its directory. The helper itself runs outside it.
79
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
80
+ try {
81
+ await swapPortableInstall(swap);
82
+ scheduleHelperCleanup(swap);
83
+ }
84
+ catch (error) {
85
+ await writeFile(join(swap.helperRoot, "update-error.txt"), `${error instanceof Error ? error.stack ?? error.message : String(error)}\n`).catch(() => undefined);
86
+ throw error;
87
+ }
88
+ }
89
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
90
+ const configPath = process.argv[2];
91
+ if (!configPath)
92
+ throw new Error("Missing portable updater helper config path");
93
+ await runPortableUpdateHelper(configPath);
94
+ }
@@ -0,0 +1,14 @@
1
+ import { type GitHubReleaseAsset, type StableGitHubRelease } from "./release-update.js";
2
+ export type PortableUpdateResult = {
3
+ version: string;
4
+ assetName: string;
5
+ };
6
+ export declare function portableTuiAssetName(version: string, target: string): string;
7
+ export declare function parseSha256Sums(text: string, assetName: string): string;
8
+ export declare function portableReleaseAssets(release: StableGitHubRelease, target: string): {
9
+ archive: GitHubReleaseAsset;
10
+ checksums: GitHubReleaseAsset;
11
+ };
12
+ export declare function safeZipEntryPath(root: string, name: string): string;
13
+ export declare function validateStagedPortableTui(root: string, version: string, target: string): Promise<void>;
14
+ export declare function schedulePortableTuiUpdate(packageRoot: string, currentVersion: string, timeoutMs?: number): Promise<PortableUpdateResult>;
@@ -0,0 +1,299 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { createReadStream, createWriteStream } from "node:fs";
4
+ import { chmod, copyFile, cp, lstat, mkdir, mkdtemp, readFile, readlink, realpath, rm, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { pipeline } from "node:stream/promises";
8
+ import { Transform } from "node:stream";
9
+ import { fileURLToPath } from "node:url";
10
+ import { x as extractTar } from "tar";
11
+ import * as yauzl from "yauzl";
12
+ import { fetchLatestStableRelease, PIX_RELEASE_TARGETS, readReleaseInstallInfo, } from "./release-update.js";
13
+ const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024;
14
+ const MAX_CHECKSUM_BYTES = 4 * 1024 * 1024;
15
+ export function portableTuiAssetName(version, target) {
16
+ if (!/^\d+\.\d+\.\d+$/u.test(version))
17
+ throw new Error(`Invalid release version: ${version}`);
18
+ if (!PIX_RELEASE_TARGETS.includes(target))
19
+ throw new Error(`Invalid release target: ${target}`);
20
+ return `pix-tui-${version}-${target}.${target.startsWith("windows-") ? "zip" : "tar.gz"}`;
21
+ }
22
+ export function parseSha256Sums(text, assetName) {
23
+ const matches = text.split(/\r?\n/u).flatMap((line) => {
24
+ const match = /^([a-f0-9]{64})\s+\*?(.+)$/u.exec(line);
25
+ return match?.[2] === assetName ? [match[1]] : [];
26
+ });
27
+ if (matches.length !== 1)
28
+ throw new Error(`Missing or ambiguous SHA256SUMS entry for ${assetName}`);
29
+ return matches[0];
30
+ }
31
+ export function portableReleaseAssets(release, target) {
32
+ const archiveName = portableTuiAssetName(release.version, target);
33
+ const exact = (name) => {
34
+ const matches = release.assets.filter((asset) => asset.name === name);
35
+ if (matches.length !== 1)
36
+ throw new Error(`Release ${release.tag} must contain exactly one ${name}`);
37
+ return matches[0];
38
+ };
39
+ return { archive: exact(archiveName), checksums: exact("SHA256SUMS") };
40
+ }
41
+ async function download(url, destination, limit, expectedSize) {
42
+ if (expectedSize !== undefined && expectedSize > limit)
43
+ throw new Error(`Release asset exceeds ${limit} bytes`);
44
+ const response = await fetch(url, {
45
+ headers: { accept: "application/octet-stream", "User-Agent": "pix-portable-updater" },
46
+ signal: AbortSignal.timeout(5 * 60_000),
47
+ });
48
+ if (!response.ok || !response.body)
49
+ throw new Error(`Release asset download failed (${response.status})`);
50
+ let received = 0;
51
+ const limiter = new Transform({
52
+ transform(chunk, _encoding, callback) {
53
+ received += chunk.length;
54
+ if (received > limit)
55
+ callback(new Error(`Release asset exceeds ${limit} bytes`));
56
+ else
57
+ callback(null, chunk);
58
+ },
59
+ });
60
+ await pipeline(response.body, limiter, createWriteStream(destination, { flags: "wx" }));
61
+ if (expectedSize !== undefined && received !== expectedSize)
62
+ throw new Error(`Release asset size mismatch: expected ${expectedSize}, got ${received}`);
63
+ }
64
+ async function sha256(path) {
65
+ const hash = createHash("sha256");
66
+ for await (const chunk of createReadStream(path))
67
+ hash.update(chunk);
68
+ return hash.digest("hex");
69
+ }
70
+ export function safeZipEntryPath(root, name) {
71
+ if (!name || name.includes("\0") || name.includes("\\") || name.startsWith("/") || /^[A-Za-z]:/u.test(name)) {
72
+ throw new Error(`Unsafe ZIP entry: ${name}`);
73
+ }
74
+ const parts = name.split("/").filter(Boolean);
75
+ if (parts.some((part) => part === "." || part === ".."))
76
+ throw new Error(`Unsafe ZIP entry: ${name}`);
77
+ const path = resolve(root, ...parts);
78
+ const rel = relative(root, path);
79
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
80
+ if (name.endsWith("/") && !rel)
81
+ return root;
82
+ throw new Error(`Unsafe ZIP entry: ${name}`);
83
+ }
84
+ return path;
85
+ }
86
+ function zipMode(entry) {
87
+ return (entry.externalFileAttributes >>> 16) & 0xffff;
88
+ }
89
+ async function extractZip(archive, destination) {
90
+ const zip = await new Promise((resolvePromise, reject) => {
91
+ yauzl.open(archive, { lazyEntries: true, strictFileNames: true, validateEntrySizes: true }, (error, value) => {
92
+ if (error)
93
+ reject(error);
94
+ else
95
+ resolvePromise(value);
96
+ });
97
+ });
98
+ await new Promise((resolvePromise, reject) => {
99
+ let settled = false;
100
+ const finish = (error) => {
101
+ if (settled)
102
+ return;
103
+ settled = true;
104
+ zip.close();
105
+ error ? reject(error) : resolvePromise();
106
+ };
107
+ zip.on("error", finish);
108
+ zip.on("end", () => finish());
109
+ zip.on("entry", (entry) => {
110
+ void (async () => {
111
+ const path = safeZipEntryPath(destination, entry.fileName);
112
+ const mode = zipMode(entry);
113
+ const kind = mode & 0o170000;
114
+ if (kind === 0o120000)
115
+ throw new Error(`ZIP symlinks are not allowed in Pix portable updates: ${entry.fileName}`);
116
+ if (entry.fileName.endsWith("/")) {
117
+ await mkdir(path, { recursive: true });
118
+ zip.readEntry();
119
+ return;
120
+ }
121
+ if (kind !== 0 && kind !== 0o100000)
122
+ throw new Error(`Unsupported ZIP entry type: ${entry.fileName}`);
123
+ await mkdir(dirname(path), { recursive: true });
124
+ const stream = await new Promise((resolveStream, rejectStream) => {
125
+ zip.openReadStream(entry, (error, value) => error ? rejectStream(error) : resolveStream(value));
126
+ });
127
+ await pipeline(stream, createWriteStream(path, { flags: "wx", mode: mode & 0o777 || 0o644 }));
128
+ zip.readEntry();
129
+ })().catch(finish);
130
+ });
131
+ zip.readEntry();
132
+ });
133
+ }
134
+ function inside(root, path) {
135
+ const rel = relative(root, path);
136
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
137
+ }
138
+ async function validatePortableLinks(root, directory = root) {
139
+ const { readdir } = await import("node:fs/promises");
140
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
141
+ const path = join(directory, entry.name);
142
+ const stat = await lstat(path);
143
+ if (stat.isSymbolicLink()) {
144
+ const destination = resolve(dirname(path), await readlink(path));
145
+ if (!inside(root, destination) || !inside(root, await realpath(destination)))
146
+ throw new Error(`Portable update contains an external link: ${relative(root, path)}`);
147
+ }
148
+ else if (stat.isDirectory()) {
149
+ await validatePortableLinks(root, path);
150
+ }
151
+ }
152
+ }
153
+ export async function validateStagedPortableTui(root, version, target) {
154
+ const manifest = JSON.parse(await readFile(join(root, "release.json"), "utf8"));
155
+ if (manifest.format !== 2 || manifest.variant !== "tui" || manifest.version !== version || manifest.target !== target) {
156
+ throw new Error("Downloaded Pix release manifest does not match the requested TUI update");
157
+ }
158
+ const marker = JSON.parse(await readFile(join(root, "app/.pix-portable.json"), "utf8"));
159
+ if (marker.variant !== "tui" || marker.version !== version || marker.target !== target)
160
+ throw new Error("Downloaded Pix portable marker is inconsistent");
161
+ for (const path of ["app/package.json", "app/bin/pix.mjs", "verify.mjs", process.platform === "win32" ? "runtime/node.exe" : "runtime/node"]) {
162
+ const stat = await lstat(join(root, path));
163
+ if (!stat.isFile())
164
+ throw new Error(`Downloaded Pix release is missing ${path}`);
165
+ }
166
+ await validatePortableLinks(root);
167
+ }
168
+ function verificationEnvironment(home, runtime) {
169
+ const allowed = Object.fromEntries(Object.entries(process.env).filter(([key, value]) => value !== undefined
170
+ && /^(SystemRoot|WINDIR|ComSpec|PATHEXT|TEMP|TMP|TMPDIR|LANG|LC_.*|TERM|COLORTERM|DISPLAY|WAYLAND_DISPLAY|DBUS_SESSION_BUS_ADDRESS|XDG_RUNTIME_DIR|SHELL|USER|USERNAME|LOGNAME|ProgramFiles|ProgramW6432|ProgramFiles\(x86\))$/iu.test(key)));
171
+ return {
172
+ ...allowed,
173
+ HOME: home,
174
+ USERPROFILE: home,
175
+ APPDATA: join(home, "AppData/Roaming"),
176
+ LOCALAPPDATA: join(home, "AppData/Local"),
177
+ XDG_CONFIG_HOME: join(home, ".config"),
178
+ XDG_CACHE_HOME: join(home, ".cache"),
179
+ PI_CODING_AGENT_DIR: join(home, ".pi/agent"),
180
+ PI_OFFLINE: "1",
181
+ PIX_RELEASE_SMOKE: "1",
182
+ PIX_SKIP_VERSION_CHECK: "1",
183
+ PIX_ACP_LOG: "error",
184
+ PATH: [runtime, process.env.PATH ?? ""].filter(Boolean).join(delimiter),
185
+ };
186
+ }
187
+ async function verifyStagedPortableTui(root, scratch) {
188
+ const runtime = join(root, "runtime");
189
+ const node = join(runtime, process.platform === "win32" ? "node.exe" : "node");
190
+ const home = join(scratch, "verify-home");
191
+ const cwd = join(scratch, "verify-workspace");
192
+ await mkdir(home, { recursive: true });
193
+ await mkdir(cwd, { recursive: true });
194
+ await new Promise((resolvePromise, reject) => {
195
+ const child = spawn(node, [join(root, "verify.mjs")], {
196
+ cwd,
197
+ env: verificationEnvironment(home, runtime),
198
+ stdio: ["ignore", "pipe", "pipe"],
199
+ windowsHide: true,
200
+ });
201
+ let output = "";
202
+ const timer = setTimeout(() => child.kill(), 120_000);
203
+ child.stdout.setEncoding("utf8").on("data", (chunk) => output = (output + chunk).slice(-32_000));
204
+ child.stderr.setEncoding("utf8").on("data", (chunk) => output = (output + chunk).slice(-32_000));
205
+ child.once("error", reject);
206
+ child.once("close", (code, signal) => {
207
+ clearTimeout(timer);
208
+ if (code === 0 && /PIX_RELEASE_RUNTIME_OK/u.test(output))
209
+ resolvePromise();
210
+ else
211
+ reject(new Error(`Downloaded Pix verification failed (${signal ?? code}): ${output}`));
212
+ });
213
+ });
214
+ }
215
+ async function extractArchive(archive, destination) {
216
+ await mkdir(destination, { recursive: true });
217
+ if (archive.endsWith(".zip"))
218
+ await extractZip(archive, destination);
219
+ else
220
+ await extractTar({ file: archive, cwd: destination, strict: true, preservePaths: false });
221
+ }
222
+ export async function schedulePortableTuiUpdate(packageRoot, currentVersion, timeoutMs = 10_000) {
223
+ const install = readReleaseInstallInfo(packageRoot);
224
+ if (!install)
225
+ throw new Error("Portable release marker is missing or invalid; automatic update is disabled");
226
+ if (install.variant !== "tui")
227
+ throw new Error("This is a Desktop installation; use the native Desktop updater");
228
+ const release = await fetchLatestStableRelease(currentVersion, timeoutMs);
229
+ if (!release)
230
+ throw new Error("No stable GitHub Release is available");
231
+ const { archive: asset, checksums } = portableReleaseAssets(release, install.target);
232
+ const scratch = await mkdtemp(join(tmpdir(), "pix portable update "));
233
+ let helperRoot;
234
+ let stagedSibling;
235
+ try {
236
+ const archive = join(scratch, asset.name);
237
+ const checksumFile = join(scratch, "SHA256SUMS");
238
+ await Promise.all([
239
+ download(asset.url, archive, MAX_ARCHIVE_BYTES, asset.size),
240
+ download(checksums.url, checksumFile, MAX_CHECKSUM_BYTES, checksums.size),
241
+ ]);
242
+ const expected = parseSha256Sums(await readFile(checksumFile, "utf8"), asset.name);
243
+ const actual = await sha256(archive);
244
+ if (actual !== expected)
245
+ throw new Error(`Downloaded Pix checksum mismatch for ${asset.name}`);
246
+ const extraction = join(scratch, "extracted");
247
+ await extractArchive(archive, extraction);
248
+ const staged = join(extraction, "pix");
249
+ await validateStagedPortableTui(staged, release.version, install.target);
250
+ await verifyStagedPortableTui(staged, scratch);
251
+ const realPackageRoot = await realpath(packageRoot);
252
+ const installRoot = dirname(realPackageRoot);
253
+ if (inside(installRoot, resolve(process.cwd()))) {
254
+ throw new Error("Run `pix update` from outside the Pix installation directory so it can be replaced safely after exit");
255
+ }
256
+ const parent = dirname(installRoot);
257
+ const suffix = `${process.pid}-${Date.now()}`;
258
+ stagedSibling = join(parent, `.${basename(installRoot)}.update-${suffix}`);
259
+ const backupRoot = join(parent, `.${basename(installRoot)}.backup-${suffix}`);
260
+ await rm(stagedSibling, { recursive: true, force: true });
261
+ await cp(staged, stagedSibling, { recursive: true, verbatimSymlinks: true });
262
+ await validateStagedPortableTui(stagedSibling, release.version, install.target);
263
+ helperRoot = await mkdtemp(join(tmpdir(), "pix updater helper "));
264
+ const nodeRelativePath = process.platform === "win32" ? "runtime/node.exe" : "runtime/node";
265
+ const helperNode = join(helperRoot, process.platform === "win32" ? "node.exe" : "node");
266
+ const helperScript = join(helperRoot, "portable-update-helper.js");
267
+ const helperConfig = join(helperRoot, "update.json");
268
+ await copyFile(join(installRoot, nodeRelativePath), helperNode);
269
+ if (process.platform !== "win32")
270
+ await chmod(helperNode, 0o755);
271
+ await copyFile(fileURLToPath(new URL("./portable-update-helper.js", import.meta.url)), helperScript);
272
+ const swap = {
273
+ parentPid: process.pid,
274
+ installRoot,
275
+ stagedRoot: stagedSibling,
276
+ backupRoot,
277
+ helperRoot,
278
+ nodeRelativePath,
279
+ };
280
+ await writeFile(helperConfig, `${JSON.stringify(swap)}\n`, { mode: 0o600 });
281
+ const child = spawn(helperNode, [helperScript, helperConfig], {
282
+ cwd: helperRoot,
283
+ detached: true,
284
+ stdio: "ignore",
285
+ windowsHide: true,
286
+ });
287
+ child.unref();
288
+ stagedSibling = undefined;
289
+ helperRoot = undefined;
290
+ return { version: release.version, assetName: asset.name };
291
+ }
292
+ finally {
293
+ await rm(scratch, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
294
+ if (stagedSibling)
295
+ await rm(stagedSibling, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
296
+ if (helperRoot)
297
+ await rm(helperRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
298
+ }
299
+ }
@@ -1,4 +1,24 @@
1
1
  export declare const PIX_RELEASES_URL = "https://github.com/dnaroid/pix/releases/latest";
2
+ export declare const PIX_RELEASES_API_URL = "https://api.github.com/repos/dnaroid/pix/releases/latest";
3
+ export declare const PIX_RELEASE_TARGETS: readonly ["linux-x64", "macos-arm64", "macos-x64", "windows-x64"];
4
+ export type ReleaseInstallVariant = "tui" | "desktop";
5
+ export type ReleaseInstallInfo = {
6
+ version: string;
7
+ target: string;
8
+ variant: ReleaseInstallVariant;
9
+ };
10
+ export type GitHubReleaseAsset = {
11
+ name: string;
12
+ url: string;
13
+ size?: number;
14
+ };
15
+ export type StableGitHubRelease = {
16
+ tag: string;
17
+ version: string;
18
+ assets: GitHubReleaseAsset[];
19
+ };
2
20
  export declare function isReleaseInstall(packageRoot: string): boolean;
21
+ export declare function readReleaseInstallInfo(packageRoot: string): ReleaseInstallInfo | undefined;
3
22
  export declare function fetchLatestReleaseVersion(_packageName: string, currentVersion: string, timeoutMs: number): Promise<string | undefined>;
4
- export declare function releaseUpdateHint(): string;
23
+ export declare function fetchLatestStableRelease(currentVersion: string, timeoutMs: number): Promise<StableGitHubRelease | undefined>;
24
+ export declare function releaseUpdateHint(packageRoot?: string): string;
@@ -1,12 +1,35 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  export const PIX_RELEASES_URL = "https://github.com/dnaroid/pix/releases/latest";
4
+ export const PIX_RELEASES_API_URL = "https://api.github.com/repos/dnaroid/pix/releases/latest";
5
+ export const PIX_RELEASE_TARGETS = ["linux-x64", "macos-arm64", "macos-x64", "windows-x64"];
4
6
  export function isReleaseInstall(packageRoot) {
5
7
  // Presence alone fails closed: a damaged manifest must never enable global npm mutation.
6
8
  return existsSync(join(packageRoot, ".pix-portable.json"));
7
9
  }
10
+ export function readReleaseInstallInfo(packageRoot) {
11
+ const marker = join(packageRoot, ".pix-portable.json");
12
+ if (!existsSync(marker))
13
+ return undefined;
14
+ try {
15
+ const data = JSON.parse(readFileSync(marker, "utf8"));
16
+ if (data.format !== 2 || typeof data.version !== "string" || !/^\d+\.\d+\.\d+$/u.test(data.version))
17
+ return undefined;
18
+ if (typeof data.target !== "string" || !PIX_RELEASE_TARGETS.includes(data.target))
19
+ return undefined;
20
+ if (data.variant !== "tui" && data.variant !== "desktop")
21
+ return undefined;
22
+ return { version: data.version, target: data.target, variant: data.variant };
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
8
28
  export async function fetchLatestReleaseVersion(_packageName, currentVersion, timeoutMs) {
9
- const response = await fetch("https://api.github.com/repos/dnaroid/pix/releases/latest", {
29
+ return (await fetchLatestStableRelease(currentVersion, timeoutMs))?.version;
30
+ }
31
+ export async function fetchLatestStableRelease(currentVersion, timeoutMs) {
32
+ const response = await fetch(PIX_RELEASES_API_URL, {
10
33
  headers: { accept: "application/vnd.github+json", "User-Agent": `pix/${currentVersion}` },
11
34
  signal: AbortSignal.timeout(timeoutMs),
12
35
  });
@@ -17,9 +40,29 @@ export async function fetchLatestReleaseVersion(_packageName, currentVersion, ti
17
40
  const data = await response.json();
18
41
  if (data.draft === true || data.prerelease === true)
19
42
  return undefined;
20
- return typeof data.tag_name === "string" && /^v\d+\.\d+\.\d+$/u.test(data.tag_name)
21
- ? data.tag_name.slice(1) : undefined;
43
+ if (typeof data.tag_name !== "string" || !/^v\d+\.\d+\.\d+$/u.test(data.tag_name))
44
+ return undefined;
45
+ const assets = Array.isArray(data.assets) ? data.assets.flatMap((entry) => {
46
+ if (typeof entry !== "object" || entry === null)
47
+ return [];
48
+ const asset = entry;
49
+ if (typeof asset.name !== "string" || typeof asset.browser_download_url !== "string")
50
+ return [];
51
+ return [{
52
+ name: asset.name,
53
+ url: asset.browser_download_url,
54
+ ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}),
55
+ }];
56
+ }) : [];
57
+ return { tag: data.tag_name, version: data.tag_name.slice(1), assets };
22
58
  }
23
- export function releaseUpdateHint() {
24
- return `Download the complete package for your OS/CPU from ${PIX_RELEASES_URL}. Close Pix before replacing it. Settings and sessions remain in your user profile. This installation does not update global npm/Pi packages.`;
59
+ export function releaseUpdateHint(packageRoot) {
60
+ const install = packageRoot ? readReleaseInstallInfo(packageRoot) : undefined;
61
+ if (install?.variant === "tui") {
62
+ return "Run `pix update` from a terminal to download, verify, and stage the matching portable release. Pix replaces its installation after the updater process exits; settings and sessions remain in your user profile.";
63
+ }
64
+ if (install?.variant === "desktop") {
65
+ return "Use the Pix Desktop update notification to download, verify, install, and restart the native application. The bundled backend does not mutate npm or portable files.";
66
+ }
67
+ return `Download the complete package for your OS/CPU from ${PIX_RELEASES_URL}. The release marker is damaged or too old for safe self-update, so automatic replacement is disabled.`;
25
68
  }
@@ -1,7 +1,9 @@
1
+ import { schedulePortableTuiUpdate } from "./portable-update.js";
1
2
  export { isReleaseInstall } from "./release-update.js";
2
3
  type PixUpdateTestDeps = {
3
4
  checkPixUpdate: typeof checkPixUpdate;
4
5
  runCommand: typeof runCommand;
6
+ schedulePortableTuiUpdate: typeof schedulePortableTuiUpdate;
5
7
  };
6
8
  export declare function setPixUpdateTestDeps(overrides?: Partial<PixUpdateTestDeps>): void;
7
9
  export type PixUpdateCliOptions = {
@@ -3,7 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
6
- import { fetchLatestReleaseVersion, isReleaseInstall, releaseUpdateHint } from "./release-update.js";
6
+ import { fetchLatestReleaseVersion, isReleaseInstall, readReleaseInstallInfo, releaseUpdateHint } from "./release-update.js";
7
+ import { schedulePortableTuiUpdate } from "./portable-update.js";
7
8
  export { isReleaseInstall } from "./release-update.js";
8
9
  const DEFAULT_UPDATE_TIMEOUT_MS = 10_000;
9
10
  const NPM_REGISTRY_URL = "https://registry.npmjs.org";
@@ -11,6 +12,7 @@ const PI_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
11
12
  const defaultPixUpdateDeps = {
12
13
  checkPixUpdate,
13
14
  runCommand,
15
+ schedulePortableTuiUpdate,
14
16
  };
15
17
  let pixUpdateDeps = defaultPixUpdateDeps;
16
18
  export function setPixUpdateTestDeps(overrides) {
@@ -19,15 +21,16 @@ export function setPixUpdateTestDeps(overrides) {
19
21
  export function pixUpdateUsage() {
20
22
  return `Usage: pix update [--check] [--force]
21
23
 
22
- Check for or install the latest published Pix package and synchronize the global Pi CLI.
24
+ Check for or install the latest Pix release for this installation channel.
23
25
 
24
26
  Options:
25
27
  --check Only check Pix and global Pi; do not install anything
26
28
  --force Reinstall Pix and its matching global Pi version
27
29
  -h, --help Show this help
28
30
 
29
- Inside the TUI, /update performs the same non-mutating check.
30
- Portable/Desktop GitHub Releases are replaced as a complete package; --force never changes global packages for those installations.
31
+ Inside the TUI, /update performs the same non-mutating check. Run pix update in a shell to apply a portable TUI update.
32
+ Portable TUI releases are downloaded, checksum-verified, smoke-tested, and atomically replaced after the updater exits.
33
+ Desktop releases update through the native Pix Desktop updater. --force never changes global packages for release installations.
31
34
  The bundled skills payload under skills/ is copied into ~/.agents/skills on startup.
32
35
  The pi-tools-suite payload under external/pi-tools-suite is updated with Pix and linked into ~/.pi/agent/extensions on startup.`;
33
36
  }
@@ -156,9 +159,11 @@ async function checkPackageUpdate(packageInfo, options) {
156
159
  }
157
160
  export function formatPixUpdateCheck(result) {
158
161
  if (isReleaseInstall(result.packageRoot)) {
159
- return ["Pix update (GitHub Release)", `current: ${result.packageName} v${result.currentVersion}`,
162
+ const install = readReleaseInstallInfo(result.packageRoot);
163
+ return [`Pix update (${install?.variant === "desktop" ? "Desktop" : "portable TUI"} GitHub Release)`, `current: ${result.packageName} v${result.currentVersion}`,
160
164
  ...(result.latestVersion ? [`latest: ${result.latestVersion}`] : []),
161
- `status: ${result.status}${result.reason ? ` (${result.reason})` : ""}`, releaseUpdateHint()].join("\n");
165
+ `status: ${result.status === "newer" ? "update available" : result.status === "current" ? "up to date" : result.status}${result.reason ? ` (${result.reason})` : ""}`,
166
+ releaseUpdateHint(result.packageRoot)].join("\n");
162
167
  }
163
168
  const lines = [
164
169
  "Pix update",
@@ -281,8 +286,33 @@ export async function runPixUpdateCli(argv = process.argv.slice(2)) {
281
286
  const check = await pixUpdateDeps.checkPixUpdate();
282
287
  console.log(formatPixUpdateCheck(check));
283
288
  if (isReleaseInstall(check.packageRoot)) {
284
- // Portable/Desktop installs are replaced as a unit. Even --force must not modify global packages.
285
- return options.checkOnly && check.status !== "unknown" && check.status !== "unavailable" ? 0 : 1;
289
+ // Release installs are replaced as a unit. Even --force must never modify global packages.
290
+ if (options.checkOnly)
291
+ return check.status !== "unknown" && check.status !== "unavailable" ? 0 : 1;
292
+ const install = readReleaseInstallInfo(check.packageRoot);
293
+ if (!install) {
294
+ console.error("Pix cannot safely self-update because its portable release marker is invalid. Download a fresh package from GitHub Releases.");
295
+ return 1;
296
+ }
297
+ if (install.variant === "desktop") {
298
+ console.error("This Pix backend belongs to Pix Desktop. Use the native Desktop update notification instead of the CLI updater.");
299
+ return 1;
300
+ }
301
+ if (check.status === "current" && !options.force)
302
+ return 0;
303
+ if (["skipped", "unknown", "unavailable"].includes(check.status) && !options.force)
304
+ return 1;
305
+ try {
306
+ const update = await pixUpdateDeps.schedulePortableTuiUpdate(check.packageRoot, check.currentVersion);
307
+ console.log(`Downloaded and verified Pix ${update.version} (${update.assetName}).`);
308
+ console.log("The portable installation will be replaced atomically after this updater process exits. Start `pix` again after the command returns.");
309
+ return 0;
310
+ }
311
+ catch (error) {
312
+ console.error(`Portable Pix update failed before replacement: ${error instanceof Error ? error.message : String(error)}`);
313
+ console.error("The current installation was left unchanged.");
314
+ return 1;
315
+ }
286
316
  }
287
317
  let globalPiCheck = checkGlobalPiInstall(check.packageRoot);
288
318
  console.log(`\n${formatGlobalPiCheck(globalPiCheck)}`);
package/docs/release.md CHANGED
@@ -54,6 +54,25 @@ Pix/ACP/dependency change. `release:smoke` and `release:smoke:desktop` rerun che
54
54
  against already built artifacts. These harnesses create temporary user profiles;
55
55
  do not invoke `verify.mjs` or the native diagnostic directly with your real HOME.
56
56
 
57
+ Desktop release builds also require the Tauri updater signing private key. CI
58
+ reads it from the `TAURI_SIGNING_PRIVATE_KEY` repository secret. A local release
59
+ can provide that environment variable or `TAURI_SIGNING_PRIVATE_KEY_PATH`; the
60
+ release helper also recognizes the ignored local path
61
+ `.artifacts/release-signing/pix-updater.key`. Never commit the private key, and
62
+ keep a durable secure backup: existing Desktop installations cannot accept future
63
+ updates signed by an unrelated replacement key.
64
+
65
+ For this repository the key pair has already been generated locally and the
66
+ public half is embedded in the release config. Configure Actions with the same
67
+ private key before pushing an updater-enabled release tag:
68
+
69
+ ```bash
70
+ gh secret set TAURI_SIGNING_PRIVATE_KEY < .artifacts/release-signing/pix-updater.key
71
+ ```
72
+
73
+ Do not generate a replacement key merely because the CI secret is missing; that
74
+ would strand clients that already trust the committed public key.
75
+
57
76
  ### Package contents and size checks
58
77
 
59
78
  The TUI archive includes Pix and its Node runtime, **not ACP**. The Desktop
@@ -94,6 +113,9 @@ The draft is intentional: an unsigned or incomplete first build must not silentl
94
113
  become the public latest version. Reruns can replace draft assets, but the release
95
114
  script refuses to overwrite published assets. Publish a new version for fixes.
96
115
  Checksums detect damaged downloads; they do not replace trusted code signatures.
116
+ The final release also contains signed Tauri updater artifacts plus `latest.json`;
117
+ the latter is generated only after all four native jobs have supplied the exact
118
+ expected asset set.
97
119
 
98
120
  ### Optional signing secrets
99
121
 
@@ -121,6 +143,14 @@ Keep keys out of the repository and out of release files. Official references:
121
143
  [Windows signing](https://v2.tauri.app/distribute/sign/windows/), and
122
144
  [Tauri resource bundling](https://v2.tauri.app/develop/resources/).
123
145
 
146
+ Tauri updater signing is independent from those OS identities. Configure
147
+ `TAURI_SIGNING_PRIVATE_KEY` with the private minisign-compatible key whose public
148
+ half is embedded in `tauri.release.conf.json`. The release matrix emits `.sig`
149
+ files for Windows NSIS/Linux AppImage and signed `.app.tar.gz` updater bundles for
150
+ both macOS architectures. `latest.json` maps the four Tauri targets to those
151
+ files. OS code-signing warnings may still occur when Developer ID/Authenticode
152
+ credentials are absent even though the updater signature itself is valid.
153
+
124
154
  ## Local release check
125
155
 
126
156
  ```bash
@@ -221,12 +251,21 @@ Some modules have optional runtime dependencies or host services:
221
251
  ## Update UX
222
252
 
223
253
  Portable TUI and packaged Desktop detect their `.pix-portable.json` marker and
224
- check stable GitHub Releases instead of npm. `/update`, `pix update --check`, and
225
- the Desktop report point to a complete replacement download. `pix update` or
226
- `--force` cannot modify global npm/Pi packages for these installs. Close Pix before
227
- replacing the complete package; sessions/settings remain in the user profile.
228
- Automatic binary replacement/Tauri Updater is not enabled in this first release
229
- pipeline. The following package-manager behavior applies only to npm installs.
254
+ check stable GitHub Releases instead of npm. `/update` and `pix update --check`
255
+ are non-mutating checks. For a portable TUI install, `pix update` downloads the
256
+ exact OS/CPU archive and `SHA256SUMS`, verifies the digest, validates and smoke
257
+ tests the staged runtime, then schedules a sibling-directory swap after the
258
+ updater process exits. The swap keeps a rollback backup until the new tree takes
259
+ the original path. A damaged marker disables automatic replacement rather than
260
+ falling back to npm.
261
+
262
+ Packaged Desktop uses Tauri Updater instead of the backend CLI. Production builds
263
+ check `latest.json` at startup and show an Update banner when a newer signed
264
+ version exists. Clicking Update downloads/verifies/installs it with progress;
265
+ macOS/Linux then offer Restart, while Windows lets the passive installer handle
266
+ the restart path. Development builds do not perform this check. Desktop and TUI
267
+ release updates never mutate global npm/Pi packages. The following package-manager
268
+ behavior applies only to npm installs.
230
269
 
231
270
  - `/update` inside Pix performs a non-mutating Pix update check and reports whether the global Pi package in the same package-manager prefix matches Pix's pinned Pi SDK version.
232
271
  - `pix update --check` performs the same compatibility check without a TTY and never mutates either package.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
5
5
  "private": false,
6
6
  "repository": {
@@ -107,12 +107,15 @@
107
107
  "@xterm/headless": "^6.0.0",
108
108
  "grok-mermaid": "0.2.2",
109
109
  "jsonc-parser": "3.3.1",
110
+ "tar": "^7.5.22",
110
111
  "typebox": "1.1.38",
111
112
  "vscode-jsonrpc": "^8.2.1",
112
- "vscode-languageserver-protocol": "^3.17.5"
113
+ "vscode-languageserver-protocol": "^3.17.5",
114
+ "yauzl": "^3.4.0"
113
115
  },
114
116
  "devDependencies": {
115
117
  "@types/node": "24.12.4",
118
+ "@types/yauzl": "^2.10.3",
116
119
  "playwright": "1.62.1",
117
120
  "tsx": "4.22.1",
118
121
  "typescript": "5.9.3"