@firstpick/pi-package-webui 0.3.5 → 0.3.7

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
@@ -125,11 +125,12 @@ Environment variables:
125
125
  - Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, and abort controls.
126
126
  - Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references with live suggestions.
127
127
  - Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, `/scoped-models`, `/tools`, and `/skills`.
128
- - Model, thinking, session, workspace, theme, optional-feature, Codex usage, network, event, and notification controls in the side panel.
128
+ - Model, thinking, session, workspace, theme, optional-feature, Codex usage, network, update/restart, event, and notification controls in the side panel.
129
129
  - Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded.
130
130
  - Per-tab cwd changes, a clickable footer cwd picker, saved path fast picks, server-persisted fast picks, and restart-safe restoration of open tabs.
131
131
  - Detected app runner dropdown for the active tab cwd, including Cargo, Bun, npm/npx/pnpm, Python/uv, Go/Golang, Zig, C/C++, Docker Compose, root/dev/scripts shell scripts, and other common project runners with live output pinned at the top of the terminal. Projects can add browseable custom runners in `.pi-webui-runners.json` with a command (default `./`) plus a relative path to the file to run.
132
132
  - Browser support for Pi extension UI prompts, widgets, status updates, browser notifications when a tab needs an extension UI response and an optional side-panel toggle for agent-done notifications.
133
+ - Localhost-only Pi update checks with a top-right update notification and a confirmed **Update & restart** action that runs `pi update`, then restarts the Web UI server.
133
134
  - Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, which can ask Pi to create or update a LEARNING.
134
135
  - Mobile-friendly layout and PWA install support where the browser allows it.
135
136
 
@@ -138,6 +139,7 @@ Useful browser endpoints exposed by the local server include:
138
139
  - `GET /api/path-suggestions?tab=<tabId>&query=<path>` for `@` file/path references with live suggestions.
139
140
  - `POST /api/action-feedback?tab=<tabId>` for feedback on final assistant output and action cards.
140
141
  - `POST /api/optional-feature-install` for installing known optional companion packages from the side panel.
142
+ - `GET /api/update-status` and localhost-only `POST /api/update` for checking Pi/Web UI updates and running `pi update` followed by a Web UI server restart.
141
143
 
142
144
  For local development, run the checkout helper directly, for example:
143
145
 
@@ -191,6 +193,7 @@ This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; b
191
193
  - The side-panel **Open to network** button rebinds the server to `0.0.0.0`, shows LAN URLs when available, and toggles to "Close for network".
192
194
  - `--host 0.0.0.0` also exposes the Web UI to the local network.
193
195
  - Any connected browser client can control Pi and run Web UI bash actions as the Web UI process user.
196
+ - The Web UI update endpoint is restricted to localhost, because it runs `pi update` and restarts the server.
194
197
  - Treat Pi Web UI as a local companion, not a hardened multi-user web service.
195
198
 
196
199
  ## Troubleshooting
package/bin/pi-webui.mjs CHANGED
@@ -19,6 +19,13 @@ const webuiHelperExtensionPath = path.join(packageRoot, "webui-rpc-helper.mjs");
19
19
  const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(homedir(), ".pi", "agent");
20
20
  const OPTIONAL_FEATURE_INSTALL_ROOT_ENV = "PI_WEBUI_OPTIONAL_FEATURE_INSTALL_ROOT";
21
21
  const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
