@firstpick/pi-package-webui 0.4.6 → 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 +204 -65
- package/package.json +10 -3
- package/public/app.js +268 -113
- package/public/index.html +9 -4
- package/public/styles.css +39 -0
- package/tests/http-endpoints-harness.test.mjs +105 -0
- package/tests/mobile-static.test.mjs +23 -11
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
|
@@ -640,7 +640,8 @@ function makeHttpError(statusCode, message) {
|
|
|
640
640
|
}
|
|
641
641
|
|
|
642
642
|
function sendError(res, statusCode, error) {
|
|
643
|
-
|
|
643
|
+
const message = statusCode >= 500 ? sanitizeError(error) : formatCliError(error);
|
|
644
|
+
sendJson(res, statusCode, { ok: false, error: message });
|
|
644
645
|
}
|
|
645
646
|
|
|
646
647
|
function formatBytes(bytes) {
|
|
@@ -1627,22 +1628,85 @@ function normalizeCustomRunnerDefinition(raw, projectRoot, { strict = false } =
|
|
|
1627
1628
|
return { id, label, command, path: filePath, args };
|
|
1628
1629
|
}
|
|
1629
1630
|
|
|
1630
|
-
|
|
1631
|
+
function customAppRunnerDiagnostic(severity, message, runner = {}) {
|
|
1632
|
+
const source = runner && typeof runner === "object" ? runner : {};
|
|
1633
|
+
return {
|
|
1634
|
+
severity,
|
|
1635
|
+
message,
|
|
1636
|
+
runnerId: source.id || "",
|
|
1637
|
+
runnerLabel: source.label || "",
|
|
1638
|
+
path: source.path || source.projectFile || "",
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function directCustomRunnerUnavailableReason(filePath, stats) {
|
|
1643
|
+
if (process.platform !== "win32" && stats && (stats.mode & 0o111) === 0) {
|
|
1644
|
+
return `Path is not executable: ${filePath}. Run chmod +x ${filePath} or set Command to bash, python3, node, etc.`;
|
|
1645
|
+
}
|
|
1646
|
+
return "";
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
async function customAppRunnerUnavailableReason(projectRoot, runner) {
|
|
1650
|
+
const filePath = runner.path;
|
|
1651
|
+
let stats;
|
|
1652
|
+
try {
|
|
1653
|
+
stats = await fileStatsIfExists(resolveProjectRelativePath(projectRoot, filePath));
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
return `Cannot access path ${filePath}: ${formatCliError(error)}`;
|
|
1656
|
+
}
|
|
1657
|
+
if (!stats?.isFile()) return `Path to file does not exist: ${filePath}`;
|
|
1658
|
+
const command = cleanCustomRunnerCommand(runner.command);
|
|
1659
|
+
const directReason = command === "./" ? directCustomRunnerUnavailableReason(filePath, stats) : "";
|
|
1660
|
+
if (directReason) return directReason;
|
|
1661
|
+
const commandParts = customRunnerCommandParts(command);
|
|
1662
|
+
if (command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) return `Command is not available: ${commandParts[0]}`;
|
|
1663
|
+
return "";
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
async function readAppRunnerConfig(projectRoot, { strictRead = false } = {}) {
|
|
1631
1667
|
const configPath = path.join(projectRoot, APP_RUNNER_CONFIG_FILE);
|
|
1632
|
-
|
|
1633
|
-
const
|
|
1668
|
+
let source = {};
|
|
1669
|
+
const diagnostics = [];
|
|
1670
|
+
try {
|
|
1671
|
+
const parsed = JSON.parse(await readFile(configPath, "utf8"));
|
|
1672
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1673
|
+
source = parsed;
|
|
1674
|
+
} else {
|
|
1675
|
+
const message = `${APP_RUNNER_CONFIG_FILE} must contain a JSON object`;
|
|
1676
|
+
if (strictRead) throw makeHttpError(400, message);
|
|
1677
|
+
diagnostics.push(customAppRunnerDiagnostic("error", message));
|
|
1678
|
+
}
|
|
1679
|
+
} catch (error) {
|
|
1680
|
+
if (error?.code !== "ENOENT") {
|
|
1681
|
+
const message = `Cannot read ${APP_RUNNER_CONFIG_FILE}: ${formatCliError(error)}`;
|
|
1682
|
+
if (strictRead) throw makeHttpError(400, message);
|
|
1683
|
+
diagnostics.push(customAppRunnerDiagnostic("error", message));
|
|
1684
|
+
console.warn(`failed to read custom app runner config ${configPath}: ${sanitizeError(error)}`);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
if (source.runners !== undefined && !Array.isArray(source.runners)) {
|
|
1688
|
+
const message = `${APP_RUNNER_CONFIG_FILE} runners must be an array`;
|
|
1689
|
+
if (strictRead) throw makeHttpError(400, message);
|
|
1690
|
+
diagnostics.push(customAppRunnerDiagnostic("error", message));
|
|
1691
|
+
}
|
|
1634
1692
|
const rawRunners = Array.isArray(source.runners) ? source.runners : [];
|
|
1635
1693
|
const runners = [];
|
|
1636
1694
|
for (const raw of rawRunners) {
|
|
1637
1695
|
try {
|
|
1638
1696
|
const runner = normalizeCustomRunnerDefinition(raw, projectRoot);
|
|
1639
|
-
if (
|
|
1697
|
+
if (runners.some((item) => item.id === runner.id)) {
|
|
1698
|
+
diagnostics.push(customAppRunnerDiagnostic("warning", `Duplicate custom runner ignored: ${runner.label || runner.path || runner.id}`, runner));
|
|
1699
|
+
} else {
|
|
1700
|
+
runners.push(runner);
|
|
1701
|
+
}
|
|
1640
1702
|
} catch (error) {
|
|
1703
|
+
const message = `Invalid custom runner ignored: ${formatCliError(error)}`;
|
|
1704
|
+
diagnostics.push(customAppRunnerDiagnostic("error", message, raw));
|
|
1641
1705
|
console.warn(`skipping invalid custom app runner in ${configPath}: ${sanitizeError(error)}`);
|
|
1642
1706
|
}
|
|
1643
1707
|
if (runners.length >= APP_RUNNER_CUSTOM_LIMIT) break;
|
|
1644
1708
|
}
|
|
1645
|
-
return { projectRoot, configPath, runners };
|
|
1709
|
+
return { projectRoot, configPath, runners, diagnostics };
|
|
1646
1710
|
}
|
|
1647
1711
|
|
|
1648
1712
|
async function writeAppRunnerConfig(projectRoot, runners) {
|
|
@@ -1660,15 +1724,12 @@ async function writeAppRunnerConfig(projectRoot, runners) {
|
|
|
1660
1724
|
|
|
1661
1725
|
async function customAppRunnerCandidate(projectRoot, configPath, runner) {
|
|
1662
1726
|
const filePath = runner.path;
|
|
1663
|
-
|
|
1664
|
-
const stats = await fileStatsIfExists(absolutePath);
|
|
1665
|
-
if (!stats?.isFile()) return null;
|
|
1727
|
+
if (await customAppRunnerUnavailableReason(projectRoot, runner)) return null;
|
|
1666
1728
|
const command = cleanCustomRunnerCommand(runner.command);
|
|
1667
1729
|
const args = parseCustomRunnerArgs(runner.args);
|
|
1668
1730
|
const commandParts = customRunnerCommandParts(command);
|
|
1669
1731
|
const effectiveCommand = command === "./" ? `./${filePath}` : commandParts[0];
|
|
1670
1732
|
const effectiveArgs = command === "./" ? args : [...commandParts.slice(1), filePath, ...args];
|
|
1671
|
-
if (command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) return null;
|
|
1672
1733
|
return appRunnerCandidate({
|
|
1673
1734
|
id: appRunnerId("custom", runner.id),
|
|
1674
1735
|
label: runner.label || path.basename(filePath),
|
|
@@ -1696,24 +1757,32 @@ async function addCustomAppRunners(runners, cwd) {
|
|
|
1696
1757
|
async function getCustomAppRunnerConfigData(tab) {
|
|
1697
1758
|
const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
|
|
1698
1759
|
const config = await readAppRunnerConfig(projectRoot);
|
|
1760
|
+
const runners = [];
|
|
1761
|
+
for (const runner of config.runners) {
|
|
1762
|
+
const unavailableReason = await customAppRunnerUnavailableReason(projectRoot, runner);
|
|
1763
|
+
runners.push({
|
|
1764
|
+
...publicCustomRunnerDefinition(runner),
|
|
1765
|
+
available: !unavailableReason,
|
|
1766
|
+
unavailableReason,
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1699
1769
|
return {
|
|
1700
1770
|
projectRoot,
|
|
1701
1771
|
displayProjectRoot: displayPath(projectRoot),
|
|
1702
1772
|
configFile: config.configPath,
|
|
1703
1773
|
displayConfigFile: displayPath(config.configPath),
|
|
1704
1774
|
relativeConfigFile: APP_RUNNER_CONFIG_FILE,
|
|
1705
|
-
runners
|
|
1775
|
+
runners,
|
|
1776
|
+
diagnostics: config.diagnostics,
|
|
1706
1777
|
};
|
|
1707
1778
|
}
|
|
1708
1779
|
|
|
1709
1780
|
async function saveCustomAppRunner(tab, rawRunner) {
|
|
1710
1781
|
const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
|
|
1711
|
-
const config = await readAppRunnerConfig(projectRoot);
|
|
1782
|
+
const config = await readAppRunnerConfig(projectRoot, { strictRead: true });
|
|
1712
1783
|
const normalized = normalizeCustomRunnerDefinition(rawRunner, projectRoot, { strict: true });
|
|
1713
|
-
const
|
|
1714
|
-
if (
|
|
1715
|
-
const commandParts = customRunnerCommandParts(normalized.command);
|
|
1716
|
-
if (normalized.command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) throw makeHttpError(400, `Command is not available: ${commandParts[0]}`);
|
|
1784
|
+
const unavailableReason = await customAppRunnerUnavailableReason(projectRoot, normalized);
|
|
1785
|
+
if (unavailableReason) throw makeHttpError(400, unavailableReason);
|
|
1717
1786
|
const runners = config.runners.filter((runner) => runner.id !== normalized.id);
|
|
1718
1787
|
if (runners.length >= APP_RUNNER_CUSTOM_LIMIT) throw makeHttpError(400, `Custom runner limit reached (${APP_RUNNER_CUSTOM_LIMIT})`);
|
|
1719
1788
|
runners.push(normalized);
|
|
@@ -3066,6 +3135,7 @@ async function getWorkspaceInfo(cwd, startedAt) {
|
|
|
3066
3135
|
let activeGitWorkflowProcess = null;
|
|
3067
3136
|
const GIT_CHANGES_COMMAND_TIMEOUT_MS = 5000;
|
|
3068
3137
|
const GIT_CHANGES_DIFF_MAX_OUTPUT = 500_000;
|
|
3138
|
+
const GIT_PULL_TIMEOUT_MS = 15 * 60 * 1000;
|
|
3069
3139
|
|
|
3070
3140
|
async function getGitRoot(cwd) {
|
|
3071
3141
|
const result = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 });
|
|
@@ -3085,40 +3155,43 @@ async function runGitReadCommand(root, args, { timeoutMs = GIT_CHANGES_COMMAND_T
|
|
|
3085
3155
|
throw new Error(String(message).trim());
|
|
3086
3156
|
}
|
|
3087
3157
|
|
|
3088
|
-
function
|
|
3089
|
-
const
|
|
3090
|
-
|
|
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";
|
|
3091
3165
|
}
|
|
3092
3166
|
|
|
3093
|
-
function
|
|
3094
|
-
const
|
|
3095
|
-
const
|
|
3096
|
-
|
|
3097
|
-
|
|
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;
|
|
3098
3172
|
}
|
|
3099
3173
|
|
|
3100
|
-
function
|
|
3174
|
+
function summarizeGitPorcelainStatus(statusText) {
|
|
3101
3175
|
const summary = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, ahead: 0, behind: 0 };
|
|
3102
3176
|
for (const line of String(statusText || "").split(/\r?\n/)) {
|
|
3103
3177
|
if (!line) continue;
|
|
3104
|
-
if (line.startsWith("
|
|
3105
|
-
const
|
|
3106
|
-
|
|
3107
|
-
|
|
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
|
+
}
|
|
3108
3184
|
continue;
|
|
3109
3185
|
}
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
if (x === "?" && y === "?") {
|
|
3113
|
-
summary.untracked += 1;
|
|
3186
|
+
if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
3187
|
+
addGitPorcelainTrackedSummary(summary, line.split(" ")[1] || "..");
|
|
3114
3188
|
continue;
|
|
3115
3189
|
}
|
|
3116
|
-
if (
|
|
3190
|
+
if (line.startsWith("u ")) {
|
|
3117
3191
|
summary.conflicted += 1;
|
|
3118
3192
|
continue;
|
|
3119
3193
|
}
|
|
3120
|
-
if (
|
|
3121
|
-
if (y && y !== " ") summary.unstaged += 1;
|
|
3194
|
+
if (line.startsWith("? ")) summary.untracked += 1;
|
|
3122
3195
|
}
|
|
3123
3196
|
return summary;
|
|
3124
3197
|
}
|
|
@@ -3176,28 +3249,77 @@ async function readGitUntrackedFile(cwd, requestedPath) {
|
|
|
3176
3249
|
return readGitUntrackedEntry(root, normalized);
|
|
3177
3250
|
}
|
|
3178
3251
|
|
|
3252
|
+
async function gitUpstreamRef(root) {
|
|
3253
|
+
try {
|
|
3254
|
+
const upstream = await runGitReadCommand(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], { timeoutMs: 5000, maxOutputLength: 10_000 });
|
|
3255
|
+
return upstream.trim();
|
|
3256
|
+
} catch {
|
|
3257
|
+
return "";
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
async function readGitIncomingChanges(root, summary) {
|
|
3262
|
+
const upstream = await gitUpstreamRef(root);
|
|
3263
|
+
const remote = {
|
|
3264
|
+
upstream,
|
|
3265
|
+
behind: Number(summary?.behind || 0) || 0,
|
|
3266
|
+
canPull: !!upstream && (Number(summary?.behind || 0) || 0) > 0,
|
|
3267
|
+
};
|
|
3268
|
+
if (!remote.canPull) return { remote, section: null };
|
|
3269
|
+
|
|
3270
|
+
const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/", "HEAD..@{upstream}"];
|
|
3271
|
+
try {
|
|
3272
|
+
const diff = await runGitReadCommand(root, diffArgs);
|
|
3273
|
+
return {
|
|
3274
|
+
remote,
|
|
3275
|
+
section: {
|
|
3276
|
+
key: "incoming",
|
|
3277
|
+
label: `Incoming from ${upstream}`,
|
|
3278
|
+
command: `git diff --unified=0 HEAD..${upstream}`,
|
|
3279
|
+
diff: diff.trimEnd(),
|
|
3280
|
+
},
|
|
3281
|
+
};
|
|
3282
|
+
} catch (error) {
|
|
3283
|
+
return { remote: { ...remote, error: sanitizeError(error) }, section: null };
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
async function pullGitChanges(cwd) {
|
|
3288
|
+
const root = await getGitRoot(cwd);
|
|
3289
|
+
const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["pull", "--ff-only"], { cwd: root, timeoutMs: GIT_PULL_TIMEOUT_MS }));
|
|
3290
|
+
if (payload.data) payload.data.root = root;
|
|
3291
|
+
if (payload.ok) payload.data.changes = await readGitChanges(root);
|
|
3292
|
+
else payload.error = (payload.data?.stderr || payload.data?.stdout || payload.error || "git pull --ff-only failed").trim();
|
|
3293
|
+
return payload;
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3179
3296
|
async function readGitChanges(cwd) {
|
|
3180
3297
|
const root = await getGitRoot(cwd);
|
|
3181
|
-
const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"];
|
|
3182
|
-
const [statusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
|
|
3298
|
+
const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
|
|
3299
|
+
const [statusText, porcelainStatusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
|
|
3183
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 }),
|
|
3184
3302
|
runGitReadCommand(root, diffArgs),
|
|
3185
|
-
runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"]),
|
|
3303
|
+
runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"]),
|
|
3186
3304
|
runGitReadCommand(root, ["ls-files", "--others", "--exclude-standard"], { maxOutputLength: 120_000 }),
|
|
3187
3305
|
]);
|
|
3306
|
+
const summary = summarizeGitPorcelainStatus(porcelainStatusText);
|
|
3307
|
+
const incoming = await readGitIncomingChanges(root, summary);
|
|
3188
3308
|
const untrackedFiles = untrackedText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
3189
3309
|
const untracked = await readGitUntrackedEntries(root, untrackedFiles);
|
|
3190
3310
|
return {
|
|
3191
3311
|
cwd,
|
|
3192
3312
|
root,
|
|
3193
|
-
branch:
|
|
3313
|
+
branch: gitBranchFromPorcelainStatus(porcelainStatusText),
|
|
3194
3314
|
generatedAt: new Date().toISOString(),
|
|
3195
|
-
summary
|
|
3315
|
+
summary,
|
|
3316
|
+
remote: incoming.remote,
|
|
3196
3317
|
status: statusText.trimEnd(),
|
|
3197
3318
|
sections: [
|
|
3198
|
-
|
|
3199
|
-
{ key: "
|
|
3200
|
-
|
|
3319
|
+
incoming.section,
|
|
3320
|
+
{ key: "staged", label: "Staged", command: "git diff --cached --unified=0", diff: stagedDiff.trimEnd() },
|
|
3321
|
+
{ key: "unstaged", label: "Unstaged", command: "git diff --unified=0", diff: unstagedDiff.trimEnd() },
|
|
3322
|
+
].filter(Boolean),
|
|
3201
3323
|
untracked,
|
|
3202
3324
|
};
|
|
3203
3325
|
}
|
|
@@ -4158,6 +4280,9 @@ try {
|
|
|
4158
4280
|
process.exit(2);
|
|
4159
4281
|
}
|
|
4160
4282
|
|
|
4283
|
+
process.env.PI_WEBUI_HOST = options.host;
|
|
4284
|
+
process.env.PI_WEBUI_PORT = String(options.port);
|
|
4285
|
+
|
|
4161
4286
|
const startupDelayMs = Number.parseInt(process.env.PI_WEBUI_START_DELAY_MS || "", 10);
|
|
4162
4287
|
delete process.env.PI_WEBUI_START_DELAY_MS;
|
|
4163
4288
|
if (Number.isFinite(startupDelayMs) && startupDelayMs > 0) {
|
|
@@ -5080,32 +5205,35 @@ async function getUpdateStatus({ force = false } = {}) {
|
|
|
5080
5205
|
checkedAt: new Date(now).toISOString(),
|
|
5081
5206
|
updateAvailable,
|
|
5082
5207
|
restartRequired: true,
|
|
5083
|
-
command: "pi update
|
|
5208
|
+
command: "pi update",
|
|
5209
|
+
allCommand: "pi update --all",
|
|
5084
5210
|
webuiDev: webuiDevServer,
|
|
5085
5211
|
pi: piStatus,
|
|
5086
5212
|
webui: webuiStatus,
|
|
5087
5213
|
packages: {
|
|
5088
5214
|
checked: false,
|
|
5089
|
-
note: "
|
|
5215
|
+
note: "Default update runs pi update for Pi only. Use update all to run pi update --all for Pi and configured packages."
|
|
5090
5216
|
},
|
|
5091
5217
|
};
|
|
5092
5218
|
updateStatusCacheAt = now;
|
|
5093
5219
|
return updateStatusCache;
|
|
5094
5220
|
}
|
|
5095
5221
|
|
|
5096
|
-
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";
|
|
5097
5225
|
if (options.piBinExplicit) {
|
|
5098
|
-
const command = await resolvePiCommand(
|
|
5099
|
-
return { ...command, label:
|
|
5226
|
+
const command = await resolvePiCommand(updateArgs);
|
|
5227
|
+
return { ...command, label, timeoutMs: PI_UPDATE_TIMEOUT_MS, maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS };
|
|
5100
5228
|
}
|
|
5101
5229
|
|
|
5102
5230
|
const pathPi = await runCommand(options.piBin, ["--version"], { timeoutMs: 3000, maxOutputLength: 4000 });
|
|
5103
5231
|
if (pathPi.exitCode === 0 && !pathPi.timedOut && !pathPi.error) {
|
|
5104
|
-
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 };
|
|
5105
5233
|
}
|
|
5106
5234
|
|
|
5107
|
-
const fallback = await resolvePiCommand(
|
|
5108
|
-
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 };
|
|
5109
5237
|
}
|
|
5110
5238
|
|
|
5111
5239
|
function packageNodeModulesPath(nodeModulesRoot, packageName) {
|
|
@@ -5280,13 +5408,9 @@ function uniqueUpdateTasks(tasks) {
|
|
|
5280
5408
|
return unique;
|
|
5281
5409
|
}
|
|
5282
5410
|
|
|
5283
|
-
async function resolveUpdateTasks() {
|
|
5411
|
+
async function resolveUpdateTasks({ all = false } = {}) {
|
|
5284
5412
|
return uniqueUpdateTasks([
|
|
5285
|
-
await resolvePiUpdateCommand(),
|
|
5286
|
-
await currentWebuiPackageUpdateTask(),
|
|
5287
|
-
await agentPackageRootUpdateTask(),
|
|
5288
|
-
await npmGlobalPackageRootUpdateTask(),
|
|
5289
|
-
await bunGlobalPackageRootUpdateTask(),
|
|
5413
|
+
await resolvePiUpdateCommand({ all }),
|
|
5290
5414
|
]);
|
|
5291
5415
|
}
|
|
5292
5416
|
|
|
@@ -5327,16 +5451,17 @@ function combinedUpdateOutput(results, field) {
|
|
|
5327
5451
|
.join("\n\n");
|
|
5328
5452
|
}
|
|
5329
5453
|
|
|
5330
|
-
async function runPiUpdateAndPrepareRestart() {
|
|
5454
|
+
async function runPiUpdateAndPrepareRestart({ all = false } = {}) {
|
|
5331
5455
|
if (piUpdateInProgress) throw makeHttpError(409, "A Pi update is already running.");
|
|
5332
5456
|
piUpdateInProgress = true;
|
|
5333
5457
|
let restartPrepared = false;
|
|
5334
5458
|
try {
|
|
5335
5459
|
const restorableTabs = await restorableTabsForRestart();
|
|
5336
|
-
const updateTasks = await resolveUpdateTasks();
|
|
5337
|
-
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.");
|
|
5338
5462
|
const command = updateTasks.map(updateTaskDisplay).join(" && ");
|
|
5339
|
-
|
|
5463
|
+
const updateLabel = all ? "Pi and package updates" : "Pi update";
|
|
5464
|
+
recordEvent({ type: "webui_update_started", command, updateAll: all, restorableTabCount: restorableTabs.length });
|
|
5340
5465
|
const results = [];
|
|
5341
5466
|
for (const task of updateTasks) results.push(await runUpdateTask(task));
|
|
5342
5467
|
|
|
@@ -5344,9 +5469,9 @@ async function runPiUpdateAndPrepareRestart() {
|
|
|
5344
5469
|
updateStatusCacheAt = 0;
|
|
5345
5470
|
const child = spawnRestartServer(restorableTabs);
|
|
5346
5471
|
restartPrepared = true;
|
|
5347
|
-
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 });
|
|
5348
5473
|
return {
|
|
5349
|
-
message:
|
|
5474
|
+
message: `${updateLabel} completed. Pi Web UI is restarting.`,
|
|
5350
5475
|
command,
|
|
5351
5476
|
commands: results.map((result) => ({ label: result.label, command: result.command })),
|
|
5352
5477
|
stdout: combinedUpdateOutput(results, "stdout"),
|
|
@@ -7236,7 +7361,10 @@ const server = createServer(async (req, res) => {
|
|
|
7236
7361
|
|
|
7237
7362
|
if (url.pathname === "/api/update" && req.method === "POST") {
|
|
7238
7363
|
requireLocalhostRoute(req, url.pathname);
|
|
7239
|
-
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 });
|
|
7240
7368
|
sendJson(res, 200, { ok: true, data });
|
|
7241
7369
|
setTimeout(() => shutdown("api update"), 20).unref();
|
|
7242
7370
|
return;
|
|
@@ -7572,6 +7700,17 @@ const server = createServer(async (req, res) => {
|
|
|
7572
7700
|
return;
|
|
7573
7701
|
}
|
|
7574
7702
|
|
|
7703
|
+
if (url.pathname === "/api/git-changes/pull" && req.method === "POST") {
|
|
7704
|
+
const body = await readJsonBody(req);
|
|
7705
|
+
const tab = getRequestedTab(req, url, body);
|
|
7706
|
+
try {
|
|
7707
|
+
sendJson(res, 200, await pullGitChanges(tab.cwd));
|
|
7708
|
+
} catch (error) {
|
|
7709
|
+
sendJson(res, 200, { ok: false, error: sanitizeError(error) });
|
|
7710
|
+
}
|
|
7711
|
+
return;
|
|
7712
|
+
}
|
|
7713
|
+
|
|
7575
7714
|
if (url.pathname === "/api/git-branches" && req.method === "GET") {
|
|
7576
7715
|
const tab = getRequestedTab(req, url);
|
|
7577
7716
|
try {
|
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",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"test": "node tests/run-all.mjs"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@earendil-works/pi-coding-agent": "^0.79.
|
|
56
|
+
"@earendil-works/pi-coding-agent": "^0.79.7"
|
|
57
57
|
},
|
|
58
58
|
"optionalDependencies": {
|
|
59
59
|
"@firstpick/pi-extension-btw": "^0.1.0",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@firstpick/pi-extension-todo-progress": "^0.2.4",
|
|
67
67
|
"@firstpick/pi-extension-tools": "^0.1.6",
|
|
68
68
|
"@firstpick/pi-extension-workflows": "^0.1.0",
|
|
69
|
-
"@firstpick/pi-package-remote-webui": "^0.1.
|
|
69
|
+
"@firstpick/pi-package-remote-webui": "^0.1.2",
|
|
70
70
|
"@firstpick/pi-prompts-git-pr": "^0.1.2",
|
|
71
71
|
"@firstpick/pi-themes-bundle": "^0.1.4"
|
|
72
72
|
},
|
|
@@ -84,5 +84,12 @@
|
|
|
84
84
|
],
|
|
85
85
|
"engines": {
|
|
86
86
|
"node": ">=22.19.0"
|
|
87
|
+
},
|
|
88
|
+
"overrides": {
|
|
89
|
+
"@earendil-works/pi-coding-agent": {
|
|
90
|
+
"protobufjs": "7.6.3",
|
|
91
|
+
"undici": "8.5.0",
|
|
92
|
+
"ws": "8.21.0"
|
|
93
|
+
}
|
|
87
94
|
}
|
|
88
95
|
}
|