@firstpick/pi-package-webui 0.4.0 → 0.4.2
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 +24 -9
- package/bin/pi-webui.mjs +415 -29
- package/index.ts +16 -1
- package/lib/trust-boundaries.mjs +1 -0
- package/package.json +1 -1
- package/public/app.js +766 -47
- package/public/index.html +44 -1
- package/public/styles.css +516 -4
- package/tests/http-endpoints-harness.test.mjs +97 -1
- package/tests/mobile-static.test.mjs +21 -10
- package/tests/session-auth-harness.test.mjs +4 -0
package/bin/pi-webui.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { createHash, randomInt, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { createReadStream } from "node:fs";
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
@@ -249,6 +249,8 @@ Options:
|
|
|
249
249
|
--pi <command> Pi executable to spawn (default: bundled dependency, then "pi")
|
|
250
250
|
--no-session Start Pi RPC with --no-session
|
|
251
251
|
--name <name> Initial Web UI tab display name
|
|
252
|
+
--remote-auth Enable startup PIN authentication for non-local clients
|
|
253
|
+
--no-remote-auth Disable startup PIN authentication
|
|
252
254
|
-h, --help Show this help
|
|
253
255
|
-v, --version Print version
|
|
254
256
|
|
|
@@ -262,8 +264,9 @@ Examples:
|
|
|
262
264
|
PI_WEBUI_PI_BIN=/path/to/pi pi-webui --no-session
|
|
263
265
|
|
|
264
266
|
Security:
|
|
265
|
-
The web UI
|
|
266
|
-
|
|
267
|
+
The web UI controls Pi tools. It binds to localhost by default. Remote PIN
|
|
268
|
+
authentication is off by default; enable it in Controls or with --remote-auth
|
|
269
|
+
before exposing the server on trusted networks.
|
|
267
270
|
`);
|
|
268
271
|
}
|
|
269
272
|
|
|
@@ -285,6 +288,7 @@ function parseArgs(argv) {
|
|
|
285
288
|
piBinExplicit: !!process.env.PI_WEBUI_PI_BIN,
|
|
286
289
|
noSession: false,
|
|
287
290
|
name: undefined,
|
|
291
|
+
remoteAuth: isTruthyEnv(process.env.PI_WEBUI_REMOTE_AUTH),
|
|
288
292
|
piArgs: [],
|
|
289
293
|
help: false,
|
|
290
294
|
version: false,
|
|
@@ -339,6 +343,14 @@ function parseArgs(argv) {
|
|
|
339
343
|
i++;
|
|
340
344
|
continue;
|
|
341
345
|
}
|
|
346
|
+
if (arg === "--remote-auth") {
|
|
347
|
+
options.remoteAuth = true;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (arg === "--no-remote-auth") {
|
|
351
|
+
options.remoteAuth = false;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
342
354
|
throw new Error(`Unknown option: ${arg}. Pass Pi CLI args after --.`);
|
|
343
355
|
}
|
|
344
356
|
|
|
@@ -649,6 +661,139 @@ async function readJsonBody(req, { limitBytes = BODY_LIMIT_BYTES } = {}) {
|
|
|
649
661
|
return JSON.parse(text);
|
|
650
662
|
}
|
|
651
663
|
|
|
664
|
+
function parseCookieHeader(header = "") {
|
|
665
|
+
const cookies = new Map();
|
|
666
|
+
for (const part of String(header || "").split(";")) {
|
|
667
|
+
const index = part.indexOf("=");
|
|
668
|
+
if (index === -1) continue;
|
|
669
|
+
const name = part.slice(0, index).trim();
|
|
670
|
+
const value = part.slice(index + 1).trim();
|
|
671
|
+
if (name) {
|
|
672
|
+
try {
|
|
673
|
+
cookies.set(name, decodeURIComponent(value));
|
|
674
|
+
} catch {
|
|
675
|
+
cookies.set(name, value);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return cookies;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function safeTimingEqual(a = "", b = "") {
|
|
683
|
+
const left = Buffer.from(String(a));
|
|
684
|
+
const right = Buffer.from(String(b));
|
|
685
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function safeReturnPath(value) {
|
|
689
|
+
const text = String(value || "/").trim();
|
|
690
|
+
if (!text.startsWith("/") || text.startsWith("//")) return "/";
|
|
691
|
+
return text;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function remoteAuthCookie(token = remoteAuth.token) {
|
|
695
|
+
const maxAge = Math.max(0, Math.floor((remoteAuth.tokenExpiresAt - Date.now()) / 1000));
|
|
696
|
+
return `pi_remote_auth=${encodeURIComponent(token || "")}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}`;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function clearRemoteAuthCookie() {
|
|
700
|
+
return "pi_remote_auth=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0";
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function requestHasRemoteAuth(req) {
|
|
704
|
+
if (!remoteAuthRequired()) return true;
|
|
705
|
+
const token = parseCookieHeader(req.headers.cookie).get("pi_remote_auth");
|
|
706
|
+
return !!(token && remoteAuth.token && remoteAuth.tokenExpiresAt > Date.now() && safeTimingEqual(token, remoteAuth.token));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function isRemoteAuthPublicPath(pathname) {
|
|
710
|
+
return pathname === "/remote-auth" || pathname === "/api/remote-auth" || pathname === "/favicon.svg";
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function shouldChallengeRemoteAuth(req, url) {
|
|
714
|
+
if (isLocalRequest(req) || !remoteAuthRequired() || isRemoteAuthPublicPath(url.pathname)) return false;
|
|
715
|
+
return !requestHasRemoteAuth(req);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function sendRemoteAuthPage(res, returnPath = "/") {
|
|
719
|
+
const safeReturn = safeReturnPath(returnPath);
|
|
720
|
+
const body = `<!doctype html>
|
|
721
|
+
<html lang="en">
|
|
722
|
+
<head>
|
|
723
|
+
<meta charset="utf-8">
|
|
724
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
725
|
+
<title>Pi Web UI Remote PIN</title>
|
|
726
|
+
<style>
|
|
727
|
+
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0f172a; color: #e5e7eb; }
|
|
728
|
+
body { min-height: 100vh; display: grid; place-items: center; margin: 0; padding: 24px; box-sizing: border-box; }
|
|
729
|
+
main { width: min(420px, 100%); padding: 28px; border: 1px solid rgba(148, 163, 184, 0.28); border-radius: 20px; background: rgba(15, 23, 42, 0.92); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
|
|
730
|
+
h1 { margin: 0 0 8px; font-size: 1.45rem; }
|
|
731
|
+
p { margin: 0 0 20px; color: #94a3b8; line-height: 1.5; }
|
|
732
|
+
label { display: block; margin-bottom: 8px; color: #cbd5e1; font-weight: 650; }
|
|
733
|
+
input { width: 100%; box-sizing: border-box; border: 1px solid rgba(148, 163, 184, 0.36); border-radius: 14px; padding: 14px 16px; background: #020617; color: #f8fafc; font: inherit; font-size: 1.6rem; letter-spacing: 0.32em; text-align: center; }
|
|
734
|
+
button { width: 100%; margin-top: 16px; border: 0; border-radius: 14px; padding: 14px 16px; background: #22c55e; color: #052e16; font: inherit; font-weight: 800; cursor: pointer; }
|
|
735
|
+
button:disabled { opacity: 0.65; cursor: wait; }
|
|
736
|
+
.error { min-height: 1.4em; margin-top: 14px; color: #fca5a5; }
|
|
737
|
+
</style>
|
|
738
|
+
</head>
|
|
739
|
+
<body>
|
|
740
|
+
<main>
|
|
741
|
+
<h1>Remote PIN required</h1>
|
|
742
|
+
<p>Enter the 4-digit PIN shown in the local Pi terminal or local Web UI to continue.</p>
|
|
743
|
+
<form id="pinForm" autocomplete="off">
|
|
744
|
+
<label for="pin">PIN</label>
|
|
745
|
+
<input id="pin" name="pin" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" autofocus required>
|
|
746
|
+
<button id="submit" type="submit">Unlock Web UI</button>
|
|
747
|
+
<div id="error" class="error" role="alert"></div>
|
|
748
|
+
</form>
|
|
749
|
+
</main>
|
|
750
|
+
<script>
|
|
751
|
+
const returnPath = ${JSON.stringify(safeReturn).replace(/</g, "\\u003c")};
|
|
752
|
+
const form = document.getElementById("pinForm");
|
|
753
|
+
const input = document.getElementById("pin");
|
|
754
|
+
const button = document.getElementById("submit");
|
|
755
|
+
const error = document.getElementById("error");
|
|
756
|
+
input.addEventListener("input", () => { input.value = input.value.replace(/\\D/g, "").slice(0, 4); error.textContent = ""; });
|
|
757
|
+
form.addEventListener("submit", async (event) => {
|
|
758
|
+
event.preventDefault();
|
|
759
|
+
button.disabled = true;
|
|
760
|
+
error.textContent = "";
|
|
761
|
+
try {
|
|
762
|
+
const response = await fetch("/api/remote-auth", {
|
|
763
|
+
method: "POST",
|
|
764
|
+
headers: { "content-type": "application/json" },
|
|
765
|
+
body: JSON.stringify({ pin: input.value }),
|
|
766
|
+
});
|
|
767
|
+
const data = await response.json().catch(() => ({}));
|
|
768
|
+
if (!response.ok || data.ok !== true) throw new Error(data.error || "Incorrect PIN");
|
|
769
|
+
window.location.replace(returnPath || "/");
|
|
770
|
+
} catch (err) {
|
|
771
|
+
error.textContent = err?.message || String(err);
|
|
772
|
+
input.select();
|
|
773
|
+
} finally {
|
|
774
|
+
button.disabled = false;
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
</script>
|
|
778
|
+
</body>
|
|
779
|
+
</html>`;
|
|
780
|
+
res.writeHead(200, {
|
|
781
|
+
"content-type": "text/html; charset=utf-8",
|
|
782
|
+
"cache-control": "no-store",
|
|
783
|
+
"x-content-type-options": "nosniff",
|
|
784
|
+
});
|
|
785
|
+
res.end(body);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function sendRemoteAuthRequired(req, res, url) {
|
|
789
|
+
const acceptsHtml = String(req.headers.accept || "").includes("text/html");
|
|
790
|
+
if (req.method === "GET" && (acceptsHtml || url.pathname === "/" || url.pathname === "/index.html" || url.pathname === "/remote-auth")) {
|
|
791
|
+
sendRemoteAuthPage(res, `${url.pathname}${url.search || ""}`);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
sendJson(res, 401, { ok: false, error: "Remote PIN required", remoteAuthRequired: true }, { "www-authenticate": "PiRemotePin" });
|
|
795
|
+
}
|
|
796
|
+
|
|
652
797
|
function sendSse(res, event) {
|
|
653
798
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
654
799
|
}
|
|
@@ -933,6 +1078,10 @@ async function installRootDeclaresPackage(root, packageName) {
|
|
|
933
1078
|
return declaredDependencySpec(pkg, packageName) !== undefined;
|
|
934
1079
|
}
|
|
935
1080
|
|
|
1081
|
+
async function installRootContainsPackage(root, packageName) {
|
|
1082
|
+
return directoryExists(packageNodeModulesPath(path.join(root, "node_modules"), packageName));
|
|
1083
|
+
}
|
|
1084
|
+
|
|
936
1085
|
function configuredAgentNpmRoot() {
|
|
937
1086
|
const root = process.env.PI_CODING_AGENT_DIR ? path.resolve(expandUserPath(process.env.PI_CODING_AGENT_DIR)) : agentDir;
|
|
938
1087
|
return path.join(root, "npm");
|
|
@@ -943,10 +1092,10 @@ async function optionalDependencyInstallRoot() {
|
|
|
943
1092
|
if (configuredRoot) return path.resolve(expandUserPath(configuredRoot));
|
|
944
1093
|
|
|
945
1094
|
const installRoot = nodeModulesParentForPackageRoot(packageRoot);
|
|
946
|
-
if (await installRootDeclaresPackage(installRoot, "@firstpick/pi-package-webui")) return installRoot;
|
|
1095
|
+
if (await installRootDeclaresPackage(installRoot, "@firstpick/pi-package-webui") || await installRootContainsPackage(installRoot, "@firstpick/pi-package-webui")) return installRoot;
|
|
947
1096
|
|
|
948
1097
|
const agentNpmRoot = configuredAgentNpmRoot();
|
|
949
|
-
if (installRoot !== agentNpmRoot && await installRootDeclaresPackage(agentNpmRoot, "@firstpick/pi-package-webui")) return agentNpmRoot;
|
|
1098
|
+
if (installRoot !== agentNpmRoot && (await installRootDeclaresPackage(agentNpmRoot, "@firstpick/pi-package-webui") || await installRootContainsPackage(agentNpmRoot, "@firstpick/pi-package-webui"))) return agentNpmRoot;
|
|
950
1099
|
|
|
951
1100
|
if (webuiDevServer) return installRoot;
|
|
952
1101
|
|
|
@@ -956,13 +1105,102 @@ async function optionalDependencyInstallRoot() {
|
|
|
956
1105
|
);
|
|
957
1106
|
}
|
|
958
1107
|
|
|
1108
|
+
function minimumPackageVersionFromSpec(spec) {
|
|
1109
|
+
const match = String(spec || "").match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
|
|
1110
|
+
return match?.[0] || "";
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function packageVersionBelowSpec(currentVersion, spec) {
|
|
1114
|
+
const minimum = minimumPackageVersionFromSpec(spec);
|
|
1115
|
+
return !!(currentVersion && minimum && isNewerPackageVersion(minimum, currentVersion));
|
|
1116
|
+
}
|
|
1117
|
+
|
|
959
1118
|
function formatCommandForDisplay(command, args) {
|
|
960
1119
|
return [command, ...args].map((part) => (/\s/.test(part) ? JSON.stringify(part) : part)).join(" ");
|
|
961
1120
|
}
|
|
962
1121
|
|
|
963
|
-
|
|
1122
|
+
let optionalPackageNodeModulesRootsCache = null;
|
|
1123
|
+
async function optionalPackageNodeModulesRoots() {
|
|
1124
|
+
if (optionalPackageNodeModulesRootsCache) return optionalPackageNodeModulesRootsCache;
|
|
1125
|
+
const roots = [];
|
|
1126
|
+
const seen = new Set();
|
|
1127
|
+
const add = (root) => {
|
|
1128
|
+
if (!root) return;
|
|
1129
|
+
const normalized = path.resolve(root);
|
|
1130
|
+
if (seen.has(normalized)) return;
|
|
1131
|
+
seen.add(normalized);
|
|
1132
|
+
roots.push(normalized);
|
|
1133
|
+
};
|
|
1134
|
+
const configuredRoot = process.env[OPTIONAL_FEATURE_INSTALL_ROOT_ENV];
|
|
1135
|
+
if (configuredRoot) add(path.join(path.resolve(expandUserPath(configuredRoot)), "node_modules"));
|
|
1136
|
+
add(path.join(packageRoot, "node_modules"));
|
|
1137
|
+
add(path.join(nodeModulesParentForPackageRoot(packageRoot), "node_modules"));
|
|
1138
|
+
add(path.join(configuredAgentNpmRoot(), "node_modules"));
|
|
1139
|
+
const npmGlobalRoot = await npmGlobalNodeModulesRoot();
|
|
1140
|
+
if (npmGlobalRoot) add(npmGlobalRoot);
|
|
1141
|
+
for (const bunRoot of await bunGlobalNodeModulesRoots()) add(bunRoot);
|
|
1142
|
+
optionalPackageNodeModulesRootsCache = roots;
|
|
1143
|
+
return roots;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
async function optionalPackageCandidateRoots(packageName) {
|
|
1147
|
+
return (await optionalPackageNodeModulesRoots()).map((root) => packageNodeModulesPath(root, packageName));
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
async function resolveInstalledPackageRoot(packageName) {
|
|
1151
|
+
const workspaceRoot = await workspacePackageRootForName(packageName);
|
|
1152
|
+
if (workspaceRoot) return workspaceRoot;
|
|
1153
|
+
for (const candidate of await optionalPackageCandidateRoots(packageName)) {
|
|
1154
|
+
if (await directoryExists(candidate)) return candidate;
|
|
1155
|
+
}
|
|
1156
|
+
return null;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async function resolveInstalledPackageSubpath(packageName, subpath = "") {
|
|
1160
|
+
const root = await resolveInstalledPackageRoot(packageName);
|
|
1161
|
+
if (!root) return null;
|
|
1162
|
+
const candidate = path.join(root, subpath || "");
|
|
1163
|
+
try {
|
|
1164
|
+
await access(candidate);
|
|
1165
|
+
return candidate;
|
|
1166
|
+
} catch {
|
|
1167
|
+
return null;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function optionalFeatureDeclaredSpec(packageName) {
|
|
1172
|
+
return declaredDependencySpec(packageJson, packageName) || "";
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
async function optionalFeaturePackageStatus(featureId) {
|
|
964
1176
|
const packageName = OPTIONAL_FEATURE_PACKAGES.get(featureId);
|
|
965
1177
|
if (!packageName) throw makeHttpError(400, `Unknown optional feature: ${featureId}`);
|
|
1178
|
+
const declaredSpec = optionalFeatureDeclaredSpec(packageName);
|
|
1179
|
+
const installedRoot = await resolveInstalledPackageRoot(packageName);
|
|
1180
|
+
const manifest = installedRoot ? await readJsonFileIfExists(path.join(installedRoot, "package.json")) : null;
|
|
1181
|
+
const installedVersion = typeof manifest?.version === "string" ? manifest.version : "";
|
|
1182
|
+
const updateAvailable = !!(installedVersion && packageVersionBelowSpec(installedVersion, declaredSpec));
|
|
1183
|
+
return {
|
|
1184
|
+
featureId,
|
|
1185
|
+
packageName,
|
|
1186
|
+
declaredSpec,
|
|
1187
|
+
installed: !!installedRoot,
|
|
1188
|
+
installedVersion,
|
|
1189
|
+
installedRoot,
|
|
1190
|
+
updateAvailable,
|
|
1191
|
+
updateReason: updateAvailable ? `installed ${installedVersion} is older than Web UI expects (${declaredSpec})` : "",
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
async function optionalFeaturePackageStatuses() {
|
|
1196
|
+
const features = [];
|
|
1197
|
+
for (const featureId of OPTIONAL_FEATURE_PACKAGES.keys()) features.push(await optionalFeaturePackageStatus(featureId));
|
|
1198
|
+
return { features };
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
async function installOptionalFeaturePackage(featureId) {
|
|
1202
|
+
const beforeStatus = await optionalFeaturePackageStatus(featureId);
|
|
1203
|
+
const packageName = beforeStatus.packageName;
|
|
966
1204
|
|
|
967
1205
|
const installRoot = await optionalDependencyInstallRoot();
|
|
968
1206
|
const npmCommand = process.env.PI_WEBUI_NPM_BIN || "npm";
|
|
@@ -978,6 +1216,8 @@ async function installOptionalFeaturePackage(featureId) {
|
|
|
978
1216
|
const details = [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
|
|
979
1217
|
throw makeHttpError(500, `Optional feature install failed: ${command}${details ? `\n${details}` : ""}`);
|
|
980
1218
|
}
|
|
1219
|
+
const afterStatus = await optionalFeaturePackageStatus(featureId);
|
|
1220
|
+
const operation = beforeStatus.installed ? "Updated" : "Installed";
|
|
981
1221
|
return {
|
|
982
1222
|
featureId,
|
|
983
1223
|
packageName,
|
|
@@ -985,7 +1225,8 @@ async function installOptionalFeaturePackage(featureId) {
|
|
|
985
1225
|
command,
|
|
986
1226
|
stdout: result.stdout,
|
|
987
1227
|
stderr: result.stderr,
|
|
988
|
-
|
|
1228
|
+
status: afterStatus,
|
|
1229
|
+
message: `${operation} optional feature package ${packageName}${afterStatus.installedVersion ? ` to ${afterStatus.installedVersion}` : ""}. Reload the active Pi tab to load new resources.`,
|
|
989
1230
|
};
|
|
990
1231
|
}
|
|
991
1232
|
|
|
@@ -2955,6 +3196,58 @@ function cleanGitCommitMessageInput(value) {
|
|
|
2955
3196
|
return message;
|
|
2956
3197
|
}
|
|
2957
3198
|
|
|
3199
|
+
function parseGitPorcelainZEntries(text) {
|
|
3200
|
+
const fields = String(text || "").split("\0").filter(Boolean);
|
|
3201
|
+
const entries = [];
|
|
3202
|
+
for (let index = 0; index < fields.length; index++) {
|
|
3203
|
+
const field = fields[index];
|
|
3204
|
+
if (field.length < 4) {
|
|
3205
|
+
entries.push({ x: "", y: "", path: field, unsupported: true });
|
|
3206
|
+
continue;
|
|
3207
|
+
}
|
|
3208
|
+
const x = field[0] || " ";
|
|
3209
|
+
const y = field[1] || " ";
|
|
3210
|
+
const filePath = field.slice(3);
|
|
3211
|
+
const entry = { x, y, path: filePath };
|
|
3212
|
+
if ((x === "R" || x === "C") && index + 1 < fields.length) entry.oldPath = fields[++index];
|
|
3213
|
+
entries.push(entry);
|
|
3214
|
+
}
|
|
3215
|
+
return entries;
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
function gitWorkflowDefaultCommitAction(entry) {
|
|
3219
|
+
if (!entry || entry.y !== " ") return "";
|
|
3220
|
+
if (entry.x === "A") return "created";
|
|
3221
|
+
if (entry.x === "M" || entry.x === "T") return "updated";
|
|
3222
|
+
if (entry.x === "D") return "deleted";
|
|
3223
|
+
return "";
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
function formatGitWorkflowDefaultCommitPath(filePath) {
|
|
3227
|
+
return String(filePath || "").replace(/[\0\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 4000);
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
async function readGitWorkflowDefaultCommitMessage(cwd) {
|
|
3231
|
+
const root = await getGitRoot(cwd);
|
|
3232
|
+
const statusText = await runGitReadCommand(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { maxOutputLength: 120_000 });
|
|
3233
|
+
const entries = parseGitPorcelainZEntries(statusText);
|
|
3234
|
+
const empty = (reason, extra = {}) => ({ root, message: "", reason, ...extra });
|
|
3235
|
+
if (entries.length === 0) return empty("No changed files are ready for a default commit message.");
|
|
3236
|
+
if (entries.length !== 1) return empty(`Expected exactly one changed file for a default commit message; found ${entries.length}.`);
|
|
3237
|
+
const [entry] = entries;
|
|
3238
|
+
const action = gitWorkflowDefaultCommitAction(entry);
|
|
3239
|
+
const displayPath = formatGitWorkflowDefaultCommitPath(entry.path);
|
|
3240
|
+
if (!action || !displayPath) {
|
|
3241
|
+
return empty("The only changed file is not a staged created, updated, or deleted file.", { path: entry.path || "" });
|
|
3242
|
+
}
|
|
3243
|
+
return {
|
|
3244
|
+
root,
|
|
3245
|
+
message: `${action} ${displayPath}`,
|
|
3246
|
+
action,
|
|
3247
|
+
path: entry.path,
|
|
3248
|
+
};
|
|
3249
|
+
}
|
|
3250
|
+
|
|
2958
3251
|
function cleanGitHubUsername(value) {
|
|
2959
3252
|
const username = String(value || "").trim().replace(/^@+/, "");
|
|
2960
3253
|
if (!username) throw new Error("GitHub username is required");
|
|
@@ -3300,6 +3593,8 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
|
|
|
3300
3593
|
switch (pathname) {
|
|
3301
3594
|
case "/api/git-workflow/message":
|
|
3302
3595
|
return { ok: true, data: await readGitWorkflowMessages(cwd) };
|
|
3596
|
+
case "/api/git-workflow/default-commit-message":
|
|
3597
|
+
return { ok: true, data: await readGitWorkflowDefaultCommitMessage(cwd) };
|
|
3303
3598
|
case "/api/git-workflow/branch-name":
|
|
3304
3599
|
return { ok: true, data: await readGitWorkflowBranchName(cwd) };
|
|
3305
3600
|
case "/api/git-workflow/pr-description":
|
|
@@ -3917,16 +4212,8 @@ function parseNodeModulesPackageRef(manifestEntry) {
|
|
|
3917
4212
|
async function resolveStartedWebuiManifestResource(manifestEntry) {
|
|
3918
4213
|
const nodeModulesRef = parseNodeModulesPackageRef(manifestEntry);
|
|
3919
4214
|
if (nodeModulesRef && WEBUI_CONTROLLED_PACKAGES.has(nodeModulesRef.packageName)) {
|
|
3920
|
-
const
|
|
3921
|
-
if (
|
|
3922
|
-
const devCandidate = path.join(workspaceRoot, nodeModulesRef.subpath);
|
|
3923
|
-
try {
|
|
3924
|
-
await access(devCandidate);
|
|
3925
|
-
return devCandidate;
|
|
3926
|
-
} catch {
|
|
3927
|
-
// Fall back to the started package's node_modules copy below.
|
|
3928
|
-
}
|
|
3929
|
-
}
|
|
4215
|
+
const installedCandidate = await resolveInstalledPackageSubpath(nodeModulesRef.packageName, nodeModulesRef.subpath);
|
|
4216
|
+
if (installedCandidate) return installedCandidate;
|
|
3930
4217
|
}
|
|
3931
4218
|
|
|
3932
4219
|
const candidate = path.resolve(packageRoot, manifestEntry);
|
|
@@ -6324,6 +6611,11 @@ const initialTab = initialTabs[0];
|
|
|
6324
6611
|
let currentHost = options.host;
|
|
6325
6612
|
let networkRebindInProgress = false;
|
|
6326
6613
|
let networkRebindTargetHost = null;
|
|
6614
|
+
const remoteAuth = {
|
|
6615
|
+
pin: undefined,
|
|
6616
|
+
token: undefined,
|
|
6617
|
+
tokenExpiresAt: 0,
|
|
6618
|
+
};
|
|
6327
6619
|
|
|
6328
6620
|
function localNetworkAddresses() {
|
|
6329
6621
|
const addresses = [];
|
|
@@ -6336,7 +6628,39 @@ function localNetworkAddresses() {
|
|
|
6336
6628
|
return [...new Set(addresses)].sort();
|
|
6337
6629
|
}
|
|
6338
6630
|
|
|
6339
|
-
function
|
|
6631
|
+
function remoteAuthRequired() {
|
|
6632
|
+
return !isLocalHost(currentHost) && !!remoteAuth.pin;
|
|
6633
|
+
}
|
|
6634
|
+
|
|
6635
|
+
function generateRemotePin() {
|
|
6636
|
+
return String(randomInt(0, 10_000)).padStart(4, "0");
|
|
6637
|
+
}
|
|
6638
|
+
|
|
6639
|
+
function enableRemoteAuth(reason = "network exposure") {
|
|
6640
|
+
remoteAuth.pin = generateRemotePin();
|
|
6641
|
+
remoteAuth.token = createHash("sha256").update(`${randomUUID()}:${remoteAuth.pin}:${Date.now()}`).digest("base64url");
|
|
6642
|
+
remoteAuth.tokenExpiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000;
|
|
6643
|
+
console.warn(`Pi Web UI remote PIN for ${reason}: ${remoteAuth.pin}`);
|
|
6644
|
+
return remoteAuth.pin;
|
|
6645
|
+
}
|
|
6646
|
+
|
|
6647
|
+
function resetRemoteAuth() {
|
|
6648
|
+
remoteAuth.pin = undefined;
|
|
6649
|
+
remoteAuth.token = undefined;
|
|
6650
|
+
remoteAuth.tokenExpiresAt = 0;
|
|
6651
|
+
}
|
|
6652
|
+
|
|
6653
|
+
function remoteAuthStatus({ includePin = false } = {}) {
|
|
6654
|
+
const enabled = !!remoteAuth.pin;
|
|
6655
|
+
const status = {
|
|
6656
|
+
enabled,
|
|
6657
|
+
required: enabled && !isLocalHost(currentHost),
|
|
6658
|
+
};
|
|
6659
|
+
if (includePin && enabled) status.pin = remoteAuth.pin;
|
|
6660
|
+
return status;
|
|
6661
|
+
}
|
|
6662
|
+
|
|
6663
|
+
function networkStatus({ includeAuthPin = false } = {}) {
|
|
6340
6664
|
const open = !isLocalHost(currentHost);
|
|
6341
6665
|
const targetHost = networkRebindTargetHost || currentHost;
|
|
6342
6666
|
const opening = networkRebindInProgress && !isLocalHost(targetHost);
|
|
@@ -6350,6 +6674,7 @@ function networkStatus() {
|
|
|
6350
6674
|
port: options.port,
|
|
6351
6675
|
localUrl: `http://127.0.0.1:${options.port}/`,
|
|
6352
6676
|
networkUrls,
|
|
6677
|
+
auth: remoteAuthStatus({ includePin: includeAuthPin }),
|
|
6353
6678
|
};
|
|
6354
6679
|
}
|
|
6355
6680
|
|
|
@@ -6373,6 +6698,23 @@ function closeSseClientsForRebind(nextHost) {
|
|
|
6373
6698
|
}
|
|
6374
6699
|
}
|
|
6375
6700
|
|
|
6701
|
+
function closeSseClientsForRemoteAuthChange() {
|
|
6702
|
+
for (const tab of tabs.values()) {
|
|
6703
|
+
const authEvent = {
|
|
6704
|
+
type: "webui_remote_auth_changed",
|
|
6705
|
+
tabId: tab.id,
|
|
6706
|
+
tabTitle: tab.title,
|
|
6707
|
+
auth: remoteAuthStatus(),
|
|
6708
|
+
};
|
|
6709
|
+
recordEvent(authEvent);
|
|
6710
|
+
for (const client of tab.sseClients) {
|
|
6711
|
+
sendSse(client, authEvent);
|
|
6712
|
+
client.end();
|
|
6713
|
+
}
|
|
6714
|
+
tab.sseClients.clear();
|
|
6715
|
+
}
|
|
6716
|
+
}
|
|
6717
|
+
|
|
6376
6718
|
function closeServerListener() {
|
|
6377
6719
|
return new Promise((resolve, reject) => {
|
|
6378
6720
|
if (!server.listening) {
|
|
@@ -6416,7 +6758,7 @@ function listenOn(host) {
|
|
|
6416
6758
|
|
|
6417
6759
|
async function openToLocalNetwork() {
|
|
6418
6760
|
const nextHost = "0.0.0.0";
|
|
6419
|
-
if (!isLocalHost(currentHost) || networkRebindInProgress) return networkStatus();
|
|
6761
|
+
if (!isLocalHost(currentHost) || networkRebindInProgress) return networkStatus({ includeAuthPin: true });
|
|
6420
6762
|
|
|
6421
6763
|
networkRebindInProgress = true;
|
|
6422
6764
|
networkRebindTargetHost = nextHost;
|
|
@@ -6426,8 +6768,8 @@ async function openToLocalNetwork() {
|
|
|
6426
6768
|
await closeServerListener();
|
|
6427
6769
|
await listenOn(nextHost);
|
|
6428
6770
|
currentHost = nextHost;
|
|
6429
|
-
console.warn(
|
|
6430
|
-
return networkStatus();
|
|
6771
|
+
console.warn(`WARNING: Web UI is now reachable from the local network${remoteAuth.pin ? " and requires the remote PIN for non-local clients" : " without remote PIN authentication"}.`);
|
|
6772
|
+
return networkStatus({ includeAuthPin: true });
|
|
6431
6773
|
} catch (error) {
|
|
6432
6774
|
console.error("Failed to open Web UI to local network:", sanitizeError(error));
|
|
6433
6775
|
if (!server.listening) {
|
|
@@ -6456,6 +6798,7 @@ async function closeNetworkAccess() {
|
|
|
6456
6798
|
await closeServerListener();
|
|
6457
6799
|
await listenOn(nextHost);
|
|
6458
6800
|
currentHost = nextHost;
|
|
6801
|
+
resetRemoteAuth();
|
|
6459
6802
|
console.warn("Web UI network access closed; listening on localhost only.");
|
|
6460
6803
|
return networkStatus();
|
|
6461
6804
|
} catch (error) {
|
|
@@ -6474,6 +6817,8 @@ async function closeNetworkAccess() {
|
|
|
6474
6817
|
}
|
|
6475
6818
|
}
|
|
6476
6819
|
|
|
6820
|
+
if (!isLocalHost(currentHost) && options.remoteAuth !== false) enableRemoteAuth("startup network listener");
|
|
6821
|
+
|
|
6477
6822
|
async function safeRpcData(tab, command, timeoutMs = STATUS_RPC_TIMEOUT_MS) {
|
|
6478
6823
|
try {
|
|
6479
6824
|
const response = await tab.rpc.send(command, timeoutMs);
|
|
@@ -6551,9 +6896,9 @@ async function tabStatusDetails(tab) {
|
|
|
6551
6896
|
};
|
|
6552
6897
|
}
|
|
6553
6898
|
|
|
6554
|
-
async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
|
|
6899
|
+
async function webuiStatus({ detailed = false, eventLimit = 40, includeAuthPin = false } = {}) {
|
|
6555
6900
|
const tab = firstTab();
|
|
6556
|
-
const network = networkStatus();
|
|
6901
|
+
const network = networkStatus({ includeAuthPin });
|
|
6557
6902
|
const statusTabs = listTabs();
|
|
6558
6903
|
const data = {
|
|
6559
6904
|
online: true,
|
|
@@ -6589,6 +6934,42 @@ const server = createServer(async (req, res) => {
|
|
|
6589
6934
|
try {
|
|
6590
6935
|
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
6591
6936
|
|
|
6937
|
+
if (url.pathname === "/remote-auth" && req.method === "GET") {
|
|
6938
|
+
sendRemoteAuthPage(res, url.searchParams.get("return") || "/");
|
|
6939
|
+
return;
|
|
6940
|
+
}
|
|
6941
|
+
|
|
6942
|
+
if (url.pathname === "/api/remote-auth" && req.method === "GET") {
|
|
6943
|
+
sendJson(res, 200, { ok: true, data: { auth: remoteAuthStatus({ includePin: isLocalRequest(req) }), local: isLocalRequest(req) } });
|
|
6944
|
+
return;
|
|
6945
|
+
}
|
|
6946
|
+
|
|
6947
|
+
if (url.pathname === "/api/remote-auth" && req.method === "POST") {
|
|
6948
|
+
const body = await readJsonBody(req);
|
|
6949
|
+
const pin = String(body.pin || "").trim();
|
|
6950
|
+
if (!remoteAuth.pin) throw makeHttpError(400, "Remote PIN authentication is not enabled");
|
|
6951
|
+
if (!/^\d{4}$/.test(pin) || !safeTimingEqual(pin, remoteAuth.pin)) throw makeHttpError(403, "Incorrect remote PIN");
|
|
6952
|
+
sendJson(res, 200, { ok: true, data: { auth: remoteAuthStatus() } }, { "set-cookie": remoteAuthCookie() });
|
|
6953
|
+
return;
|
|
6954
|
+
}
|
|
6955
|
+
|
|
6956
|
+
if (shouldChallengeRemoteAuth(req, url)) {
|
|
6957
|
+
sendRemoteAuthRequired(req, res, url);
|
|
6958
|
+
return;
|
|
6959
|
+
}
|
|
6960
|
+
|
|
6961
|
+
if (url.pathname === "/api/remote-auth/settings" && req.method === "POST") {
|
|
6962
|
+
requireLocalhostRoute(req, url.pathname);
|
|
6963
|
+
const body = await readJsonBody(req);
|
|
6964
|
+
if (body.enabled === true) enableRemoteAuth("side panel toggle");
|
|
6965
|
+
else if (body.enabled === false) resetRemoteAuth();
|
|
6966
|
+
else throw makeHttpError(400, "enabled must be true or false");
|
|
6967
|
+
closeSseClientsForRemoteAuthChange();
|
|
6968
|
+
const headers = body.enabled === false ? { "set-cookie": clearRemoteAuthCookie() } : {};
|
|
6969
|
+
sendJson(res, 200, { ok: true, data: { auth: remoteAuthStatus({ includePin: true }), network: networkStatus({ includeAuthPin: true }) } }, headers);
|
|
6970
|
+
return;
|
|
6971
|
+
}
|
|
6972
|
+
|
|
6592
6973
|
if (url.pathname === "/api/tabs" && req.method === "GET") {
|
|
6593
6974
|
sendJson(res, 200, { ok: true, data: { tabs: await listTabsWithReconciledActivity() } });
|
|
6594
6975
|
return;
|
|
@@ -6658,7 +7039,7 @@ const server = createServer(async (req, res) => {
|
|
|
6658
7039
|
}
|
|
6659
7040
|
|
|
6660
7041
|
if (url.pathname === "/api/health" && req.method === "GET") {
|
|
6661
|
-
const status = await webuiStatus();
|
|
7042
|
+
const status = await webuiStatus({ includeAuthPin: isLocalRequest(req) });
|
|
6662
7043
|
sendJson(res, 200, {
|
|
6663
7044
|
ok: true,
|
|
6664
7045
|
webuiVersion: status.webuiVersion,
|
|
@@ -6679,7 +7060,7 @@ const server = createServer(async (req, res) => {
|
|
|
6679
7060
|
const detailed = ["1", "true", "yes", "detailed"].includes(String(url.searchParams.get("detailed") || "").toLowerCase());
|
|
6680
7061
|
const parsedEventLimit = Number.parseInt(url.searchParams.get("events") || "40", 10);
|
|
6681
7062
|
const eventLimit = Number.isFinite(parsedEventLimit) ? parsedEventLimit : 40;
|
|
6682
|
-
sendJson(res, 200, { ok: true, data: await webuiStatus({ detailed, eventLimit }) });
|
|
7063
|
+
sendJson(res, 200, { ok: true, data: await webuiStatus({ detailed, eventLimit, includeAuthPin: isLocalRequest(req) }) });
|
|
6683
7064
|
return;
|
|
6684
7065
|
}
|
|
6685
7066
|
|
|
@@ -6716,13 +7097,13 @@ const server = createServer(async (req, res) => {
|
|
|
6716
7097
|
}
|
|
6717
7098
|
|
|
6718
7099
|
if (url.pathname === "/api/network" && req.method === "GET") {
|
|
6719
|
-
sendJson(res, 200, { ok: true, data: networkStatus() });
|
|
7100
|
+
sendJson(res, 200, { ok: true, data: networkStatus({ includeAuthPin: isLocalRequest(req) }) });
|
|
6720
7101
|
return;
|
|
6721
7102
|
}
|
|
6722
7103
|
|
|
6723
7104
|
if (url.pathname === "/api/network/open" && req.method === "POST") {
|
|
6724
7105
|
requireLocalhostRoute(req, url.pathname);
|
|
6725
|
-
const before = networkStatus();
|
|
7106
|
+
const before = networkStatus({ includeAuthPin: true });
|
|
6726
7107
|
const shouldOpen = !before.open && !networkRebindInProgress;
|
|
6727
7108
|
sendJson(res, 202, { ok: true, data: { ...before, opening: shouldOpen || before.opening, closing: before.closing } }, { connection: "close" });
|
|
6728
7109
|
if (shouldOpen) {
|
|
@@ -6733,7 +7114,7 @@ const server = createServer(async (req, res) => {
|
|
|
6733
7114
|
|
|
6734
7115
|
if (url.pathname === "/api/network/close" && req.method === "POST") {
|
|
6735
7116
|
requireLocalhostRoute(req, url.pathname);
|
|
6736
|
-
const before = networkStatus();
|
|
7117
|
+
const before = networkStatus({ includeAuthPin: true });
|
|
6737
7118
|
const shouldClose = before.open && !networkRebindInProgress;
|
|
6738
7119
|
sendJson(res, 202, { ok: true, data: { ...before, opening: before.opening, closing: shouldClose || before.closing } }, { connection: "close" });
|
|
6739
7120
|
if (shouldClose) {
|
|
@@ -6964,6 +7345,11 @@ const server = createServer(async (req, res) => {
|
|
|
6964
7345
|
return;
|
|
6965
7346
|
}
|
|
6966
7347
|
|
|
7348
|
+
if (url.pathname === "/api/optional-features" && req.method === "GET") {
|
|
7349
|
+
sendJson(res, 200, { ok: true, data: await optionalFeaturePackageStatuses() });
|
|
7350
|
+
return;
|
|
7351
|
+
}
|
|
7352
|
+
|
|
6967
7353
|
if (url.pathname === "/api/optional-feature-install" && req.method === "POST") {
|
|
6968
7354
|
requireLocalhostRoute(req, url.pathname);
|
|
6969
7355
|
const body = await readJsonBody(req);
|
|
@@ -7216,7 +7602,7 @@ server.listen(options.port, currentHost, () => {
|
|
|
7216
7602
|
else console.log("Pi RPC: waiting for CWD selection in the Web UI");
|
|
7217
7603
|
if (restoreTabs.length) console.log(`Restored Web UI tabs: ${initialTabs.length}`);
|
|
7218
7604
|
if (!isLocalHost(currentHost)) {
|
|
7219
|
-
console.warn(
|
|
7605
|
+
console.warn(`WARNING: Web UI is exposed to the network. Remote PIN auth is ${remoteAuth.pin ? "enabled" : "OFF"}; only expose it on trusted networks.`);
|
|
7220
7606
|
}
|
|
7221
7607
|
});
|
|
7222
7608
|
|