22
+ let piPackageJson = {};
23
+ try {
24
+ const piPackageJsonPath = require.resolve("@earendil-works/pi-coding-agent/package.json", { paths: [packageRoot] });
25
+ piPackageJson = JSON.parse(await readFile(piPackageJsonPath, "utf8"));
26
+ } catch {
27
+ piPackageJson = {};
28
+ }
22
29
  const nativeParityMatrix = JSON.parse(await readFile(path.join(packageRoot, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
23
30
  const webuiDevServer = isTruthyEnv(process.env.PI_WEBUI_DEV) || isSourceCheckout(packageRoot);
24
31
 
@@ -28,6 +35,14 @@ const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
28
35
  const WEBUI_HELPER_TIMEOUT_MS = 8 * 1000;
29
36
  const WEBUI_HELPER_COMMAND = "webui-helper";
30
37
  const WEBUI_HELPER_RESPONSE_PREFIX = "__PI_WEBUI_HELPER_RESPONSE__:";
38
+ const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
39
+ const WEBUI_PACKAGE = packageJson.name || "@firstpick/pi-package-webui";
40
+ const PI_LATEST_VERSION_URL = process.env.PI_WEBUI_PI_LATEST_VERSION_URL || "https://pi.dev/api/latest-version";
41
+ const NPM_REGISTRY_URL = (process.env.PI_WEBUI_NPM_REGISTRY_URL || "https://registry.npmjs.org").replace(/\/+$/, "");
42
+ const UPDATE_STATUS_CACHE_MS = 10 * 60 * 1000;
43
+ const UPDATE_STATUS_TIMEOUT_MS = 10 * 1000;
44
+ const PI_UPDATE_TIMEOUT_MS = 15 * 60 * 1000;
45
+ const PI_UPDATE_OUTPUT_MAX_CHARS = 120_000;
31
46
  const CODEX_USAGE_TIMEOUT_MS = 15 * 1000;
32
47
  const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
33
48
  const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
@@ -363,6 +378,51 @@ function delay(ms) {
363
378
  return new Promise((resolve) => setTimeout(resolve, ms));
364
379
  }
365
380
 
381
+ function truncateLongText(value, maxLength = 8000) {
382
+ const text = String(value || "");
383
+ if (text.length <= maxLength) return text;
384
+ return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
385
+ }
386
+
387
+ function parsePackageVersion(version) {
388
+ const match = String(version || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/);
389
+ if (!match) return undefined;
390
+ return {
391
+ major: Number.parseInt(match[1], 10),
392
+ minor: Number.parseInt(match[2], 10),
393
+ patch: Number.parseInt(match[3], 10),
394
+ prerelease: match[4],
395
+ };
396
+ }
397
+
398
+ function comparePackageVersions(leftVersion, rightVersion) {
399
+ const left = parsePackageVersion(leftVersion);
400
+ const right = parsePackageVersion(rightVersion);
401
+ if (!left || !right) return undefined;
402
+ if (left.major !== right.major) return left.major - right.major;
403
+ if (left.minor !== right.minor) return left.minor - right.minor;
404
+ if (left.patch !== right.patch) return left.patch - right.patch;
405
+ if (left.prerelease === right.prerelease) return 0;
406
+ if (!left.prerelease) return 1;
407
+ if (!right.prerelease) return -1;
408
+ return left.prerelease.localeCompare(right.prerelease);
409
+ }
410
+
411
+ function isNewerPackageVersion(candidateVersion, currentVersion) {
412
+ const comparison = comparePackageVersions(candidateVersion, currentVersion);
413
+ if (comparison !== undefined) return comparison > 0;
414
+ return String(candidateVersion || "").trim() !== String(currentVersion || "").trim();
415
+ }
416
+
417
+ async function fetchJsonWithTimeout(url, { timeoutMs = UPDATE_STATUS_TIMEOUT_MS, headers = {} } = {}) {
418
+ const response = await fetch(url, {
419
+ headers,
420
+ signal: AbortSignal.timeout(timeoutMs),
421
+ });
422
+ if (!response.ok) throw new Error(`${response.status}${response.statusText ? ` ${response.statusText}` : ""}`);
423
+ return response.json();
424
+ }
425
+
366
426
  class PiRpcProcess {
367
427
  constructor({ command, args, displayCommand, cwd }) {
368
428
  this.command = command;
@@ -2694,6 +2754,8 @@ async function getWorkspaceInfo(cwd, startedAt) {
2694
2754
  }
2695
2755
 
2696
2756
  let activeGitWorkflowProcess = null;
2757
+ const GIT_CHANGES_COMMAND_TIMEOUT_MS = 5000;
2758
+ const GIT_CHANGES_DIFF_MAX_OUTPUT = 500_000;
2697
2759
 
2698
2760
  async function getGitRoot(cwd) {
2699
2761
  const result = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 });
@@ -2703,6 +2765,120 @@ async function getGitRoot(cwd) {
2703
2765
  return path.resolve(result.stdout.trim());
2704
2766
  }
2705
2767
 
2768
+ async function runGitReadCommand(root, args, { timeoutMs = GIT_CHANGES_COMMAND_TIMEOUT_MS, maxOutputLength = GIT_CHANGES_DIFF_MAX_OUTPUT } = {}) {
2769
+ const result = await runCommand("git", args, { cwd: root, timeoutMs, maxOutputLength });
2770
+ if (result.exitCode === 0 && !result.timedOut && !result.error) return result.stdout;
2771
+ const command = formatGitCommand(args);
2772
+ const message = result.timedOut
2773
+ ? `${command} timed out`
2774
+ : (result.stderr || result.stdout || result.error || `${command} failed with exit code ${result.exitCode ?? "unknown"}`);
2775
+ throw new Error(String(message).trim());
2776
+ }
2777
+
2778
+ function gitBranchFromStatus(statusText) {
2779
+ const branchLine = String(statusText || "").split(/\r?\n/).find((line) => line.startsWith("## ")) || "";
2780
+ return branchLine.slice(3).trim().replace(/\.\.\..*$/, "") || "detached";
2781
+ }
2782
+
2783
+ function summarizeGitShortStatus(statusText) {
2784
+ const summary = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0 };
2785
+ for (const line of String(statusText || "").split(/\r?\n/)) {
2786
+ if (!line || line.startsWith("## ")) continue;
2787
+ const x = line[0] || " ";
2788
+ const y = line[1] || " ";
2789
+ if (x === "?" && y === "?") {
2790
+ summary.untracked += 1;
2791
+ continue;
2792
+ }
2793
+ if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) {
2794
+ summary.conflicted += 1;
2795
+ continue;
2796
+ }
2797
+ if (x && x !== " ") summary.staged += 1;
2798
+ if (y && y !== " ") summary.unstaged += 1;
2799
+ }
2800
+ return summary;
2801
+ }
2802
+
2803
+ function resolveGitRelativePath(root, relativePath) {
2804
+ const normalized = String(relativePath || "").trim();
2805
+ if (!normalized || normalized.includes("\0")) throw new Error("Invalid git path");
2806
+ const resolved = path.resolve(root, normalized);
2807
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) throw new Error(`Git path escapes repository: ${normalized}`);
2808
+ return resolved;
2809
+ }
2810
+
2811
+ function isLikelyBinaryBuffer(buffer) {
2812
+ return Buffer.isBuffer(buffer) && buffer.includes(0);
2813
+ }
2814
+
2815
+ function normalizeGitRelativePath(root, relativePath) {
2816
+ const resolved = resolveGitRelativePath(root, relativePath);
2817
+ return path.relative(root, resolved).split(path.sep).join("/");
2818
+ }
2819
+
2820
+ async function readGitUntrackedEntry(root, file) {
2821
+ const normalized = normalizeGitRelativePath(root, file);
2822
+ const filePath = resolveGitRelativePath(root, normalized);
2823
+ const info = await stat(filePath);
2824
+ if (!info.isFile()) return { path: normalized, size: info.size, binary: false, content: "", error: "Not a regular file" };
2825
+ const buffer = await readFile(filePath);
2826
+ const binary = isLikelyBinaryBuffer(buffer);
2827
+ return {
2828
+ path: normalized,
2829
+ size: info.size,
2830
+ binary,
2831
+ content: binary ? "" : buffer.toString("utf8"),
2832
+ };
2833
+ }
2834
+
2835
+ async function readGitUntrackedEntries(root, files) {
2836
+ const entries = [];
2837
+ for (const file of files) {
2838
+ try {
2839
+ entries.push(await readGitUntrackedEntry(root, file));
2840
+ } catch (error) {
2841
+ entries.push({ path: file, size: 0, binary: false, content: "", error: sanitizeError(error) });
2842
+ }
2843
+ }
2844
+ return entries;
2845
+ }
2846
+
2847
+ async function readGitUntrackedFile(cwd, requestedPath) {
2848
+ const root = await getGitRoot(cwd);
2849
+ const normalized = normalizeGitRelativePath(root, requestedPath);
2850
+ const listed = await runGitReadCommand(root, ["ls-files", "--others", "--exclude-standard", "--", normalized], { maxOutputLength: 120_000 });
2851
+ const files = listed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
2852
+ if (!files.includes(normalized)) throw new Error(`Not an untracked file: ${normalized}`);
2853
+ return readGitUntrackedEntry(root, normalized);
2854
+ }
2855
+
2856
+ async function readGitChanges(cwd) {
2857
+ const root = await getGitRoot(cwd);
2858
+ const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"];
2859
+ const [statusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
2860
+ runGitReadCommand(root, ["status", "--short", "--branch", "--untracked-files=all"], { maxOutputLength: 120_000 }),
2861
+ runGitReadCommand(root, diffArgs),
2862
+ runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"]),
2863
+ runGitReadCommand(root, ["ls-files", "--others", "--exclude-standard"], { maxOutputLength: 120_000 }),
2864
+ ]);
2865
+ const untrackedFiles = untrackedText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
2866
+ const untracked = await readGitUntrackedEntries(root, untrackedFiles);
2867
+ return {
2868
+ cwd,
2869
+ root,
2870
+ branch: gitBranchFromStatus(statusText),
2871
+ generatedAt: new Date().toISOString(),
2872
+ summary: summarizeGitShortStatus(statusText),
2873
+ status: statusText.trimEnd(),
2874
+ sections: [
2875
+ { key: "staged", label: "Staged", command: "git diff --cached", diff: stagedDiff.trimEnd() },
2876
+ { key: "unstaged", label: "Unstaged", command: "git diff", diff: unstagedDiff.trimEnd() },
2877
+ ],
2878
+ untracked,
2879
+ };
2880
+ }
2881
+
2706
2882
  function commitMessagePaths(root) {
2707
2883
  return {
2708
2884
  shortPath: path.join(root, "dev", "COMMIT", "staged-commit-short.txt"),
@@ -2769,6 +2945,68 @@ async function currentGitBranch(root) {
2769
2945
  return branch;
2770
2946
  }
2771
2947
 
2948
+ async function currentGitBranchForPicker(root) {
2949
+ try {
2950
+ return (await runGitReadCommand(root, ["branch", "--show-current"], { timeoutMs: 5000, maxOutputLength: 10_000 })).trim();
2951
+ } catch {
2952
+ return "";
2953
+ }
2954
+ }
2955
+
2956
+ function normalizeGitBranchList(branchText, current = "") {
2957
+ const seen = new Set();
2958
+ const branches = [];
2959
+ for (const line of String(branchText || "").split(/\r?\n/)) {
2960
+ const name = line.trim();
2961
+ if (!name || seen.has(name)) continue;
2962
+ seen.add(name);
2963
+ branches.push({ name, current: !!current && name === current });
2964
+ }
2965
+ return branches.sort((left, right) => {
2966
+ if (left.current !== right.current) return left.current ? -1 : 1;
2967
+ return left.name.localeCompare(right.name);
2968
+ });
2969
+ }
2970
+
2971
+ async function readGitBranches(cwd) {
2972
+ const root = await getGitRoot(cwd);
2973
+ const [current, branchText] = await Promise.all([
2974
+ currentGitBranchForPicker(root),
2975
+ runGitReadCommand(root, ["branch", "--format=%(refname:short)"], { timeoutMs: 5000, maxOutputLength: 120_000 }),
2976
+ ]);
2977
+ return {
2978
+ cwd,
2979
+ root,
2980
+ current,
2981
+ generatedAt: new Date().toISOString(),
2982
+ branches: normalizeGitBranchList(branchText, current),
2983
+ };
2984
+ }
2985
+
2986
+ async function switchGitBranch(cwd, branch, { create = false } = {}) {
2987
+ const root = await getGitRoot(cwd);
2988
+ const targetBranch = cleanGitBranchName(branch);
2989
+ await validateGitBranchName(root, targetBranch);
2990
+ const branches = await readGitBranches(cwd);
2991
+ const branchExists = branches.branches.some((item) => item.name === targetBranch);
2992
+ if (create && branchExists) throw new Error(`Local git branch already exists: ${targetBranch}`);
2993
+ if (!create && !branchExists) throw new Error(`Unknown local git branch: ${targetBranch}`);
2994
+ if (!create && branches.current === targetBranch) {
2995
+ return { ok: true, data: { command: `git switch ${targetBranch}`, stdout: "", stderr: "", exitCode: 0, branch: targetBranch, root, switched: false, created: false } };
2996
+ }
2997
+ const args = create ? ["switch", "-c", targetBranch] : ["switch", targetBranch];
2998
+ const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(args, { cwd: root, timeoutMs: 10 * 60 * 1000 }));
2999
+ if (payload.ok) {
3000
+ payload.data.branch = targetBranch;
3001
+ payload.data.root = root;
3002
+ payload.data.switched = true;
3003
+ payload.data.created = create;
3004
+ } else {
3005
+ payload.error = (payload.data?.stderr || payload.data?.stdout || payload.error || `Failed to ${create ? "create and switch to" : "switch to"} ${targetBranch}`).trim();
3006
+ }
3007
+ return payload;
3008
+ }
3009
+
2772
3010
  async function defaultGitRemote(root) {
2773
3011
  const result = await runGitWorkflowCommand(["remote"], { cwd: root, timeoutMs: 5000 });
2774
3012
  if (result.exitCode !== 0) throw new Error((result.stderr || result.stdout || "Cannot list git remotes").trim());
@@ -3912,6 +4150,173 @@ function spawnRestartServer(restorableTabs) {
3912
4150
  return child;
3913
4151
  }
3914
4152
 
4153
+ let updateStatusCache = null;
4154
+ let updateStatusCacheAt = 0;
4155
+ let piUpdateInProgress = false;
4156
+
4157
+ function updateChecksSkippedReason() {
4158
+ if (process.env.PI_OFFLINE) return "PI_OFFLINE is set";
4159
+ if (process.env.PI_SKIP_VERSION_CHECK) return "PI_SKIP_VERSION_CHECK is set";
4160
+ return "";
4161
+ }
4162
+
4163
+ function basePackageUpdateStatus(packageName, currentVersion) {
4164
+ return {
4165
+ packageName,
4166
+ currentVersion: String(currentVersion || ""),
4167
+ latestVersion: null,
4168
+ updateAvailable: false,
4169
+ checked: false,
4170
+ skipped: false,
4171
+ skippedReason: "",
4172
+ error: "",
4173
+ };
4174
+ }
4175
+
4176
+ async function checkLatestPiReleaseStatus() {
4177
+ const status = basePackageUpdateStatus(PI_CODING_AGENT_PACKAGE, piPackageJson.version);
4178
+ const skippedReason = updateChecksSkippedReason();
4179
+ if (skippedReason) {
4180
+ status.skipped = true;
4181
+ status.skippedReason = skippedReason;
4182
+ return status;
4183
+ }
4184
+ try {
4185
+ const data = await fetchJsonWithTimeout(PI_LATEST_VERSION_URL, {
4186
+ headers: {
4187
+ "User-Agent": `pi-webui/${packageJson.version} pi/${piPackageJson.version || "unknown"}`,
4188
+ accept: "application/json",
4189
+ },
4190
+ });
4191
+ const latestVersion = typeof data.version === "string" ? data.version.trim() : "";
4192
+ if (!latestVersion) throw new Error("latest-version response did not include a version");
4193
+ status.latestVersion = latestVersion;
4194
+ status.packageName = typeof data.packageName === "string" && data.packageName.trim() ? data.packageName.trim() : PI_CODING_AGENT_PACKAGE;
4195
+ status.note = typeof data.note === "string" && data.note.trim() ? data.note.trim() : "";
4196
+ status.updateAvailable = status.currentVersion ? isNewerPackageVersion(latestVersion, status.currentVersion) : false;
4197
+ status.checked = true;
4198
+ } catch (error) {
4199
+ status.error = sanitizeError(error);
4200
+ }
4201
+ return status;
4202
+ }
4203
+
4204
+ function npmLatestPackageUrl(packageName) {
4205
+ return `${NPM_REGISTRY_URL}/${encodeURIComponent(packageName)}/latest`;
4206
+ }
4207
+
4208
+ async function checkLatestNpmPackageStatus(packageName, currentVersion) {
4209
+ const status = basePackageUpdateStatus(packageName, currentVersion);
4210
+ const skippedReason = updateChecksSkippedReason();
4211
+ if (skippedReason) {
4212
+ status.skipped = true;
4213
+ status.skippedReason = skippedReason;
4214
+ return status;
4215
+ }
4216
+ try {
4217
+ const data = await fetchJsonWithTimeout(npmLatestPackageUrl(packageName), {
4218
+ headers: {
4219
+ "User-Agent": `pi-webui/${packageJson.version}`,
4220
+ accept: "application/json",
4221
+ },
4222
+ });
4223
+ const latestVersion = typeof data.version === "string" ? data.version.trim() : "";
4224
+ if (!latestVersion) throw new Error(`${packageName} latest metadata did not include a version`);
4225
+ status.latestVersion = latestVersion;
4226
+ status.updateAvailable = status.currentVersion ? isNewerPackageVersion(latestVersion, status.currentVersion) : false;
4227
+ status.checked = true;
4228
+ } catch (error) {
4229
+ status.error = sanitizeError(error);
4230
+ }
4231
+ return status;
4232
+ }
4233
+
4234
+ function updateStatusForRequest(status, req) {
4235
+ return {
4236
+ ...status,
4237
+ canRunUpdate: isLocalAddress(req?.socket?.remoteAddress),
4238
+ updateInProgress: piUpdateInProgress,
4239
+ };
4240
+ }
4241
+
4242
+ async function getUpdateStatus({ force = false } = {}) {
4243
+ const now = Date.now();
4244
+ if (!force && updateStatusCache && now - updateStatusCacheAt < UPDATE_STATUS_CACHE_MS) return updateStatusCache;
4245
+ const [piStatus, webuiStatus] = await Promise.all([
4246
+ checkLatestPiReleaseStatus(),
4247
+ checkLatestNpmPackageStatus(WEBUI_PACKAGE, packageJson.version),
4248
+ ]);
4249
+ const updateAvailable = !!(piStatus.updateAvailable || webuiStatus.updateAvailable);
4250
+ updateStatusCache = {
4251
+ checkedAt: new Date(now).toISOString(),
4252
+ updateAvailable,
4253
+ restartRequired: true,
4254
+ command: "pi update",
4255
+ webuiDev: webuiDevServer,
4256
+ pi: piStatus,
4257
+ webui: webuiStatus,
4258
+ packages: {
4259
+ checked: false,
4260
+ note: "pi update will also update configured unpinned Pi packages.",
4261
+ },
4262
+ };
4263
+ updateStatusCacheAt = now;
4264
+ return updateStatusCache;
4265
+ }
4266
+
4267
+ async function resolvePiUpdateCommand() {
4268
+ if (options.piBinExplicit) {
4269
+ return { command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
4270
+ }
4271
+
4272
+ const pathPi = await runCommand(options.piBin, ["--version"], { timeoutMs: 3000, maxOutputLength: 4000 });
4273
+ if (pathPi.exitCode === 0 && !pathPi.timedOut && !pathPi.error) {
4274
+ return { command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
4275
+ }
4276
+
4277
+ return resolvePiCommand(["update"]);
4278
+ }
4279
+
4280
+ async function runPiUpdateAndPrepareRestart() {
4281
+ if (piUpdateInProgress) throw makeHttpError(409, "A Pi update is already running.");
4282
+ piUpdateInProgress = true;
4283
+ let restartPrepared = false;
4284
+ try {
4285
+ const restorableTabs = await restorableTabsForRestart();
4286
+ const piCommand = await resolvePiUpdateCommand();
4287
+ const command = piCommand.displayCommand || formatCommandForDisplay(piCommand.command, piCommand.args || []);
4288
+ recordEvent({ type: "webui_update_started", command, restorableTabCount: restorableTabs.length });
4289
+ const result = await runCommand(piCommand.command, piCommand.args || [], {
4290
+ cwd: process.cwd(),
4291
+ timeoutMs: PI_UPDATE_TIMEOUT_MS,
4292
+ maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS,
4293
+ });
4294
+ const ok = result.exitCode === 0 && !result.timedOut && !result.error;
4295
+ if (!ok) {
4296
+ const details = [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
4297
+ recordEvent({ type: "webui_update_failed", command, error: truncateStatusText(details || `exit code ${result.exitCode ?? "unknown"}`) });
4298
+ throw makeHttpError(500, truncateLongText(`Pi update failed: ${command}${details ? `\n${details}` : ""}`));
4299
+ }
4300
+
4301
+ updateStatusCache = null;
4302
+ updateStatusCacheAt = 0;
4303
+ const child = spawnRestartServer(restorableTabs);
4304
+ restartPrepared = true;
4305
+ recordEvent({ type: "webui_update_restarting", command, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
4306
+ return {
4307
+ message: "Pi update completed. Pi Web UI is restarting.",
4308
+ command,
4309
+ stdout: result.stdout,
4310
+ stderr: result.stderr,
4311
+ webuiPid: process.pid,
4312
+ nextWebuiPid: child.pid,
4313
+ restorableTabCount: restorableTabs.length,
4314
+ };
4315
+ } finally {
4316
+ if (!restartPrepared) piUpdateInProgress = false;
4317
+ }
4318
+ }
4319
+
3915
4320
  function rememberClosedRestorableTab(tab, state = null) {
3916
4321
  const descriptor = restorableTabDescriptor(tab, state);
3917
4322
  if (!descriptor) return;
@@ -5517,6 +5922,13 @@ const server = createServer(async (req, res) => {
5517
5922
  return;
5518
5923
  }
5519
5924
 
5925
+ if (url.pathname === "/api/update-status" && req.method === "GET") {
5926
+ const force = ["1", "true", "yes", "refresh"].includes(String(url.searchParams.get("refresh") || "").toLowerCase());
5927
+ const status = await getUpdateStatus({ force });
5928
+ sendJson(res, 200, { ok: true, data: updateStatusForRequest(status, req) });
5929
+ return;
5930
+ }
5931
+
5520
5932
  if (url.pathname === "/api/native-parity" && req.method === "GET") {
5521
5933
  sendJson(res, 200, { ok: true, data: nativeParityMatrix });
5522
5934
  return;
@@ -5577,6 +5989,14 @@ const server = createServer(async (req, res) => {
5577
5989
  return;
5578
5990
  }
5579
5991
 
5992
+ if (url.pathname === "/api/update" && req.method === "POST") {
5993
+ if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Updating Pi from the Web UI is only allowed from localhost");
5994
+ const data = await runPiUpdateAndPrepareRestart();
5995
+ sendJson(res, 200, { ok: true, data });
5996
+ setTimeout(() => shutdown("api update"), 20).unref();
5997
+ return;
5998
+ }
5999
+
5580
6000
  if (url.pathname === "/api/shutdown" && req.method === "POST") {
5581
6001
  if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Shutdown is only allowed from localhost");
5582
6002
  sendJson(res, 200, { ok: true, message: "Pi Web UI shutting down", webuiPid: process.pid });
@@ -5855,6 +6275,47 @@ const server = createServer(async (req, res) => {
5855
6275
  return;
5856
6276
  }
5857
6277
 
6278
+ if (url.pathname === "/api/git-changes" && req.method === "GET") {
6279
+ const tab = getRequestedTab(req, url);
6280
+ try {
6281
+ sendJson(res, 200, { ok: true, data: await readGitChanges(tab.cwd) });
6282
+ } catch (error) {
6283
+ sendJson(res, 200, { ok: false, error: sanitizeError(error) });
6284
+ }
6285
+ return;
6286
+ }
6287
+
6288
+ if (url.pathname === "/api/git-changes/untracked-file" && req.method === "GET") {
6289
+ const tab = getRequestedTab(req, url);
6290
+ try {
6291
+ sendJson(res, 200, { ok: true, data: await readGitUntrackedFile(tab.cwd, url.searchParams.get("path") || "") });
6292
+ } catch (error) {
6293
+ sendJson(res, 200, { ok: false, error: sanitizeError(error) });
6294
+ }
6295
+ return;
6296
+ }
6297
+
6298
+ if (url.pathname === "/api/git-branches" && req.method === "GET") {
6299
+ const tab = getRequestedTab(req, url);
6300
+ try {
6301
+ sendJson(res, 200, { ok: true, data: await readGitBranches(tab.cwd) });
6302
+ } catch (error) {
6303
+ sendJson(res, 200, { ok: false, error: sanitizeError(error) });
6304
+ }
6305
+ return;
6306
+ }
6307
+
6308
+ if (url.pathname === "/api/git-branch" && req.method === "POST") {
6309
+ const body = await readJsonBody(req);
6310
+ const tab = getRequestedTab(req, url, body);
6311
+ try {
6312
+ sendJson(res, 200, await switchGitBranch(tab.cwd, body.branch, { create: body.create === true }));
6313
+ } catch (error) {
6314
+ sendJson(res, 200, { ok: false, error: sanitizeError(error) });
6315
+ }
6316
+ return;
6317
+ }
6318
+
5858
6319
  if (url.pathname.startsWith("/api/git-workflow/")) {
5859
6320
  const body = req.method === "POST" ? await readJsonBody(req) : {};
5860
6321
  const tab = getRequestedTab(req, url, body);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",