pi-ui-extend 1.0.50 → 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
@@ -123,8 +123,31 @@ Later files override earlier ones. Disable modules with `disabledModules`, `PI_T
123
123
 
124
124
  ## Install
125
125
 
126
+ ### Download a ready-to-run release
127
+
128
+ [GitHub Releases](https://github.com/dnaroid/pix/releases) contains published
129
+ portable TUI archives and Pix Desktop installers when a release has completed
130
+ the native build/verification pipeline. Choose **TUI** or **Desktop** and your
131
+ OS/CPU: Windows x64, macOS Apple Silicon, macOS Intel, or Linux x64.
132
+
133
+ These packages contain Node.js and the Pix/ACP runtime; no separate Node/npm
134
+ installation is required to launch them. For TUI, extract the whole archive and
135
+ run `pix` (macOS/Linux) or `pix.cmd` (Windows) from your terminal. For Desktop,
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.
142
+ Git, tools needed by your projects, provider credentials and optional voice/
143
+ clipboard helpers are separate. Unsigned/ad-hoc builds may show OS warnings;
144
+ check the release notes before installing. See the [release guide](docs/release.md)
145
+ for building, signing, checksums and updates. npm installation remains available below.
146
+
126
147
  ### Requirements
127
148
 
149
+ The Node/npm requirements below apply to the npm/source installation channel.
150
+
128
151
  - **Node.js `>=22.19.0 <25`**
129
152
  - macOS, Linux, or Windows terminal with 256-color support
130
153
  - npm or another way to run the published npm package
@@ -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
+ }
@@ -0,0 +1,24 @@
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
+ };
20
+ export declare function isReleaseInstall(packageRoot: string): boolean;
21
+ export declare function readReleaseInstallInfo(packageRoot: string): ReleaseInstallInfo | undefined;
22
+ export declare function fetchLatestReleaseVersion(_packageName: string, currentVersion: string, timeoutMs: number): Promise<string | undefined>;
23
+ export declare function fetchLatestStableRelease(currentVersion: string, timeoutMs: number): Promise<StableGitHubRelease | undefined>;
24
+ export declare function releaseUpdateHint(packageRoot?: string): string;
@@ -0,0 +1,68 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
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"];
6
+ export function isReleaseInstall(packageRoot) {
7
+ // Presence alone fails closed: a damaged manifest must never enable global npm mutation.
8
+ return existsSync(join(packageRoot, ".pix-portable.json"));
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
+ }
28
+ export async function fetchLatestReleaseVersion(_packageName, currentVersion, timeoutMs) {
29
+ return (await fetchLatestStableRelease(currentVersion, timeoutMs))?.version;
30
+ }
31
+ export async function fetchLatestStableRelease(currentVersion, timeoutMs) {
32
+ const response = await fetch(PIX_RELEASES_API_URL, {
33
+ headers: { accept: "application/vnd.github+json", "User-Agent": `pix/${currentVersion}` },
34
+ signal: AbortSignal.timeout(timeoutMs),
35
+ });
36
+ if (response.status === 404)
37
+ return undefined;
38
+ if (!response.ok)
39
+ throw new Error(`GitHub Releases returned ${response.status}`);
40
+ const data = await response.json();
41
+ if (data.draft === true || data.prerelease === true)
42
+ return 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 };
58
+ }
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.`;
68
+ }
@@ -1,6 +1,9 @@
1
+ import { schedulePortableTuiUpdate } from "./portable-update.js";
2
+ export { isReleaseInstall } from "./release-update.js";
1
3
  type PixUpdateTestDeps = {
2
4
  checkPixUpdate: typeof checkPixUpdate;
3
5
  runCommand: typeof runCommand;
6
+ schedulePortableTuiUpdate: typeof schedulePortableTuiUpdate;
4
7
  };
5
8
  export declare function setPixUpdateTestDeps(overrides?: Partial<PixUpdateTestDeps>): void;
