@firstpick/pi-package-webui 0.4.7 → 0.4.8
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 +9 -9
- package/bin/pi-webui.mjs +54 -44
- package/package.json +1 -1
- package/public/app.js +126 -89
- package/public/index.html +5 -3
- package/tests/http-endpoints-harness.test.mjs +57 -0
- package/tests/mobile-static.test.mjs +14 -9
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Local browser UI for [Pi coding agent](https://www.npmjs.com/package/@earendil-w
|
|
|
6
6
|
|
|
7
7
|
Pi Web UI gives you a local browser companion for Pi: multi-tab chat, streaming output, model controls, uploads, slash-command helpers, workspace navigation, and optional extension widgets.
|
|
8
8
|
|
|
9
|
-
> **Security:** Pi Web UI can control the spawned Pi session and run anything that session is allowed to run. It binds to `127.0.0.1` by default. Remote PIN
|
|
9
|
+
> **Security:** Pi Web UI can control the spawned Pi session and run anything that session is allowed to run. It binds to `127.0.0.1` by default. Trusted-LAN opening/closing and Remote PIN auth controls are owned by the optional `@firstpick/pi-package-remote-webui` companion; when enabled, Remote PIN auth persists for later Web UI starts.
|
|
10
10
|
|
|
11
11
|
## Requirements
|
|
12
12
|
|
|
@@ -134,13 +134,13 @@ Environment variables:
|
|
|
134
134
|
- Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, edit-and-retry from user prompts, and guarded abort controls that require holding Esc or the Abort button for 3 seconds.
|
|
135
135
|
- Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references with live suggestions.
|
|
136
136
|
- Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, `/scoped-models`, `/tools`, and `/skills`.
|
|
137
|
-
- Model, thinking, session, workspace, theme, optional-feature, Codex usage,
|
|
137
|
+
- Model, thinking, session, workspace, theme, optional-feature, Codex usage, optional Remote WebUI, update/restart, event, and notification controls in the side panel.
|
|
138
138
|
- Persistent context-window meter with manual compact and auto-compaction controls near the composer.
|
|
139
139
|
- Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded.
|
|
140
140
|
- Per-tab cwd changes, a clickable footer cwd picker, saved path fast picks, server-persisted fast picks, and restart-safe restoration of open tabs.
|
|
141
141
|
- 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.
|
|
142
142
|
- Browser support for Pi extension UI prompts, widgets, status updates, `/btw` side-question output widgets with optional context transfer/live steering, browser notifications when a tab needs an extension UI response, and an optional side-panel toggle for agent-done notifications.
|
|
143
|
-
- Localhost-only Pi/Web UI update checks with a top-right update notification and
|
|
143
|
+
- Localhost-only Pi/Web UI update checks with a top-right update notification and confirmed restart actions: **Update Pi & restart** runs `pi update` for Pi-only updates, while **Update Pi + Packages & Restart** runs `pi update --all` for Pi plus configured packages.
|
|
144
144
|
- Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, which can ask Pi to create or update a LEARNING.
|
|
145
145
|
- Mobile-friendly layout and PWA install support where the browser allows it.
|
|
146
146
|
|
|
@@ -150,7 +150,7 @@ Useful browser endpoints exposed by the local server include:
|
|
|
150
150
|
- `POST /api/action-feedback?tab=<tabId>` for feedback on final assistant output and action cards.
|
|
151
151
|
- `GET /api/optional-features` for optional companion package install/update status.
|
|
152
152
|
- `POST /api/optional-feature-install` for installing or updating known optional companion packages from the side panel.
|
|
153
|
-
- `GET /api/update-status` and localhost-only `POST /api/update` for checking Pi/Web UI updates and running `pi update`
|
|
153
|
+
- `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. Use `POST /api/update?all=1` to run `pi update --all` for Pi plus configured packages.
|
|
154
154
|
- `GET /api/remote-auth`, `POST /api/remote-auth`, and localhost-only `POST /api/remote-auth/settings` for optional 4-digit PIN authentication when serving non-local browser clients.
|
|
155
155
|
|
|
156
156
|
For local development, run the checkout helper directly, for example:
|
|
@@ -178,7 +178,7 @@ Optional companions:
|
|
|
178
178
|
- `@firstpick/pi-extension-setup-skills` — TUI `/skills` setup command alongside WebUI-native skill toggles.
|
|
179
179
|
- `@firstpick/pi-extension-todo-progress` — todo-progress rendering.
|
|
180
180
|
- `@firstpick/pi-extension-tools` — TUI `/tools` active-tool manager alongside WebUI-native tool toggles.
|
|
181
|
-
- `@firstpick/pi-package-remote-webui` — `/remote` trusted-LAN QR helper for
|
|
181
|
+
- `@firstpick/pi-package-remote-webui` — `/remote` trusted-LAN QR helper plus the optional browser controls for opening/closing LAN access and Remote PIN auth.
|
|
182
182
|
- `@firstpick/pi-extension-git-footer-status` — richer extension-owned git/footer status, including the structured Web UI footer payload.
|
|
183
183
|
- `@firstpick/pi-extension-stats` — stats commands and status data.
|
|
184
184
|
- `@firstpick/pi-themes-bundle` — Web UI and Pi theme resources.
|
|
@@ -208,9 +208,9 @@ This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; b
|
|
|
208
208
|
## Network safety
|
|
209
209
|
|
|
210
210
|
- Default bind is localhost-only: `127.0.0.1:31415`.
|
|
211
|
-
-
|
|
212
|
-
- The
|
|
213
|
-
- Localhost clients stay frictionless and can toggle Remote PIN auth; changing the toggle persists the preference and disconnects existing event streams so remote clients must re-authenticate after enablement.
|
|
211
|
+
- When `@firstpick/pi-package-remote-webui` is loaded and enabled, the side-panel **Remote WebUI** controls dispatch through `/remote`: opening rebinds the server to `0.0.0.0`, shows LAN URLs when available, and toggles to "Close for network".
|
|
212
|
+
- The optional **Remote PIN auth** toggle is off by default on first use. When enabled through `/remote auth on` or the Remote WebUI controls, the server saves that preference, generates a fresh random 4-digit PIN for each server start, shows it in the Remote WebUI controls and `/webui-status`, and requires it from non-local browser clients.
|
|
213
|
+
- Localhost clients stay frictionless and can toggle Remote PIN auth through the remote companion; changing the toggle persists the preference and disconnects existing event streams so remote clients must re-authenticate after enablement.
|
|
214
214
|
- `--host 0.0.0.0` also exposes the Web UI to the local network; pass `--remote-auth` to start with PIN auth already enabled.
|
|
215
215
|
- Any connected browser client with access (and the PIN, if enabled) can control Pi and run Web UI bash actions as the Web UI process user.
|
|
216
216
|
- Remote PIN auth is a simple trusted-LAN HTTP gate, not hardened multi-user authentication; do not expose it to untrusted networks.
|
|
@@ -222,5 +222,5 @@ This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; b
|
|
|
222
222
|
- **`/webui-start` is missing:** restart Pi after installing the package.
|
|
223
223
|
- **Wrong port or existing server:** use `/webui-status detailed`, or start on another port with `/webui-start --port 31500`.
|
|
224
224
|
- **Optional feature is disabled or missing:** check the side panel, install the companion package if needed, then run `/reload` in the active Pi tab.
|
|
225
|
-
- **Remote browser asks for a PIN:** read it from
|
|
225
|
+
- **Remote browser asks for a PIN:** read it from the optional **Remote WebUI** side-panel controls, `/webui-status`, `/remote status`, or the local Web UI server log. Disable the toggle from localhost to remove the PIN gate.
|
|
226
226
|
- **PWA install or notifications are unavailable:** use `localhost` or HTTPS; browser support varies on LAN HTTP URLs.
|
package/bin/pi-webui.mjs
CHANGED
|
@@ -3155,40 +3155,43 @@ async function runGitReadCommand(root, args, { timeoutMs = GIT_CHANGES_COMMAND_T
|
|
|
3155
3155
|
throw new Error(String(message).trim());
|
|
3156
3156
|
}
|
|
3157
3157
|
|
|
3158
|
-
function
|
|
3159
|
-
const
|
|
3160
|
-
|
|
3158
|
+
function gitBranchFromPorcelainStatus(statusText) {
|
|
3159
|
+
for (const line of String(statusText || "").split(/\r?\n/)) {
|
|
3160
|
+
if (!line.startsWith("# branch.head ")) continue;
|
|
3161
|
+
const branch = line.slice("# branch.head ".length).trim();
|
|
3162
|
+
return branch && branch !== "(detached)" ? branch : "detached";
|
|
3163
|
+
}
|
|
3164
|
+
return "detached";
|
|
3161
3165
|
}
|
|
3162
3166
|
|
|
3163
|
-
function
|
|
3164
|
-
const
|
|
3165
|
-
const
|
|
3166
|
-
|
|
3167
|
-
|
|
3167
|
+
function addGitPorcelainTrackedSummary(summary, xy) {
|
|
3168
|
+
const x = xy?.[0] || ".";
|
|
3169
|
+
const y = xy?.[1] || ".";
|
|
3170
|
+
if (x !== ".") summary.staged += 1;
|
|
3171
|
+
if (y !== ".") summary.unstaged += 1;
|
|
3168
3172
|
}
|
|
3169
3173
|
|
|
3170
|
-
function
|
|
3174
|
+
function summarizeGitPorcelainStatus(statusText) {
|
|
3171
3175
|
const summary = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, ahead: 0, behind: 0 };
|
|
3172
3176
|
for (const line of String(statusText || "").split(/\r?\n/)) {
|
|
3173
3177
|
if (!line) continue;
|
|
3174
|
-
if (line.startsWith("
|
|
3175
|
-
const
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
+
if (line.startsWith("# branch.ab ")) {
|
|
3179
|
+
const match = line.match(/\+(\d+)\s+-(\d+)/);
|
|
3180
|
+
if (match) {
|
|
3181
|
+
summary.ahead = Number.parseInt(match[1] || "0", 10) || 0;
|
|
3182
|
+
summary.behind = Number.parseInt(match[2] || "0", 10) || 0;
|
|
3183
|
+
}
|
|
3178
3184
|
continue;
|
|
3179
3185
|
}
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
if (x === "?" && y === "?") {
|
|
3183
|
-
summary.untracked += 1;
|
|
3186
|
+
if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
3187
|
+
addGitPorcelainTrackedSummary(summary, line.split(" ")[1] || "..");
|
|
3184
3188
|
continue;
|
|
3185
3189
|
}
|
|
3186
|
-
if (
|
|
3190
|
+
if (line.startsWith("u ")) {
|
|
3187
3191
|
summary.conflicted += 1;
|
|
3188
3192
|
continue;
|
|
3189
3193
|
}
|
|
3190
|
-
if (
|
|
3191
|
-
if (y && y !== " ") summary.unstaged += 1;
|
|
3194
|
+
if (line.startsWith("? ")) summary.untracked += 1;
|
|
3192
3195
|
}
|
|
3193
3196
|
return summary;
|
|
3194
3197
|
}
|
|
@@ -3293,20 +3296,21 @@ async function pullGitChanges(cwd) {
|
|
|
3293
3296
|
async function readGitChanges(cwd) {
|
|
3294
3297
|
const root = await getGitRoot(cwd);
|
|
3295
3298
|
const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
|
|
3296
|
-
const [statusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
|
|
3299
|
+
const [statusText, porcelainStatusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
|
|
3297
3300
|
runGitReadCommand(root, ["status", "--short", "--branch", "--untracked-files=all"], { maxOutputLength: 120_000 }),
|
|
3301
|
+
runGitReadCommand(root, ["status", "--porcelain=2", "--branch", "--untracked-files=all"], { maxOutputLength: 120_000 }),
|
|
3298
3302
|
runGitReadCommand(root, diffArgs),
|
|
3299
3303
|
runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"]),
|
|
3300
3304
|
runGitReadCommand(root, ["ls-files", "--others", "--exclude-standard"], { maxOutputLength: 120_000 }),
|
|
3301
3305
|
]);
|
|
3302
|
-
const summary =
|
|
3306
|
+
const summary = summarizeGitPorcelainStatus(porcelainStatusText);
|
|
3303
3307
|
const incoming = await readGitIncomingChanges(root, summary);
|
|
3304
3308
|
const untrackedFiles = untrackedText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
3305
3309
|
const untracked = await readGitUntrackedEntries(root, untrackedFiles);
|
|
3306
3310
|
return {
|
|
3307
3311
|
cwd,
|
|
3308
3312
|
root,
|
|
3309
|
-
branch:
|
|
3313
|
+
branch: gitBranchFromPorcelainStatus(porcelainStatusText),
|
|
3310
3314
|
generatedAt: new Date().toISOString(),
|
|
3311
3315
|
summary,
|
|
3312
3316
|
remote: incoming.remote,
|
|
@@ -4276,6 +4280,9 @@ try {
|
|
|
4276
4280
|
process.exit(2);
|
|
4277
4281
|
}
|
|
4278
4282
|
|
|
4283
|
+
process.env.PI_WEBUI_HOST = options.host;
|
|
4284
|
+
process.env.PI_WEBUI_PORT = String(options.port);
|
|
4285
|
+
|
|
4279
4286
|
const startupDelayMs = Number.parseInt(process.env.PI_WEBUI_START_DELAY_MS || "", 10);
|
|
4280
4287
|
delete process.env.PI_WEBUI_START_DELAY_MS;
|
|
4281
4288
|
if (Number.isFinite(startupDelayMs) && startupDelayMs > 0) {
|
|
@@ -5198,32 +5205,35 @@ async function getUpdateStatus({ force = false } = {}) {
|
|
|
5198
5205
|
checkedAt: new Date(now).toISOString(),
|
|
5199
5206
|
updateAvailable,
|
|
5200
5207
|
restartRequired: true,
|
|
5201
|
-
command: "pi update
|
|
5208
|
+
command: "pi update",
|
|
5209
|
+
allCommand: "pi update --all",
|
|
5202
5210
|
webuiDev: webuiDevServer,
|
|
5203
5211
|
pi: piStatus,
|
|
5204
5212
|
webui: webuiStatus,
|
|
5205
5213
|
packages: {
|
|
5206
5214
|
checked: false,
|
|
5207
|
-
note: "
|
|
5215
|
+
note: "Default update runs pi update for Pi only. Use update all to run pi update --all for Pi and configured packages."
|
|
5208
5216
|
},
|
|
5209
5217
|
};
|
|
5210
5218
|
updateStatusCacheAt = now;
|
|
5211
5219
|
return updateStatusCache;
|
|
5212
5220
|
}
|
|
5213
5221
|
|
|
5214
|
-
async function resolvePiUpdateCommand() {
|
|
5222
|
+
async function resolvePiUpdateCommand({ all = false } = {}) {
|
|
5223
|
+
const updateArgs = all ? ["update", "--all"] : ["update"];
|
|
5224
|
+
const label = all ? "Pi CLI and configured packages" : "Pi CLI";
|
|
5215
5225
|
if (options.piBinExplicit) {
|
|
5216
|
-
const command = await resolvePiCommand(
|
|
5217
|
-
return { ...command, label:
|
|
5226
|
+
const command = await resolvePiCommand(updateArgs);
|
|
5227
|
+
return { ...command, label, timeoutMs: PI_UPDATE_TIMEOUT_MS, maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS };
|
|
5218
5228
|
}
|
|
5219
5229
|
|
|
5220
5230
|
const pathPi = await runCommand(options.piBin, ["--version"], { timeoutMs: 3000, maxOutputLength: 4000 });
|
|
5221
5231
|
if (pathPi.exitCode === 0 && !pathPi.timedOut && !pathPi.error) {
|
|
5222
|
-
return { label
|
|
5232
|
+
return { label, command: options.piBin, args: updateArgs, displayCommand: formatCommandForDisplay(options.piBin, updateArgs), timeoutMs: PI_UPDATE_TIMEOUT_MS, maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS };
|
|
5223
5233
|
}
|
|
5224
5234
|
|
|
5225
|
-
const fallback = await resolvePiCommand(
|
|
5226
|
-
return { ...fallback, label:
|
|
5235
|
+
const fallback = await resolvePiCommand(updateArgs);
|
|
5236
|
+
return { ...fallback, label: `bundled ${label}`, timeoutMs: PI_UPDATE_TIMEOUT_MS, maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS };
|
|
5227
5237
|
}
|
|
5228
5238
|
|
|
5229
5239
|
function packageNodeModulesPath(nodeModulesRoot, packageName) {
|
|
@@ -5398,13 +5408,9 @@ function uniqueUpdateTasks(tasks) {
|
|
|
5398
5408
|
return unique;
|
|
5399
5409
|
}
|
|
5400
5410
|
|
|
5401
|
-
async function resolveUpdateTasks() {
|
|
5411
|
+
async function resolveUpdateTasks({ all = false } = {}) {
|
|
5402
5412
|
return uniqueUpdateTasks([
|
|
5403
|
-
await resolvePiUpdateCommand(),
|
|
5404
|
-
await currentWebuiPackageUpdateTask(),
|
|
5405
|
-
await agentPackageRootUpdateTask(),
|
|
5406
|
-
await npmGlobalPackageRootUpdateTask(),
|
|
5407
|
-
await bunGlobalPackageRootUpdateTask(),
|
|
5413
|
+
await resolvePiUpdateCommand({ all }),
|
|
5408
5414
|
]);
|
|
5409
5415
|
}
|
|
5410
5416
|
|
|
@@ -5445,16 +5451,17 @@ function combinedUpdateOutput(results, field) {
|
|
|
5445
5451
|
.join("\n\n");
|
|
5446
5452
|
}
|
|
5447
5453
|
|
|
5448
|
-
async function runPiUpdateAndPrepareRestart() {
|
|
5454
|
+
async function runPiUpdateAndPrepareRestart({ all = false } = {}) {
|
|
5449
5455
|
if (piUpdateInProgress) throw makeHttpError(409, "A Pi update is already running.");
|
|
5450
5456
|
piUpdateInProgress = true;
|
|
5451
5457
|
let restartPrepared = false;
|
|
5452
5458
|
try {
|
|
5453
5459
|
const restorableTabs = await restorableTabsForRestart();
|
|
5454
|
-
const updateTasks = await resolveUpdateTasks();
|
|
5455
|
-
if (!updateTasks.length) throw makeHttpError(500, "No Pi
|
|
5460
|
+
const updateTasks = await resolveUpdateTasks({ all });
|
|
5461
|
+
if (!updateTasks.length) throw makeHttpError(500, "No Pi update command could be resolved.");
|
|
5456
5462
|
const command = updateTasks.map(updateTaskDisplay).join(" && ");
|
|
5457
|
-
|
|
5463
|
+
const updateLabel = all ? "Pi and package updates" : "Pi update";
|
|
5464
|
+
recordEvent({ type: "webui_update_started", command, updateAll: all, restorableTabCount: restorableTabs.length });
|
|
5458
5465
|
const results = [];
|
|
5459
5466
|
for (const task of updateTasks) results.push(await runUpdateTask(task));
|
|
5460
5467
|
|
|
@@ -5462,9 +5469,9 @@ async function runPiUpdateAndPrepareRestart() {
|
|
|
5462
5469
|
updateStatusCacheAt = 0;
|
|
5463
5470
|
const child = spawnRestartServer(restorableTabs);
|
|
5464
5471
|
restartPrepared = true;
|
|
5465
|
-
recordEvent({ type: "webui_update_restarting", command, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
|
|
5472
|
+
recordEvent({ type: "webui_update_restarting", command, updateAll: all, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
|
|
5466
5473
|
return {
|
|
5467
|
-
message:
|
|
5474
|
+
message: `${updateLabel} completed. Pi Web UI is restarting.`,
|
|
5468
5475
|
command,
|
|
5469
5476
|
commands: results.map((result) => ({ label: result.label, command: result.command })),
|
|
5470
5477
|
stdout: combinedUpdateOutput(results, "stdout"),
|
|
@@ -7354,7 +7361,10 @@ const server = createServer(async (req, res) => {
|
|
|
7354
7361
|
|
|
7355
7362
|
if (url.pathname === "/api/update" && req.method === "POST") {
|
|
7356
7363
|
requireLocalhostRoute(req, url.pathname);
|
|
7357
|
-
const
|
|
7364
|
+
const body = await readJsonBody(req);
|
|
7365
|
+
const queryAll = ["1", "true", "yes", "all"].includes(String(url.searchParams.get("all") || "").toLowerCase());
|
|
7366
|
+
const bodyAll = body?.all === true || String(body?.mode || "").toLowerCase() === "all";
|
|
7367
|
+
const data = await runPiUpdateAndPrepareRestart({ all: queryAll || bodyAll });
|
|
7358
7368
|
sendJson(res, 200, { ok: true, data });
|
|
7359
7369
|
setTimeout(() => shutdown("api update"), 20).unref();
|
|
7360
7370
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-package-webui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
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",
|
package/public/app.js
CHANGED
|
@@ -24,6 +24,7 @@ const elements = {
|
|
|
24
24
|
updateNotificationMessage: $("#updateNotificationMessage"),
|
|
25
25
|
updateNotificationDetail: $("#updateNotificationDetail"),
|
|
26
26
|
updateNotificationUpdateButton: $("#updateNotificationUpdateButton"),
|
|
27
|
+
updateNotificationUpdateAllButton: $("#updateNotificationUpdateAllButton"),
|
|
27
28
|
updateNotificationDismissButton: $("#updateNotificationDismissButton"),
|
|
28
29
|
serverOfflineCommand: $("#serverOfflineCommand"),
|
|
29
30
|
serverOfflineSlashCommand: $("#serverOfflineSlashCommand"),
|
|
@@ -129,6 +130,7 @@ const elements = {
|
|
|
129
130
|
backgroundChooseButton: $("#backgroundChooseButton"),
|
|
130
131
|
backgroundClearButton: $("#backgroundClearButton"),
|
|
131
132
|
backgroundStatus: $("#backgroundStatus"),
|
|
133
|
+
networkControlField: $("#networkControlField"),
|
|
132
134
|
networkStatus: $("#networkStatus"),
|
|
133
135
|
remoteAuthToggle: $("#remoteAuthToggle"),
|
|
134
136
|
remoteAuthStatus: $("#remoteAuthStatus"),
|
|
@@ -436,6 +438,9 @@ const GIT_INIT_STACK_STORAGE_KEY = "pi-webui-git-init-stack";
|
|
|
436
438
|
const STATS_WEBUI_STATUS_KEY = "stats-webui";
|
|
437
439
|
const STATS_WEBUI_PAYLOAD_TYPE = "firstpick.pi-extension-stats.overlay";
|
|
438
440
|
const STATS_WEBUI_PAYLOAD_VERSION = 1;
|
|
441
|
+
const REMOTE_WEBUI_CONTROLS_STATUS_KEY = "pi-remote-webui:controls";
|
|
442
|
+
const REMOTE_WEBUI_CONTROLS_PAYLOAD_TYPE = "firstpick.pi-package-remote-webui.controls";
|
|
443
|
+
const REMOTE_WEBUI_CONTROLS_PAYLOAD_VERSION = 1;
|
|
439
444
|
const BTW_WEBUI_STATUS_KEY = "btw-webui";
|
|
440
445
|
const BTW_OUTPUT_WIDGET_KEY = "btw:output";
|
|
441
446
|
const BTW_FOOTER_WIDGET_KEY = "btw:footer";
|
|
@@ -2825,22 +2830,34 @@ function renderUpdateNotification(status = latestUpdateStatus, { force = false }
|
|
|
2825
2830
|
}
|
|
2826
2831
|
|
|
2827
2832
|
const canRunUpdate = latestUpdateStatus.canRunUpdate !== false;
|
|
2833
|
+
const hasPiUpdate = !!latestUpdateStatus.pi?.updateAvailable;
|
|
2834
|
+
const hasPackageUpdate = !!latestUpdateStatus.webui?.updateAvailable;
|
|
2828
2835
|
if (elements.updateNotificationTitle) elements.updateNotificationTitle.textContent = items.length === 1 ? `${items[0]} available` : "Pi updates available";
|
|
2829
2836
|
if (elements.updateNotificationMessage) {
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2837
|
+
let message = "Updates are available. Direct Web UI updates are only enabled from localhost on the host machine.";
|
|
2838
|
+
if (canRunUpdate) {
|
|
2839
|
+
if (hasPiUpdate && hasPackageUpdate) message = "Run pi update for Pi only, or pi update --all to include Web UI/package updates, then restart this Web UI server automatically.";
|
|
2840
|
+
else if (hasPackageUpdate) message = "Run pi update --all to update Web UI/package entries, then restart this Web UI server automatically.";
|
|
2841
|
+
else message = "Run pi update for Pi only, then restart this Web UI server automatically.";
|
|
2842
|
+
}
|
|
2843
|
+
elements.updateNotificationMessage.textContent = message;
|
|
2833
2844
|
}
|
|
2834
2845
|
const details = [
|
|
2835
2846
|
items.join(" · "),
|
|
2836
|
-
latestUpdateStatus.webuiDev && latestUpdateStatus.webui?.updateAvailable ? "The current Web UI is a dev checkout; update
|
|
2847
|
+
latestUpdateStatus.webuiDev && latestUpdateStatus.webui?.updateAvailable ? "The current Web UI is a dev checkout; pi update --all refreshes configured package dependencies when possible." : "",
|
|
2837
2848
|
latestUpdateStatus.packages?.note || "",
|
|
2838
2849
|
].filter(Boolean).join(" ");
|
|
2839
2850
|
if (elements.updateNotificationDetail) elements.updateNotificationDetail.textContent = details;
|
|
2840
2851
|
if (elements.updateNotificationUpdateButton) {
|
|
2841
|
-
elements.updateNotificationUpdateButton.hidden = !canRunUpdate;
|
|
2852
|
+
elements.updateNotificationUpdateButton.hidden = !canRunUpdate || !hasPiUpdate;
|
|
2842
2853
|
elements.updateNotificationUpdateButton.disabled = updateRequestInProgress || latestUpdateStatus.updateInProgress;
|
|
2843
|
-
elements.updateNotificationUpdateButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update & restart";
|
|
2854
|
+
elements.updateNotificationUpdateButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update Pi & restart";
|
|
2855
|
+
}
|
|
2856
|
+
if (elements.updateNotificationUpdateAllButton) {
|
|
2857
|
+
elements.updateNotificationUpdateAllButton.hidden = !canRunUpdate || !hasPackageUpdate;
|
|
2858
|
+
elements.updateNotificationUpdateAllButton.disabled = updateRequestInProgress || latestUpdateStatus.updateInProgress;
|
|
2859
|
+
elements.updateNotificationUpdateAllButton.classList.toggle("primary", !hasPiUpdate);
|
|
2860
|
+
elements.updateNotificationUpdateAllButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update all & restart";
|
|
2844
2861
|
}
|
|
2845
2862
|
clearTimeout(updateNotificationHideTimer);
|
|
2846
2863
|
panel.hidden = false;
|
|
@@ -2871,30 +2888,33 @@ function initializeUpdateNotifications() {
|
|
|
2871
2888
|
}, UPDATE_STATUS_INITIAL_DELAY_MS);
|
|
2872
2889
|
}
|
|
2873
2890
|
|
|
2874
|
-
function piUpdateConfirmationText() {
|
|
2891
|
+
function piUpdateConfirmationText({ all = false } = {}) {
|
|
2875
2892
|
const items = updateNotificationItems();
|
|
2876
2893
|
const workingWarning = hasWorkingTab() ? "\n\nOne or more Pi tabs look busy or blocked. Finish or abort in-flight work before updating if you need to preserve it." : "";
|
|
2877
2894
|
const versionText = items.length ? `\n\nDetected update: ${items.join(" · ")}.` : "";
|
|
2878
|
-
|
|
2895
|
+
const command = all ? "pi update --all" : "pi update";
|
|
2896
|
+
const scope = all ? "Pi and configured package updates" : "Pi only";
|
|
2897
|
+
return `Run ${scope} now?${versionText}\n\nThis will run \"${command}\" on the Web UI host. After it finishes, Pi Web UI will restart itself. Browser clients will briefly disconnect, and managed Pi tabs/RPC processes will be restarted from saved session state when possible.${workingWarning}`;
|
|
2879
2898
|
}
|
|
2880
2899
|
|
|
2881
|
-
async function runPiUpdateAndRestart() {
|
|
2900
|
+
async function runPiUpdateAndRestart({ all = false } = {}) {
|
|
2882
2901
|
if (updateRequestInProgress) return;
|
|
2883
2902
|
if (latestUpdateStatus?.canRunUpdate === false) {
|
|
2884
|
-
addEvent("Pi
|
|
2903
|
+
addEvent("Pi updates can only be started from localhost on the Web UI host", "warn");
|
|
2885
2904
|
renderUpdateNotification(latestUpdateStatus, { force: true });
|
|
2886
2905
|
return;
|
|
2887
2906
|
}
|
|
2888
|
-
if (!confirm(piUpdateConfirmationText())) return;
|
|
2907
|
+
if (!confirm(piUpdateConfirmationText({ all }))) return;
|
|
2889
2908
|
|
|
2909
|
+
const updateLabel = all ? "Pi and package updates" : "Pi update";
|
|
2890
2910
|
updateRequestInProgress = true;
|
|
2891
2911
|
hideUpdateNotification();
|
|
2892
2912
|
setServerActionBusy("Updating…");
|
|
2893
|
-
setServerActionStatus(
|
|
2894
|
-
setServerRestartOverlay(true,
|
|
2913
|
+
setServerActionStatus(`Running ${updateLabel}. The server will restart after the update completes…`, "warn");
|
|
2914
|
+
setServerRestartOverlay(true, `Running ${updateLabel}. The server will restart after the update completes…`);
|
|
2895
2915
|
try {
|
|
2896
|
-
await api("/api/update", { method: "POST", scoped: false });
|
|
2897
|
-
addEvent(
|
|
2916
|
+
await api(all ? "/api/update?all=1" : "/api/update", { method: "POST", scoped: false });
|
|
2917
|
+
addEvent(`${updateLabel} completed; Pi Web UI server restart requested`, "warn");
|
|
2898
2918
|
} catch (error) {
|
|
2899
2919
|
if (!error?.backendOffline) {
|
|
2900
2920
|
updateRequestInProgress = false;
|
|
@@ -3811,6 +3831,11 @@ function setOptionalFeatureDisabled(featureId, disabled) {
|
|
|
3811
3831
|
btwWidgetComposerOpen = false;
|
|
3812
3832
|
btwWidgetInputDraft = "";
|
|
3813
3833
|
}
|
|
3834
|
+
if (featureId === "remoteWebui") {
|
|
3835
|
+
statusEntries.delete(REMOTE_WEBUI_CONTROLS_STATUS_KEY);
|
|
3836
|
+
statusEntries.delete("pi-remote-webui");
|
|
3837
|
+
widgets.delete("pi-remote-webui");
|
|
3838
|
+
}
|
|
3814
3839
|
storeDisabledOptionalFeatures();
|
|
3815
3840
|
renderOptionalFeatureDependentDisplays();
|
|
3816
3841
|
const tabContext = activeTabContext();
|
|
@@ -5978,6 +6003,57 @@ function parseGitFooterWebuiPayloadRaw(raw) {
|
|
|
5978
6003
|
}
|
|
5979
6004
|
}
|
|
5980
6005
|
|
|
6006
|
+
function parseRemoteWebuiControlsPayloadRaw(raw) {
|
|
6007
|
+
if (!raw) return null;
|
|
6008
|
+
try {
|
|
6009
|
+
const parsed = JSON.parse(raw);
|
|
6010
|
+
if (!parsed || parsed.type !== REMOTE_WEBUI_CONTROLS_PAYLOAD_TYPE || parsed.version !== REMOTE_WEBUI_CONTROLS_PAYLOAD_VERSION) return null;
|
|
6011
|
+
if (parsed.featureId !== "remoteWebui") return null;
|
|
6012
|
+
const commands = parsed.commands && typeof parsed.commands === "object" ? parsed.commands : {};
|
|
6013
|
+
return {
|
|
6014
|
+
title: cleanFooterPayloadText(parsed.title, "Remote WebUI", 80),
|
|
6015
|
+
description: cleanFooterPayloadText(parsed.description, "Trusted-LAN browser access controlled by the Remote WebUI package.", 240),
|
|
6016
|
+
commands: {
|
|
6017
|
+
open: typeof commands.open === "string" ? commands.open : "/remote",
|
|
6018
|
+
close: typeof commands.close === "string" ? commands.close : "/remote close",
|
|
6019
|
+
refresh: typeof commands.refresh === "string" ? commands.refresh : "/remote refresh",
|
|
6020
|
+
status: typeof commands.status === "string" ? commands.status : "/remote status",
|
|
6021
|
+
authOn: typeof commands.authOn === "string" ? commands.authOn : "/remote auth on",
|
|
6022
|
+
authOff: typeof commands.authOff === "string" ? commands.authOff : "/remote auth off",
|
|
6023
|
+
},
|
|
6024
|
+
};
|
|
6025
|
+
} catch {
|
|
6026
|
+
return null;
|
|
6027
|
+
}
|
|
6028
|
+
}
|
|
6029
|
+
|
|
6030
|
+
function remoteWebuiControlsPayload() {
|
|
6031
|
+
if (isOptionalFeatureDisabled("remoteWebui")) return null;
|
|
6032
|
+
return parseRemoteWebuiControlsPayloadRaw(statusEntries.get(REMOTE_WEBUI_CONTROLS_STATUS_KEY));
|
|
6033
|
+
}
|
|
6034
|
+
|
|
6035
|
+
function remoteWebuiDefaultPortArg() {
|
|
6036
|
+
const port = Number.parseInt(String(latestNetwork?.port || DEFAULT_WEBUI_PORT), 10);
|
|
6037
|
+
return Number.isFinite(port) && port > 0 && port <= 65535 && String(port) !== DEFAULT_WEBUI_PORT ? ` --port ${port}` : "";
|
|
6038
|
+
}
|
|
6039
|
+
|
|
6040
|
+
function remoteWebuiFallbackCommand(name, fallback) {
|
|
6041
|
+
const portArg = remoteWebuiDefaultPortArg();
|
|
6042
|
+
const commands = {
|
|
6043
|
+
open: `/remote${portArg}`,
|
|
6044
|
+
close: `/remote close${portArg}`,
|
|
6045
|
+
refresh: `/remote refresh${portArg}`,
|
|
6046
|
+
status: `/remote status${portArg}`,
|
|
6047
|
+
authOn: `/remote auth on${portArg}`,
|
|
6048
|
+
authOff: `/remote auth off${portArg}`,
|
|
6049
|
+
};
|
|
6050
|
+
return commands[name] || fallback;
|
|
6051
|
+
}
|
|
6052
|
+
|
|
6053
|
+
function remoteWebuiCommand(name, fallback) {
|
|
6054
|
+
return remoteWebuiControlsPayload()?.commands?.[name] || remoteWebuiFallbackCommand(name, fallback);
|
|
6055
|
+
}
|
|
6056
|
+
|
|
5981
6057
|
function readCachedGitFooterWebuiPayloadRaw() {
|
|
5982
6058
|
try {
|
|
5983
6059
|
const cached = JSON.parse(localStorage.getItem(GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY) || "null");
|
|
@@ -14475,7 +14551,7 @@ function updateOptionalFeatureAvailability() {
|
|
|
14475
14551
|
optionalFeatureAvailability.tuiSkillsCommand = hasLoadedRpcCommand("skills");
|
|
14476
14552
|
optionalFeatureAvailability.todoProgressWidget = hasAvailableCommand("todo-progress-status") || optionalFeatureAvailability.todoProgressWidget || widgets.has("todo-progress");
|
|
14477
14553
|
optionalFeatureAvailability.tuiToolsCommand = hasLoadedRpcCommand("tools");
|
|
14478
|
-
optionalFeatureAvailability.remoteWebui = hasAvailableCommand("remote") || optionalFeatureAvailability.remoteWebui || statusEntries.has("pi-remote-webui") || widgets.has("pi-remote-webui");
|
|
14554
|
+
optionalFeatureAvailability.remoteWebui = hasAvailableCommand("remote") || optionalFeatureAvailability.remoteWebui || statusEntries.has("pi-remote-webui") || statusEntries.has(REMOTE_WEBUI_CONTROLS_STATUS_KEY) || widgets.has("pi-remote-webui");
|
|
14479
14555
|
optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
|
|
14480
14556
|
requestGitFooterWebuiPayload();
|
|
14481
14557
|
renderOptionalFeatureControls();
|
|
@@ -14633,6 +14709,14 @@ function renderOptionalFeatureControls() {
|
|
|
14633
14709
|
optionalFeatureUnavailableMessage("remoteWebui"),
|
|
14634
14710
|
);
|
|
14635
14711
|
}
|
|
14712
|
+
if (elements.networkControlField) {
|
|
14713
|
+
elements.networkControlField.hidden = !hasRemoteWebuiCommand;
|
|
14714
|
+
elements.networkControlField.classList.toggle("feature-unavailable", !hasRemoteWebuiCommand);
|
|
14715
|
+
const label = elements.networkControlField.querySelector("label");
|
|
14716
|
+
const payload = remoteWebuiControlsPayload();
|
|
14717
|
+
if (label) label.textContent = payload?.title || "Remote WebUI";
|
|
14718
|
+
elements.networkControlField.title = hasRemoteWebuiCommand ? payload?.description || "Remote WebUI controls are provided by @firstpick/pi-package-remote-webui." : optionalFeatureUnavailableMessage("remoteWebui");
|
|
14719
|
+
}
|
|
14636
14720
|
|
|
14637
14721
|
renderOptionalFeaturePanel();
|
|
14638
14722
|
}
|
|
@@ -16064,21 +16148,25 @@ async function refreshNetworkStatus() {
|
|
|
16064
16148
|
renderNetworkStatus();
|
|
16065
16149
|
}
|
|
16066
16150
|
|
|
16067
|
-
async function
|
|
16068
|
-
const
|
|
16069
|
-
|
|
16070
|
-
|
|
16071
|
-
|
|
16072
|
-
|
|
16073
|
-
|
|
16074
|
-
return;
|
|
16151
|
+
async function runRemoteWebuiCommand(command) {
|
|
16152
|
+
const commandName = String(command || "").replace(/^\//, "").split(/\s+/, 1)[0] || "remote";
|
|
16153
|
+
if (!isOptionalFeatureEnabled("remoteWebui") || !hasAvailableCommand(commandName)) {
|
|
16154
|
+
const message = commandUnavailableMessage(commandName);
|
|
16155
|
+
addEvent(message, "warn");
|
|
16156
|
+
refreshCommands(activeTabContext()).catch((error) => addEvent(error.message || String(error), "error"));
|
|
16157
|
+
return false;
|
|
16075
16158
|
}
|
|
16159
|
+
await runNativeCommandMenu(command);
|
|
16160
|
+
return true;
|
|
16161
|
+
}
|
|
16076
16162
|
|
|
16163
|
+
async function toggleRemoteAuth() {
|
|
16164
|
+
const enable = !latestNetwork?.auth?.enabled;
|
|
16077
16165
|
elements.remoteAuthToggle.disabled = true;
|
|
16078
16166
|
try {
|
|
16079
|
-
|
|
16080
|
-
|
|
16081
|
-
|
|
16167
|
+
await runRemoteWebuiCommand(remoteWebuiCommand(enable ? "authOn" : "authOff", enable ? "/remote auth on" : "/remote auth off"));
|
|
16168
|
+
await delay(250);
|
|
16169
|
+
await refreshNetworkStatus();
|
|
16082
16170
|
} catch (error) {
|
|
16083
16171
|
addEvent(error.message || String(error), "error");
|
|
16084
16172
|
} finally {
|
|
@@ -16896,35 +16984,15 @@ function scheduleForegroundReconcile(reason = "resume", delay = FOREGROUND_RECON
|
|
|
16896
16984
|
}
|
|
16897
16985
|
|
|
16898
16986
|
async function openToNetwork() {
|
|
16899
|
-
|
|
16900
|
-
await closeNetworkAccess();
|
|
16901
|
-
return;
|
|
16902
|
-
}
|
|
16903
|
-
if (!confirm(`Open Pi Web UI to your local network?\n\nRemote PIN auth is ${latestNetwork?.auth?.enabled ? "ON" : "OFF"}. The Web UI can control Pi/tools, so only do this on a trusted LAN.`)) return;
|
|
16904
|
-
|
|
16987
|
+
const open = !!latestNetwork?.open;
|
|
16905
16988
|
elements.openNetworkButton.disabled = true;
|
|
16906
|
-
elements.openNetworkButton.textContent = "Opening…";
|
|
16989
|
+
elements.openNetworkButton.textContent = open ? "Closing…" : "Opening…";
|
|
16907
16990
|
try {
|
|
16908
|
-
await
|
|
16909
|
-
|
|
16910
|
-
renderNetworkStatus();
|
|
16911
|
-
addEvent("opening webui to local network", "warn");
|
|
16912
|
-
for (let attempt = 0; attempt < 20; attempt++) {
|
|
16913
|
-
await delay(350);
|
|
16914
|
-
try {
|
|
16915
|
-
await refreshNetworkStatus();
|
|
16916
|
-
if (latestNetwork?.open && !latestNetwork?.opening) {
|
|
16917
|
-
const url = latestNetwork.networkUrls?.[0];
|
|
16918
|
-
addEvent(`webui open to local network${url ? `: ${url}` : ""}`, "warn");
|
|
16919
|
-
return;
|
|
16920
|
-
}
|
|
16921
|
-
} catch {
|
|
16922
|
-
// The listener briefly drops while rebinding; retry.
|
|
16923
|
-
}
|
|
16924
|
-
}
|
|
16991
|
+
await runRemoteWebuiCommand(remoteWebuiCommand(open ? "close" : "open", open ? "/remote close" : "/remote"));
|
|
16992
|
+
await delay(350);
|
|
16925
16993
|
await refreshNetworkStatus();
|
|
16926
16994
|
} catch (error) {
|
|
16927
|
-
addEvent(error.message, "error");
|
|
16995
|
+
addEvent(error.message || String(error), "error");
|
|
16928
16996
|
} finally {
|
|
16929
16997
|
renderNetworkStatus();
|
|
16930
16998
|
}
|
|
@@ -16932,41 +17000,7 @@ async function openToNetwork() {
|
|
|
16932
17000
|
|
|
16933
17001
|
async function closeNetworkAccess() {
|
|
16934
17002
|
if (!latestNetwork?.open) return;
|
|
16935
|
-
|
|
16936
|
-
|
|
16937
|
-
elements.openNetworkButton.disabled = true;
|
|
16938
|
-
elements.openNetworkButton.textContent = "Closing…";
|
|
16939
|
-
try {
|
|
16940
|
-
await api("/api/network/close", { method: "POST", scoped: false });
|
|
16941
|
-
latestNetwork = { ...(latestNetwork || {}), opening: false, closing: true };
|
|
16942
|
-
renderNetworkStatus();
|
|
16943
|
-
addEvent("closing webui network access", "warn");
|
|
16944
|
-
let refreshFailed = false;
|
|
16945
|
-
for (let attempt = 0; attempt < 20; attempt++) {
|
|
16946
|
-
await delay(350);
|
|
16947
|
-
try {
|
|
16948
|
-
await refreshNetworkStatus();
|
|
16949
|
-
if (!latestNetwork?.open && !latestNetwork?.closing) {
|
|
16950
|
-
addEvent("webui closed to local-only access", "warn");
|
|
16951
|
-
return;
|
|
16952
|
-
}
|
|
16953
|
-
} catch {
|
|
16954
|
-
refreshFailed = true;
|
|
16955
|
-
// Remote tabs will lose access after the listener returns to localhost.
|
|
16956
|
-
}
|
|
16957
|
-
}
|
|
16958
|
-
if (refreshFailed) {
|
|
16959
|
-
latestNetwork = { ...(latestNetwork || {}), open: false, opening: false, closing: false, networkUrls: [] };
|
|
16960
|
-
renderNetworkStatus();
|
|
16961
|
-
addEvent("webui network access closed; reconnect from this machine if this tab loses access", "warn");
|
|
16962
|
-
return;
|
|
16963
|
-
}
|
|
16964
|
-
addEvent("network close requested, but the server still reports network access open", "warn");
|
|
16965
|
-
} catch (error) {
|
|
16966
|
-
addEvent(error.message, "error");
|
|
16967
|
-
} finally {
|
|
16968
|
-
renderNetworkStatus();
|
|
16969
|
-
}
|
|
17003
|
+
await openToNetwork();
|
|
16970
17004
|
}
|
|
16971
17005
|
|
|
16972
17006
|
function setServerActionStatus(message = "", level = "info") {
|
|
@@ -16982,10 +17016,11 @@ function updateServerActionButton() {
|
|
|
16982
17016
|
const button = elements.runServerActionButton;
|
|
16983
17017
|
if (!button) return;
|
|
16984
17018
|
button.disabled = !action;
|
|
16985
|
-
button.textContent = action === "restart" ? "Restart" : action === "update" ? "Update" : action === "stop" ? "Stop" : "Run";
|
|
17019
|
+
button.textContent = action === "restart" ? "Restart" : action === "update" || action === "update-all" ? "Update" : action === "stop" ? "Stop" : "Run";
|
|
16986
17020
|
button.classList.toggle("danger", action === "stop");
|
|
16987
17021
|
if (action === "restart") setServerActionStatus("Ready to restart the Web UI server.", "info");
|
|
16988
|
-
else if (action === "update") setServerActionStatus("Ready to run pi update, then restart the Web UI server.", "info");
|
|
17022
|
+
else if (action === "update") setServerActionStatus("Ready to run pi update for Pi only, then restart the Web UI server.", "info");
|
|
17023
|
+
else if (action === "update-all") setServerActionStatus("Ready to run pi update --all for Pi and configured packages, then restart the Web UI server.", "info");
|
|
16989
17024
|
else if (action === "stop") setServerActionStatus("Ready to stop the Web UI server.", "info");
|
|
16990
17025
|
else setServerActionStatus();
|
|
16991
17026
|
}
|
|
@@ -17093,6 +17128,7 @@ async function runSelectedServerAction() {
|
|
|
17093
17128
|
const action = elements.serverActionSelect?.value || "";
|
|
17094
17129
|
if (action === "restart") await restartServer();
|
|
17095
17130
|
else if (action === "update") await runPiUpdateAndRestart();
|
|
17131
|
+
else if (action === "update-all") await runPiUpdateAndRestart({ all: true });
|
|
17096
17132
|
else if (action === "stop") await stopServer();
|
|
17097
17133
|
}
|
|
17098
17134
|
|
|
@@ -18477,6 +18513,7 @@ elements.openNetworkButton.addEventListener("click", openToNetwork);
|
|
|
18477
18513
|
elements.serverActionSelect.addEventListener("change", updateServerActionButton);
|
|
18478
18514
|
elements.runServerActionButton.addEventListener("click", () => runSelectedServerAction().catch((error) => addEvent(error.message || String(error), "error")));
|
|
18479
18515
|
elements.updateNotificationUpdateButton?.addEventListener("click", () => runPiUpdateAndRestart().catch((error) => addEvent(error.message || String(error), "error")));
|
|
18516
|
+
elements.updateNotificationUpdateAllButton?.addEventListener("click", () => runPiUpdateAndRestart({ all: true }).catch((error) => addEvent(error.message || String(error), "error")));
|
|
18480
18517
|
elements.updateNotificationDismissButton?.addEventListener("click", () => hideUpdateNotification({ remember: true }));
|
|
18481
18518
|
updateServerActionButton();
|
|
18482
18519
|
elements.agentDoneNotificationsToggle.addEventListener("change", () => {
|
package/public/index.html
CHANGED
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
<p id="updateNotificationMessage">A newer Pi version is available.</p>
|
|
53
53
|
<p id="updateNotificationDetail" class="update-notification-detail muted"></p>
|
|
54
54
|
<div class="update-notification-actions">
|
|
55
|
-
<button id="updateNotificationUpdateButton" class="primary" type="button">Update & restart</button>
|
|
55
|
+
<button id="updateNotificationUpdateButton" class="primary" type="button">Update Pi & restart</button>
|
|
56
|
+
<button id="updateNotificationUpdateAllButton" type="button">Update all & restart</button>
|
|
56
57
|
<button id="updateNotificationDismissButton" type="button">Later</button>
|
|
57
58
|
</div>
|
|
58
59
|
</div>
|
|
@@ -379,8 +380,8 @@
|
|
|
379
380
|
</div>
|
|
380
381
|
<div id="backgroundStatus" class="background-status muted">Theme default</div>
|
|
381
382
|
</div>
|
|
382
|
-
<div class="control-field network-control-field">
|
|
383
|
-
<label>
|
|
383
|
+
<div id="networkControlField" class="control-field network-control-field" hidden>
|
|
384
|
+
<label>Remote WebUI</label>
|
|
384
385
|
<div id="networkStatus" class="network-status closed">Local only</div>
|
|
385
386
|
<label class="toggle-control remote-auth-toggle" for="remoteAuthToggle">
|
|
386
387
|
<input id="remoteAuthToggle" type="checkbox" />
|
|
@@ -398,6 +399,7 @@
|
|
|
398
399
|
<option value="" selected>Choose action…</option>
|
|
399
400
|
<option value="restart">Restart Server</option>
|
|
400
401
|
<option value="update">Update Pi & Restart</option>
|
|
402
|
+
<option value="update-all">Update Pi + Packages & Restart</option>
|
|
401
403
|
<option value="stop">Stop Server</option>
|
|
402
404
|
</select>
|
|
403
405
|
<button id="runServerActionButton" type="button" disabled>Run</button>
|
|
@@ -37,6 +37,22 @@ async function request(host, pathname, { method = "GET", body, timeoutMs = 5_000
|
|
|
37
37
|
return { status: response.status, body: payload };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function runGitFixture(args, cwd, message) {
|
|
41
|
+
const result = spawnSync("git", args, {
|
|
42
|
+
cwd,
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
env: {
|
|
45
|
+
...process.env,
|
|
46
|
+
GIT_AUTHOR_NAME: "Pi WebUI Test",
|
|
47
|
+
GIT_AUTHOR_EMAIL: "pi-webui-test@example.invalid",
|
|
48
|
+
GIT_COMMITTER_NAME: "Pi WebUI Test",
|
|
49
|
+
GIT_COMMITTER_EMAIL: "pi-webui-test@example.invalid",
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
assert.equal(result.status, 0, `${message}\n$ git ${args.join(" ")}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
|
53
|
+
return result.stdout.trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
const cwd = await mkdtemp(path.join(tmpdir(), "pi-webui-http-harness-"));
|
|
41
57
|
const settingsFile = path.join(cwd, "webui-settings.json");
|
|
42
58
|
await chmod(fakePi, 0o755);
|
|
@@ -152,6 +168,47 @@ try {
|
|
|
152
168
|
assert.equal(gitMain.status, 200);
|
|
153
169
|
assert.equal(gitMain.body?.ok, true, "main branch endpoint should rename the branch");
|
|
154
170
|
|
|
171
|
+
const remoteFixtureRoot = await mkdtemp(path.join(tmpdir(), "pi-webui-git-remote-"));
|
|
172
|
+
const remoteBare = path.join(remoteFixtureRoot, "origin.git");
|
|
173
|
+
const localRepo = path.join(remoteFixtureRoot, "local");
|
|
174
|
+
const remoteWork = path.join(remoteFixtureRoot, "remote-work");
|
|
175
|
+
runGitFixture(["init", "--bare", remoteBare], remoteFixtureRoot, "remote fixture should initialize a bare origin");
|
|
176
|
+
runGitFixture(["init", localRepo], remoteFixtureRoot, "remote fixture should initialize a local repo");
|
|
177
|
+
runGitFixture(["config", "user.name", "Pi WebUI Test"], localRepo, "local repo should set a user name");
|
|
178
|
+
runGitFixture(["config", "user.email", "pi-webui-test@example.invalid"], localRepo, "local repo should set a user email");
|
|
179
|
+
await writeFile(path.join(localRepo, "incoming.txt"), "base\n");
|
|
180
|
+
runGitFixture(["add", "incoming.txt"], localRepo, "local repo should stage base content");
|
|
181
|
+
runGitFixture(["commit", "-m", "base"], localRepo, "local repo should commit base content");
|
|
182
|
+
runGitFixture(["branch", "-M", "main"], localRepo, "local repo should rename main branch");
|
|
183
|
+
runGitFixture(["remote", "add", "origin", remoteBare], localRepo, "local repo should add bare origin");
|
|
184
|
+
runGitFixture(["push", "-u", "origin", "main"], localRepo, "local repo should push main to bare origin");
|
|
185
|
+
runGitFixture(["symbolic-ref", "HEAD", "refs/heads/main"], remoteBare, "bare origin should advertise main as HEAD");
|
|
186
|
+
runGitFixture(["clone", remoteBare, remoteWork], remoteFixtureRoot, "remote worktree should clone bare origin");
|
|
187
|
+
runGitFixture(["config", "user.name", "Pi WebUI Test"], remoteWork, "remote worktree should set a user name");
|
|
188
|
+
runGitFixture(["config", "user.email", "pi-webui-test@example.invalid"], remoteWork, "remote worktree should set a user email");
|
|
189
|
+
await writeFile(path.join(remoteWork, "incoming.txt"), "base\nremote one\n");
|
|
190
|
+
runGitFixture(["commit", "-am", "remote one"], remoteWork, "remote worktree should commit first incoming change");
|
|
191
|
+
await writeFile(path.join(remoteWork, "incoming.txt"), "base\nremote one\nremote two\n");
|
|
192
|
+
runGitFixture(["commit", "-am", "remote two"], remoteWork, "remote worktree should commit second incoming change");
|
|
193
|
+
runGitFixture(["push", "origin", "main"], remoteWork, "remote worktree should push incoming commits");
|
|
194
|
+
runGitFixture(["fetch", "origin"], localRepo, "local repo should fetch incoming commits");
|
|
195
|
+
|
|
196
|
+
const remoteTab = await request("127.0.0.1", "/api/tabs", { method: "POST", body: { cwd: localRepo, title: "remote-behind-fixture" } });
|
|
197
|
+
assert.equal(remoteTab.status, 201);
|
|
198
|
+
const remoteTabId = remoteTab.body?.data?.tab?.id;
|
|
199
|
+
assert.ok(remoteTabId, "remote fixture tab should have an id");
|
|
200
|
+
const incomingChanges = await request("127.0.0.1", `/api/git-changes?tab=${encodeURIComponent(remoteTabId)}`);
|
|
201
|
+
assert.equal(incomingChanges.status, 200);
|
|
202
|
+
assert.equal(incomingChanges.body?.ok, true, "git changes endpoint should load a fetched-behind repo");
|
|
203
|
+
assert.equal(incomingChanges.body?.data?.summary?.behind, 2, "git changes endpoint should report two fetched commits behind");
|
|
204
|
+
assert.equal(incomingChanges.body?.data?.remote?.canPull, true, "git changes endpoint should mark fetched commits as pullable");
|
|
205
|
+
assert.ok(incomingChanges.body?.data?.sections?.some((section) => section.key === "incoming"), "git changes endpoint should include an incoming diff section");
|
|
206
|
+
|
|
207
|
+
const pullIncoming = await request("127.0.0.1", "/api/git-changes/pull", { method: "POST", body: { tab: remoteTabId }, timeoutMs: 20_000 });
|
|
208
|
+
assert.equal(pullIncoming.status, 200);
|
|
209
|
+
assert.equal(pullIncoming.body?.ok, true, "pull endpoint should fast-forward fetched incoming commits");
|
|
210
|
+
assert.equal(pullIncoming.body?.data?.changes?.summary?.behind, 0, "pull endpoint should refresh changes with no remote commits left behind");
|
|
211
|
+
|
|
155
212
|
const gitRemote = await request("127.0.0.1", "/api/git-workflow/remote", { method: "POST", body: { username: "Firstp1ck", repoName: "pi-webui-http-harness", tab: tabId } });
|
|
156
213
|
assert.equal(gitRemote.status, 200);
|
|
157
214
|
assert.equal(gitRemote.body?.ok, true, "remote endpoint should add origin without pushing");
|
|
@@ -357,6 +357,7 @@ assert.match(app, /function updateGitChangesCurrentFileHeader\(\)[\s\S]*?querySe
|
|
|
357
357
|
assert.match(server, /async function readGitUntrackedEntry\(root, file\)[\s\S]*?content: binary \? "" : buffer\.toString\("utf8"\)/, "server should include complete text contents for untracked files");
|
|
358
358
|
assert.match(server, /url\.pathname === "\/api\/git-changes\/untracked-file" && req\.method === "GET"/, "server should expose a focused untracked-file content endpoint for stale path-only payload fallbacks");
|
|
359
359
|
assert.match(server, /async function readGitChanges\(cwd\)[\s\S]*?const diffArgs = \["diff", "--no-ext-diff"[\s\S]*?"--unified=0"[\s\S]*?\["diff", "--cached"/, "server should collect compact staged and unstaged git diffs for the changes modal");
|
|
360
|
+
assert.match(server, /\["status", "--porcelain=2", "--branch", "--untracked-files=all"\][\s\S]*?summarizeGitPorcelainStatus\(porcelainStatusText\)/, "server should derive behind/ahead from locale-independent porcelain status so the Pull button activates after fetch");
|
|
360
361
|
assert.match(server, /async function readGitIncomingChanges\(root, summary\)[\s\S]*?"HEAD\.\.@\{upstream\}"/, "server should collect incoming upstream diffs when remote commits are behind");
|
|
361
362
|
assert.match(server, /url\.pathname === "\/api\/git-changes" && req\.method === "GET"/, "server should expose GET /api/git-changes for the changes modal");
|
|
362
363
|
assert.match(server, /url\.pathname === "\/api\/git-changes\/pull" && req\.method === "POST"[\s\S]*?pullGitChanges\(tab\.cwd\)/, "server should expose POST /api/git-changes/pull for the changes modal Pull button");
|
|
@@ -432,8 +433,9 @@ assert.match(app, /initializeCustomBackground\(\)\.catch/, "startup should resto
|
|
|
432
433
|
assert.match(app, /Restart Web UI to load themes/, "frontend should explain when a stale server cannot serve the themes endpoint");
|
|
433
434
|
assert.match(app, /themeSelect\.addEventListener\("change"/, "side-panel theme selector should switch themes immediately");
|
|
434
435
|
assert.match(app, /open \? "Close for network" : "Open to network"/, "network button should toggle from open to close action");
|
|
435
|
-
assert.match(app, /remoteAuthToggle: \$\("#remoteAuthToggle"\)/, "
|
|
436
|
-
assert.match(
|
|
436
|
+
assert.match(app, /remoteAuthToggle: \$\("#remoteAuthToggle"\)/, "Remote WebUI controls should bind the remote PIN auth toggle");
|
|
437
|
+
assert.match(html, /id="networkControlField"[^>]*hidden/, "Remote WebUI browser controls should be hidden until the optional package is loaded and enabled");
|
|
438
|
+
assert.match(app, /remoteWebuiCommand\(enable \? "authOn" : "authOff"/, "remote PIN auth toggle should dispatch through the Remote WebUI package command");
|
|
437
439
|
assert.match(server, /function webuiSettingsFile\(\)[\s\S]*pi-webui[\s\S]*settings\.json/, "server should persist Web UI settings under a pi-webui settings file");
|
|
438
440
|
assert.match(server, /let persistedRemoteAuthEnabled = await readPersistedRemoteAuthEnabled\(\)/, "server should load the saved Remote PIN auth preference before startup");
|
|
439
441
|
assert.match(server, /if \(remoteAuthStartupEnabled\(\)\) enableRemoteAuth\(remoteAuthStartupReason\(\)\)/, "saved Remote PIN auth preference should enable auth on startup");
|
|
@@ -441,7 +443,7 @@ assert.match(server, /await saveRemoteAuthPreference\(true\)/, "enabling Remote
|
|
|
441
443
|
assert.match(server, /await saveRemoteAuthPreference\(false\)/, "disabling Remote PIN auth should persist the off preference");
|
|
442
444
|
assert.match(server, /function pinFromHash\(\)[\s\S]*new URLSearchParams\(String\(window\.location\.hash \|\| ""\)\.replace\(\/\^#\/, ""\)\)/, "remote auth page should read QR-provided PINs from the URL fragment");
|
|
443
445
|
assert.match(server, /window\.history\.replaceState\(null, "", window\.location\.pathname \+ \(window\.location\.search \|\| ""\)\)/, "remote auth page should scrub fragment PINs from the address bar before authenticating");
|
|
444
|
-
assert.match(app, /
|
|
446
|
+
assert.match(app, /remoteWebuiCommand\(open \? "close" : "open"/, "network open\/close button should dispatch through the Remote WebUI package command");
|
|
445
447
|
assert.match(app, /webuiVersionBadge: \$\("#webuiVersionBadge"\)/, "frontend should bind the Control Deck version badge");
|
|
446
448
|
assert.match(app, /webuiDevBadge: \$\("#webuiDevBadge"\)/, "frontend should bind the Control Deck dev badge");
|
|
447
449
|
assert.match(app, /function refreshWebuiVersion\(\)[\s\S]*api\("\/api\/health", \{ scoped: false \}\)[\s\S]*setWebuiVersion\(health\.webuiVersion\)[\s\S]*setWebuiDevServer\(isWebuiDevMetadata\(health\)\)/, "frontend should load Web UI version and dev mode from health metadata");
|
|
@@ -456,7 +458,7 @@ assert.match(server, /cwdExplicit: false/, "server should track whether startup
|
|
|
456
458
|
assert.match(server, /return options\.cwdExplicit \? \[await createTab\(\)\] : \[\]/, "server should wait for UI cwd selection when no --cwd is supplied");
|
|
457
459
|
assert.match(server, /async function resolvedPiCliScript\(\)[\s\S]*require\.resolve\.paths\(PI_CODING_AGENT_PACKAGE\)[\s\S]*nodeModulesRoot[\s\S]*dist[\s\S]*cli\.js/, "server should resolve the bundled Pi CLI through Node resolution roots so hoisted global installs can spawn RPC tabs");
|
|
458
460
|
assert.match(server, /const bundledCli = await resolvedPiCliScript\(\)/, "standalone server should prefer the resolved Pi CLI script before falling back to PATH pi");
|
|
459
|
-
assert.match(server, /if \(options\.piBinExplicit\) \{\n\s+const command = await resolvePiCommand\(\
|
|
461
|
+
assert.match(server, /if \(options\.piBinExplicit\) \{\n\s+const command = await resolvePiCommand\(updateArgs\)/, "explicit --pi JavaScript launchers should also work for update commands");
|
|
460
462
|
assert.match(app, /serverActionSelect\.addEventListener\("change", updateServerActionButton\)/, "Server action dropdown should control the guarded run button");
|
|
461
463
|
assert.match(app, /runServerActionButton\.addEventListener\("click"[\s\S]*runSelectedServerAction/, "Server action run button should execute the selected action");
|
|
462
464
|
assert.match(app, /api\("\/api\/restart", \{ method: "POST", scoped: false \}\)/, "Restart Server action should call the unscoped restart endpoint");
|
|
@@ -466,10 +468,12 @@ assert.match(app, /api\("\/api\/shutdown", \{ method: "POST", scoped: false \}\)
|
|
|
466
468
|
assert.match(server, /url\.pathname === "\/api\/restart" && req\.method === "POST"/, "server should expose restart endpoint");
|
|
467
469
|
assert.match(server, /PI_WEBUI_RESTORE_TABS: JSON\.stringify\(restorableTabs \|\| \[\]\)/, "server restart should preserve restorable tab metadata");
|
|
468
470
|
assert.match(server, /if \(webuiDevServer\) env\.PI_WEBUI_DEV = "1";/, "server restart should explicitly preserve dev mode");
|
|
469
|
-
assert.match(server, /
|
|
470
|
-
assert.match(server, /function
|
|
471
|
-
assert.match(app, /
|
|
472
|
-
assert.match(
|
|
471
|
+
assert.match(server, /const updateArgs = all \? \["update", "--all"\] : \["update"\]/, "server update should use pi update by default and pi update --all for package-inclusive updates");
|
|
472
|
+
assert.match(server, /async function resolveUpdateTasks\(\{ all = false \} = \{\}\)[\s\S]*await resolvePiUpdateCommand\(\{ all \}\)/, "server update should resolve a single Pi update command with the selected all mode");
|
|
473
|
+
assert.match(app, /const command = all \? "pi update --all" : "pi update"/, "frontend update confirmation should describe self-only and all update commands");
|
|
474
|
+
assert.match(app, /api\(all \? "\/api\/update\?all=1" : "\/api\/update"/, "frontend all update should call the explicit all-mode endpoint");
|
|
475
|
+
assert.match(html, /<option value="update-all">Update Pi \+ Packages & Restart<\/option>/, "side panel should expose pi update --all as a separate server action");
|
|
476
|
+
assert.match(readme, /`pi update` for Pi-only updates[\s\S]*`pi update --all` for Pi plus configured packages/, "README should document self-only and all update modes");
|
|
473
477
|
assert.match(server, /async function closeNetworkAccess\(\)/, "server should expose a local-only rebind helper for closing network access");
|
|
474
478
|
assert.match(server, /url\.pathname === "\/api\/network\/close" && req\.method === "POST"/, "server should route network close requests");
|
|
475
479
|
assert.match(server, /server\.closeAllConnections\?\.\(\)/, "network rebind should force-close long-lived clients so close-to-localhost can complete");
|
|
@@ -626,8 +630,9 @@ assert.match(app, /id: "tuiSkillsCommand"[\s\S]*?@firstpick\/pi-extension-setup-
|
|
|
626
630
|
assert.match(app, /id: "tuiToolsCommand"[\s\S]*?@firstpick\/pi-extension-tools/, "optional features should include the TUI tools command companion");
|
|
627
631
|
assert.match(app, /id: "remoteWebui"[\s\S]*?@firstpick\/pi-package-remote-webui/, "optional features should include the Remote WebUI companion");
|
|
628
632
|
assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasAvailableCommand\("safety-guard"\)[\s\S]*hasLoadedRpcCommand\("skills"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)[\s\S]*hasLoadedRpcCommand\("tools"\)[\s\S]*hasAvailableCommand\("remote"\)/, "optional feature detection should call RPC-visible commands directly and distinguish native resource selectors from TUI companions");
|
|
629
|
-
assert.match(app, /hasRemoteWebuiCommand = isOptionalFeatureEnabled\("remoteWebui"\) && hasAvailableCommand\("remote"\)[\s\S]*optionsRemoteButton\.hidden = !hasRemoteWebuiCommand/, "Options menu should show
|
|
633
|
+
assert.match(app, /hasRemoteWebuiCommand = isOptionalFeatureEnabled\("remoteWebui"\) && hasAvailableCommand\("remote"\)[\s\S]*optionsRemoteButton\.hidden = !hasRemoteWebuiCommand[\s\S]*networkControlField\.hidden = !hasRemoteWebuiCommand/, "Options menu and browser controls should show Remote WebUI only when /remote is loaded and enabled");
|
|
630
634
|
assert.match(app, /if \(key === "pi-remote-webui"\) return "remoteWebui"/, "optional feature handling should recognize Remote WebUI widget events without rendering them as overlays");
|
|
635
|
+
assert.match(app, /REMOTE_WEBUI_CONTROLS_PAYLOAD_TYPE = "firstpick\.pi-package-remote-webui\.controls"/, "Remote WebUI package should announce browser controls through a package-owned status payload");
|
|
631
636
|
assert.match(app, /function combineIdenticalDuplicateCommands\(commands\)[\s\S]*duplicateGroups[\s\S]*duplicateCount: group\.length/, "identical duplicate RPC commands should be combined into one visible command entry");
|
|
632
637
|
assert.match(app, /if \(kind === "prompt" && attachments\.length === 0\) message = resolveRpcSlashCommandMessage\(message\)/, "manual slash prompts should resolve combined duplicate command aliases before reaching Pi RPC");
|
|
633
638
|
assert.match(app, /if \(!isOptionalFeatureEnabled\("todoProgressWidget"\)\) return String\(text \|\| ""\)/, "todo progress line stripping should only run when the todo feature is detected and enabled");
|