@firstpick/pi-package-webui 0.3.4 → 0.3.6

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,11 +35,20 @@ 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";
34
49
  const OPENAI_CODEX_USAGE_ENDPOINT = process.env.PI_WEBUI_CODEX_USAGE_URL || "https://chatgpt.com/backend-api/wham/usage";
35
50
  const BODY_LIMIT_BYTES = 1024 * 1024;
51
+ const SKILL_FILE_BODY_LIMIT_BYTES = 2 * 1024 * 1024;
36
52
  const PROMPT_BODY_LIMIT_BYTES = 24 * 1024 * 1024;
37
53
  const UPLOAD_BODY_LIMIT_BYTES = 96 * 1024 * 1024;
38
54
  const ATTACHMENT_UPLOAD_MAX_FILES = 12;
@@ -362,6 +378,51 @@ function delay(ms) {
362
378
  return new Promise((resolve) => setTimeout(resolve, ms));
363
379
  }
364
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
+
365
426
  class PiRpcProcess {
366
427
  constructor({ command, args, displayCommand, cwd }) {
367
428
  this.command = command;
@@ -3911,6 +3972,173 @@ function spawnRestartServer(restorableTabs) {
3911
3972
  return child;
3912
3973
  }
3913
3974
 
3975
+ let updateStatusCache = null;
3976
+ let updateStatusCacheAt = 0;
3977
+ let piUpdateInProgress = false;
3978
+
3979
+ function updateChecksSkippedReason() {
3980
+ if (process.env.PI_OFFLINE) return "PI_OFFLINE is set";
3981
+ if (process.env.PI_SKIP_VERSION_CHECK) return "PI_SKIP_VERSION_CHECK is set";
3982
+ return "";
3983
+ }
3984
+
3985
+ function basePackageUpdateStatus(packageName, currentVersion) {
3986
+ return {
3987
+ packageName,
3988
+ currentVersion: String(currentVersion || ""),
3989
+ latestVersion: null,
3990
+ updateAvailable: false,
3991
+ checked: false,
3992
+ skipped: false,
3993
+ skippedReason: "",
3994
+ error: "",
3995
+ };
3996
+ }
3997
+
3998
+ async function checkLatestPiReleaseStatus() {
3999
+ const status = basePackageUpdateStatus(PI_CODING_AGENT_PACKAGE, piPackageJson.version);
4000
+ const skippedReason = updateChecksSkippedReason();
4001
+ if (skippedReason) {
4002
+ status.skipped = true;
4003
+ status.skippedReason = skippedReason;
4004
+ return status;
4005
+ }
4006
+ try {
4007
+ const data = await fetchJsonWithTimeout(PI_LATEST_VERSION_URL, {
4008
+ headers: {
4009
+ "User-Agent": `pi-webui/${packageJson.version} pi/${piPackageJson.version || "unknown"}`,
4010
+ accept: "application/json",
4011
+ },
4012
+ });
4013
+ const latestVersion = typeof data.version === "string" ? data.version.trim() : "";
4014
+ if (!latestVersion) throw new Error("latest-version response did not include a version");
4015
+ status.latestVersion = latestVersion;
4016
+ status.packageName = typeof data.packageName === "string" && data.packageName.trim() ? data.packageName.trim() : PI_CODING_AGENT_PACKAGE;
4017
+ status.note = typeof data.note === "string" && data.note.trim() ? data.note.trim() : "";
4018
+ status.updateAvailable = status.currentVersion ? isNewerPackageVersion(latestVersion, status.currentVersion) : false;
4019
+ status.checked = true;
4020
+ } catch (error) {
4021
+ status.error = sanitizeError(error);
4022
+ }
4023
+ return status;
4024
+ }
4025
+
4026
+ function npmLatestPackageUrl(packageName) {
4027
+ return `${NPM_REGISTRY_URL}/${encodeURIComponent(packageName)}/latest`;
4028
+ }
4029
+
4030
+ async function checkLatestNpmPackageStatus(packageName, currentVersion) {
4031
+ const status = basePackageUpdateStatus(packageName, currentVersion);
4032
+ const skippedReason = updateChecksSkippedReason();
4033
+ if (skippedReason) {
4034
+ status.skipped = true;
4035
+ status.skippedReason = skippedReason;
4036
+ return status;
4037
+ }
4038
+ try {
4039
+ const data = await fetchJsonWithTimeout(npmLatestPackageUrl(packageName), {
4040
+ headers: {
4041
+ "User-Agent": `pi-webui/${packageJson.version}`,
4042
+ accept: "application/json",
4043
+ },
4044
+ });
4045
+ const latestVersion = typeof data.version === "string" ? data.version.trim() : "";
4046
+ if (!latestVersion) throw new Error(`${packageName} latest metadata did not include a version`);
4047
+ status.latestVersion = latestVersion;
4048
+ status.updateAvailable = status.currentVersion ? isNewerPackageVersion(latestVersion, status.currentVersion) : false;
4049
+ status.checked = true;
4050
+ } catch (error) {
4051
+ status.error = sanitizeError(error);
4052
+ }
4053
+ return status;
4054
+ }
4055
+
4056
+ function updateStatusForRequest(status, req) {
4057
+ return {
4058
+ ...status,
4059
+ canRunUpdate: isLocalAddress(req?.socket?.remoteAddress),
4060
+ updateInProgress: piUpdateInProgress,
4061
+ };
4062
+ }
4063
+
4064
+ async function getUpdateStatus({ force = false } = {}) {
4065
+ const now = Date.now();
4066
+ if (!force && updateStatusCache && now - updateStatusCacheAt < UPDATE_STATUS_CACHE_MS) return updateStatusCache;
4067
+ const [piStatus, webuiStatus] = await Promise.all([
4068
+ checkLatestPiReleaseStatus(),
4069
+ checkLatestNpmPackageStatus(WEBUI_PACKAGE, packageJson.version),
4070
+ ]);
4071
+ const updateAvailable = !!(piStatus.updateAvailable || webuiStatus.updateAvailable);
4072
+ updateStatusCache = {
4073
+ checkedAt: new Date(now).toISOString(),
4074
+ updateAvailable,
4075
+ restartRequired: true,
4076
+ command: "pi update",
4077
+ webuiDev: webuiDevServer,
4078
+ pi: piStatus,
4079
+ webui: webuiStatus,
4080
+ packages: {
4081
+ checked: false,
4082
+ note: "pi update will also update configured unpinned Pi packages.",
4083
+ },
4084
+ };
4085
+ updateStatusCacheAt = now;
4086
+ return updateStatusCache;
4087
+ }
4088
+
4089
+ async function resolvePiUpdateCommand() {
4090
+ if (options.piBinExplicit) {
4091
+ return { command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
4092
+ }
4093
+
4094
+ const pathPi = await runCommand(options.piBin, ["--version"], { timeoutMs: 3000, maxOutputLength: 4000 });
4095
+ if (pathPi.exitCode === 0 && !pathPi.timedOut && !pathPi.error) {
4096
+ return { command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
4097
+ }
4098
+
4099
+ return resolvePiCommand(["update"]);
4100
+ }
4101
+
4102
+ async function runPiUpdateAndPrepareRestart() {
4103
+ if (piUpdateInProgress) throw makeHttpError(409, "A Pi update is already running.");
4104
+ piUpdateInProgress = true;
4105
+ let restartPrepared = false;
4106
+ try {
4107
+ const restorableTabs = await restorableTabsForRestart();
4108
+ const piCommand = await resolvePiUpdateCommand();
4109
+ const command = piCommand.displayCommand || formatCommandForDisplay(piCommand.command, piCommand.args || []);
4110
+ recordEvent({ type: "webui_update_started", command, restorableTabCount: restorableTabs.length });
4111
+ const result = await runCommand(piCommand.command, piCommand.args || [], {
4112
+ cwd: process.cwd(),
4113
+ timeoutMs: PI_UPDATE_TIMEOUT_MS,
4114
+ maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS,
4115
+ });
4116
+ const ok = result.exitCode === 0 && !result.timedOut && !result.error;
4117
+ if (!ok) {
4118
+ const details = [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
4119
+ recordEvent({ type: "webui_update_failed", command, error: truncateStatusText(details || `exit code ${result.exitCode ?? "unknown"}`) });
4120
+ throw makeHttpError(500, truncateLongText(`Pi update failed: ${command}${details ? `\n${details}` : ""}`));
4121
+ }
4122
+
4123
+ updateStatusCache = null;
4124
+ updateStatusCacheAt = 0;
4125
+ const child = spawnRestartServer(restorableTabs);
4126
+ restartPrepared = true;
4127
+ recordEvent({ type: "webui_update_restarting", command, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
4128
+ return {
4129
+ message: "Pi update completed. Pi Web UI is restarting.",
4130
+ command,
4131
+ stdout: result.stdout,
4132
+ stderr: result.stderr,
4133
+ webuiPid: process.pid,
4134
+ nextWebuiPid: child.pid,
4135
+ restorableTabCount: restorableTabs.length,
4136
+ };
4137
+ } finally {
4138
+ if (!restartPrepared) piUpdateInProgress = false;
4139
+ }
4140
+ }
4141
+
3914
4142
  function rememberClosedRestorableTab(tab, state = null) {
3915
4143
  const descriptor = restorableTabDescriptor(tab, state);
3916
4144
  if (!descriptor) return;
@@ -4242,6 +4470,109 @@ async function getMergedSkillConfigData(tab) {
4242
4470
  return { skills: mergeRuntimeAndResolvedSkills(runtime.skills || [], resolved.skills || []) };
4243
4471
  }
4244
4472
 
4473
+ function normalizeSkillRequestName(value) {
4474
+ return String(value || "").trim().replace(/^skill:/i, "").toLowerCase();
4475
+ }
4476
+
4477
+ function skillFileRequestParts(source = {}) {
4478
+ return {
4479
+ name: normalizeSkillRequestName(source.name || source.skillName),
4480
+ filePath: String(source.path || source.filePath || "").trim(),
4481
+ };
4482
+ }
4483
+
4484
+ function sameResolvedPath(left, right) {
4485
+ if (!left || !right) return false;
4486
+ return path.resolve(left) === path.resolve(right);
4487
+ }
4488
+
4489
+ function skillFilePathInside(root, target) {
4490
+ if (!root || !target) return false;
4491
+ const relative = path.relative(path.resolve(root), path.resolve(target));
4492
+ return !relative || (!relative.startsWith("..") && !path.isAbsolute(relative));
4493
+ }
4494
+
4495
+ function skillNameFromSkillFilePath(filePath) {
4496
+ const normalized = String(filePath || "").replace(/\\/g, "/");
4497
+ const match = normalized.match(/\/skills\/([^/]+)\/SKILL\.md$/i);
4498
+ return normalizeSkillRequestName(match?.[1] || "");
4499
+ }
4500
+
4501
+ async function resolveExplicitSkillFilePath(tab, filePath, requestedName = "") {
4502
+ const resolvedPath = path.resolve(filePath || "");
4503
+ const pathSkillName = skillNameFromSkillFilePath(resolvedPath);
4504
+ if (!pathSkillName) throw makeHttpError(400, "Skill path must point to /skills/<name>/SKILL.md");
4505
+ if (requestedName && requestedName !== pathSkillName) throw makeHttpError(400, "Skill name does not match the requested SKILL.md path");
4506
+ const allowedRoots = [agentDir, path.join(tab?.cwd || options.cwd, ".pi")];
4507
+ if (!allowedRoots.some((root) => skillFilePathInside(root, resolvedPath))) {
4508
+ throw makeHttpError(403, "Skill path is outside allowed Pi skill locations");
4509
+ }
4510
+ const info = await stat(resolvedPath).catch(() => null);
4511
+ if (!info?.isFile()) throw makeHttpError(404, `Skill file not found: ${resolvedPath}`);
4512
+ return {
4513
+ name: pathSkillName,
4514
+ description: "",
4515
+ filePath: resolvedPath,
4516
+ enabled: true,
4517
+ fileStats: info,
4518
+ };
4519
+ }
4520
+
4521
+ async function resolveEditableSkillFile(tab, request = {}) {
4522
+ const { name, filePath } = skillFileRequestParts(request);
4523
+ if (!name && !filePath) throw makeHttpError(400, "Skill name or path is required");
4524
+ const { skills } = await resolveSkillResources(tab);
4525
+ const skill = skills.find((item) => (
4526
+ filePath ? sameResolvedPath(item.filePath, filePath) : name && normalizeSkillRequestName(item.name) === name
4527
+ ));
4528
+ if (skill?.filePath) {
4529
+ if (path.basename(skill.filePath) !== "SKILL.md") throw makeHttpError(400, "Only SKILL.md files can be edited from skill tags");
4530
+ const info = await stat(skill.filePath).catch(() => null);
4531
+ if (!info?.isFile()) throw makeHttpError(404, `Skill file not found: ${skill.filePath}`);
4532
+ return { ...skill, filePath: path.resolve(skill.filePath), fileStats: info };
4533
+ }
4534
+ if (filePath) return resolveExplicitSkillFilePath(tab, filePath, name);
4535
+ throw makeHttpError(404, "Skill is not configured in this Pi tab");
4536
+ }
4537
+
4538
+ async function getSkillFileData(tab, request = {}) {
4539
+ const skill = await resolveEditableSkillFile(tab, request);
4540
+ const content = await readFile(skill.filePath, "utf8");
4541
+ return {
4542
+ name: parseSkillFrontmatter(content, skill.filePath).name || skill.name,
4543
+ description: skill.description || "",
4544
+ path: skill.filePath,
4545
+ content,
4546
+ mtimeMs: skill.fileStats.mtimeMs,
4547
+ size: skill.fileStats.size,
4548
+ enabled: skill.enabled === true,
4549
+ };
4550
+ }
4551
+
4552
+ async function saveSkillFileData(tab, body = {}) {
4553
+ if (typeof body.content !== "string") throw makeHttpError(400, "Skill content must be a string");
4554
+ if (body.content.includes("\0")) throw makeHttpError(400, "Skill content cannot contain null bytes");
4555
+ if (Buffer.byteLength(body.content, "utf8") > SKILL_FILE_BODY_LIMIT_BYTES) throw makeHttpError(413, `Skill file is too large (limit ${formatBytes(SKILL_FILE_BODY_LIMIT_BYTES)})`);
4556
+ const skill = await resolveEditableSkillFile(tab, body);
4557
+ const expectedMtimeMs = Number(body.mtimeMs);
4558
+ if (Number.isFinite(expectedMtimeMs) && Math.abs(skill.fileStats.mtimeMs - expectedMtimeMs) > 5) {
4559
+ throw makeHttpError(409, "Skill file changed on disk after it was opened. Reopen it before saving.");
4560
+ }
4561
+ const tmpFile = `${skill.filePath}.${process.pid}.${Date.now()}.tmp`;
4562
+ await writeFile(tmpFile, body.content, { encoding: "utf8", mode: skill.fileStats.mode & 0o777 });
4563
+ await rename(tmpFile, skill.filePath);
4564
+ const nextStats = await stat(skill.filePath);
4565
+ const metadata = parseSkillFrontmatter(body.content, skill.filePath);
4566
+ return {
4567
+ name: metadata.name || skill.name,
4568
+ description: metadata.description || skill.description || "",
4569
+ path: skill.filePath,
4570
+ mtimeMs: nextStats.mtimeMs,
4571
+ size: nextStats.size,
4572
+ enabled: skill.enabled === true,
4573
+ };
4574
+ }
4575
+
4245
4576
  function getResourcePatternForSkill(tab, skill) {
4246
4577
  const info = skill.sourceInfo || {};
4247
4578
  const baseDir = info.baseDir || (info.scope === "project" ? path.join(tab?.cwd || options.cwd, ".pi") : agentDir);
@@ -5413,6 +5744,13 @@ const server = createServer(async (req, res) => {
5413
5744
  return;
5414
5745
  }
5415
5746
 
5747
+ if (url.pathname === "/api/update-status" && req.method === "GET") {
5748
+ const force = ["1", "true", "yes", "refresh"].includes(String(url.searchParams.get("refresh") || "").toLowerCase());
5749
+ const status = await getUpdateStatus({ force });
5750
+ sendJson(res, 200, { ok: true, data: updateStatusForRequest(status, req) });
5751
+ return;
5752
+ }
5753
+
5416
5754
  if (url.pathname === "/api/native-parity" && req.method === "GET") {
5417
5755
  sendJson(res, 200, { ok: true, data: nativeParityMatrix });
5418
5756
  return;
@@ -5473,6 +5811,14 @@ const server = createServer(async (req, res) => {
5473
5811
  return;
5474
5812
  }
5475
5813
 
5814
+ if (url.pathname === "/api/update" && req.method === "POST") {
5815
+ if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Updating Pi from the Web UI is only allowed from localhost");
5816
+ const data = await runPiUpdateAndPrepareRestart();
5817
+ sendJson(res, 200, { ok: true, data });
5818
+ setTimeout(() => shutdown("api update"), 20).unref();
5819
+ return;
5820
+ }
5821
+
5476
5822
  if (url.pathname === "/api/shutdown" && req.method === "POST") {
5477
5823
  if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Shutdown is only allowed from localhost");
5478
5824
  sendJson(res, 200, { ok: true, message: "Pi Web UI shutting down", webuiPid: process.pid });
@@ -5685,6 +6031,20 @@ const server = createServer(async (req, res) => {
5685
6031
  return;
5686
6032
  }
5687
6033
 
6034
+ if (url.pathname === "/api/skill-file" && req.method === "GET") {
6035
+ const tab = getRequestedTab(req, url);
6036
+ sendJson(res, 200, { ok: true, data: await getSkillFileData(tab, { name: url.searchParams.get("name"), path: url.searchParams.get("path") }) });
6037
+ return;
6038
+ }
6039
+
6040
+ if (url.pathname === "/api/skill-file" && req.method === "POST") {
6041
+ if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Saving skill files is only allowed from localhost");
6042
+ const body = await readJsonBody(req, { limitBytes: SKILL_FILE_BODY_LIMIT_BYTES });
6043
+ const tab = getRequestedTab(req, url, body);
6044
+ sendJson(res, 200, { ok: true, data: await saveSkillFileData(tab, body) });
6045
+ return;
6046
+ }
6047
+
5688
6048
  if (url.pathname === "/api/settings" && req.method === "GET") {
5689
6049
  const tab = getRequestedTab(req, url);
5690
6050
  sendJson(res, 200, { ok: true, data: nativeSettingsPayload(settingsManagerForTab(tab)) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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",