6
9
  export type PixUpdateCliOptions = {
@@ -59,4 +62,3 @@ export declare function getPixSelfUpdateCommand(packageName: string, latestVersi
59
62
  export declare function getGlobalPiUpdateCommand(targetVersion: string, pixPackageRoot?: string): PixSelfUpdateCommand | undefined;
60
63
  export declare function runPixUpdateCli(argv?: readonly string[]): Promise<number>;
61
64
  declare function runCommand(command: PixSelfUpdateCommand): Promise<void>;
62
- export {};
@@ -3,12 +3,16 @@ 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, readReleaseInstallInfo, releaseUpdateHint } from "./release-update.js";
7
+ import { schedulePortableTuiUpdate } from "./portable-update.js";
8
+ export { isReleaseInstall } from "./release-update.js";
6
9
  const DEFAULT_UPDATE_TIMEOUT_MS = 10_000;
7
10
  const NPM_REGISTRY_URL = "https://registry.npmjs.org";
8
11
  const PI_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
9
12
  const defaultPixUpdateDeps = {
10
13
  checkPixUpdate,
11
14
  runCommand,
15
+ schedulePortableTuiUpdate,
12
16
  };
13
17
  let pixUpdateDeps = defaultPixUpdateDeps;
14
18
  export function setPixUpdateTestDeps(overrides) {
@@ -17,14 +21,16 @@ export function setPixUpdateTestDeps(overrides) {
17
21
  export function pixUpdateUsage() {
18
22
  return `Usage: pix update [--check] [--force]
19
23
 
20
- 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.
21
25
 
22
26
  Options:
23
27
  --check Only check Pix and global Pi; do not install anything
24
28
  --force Reinstall Pix and its matching global Pi version
25
29
  -h, --help Show this help
26
30
 
27
- Inside the TUI, /update performs the same non-mutating check.
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.
28
34
  The bundled skills payload under skills/ is copied into ~/.agents/skills on startup.
29
35
  The pi-tools-suite payload under external/pi-tools-suite is updated with Pix and linked into ~/.pi/agent/extensions on startup.`;
30
36
  }
@@ -54,7 +60,9 @@ export function getPixPackageVersion(packageRoot) {
54
60
  }
55
61
  export async function checkPixUpdate(options = {}) {
56
62
  const packageInfo = readPixPackageInfo(options.packageRoot);
57
- return await checkPackageUpdate(packageInfo, options);
63
+ return await checkPackageUpdate(packageInfo, isReleaseInstall(packageInfo.packageRoot)
64
+ ? { ...options, fetchLatestVersion: options.fetchLatestVersion ?? fetchLatestReleaseVersion }
65
+ : options);
58
66
  }
59
67
  export async function checkPiUpdate(options = {}) {
60
68
  const packageRoot = options.packageRoot ?? findPiPackageRoot(options.pixPackageRoot);
@@ -133,7 +141,7 @@ async function checkPackageUpdate(packageInfo, options) {
133
141
  try {
134
142
  const latestVersion = await (options.fetchLatestVersion ?? fetchLatestNpmVersion)(packageInfo.name, packageInfo.version, options.timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS);
135
143
  if (!latestVersion) {
136
- return { ...base, status: "unknown", reason: "npm registry did not return a latest version" };
144
+ return { ...base, status: "unknown", reason: "the distribution channel did not return a latest version" };
137
145
  }
138
146
  return {
139
147
  ...base,
@@ -150,6 +158,13 @@ async function checkPackageUpdate(packageInfo, options) {
150
158
  }
151
159
  }
152
160
  export function formatPixUpdateCheck(result) {
161
+ if (isReleaseInstall(result.packageRoot)) {
162
+ const install = readReleaseInstallInfo(result.packageRoot);
163
+ return [`Pix update (${install?.variant === "desktop" ? "Desktop" : "portable TUI"} GitHub Release)`, `current: ${result.packageName} v${result.currentVersion}`,
164
+ ...(result.latestVersion ? [`latest: ${result.latestVersion}`] : []),
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");
167
+ }
153
168
  const lines = [
154
169
  "Pix update",
155
170
  `current: ${result.packageName} v${result.currentVersion}`,
@@ -204,6 +219,8 @@ export function formatGlobalPiCheck(result) {
204
219
  return lines.join("\n");
205
220
  }
206
221
  export function formatPixStartupUpdateDialog(result) {
222
+ if (isReleaseInstall(result.packageRoot))
223
+ return formatPixUpdateCheck(result);
207
224
  const lines = [
208
225
  "A new Pix version is available.",
209
226
  `current: ${result.packageName} v${result.currentVersion}`,
@@ -231,6 +248,8 @@ export function getGlobalPiUpdateCommand(targetVersion, pixPackageRoot = readPix
231
248
  return getGlobalPackageUpdateCommand(PI_PACKAGE_NAME, targetVersion, pixPackageRoot);
232
249
  }
233
250
  function getGlobalPackageUpdateCommand(packageName, version, packageRoot) {
251
+ if (isReleaseInstall(packageRoot))
252
+ return undefined;
234
253
  if (!packageRootLooksPackageManaged(packageRoot))
235
254
  return undefined;
236
255
  const installSpec = version ? `${packageName}@${version}` : packageName;
@@ -266,6 +285,35 @@ export async function runPixUpdateCli(argv = process.argv.slice(2)) {
266
285
  }
267
286
  const check = await pixUpdateDeps.checkPixUpdate();
268
287
  console.log(formatPixUpdateCheck(check));
288
+ if (isReleaseInstall(check.packageRoot)) {
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
+ }
316
+ }
269
317
  let globalPiCheck = checkGlobalPiInstall(check.packageRoot);
270
318
  console.log(`\n${formatGlobalPiCheck(globalPiCheck)}`);
271
319
  if (options.checkOnly)
@@ -9,7 +9,7 @@ import { createId } from "../id.js";
9
9
  import { runProcess } from "../process.js";
10
10
  import { copyTextToClipboard } from "../screen/clipboard.js";
11
11
  import { formatAccountUsageReport, queryAccountUsageReport } from "../model/model-usage-status.js";
12
- import { checkGlobalPiInstall, checkPixUpdate, formatGlobalPiCheck, formatPixUpdateCheck, parsePixUpdateArgs, pixUpdateUsage, } from "../cli/update.js";
12
+ import { checkGlobalPiInstall, isReleaseInstall, checkPixUpdate, formatGlobalPiCheck, formatPixUpdateCheck, parsePixUpdateArgs, pixUpdateUsage, } from "../cli/update.js";
13
13
  import { createStartupInfoMessage } from "../cli/startup-info.js";
14
14
  import { createReloadContextInventory, formatReloadContextInventory } from "./reload-context-inventory.js";
15
15
  import { getCompleteSessionStats } from "../session/session-stats.js";
@@ -259,6 +259,17 @@ export class SessionCommandActions {
259
259
  const result = await checkPixUpdate();
260
260
  if (!isCommandScopeActive(this.host, scope))
261
261
  return;
262
+ if (isReleaseInstall(result.packageRoot)) {
263
+ this.host.addEntry({ id: createId("system"), kind: "system", text: formatPixUpdateCheck(result) });
264
+ this.host.setSessionStatus(runtime.session);
265
+ if (result.status === "newer")
266
+ this.host.toast.info("A new Pix release is available");
267
+ else if (result.status === "current")
268
+ this.host.toast.success("Pix is up to date");
269
+ else
270
+ this.host.toast.warning("Pix update check incomplete");
271
+ return;
272
+ }
262
273
  const globalPiResult = checkGlobalPiInstall(result.packageRoot);
263
274
  const forceHint = options.force ? "\n\n/update is check-only. To force a reinstall, run `pix update --force` in your shell and restart Pix." : "";
264
275
  this.host.addEntry({
@@ -39,7 +39,9 @@ current product non-goals.
39
39
  markdown/diff rendering, or authentication management.
40
40
  - Bundling Node or a platform-specific standalone `pix-acp` executable in this
41
41
  first slice. Development uses Node plus pix-acp's pinned pi npm dependency;
42
- distributable sidecar packaging is a follow-up milestone.
42
+ distributable packaging is now governed by
43
+ [`release-distribution.md`](../specs/release-distribution.md), using bundled Node,
44
+ Pix and ACP resources for release builds while preserving source-mode development.
43
45
  - Reimplementing the pi runtime in Rust or the webview.
44
46
 
45
47
  ## Behavior
package/docs/release.md CHANGED
@@ -2,6 +2,155 @@
2
2
 
3
3
  Use this checklist before publishing `pi-ui-extend` so installs work on macOS, Linux, and Windows and the `pi-tools-suite` extension payload is included.
4
4
 
5
+ ## Downloadable TUI and Desktop releases
6
+
7
+ One stable `vX.Y.Z` tag produces npm publication and a **draft GitHub Release**
8
+ containing portable TUI and Desktop installers. The contract is in
9
+ [`specs/release-distribution.md`](../specs/release-distribution.md).
10
+
11
+ | Target | TUI | Desktop |
12
+ | --- | --- | --- |
13
+ | Windows x64 | `.zip` with `pix.cmd` | NSIS `-setup.exe` and `.msi` |
14
+ | macOS Apple Silicon | `.tar.gz` with `pix` | `.dmg` |
15
+ | macOS Intel | `.tar.gz` with `pix` | `.dmg` |
16
+ | Linux x64 | `.tar.gz` with `pix` | `.AppImage` and `.deb` |
17
+
18
+ Each download contains its own pinned Node.js and dependencies. Users do not need
19
+ Node/npm to launch Pix. Keep the complete TUI directory together and run its
20
+ launcher from a terminal; add that directory to PATH or symlink `pix` on Unix.
21
+ Desktop includes ACP and does not require an npm-installed Pix. Git, shells,
22
+ project runtimes, optional helpers and provider credentials remain separate.
23
+ macOS release packages require 13.5 or newer, matching the bundled Node 24 binary.
24
+ Linux bundles are built on Ubuntu 22.04; do not advertise support for older
25
+ glibc/WebKit environments without running additional compatibility tests.
26
+
27
+ ### Local build
28
+
29
+ Select a supported Node on PATH (`.node-version` is the CI/build pin), install
30
+ Rust and the platform's [Tauri build prerequisites](https://v2.tauri.app/start/prerequisites/), then run:
31
+
32
+ ```bash
33
+ npm ci --ignore-scripts
34
+ npm ci --prefix acp --ignore-scripts
35
+ npm ci --prefix desktop --ignore-scripts
36
+ npm run release:build
37
+ # Or specify this machine's target:
38
+ npm run release:build -- macos-arm64
39
+ # Only the portable TUI archive:
40
+ npm run release:build -- macos-arm64 --tui-only
41
+ ```
42
+
43
+ Use the corresponding native machine/runner for each target. Cross-host packaging
44
+ is rejected because native modules must match both CPU and OS. Output is under
45
+ `.artifacts/releases/<target>/assets/`. Builds test the real unpacked package and
46
+ the relocated/installed GUI, including an app copied from a mounted read-only DMG.
47
+ The release builder sets `CI=true` even locally so DMG creation does not need Finder
48
+ automation permissions. Development commands (`dev:desktop`, `watch:all`,
49
+ `build:desktop`) remain separate and do not make distributable bundles.
50
+
51
+ To iterate on the Desktop wrapper after preparing its runtime, use
52
+ `npm run release:build:desktop -- <target>`; rebuild the full release after any
53
+ Pix/ACP/dependency change. `release:smoke` and `release:smoke:desktop` rerun checks
54
+ against already built artifacts. These harnesses create temporary user profiles;
55
+ do not invoke `verify.mjs` or the native diagnostic directly with your real HOME.
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
+
76
+ ### Package contents and size checks
77
+
78
+ The TUI archive includes Pix and its Node runtime, **not ACP**. The Desktop
79
+ runtime additionally includes ACP. Preparation cleans generated `dist` directories
80
+ before compilation so old emitted files or Vosk models cannot leak into a release;
81
+ it does not delete source files or the user's `models/` directory.
82
+
83
+ The packager removes foreign esbuild/PTY/clipboard variants and debug source maps,
84
+ then shares only ACP packages proven equivalent to the parent installation by
85
+ file content and dependency graph. Different dependency versions/contexts remain
86
+ isolated. Runtime TypeScript, declarations, native binaries for this target,
87
+ WASM and license files are preserved.
88
+
89
+ Inspect `.artifacts/releases/<target>/pix/SIZE.json` for TUI and
90
+ `.artifacts/releases/<target>/desktop/pix/SIZE.json` for the Desktop backend.
91
+ Adjacent `OPTIMIZATIONS.json` reports explain the pruning and sharing decisions.
92
+ Sizes count regular-file bytes, not filesystem block allocation. The native
93
+ Desktop host and signing metadata are additional to its backend payload.
94
+
95
+ Builds fail above these budgets: TUI 384 MiB unpacked / 160 MiB download;
96
+ Desktop backend 512 MiB unpacked / 240 MiB installer. The limits live in
97
+ `scripts/release/size-budget.mjs`; review dependency growth before changing them.
98
+ CI also uploads `size-<target>` report artifacts separately from release assets.
99
+ Smoke tests execute native esbuild, PTY and extension loading after unpacking,
100
+ and additionally exercise ACP and the native application for Desktop.
101
+
102
+ ### CI and publishing
103
+
104
+ The `Publish` workflow uses native Ubuntu 22.04 x64, macOS 15 ARM64, macOS 15 Intel
105
+ and Windows 2022 x64 runners. `Actions → Publish → Run workflow` builds and tests
106
+ all packages without publishing anything. The ten installers/archives are
107
+ available as four Actions artifacts. On a version-tag push, the final job waits
108
+ for the full native matrix and npm publication, validates the complete set,
109
+ adds `SHA256SUMS`, and uploads everything to a draft release. Only then review
110
+ the release notes/signing status and click **Publish release** in GitHub.
111
+
112
+ The draft is intentional: an unsigned or incomplete first build must not silently
113
+ become the public latest version. Reruns can replace draft assets, but the release
114
+ script refuses to overwrite published assets. Publish a new version for fixes.
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.
119
+
120
+ ### Optional signing secrets
121
+
122
+ Without signing secrets, macOS builds use ad-hoc signatures and Windows installers
123
+ are unsigned. Such builds are suitable for testing but may trigger OS warnings.
124
+ Trusted signing/notarization cannot be configured without the maintainer's keys.
125
+
126
+ For macOS, configure repository Actions secrets `APPLE_CERTIFICATE` (base64 P12),
127
+ `APPLE_CERTIFICATE_PASSWORD`, and `APPLE_SIGNING_IDENTITY` (Developer ID Application).
128
+ For Tauri notarization also set `APPLE_ID`, `APPLE_PASSWORD` (app-specific password)
129
+ and `APPLE_TEAM_ID`. The workflow imports the certificate into a temporary keychain,
130
+ signs nested runtime binaries before the app, and cleans the keychain afterwards.
131
+ The Node executable has separate JIT/native-addon entitlements. An ad-hoc signature
132
+ is not Developer ID signing. The TUI tarball is not a notarized installer; test its
133
+ download/quarantine UX separately before promising warning-free installation.
134
+
135
+ For Windows, configure `WINDOWS_CERTIFICATE` (base64 PFX) and
136
+ `WINDOWS_CERTIFICATE_PASSWORD`. The workflow imports it into the runner's user
137
+ certificate store, passes its thumbprint to Tauri and removes it afterwards.
138
+ Hardware-backed/cloud signing requires adapting that signing step to the selected
139
+ provider; the PFX path does not claim to cover every certificate provider.
140
+
141
+ Keep keys out of the repository and out of release files. Official references:
142
+ [macOS signing](https://v2.tauri.app/distribute/sign/macos/),
143
+ [Windows signing](https://v2.tauri.app/distribute/sign/windows/), and
144
+ [Tauri resource bundling](https://v2.tauri.app/develop/resources/).
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
+
5
154
  ## Local release check
6
155
 
7
156
  ```bash
@@ -20,7 +169,7 @@ The normative CI invariants are recorded in [`specs/ci-release.md`](../specs/ci-
20
169
  The GitHub Actions release workflow has two different responsibilities and they should stay separate:
21
170
 
22
171
  - `build-and-test` runs on Ubuntu, macOS, and Windows. This is the cross-platform correctness gate: type checks, unit/integration tests, platform-specific host checks, and the Pix build belong here.
23
- - Package smoke tests validate the artifact a user actually installs. Run the full payload/tarball smoke test on Ubuntu, and a smaller pack/install/CLI sanity check on Windows. A separate macOS package smoke job is unnecessary unless packaging gains macOS-specific behavior.
172
+ - npm package smoke tests run the full payload/tarball test on Ubuntu and a smaller pack/install/CLI check on Windows. Portable/Tauri release artifacts additionally run smoke tests on all four native targets.
24
173
 
25
174
  Keep CI tests deterministic across runner speed and operating systems:
26
175
 
@@ -58,7 +207,12 @@ npm run publish-npm -- 0.2.0 # exact version
58
207
 
59
208
  The command requires a clean working tree on `master`, pulls latest from `origin/master`, runs `release:check`, runs `npm version`, runs the tarball smoke test, then pushes the branch and `v*` tag. The tag workflow verifies that `package.json` matches the tag before `npm publish --access public`.
60
209
 
61
- Only the root `package.json` version is bumped for Pix releases. Do not bump `external/pi-tools-suite/package.json` unless publishing the suite as a separate package.
210
+ The root `package.json` version is authoritative. Its npm `version` hook synchronizes
211
+ ACP, Desktop, Tauri, Cargo and lockfile versions and stages them before npm creates
212
+ the release commit/tag. `npm run release:version:check` detects drift; use
213
+ `npm run release:version` to repair manifests after a manual root-version edit.
214
+ Only stable `X.Y.Z` versions within MSI bounds are supported by this release path.
215
+ Do not bump `external/pi-tools-suite/package.json` unless publishing the suite separately.
62
216
 
63
217
  ## Tarball smoke test
64
218
 
@@ -96,6 +250,23 @@ Some modules have optional runtime dependencies or host services:
96
250
 
97
251
  ## Update UX
98
252
 
253
+ Portable TUI and packaged Desktop detect their `.pix-portable.json` marker and
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.
269
+
99
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.
100
271
  - `pix update --check` performs the same compatibility check without a TTY and never mutates either package.
101
272
  - `pix update` first updates a package-manager installation of Pix when needed, then installs the global `@earendil-works/pi-coding-agent` at the exact version pinned by the resulting Pix package. This keeps the shared `pi-tools-suite` host ABI aligned. The next Pix startup refreshes the user extension link.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.50",
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": {
@@ -59,7 +59,7 @@
59
59
  "rebuild:all": "npm run build:desktop",
60
60
  "check": "npm run check:inner",
61
61
  "precheck:inner": "npm run generate-schemas --silent",
62
- "check:inner": "npm run sync:sdk-pin:check --silent && tsc --noEmit && npm run test:inner",
62
+ "check:inner": "npm run sync:sdk-pin:check --silent && npm run release:version:check --silent && tsc --noEmit && npm run test:inner && npm run test:release",
63
63
  "test": "npm run test:inner",
64
64
  "test:inner": "node --import tsx --test \"tests/**/*.test.ts\"",
65
65
  "test:tools-suite": "npm --prefix external/pi-tools-suite test",
@@ -82,6 +82,15 @@
82
82
  "smoke-test": "bash scripts/smoke-test-package.sh",
83
83
  "smoke-test:cli": "node scripts/smoke-test-package-cli.mjs",
84
84
  "release:check": "npm run check && npm run build:pix && npm run pack:dry-run",
85
+ "release:version": "node scripts/release/sync-version.mjs",
86
+ "release:version:check": "node scripts/release/sync-version.mjs --check",
87
+ "version": "node scripts/release/sync-version.mjs --stage",
88
+ "release:prepare": "node scripts/release/prepare.mjs",
89
+ "release:build": "node scripts/release/bundle.mjs",
90
+ "release:build:desktop": "node scripts/release/build-desktop.mjs",
91
+ "release:smoke": "node scripts/release/smoke.mjs",
92
+ "release:smoke:desktop": "node scripts/release/smoke-desktop.mjs",
93
+ "test:release": "node --test scripts/release/test/*.test.mjs",
85
94
  "publish-npm": "bash scripts/publish.sh",
86
95
  "generate-schemas": "tsx scripts/generate-schemas.ts",
87
96
  "generate-schemas:check": "tsx scripts/generate-schemas.ts --check",
@@ -98,12 +107,15 @@
98
107
  "@xterm/headless": "^6.0.0",
99
108
  "grok-mermaid": "0.2.2",
100
109
  "jsonc-parser": "3.3.1",
110
+ "tar": "^7.5.22",
101
111
  "typebox": "1.1.38",
102
112
  "vscode-jsonrpc": "^8.2.1",
103
- "vscode-languageserver-protocol": "^3.17.5"
113
+ "vscode-languageserver-protocol": "^3.17.5",
114
+ "yauzl": "^3.4.0"
104
115
  },
105
116
  "devDependencies": {
106
117
  "@types/node": "24.12.4",
118
+ "@types/yauzl": "^2.10.3",
107
119
  "playwright": "1.62.1",
108
120
  "tsx": "4.22.1",
109
121
  "typescript": "5.9.3"