@firstpick/pi-package-webui 0.1.0 → 0.1.1
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 +121 -43
- package/bin/pi-webui.mjs +433 -40
- package/index.ts +150 -18
- package/package.json +1 -1
- package/public/app.js +556 -19
- package/public/favicon.svg +8 -0
- package/public/index.html +28 -0
- package/public/styles.css +317 -0
package/bin/pi-webui.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
-
import { access, readFile, stat } from "node:fs/promises";
|
|
5
|
+
import { access, readFile, readdir, stat } from "node:fs/promises";
|
|
6
|
+
import { networkInterfaces } from "node:os";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import { StringDecoder } from "node:string_decoder";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -137,6 +138,14 @@ function isLocalHost(host) {
|
|
|
137
138
|
return host === "localhost" || host === "::1" || host === "[::1]" || host.startsWith("127.");
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
function formatUrlHost(host) {
|
|
142
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isLocalAddress(address = "") {
|
|
146
|
+
return address === "::1" || address.startsWith("127.") || address === "::ffff:127.0.0.1" || address.startsWith("::ffff:127.");
|
|
147
|
+
}
|
|
148
|
+
|
|
140
149
|
function sanitizeError(error) {
|
|
141
150
|
if (!error) return "Unknown error";
|
|
142
151
|
if (typeof error === "string") return error;
|
|
@@ -315,6 +324,12 @@ function sendJson(res, statusCode, payload) {
|
|
|
315
324
|
res.end(body);
|
|
316
325
|
}
|
|
317
326
|
|
|
327
|
+
function makeHttpError(statusCode, message) {
|
|
328
|
+
const error = new Error(message);
|
|
329
|
+
error.statusCode = statusCode;
|
|
330
|
+
return error;
|
|
331
|
+
}
|
|
332
|
+
|
|
318
333
|
function sendError(res, statusCode, error) {
|
|
319
334
|
sendJson(res, statusCode, { ok: false, error: sanitizeError(error) });
|
|
320
335
|
}
|
|
@@ -379,6 +394,84 @@ function displayPath(cwd) {
|
|
|
379
394
|
return normalized;
|
|
380
395
|
}
|
|
381
396
|
|
|
397
|
+
function expandUserPath(value) {
|
|
398
|
+
const input = String(value || "").trim();
|
|
399
|
+
if (input === "~") {
|
|
400
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
401
|
+
if (!home) throw makeHttpError(400, "Cannot expand ~ because no home directory is configured");
|
|
402
|
+
return home;
|
|
403
|
+
}
|
|
404
|
+
if (input.startsWith("~/") || input.startsWith("~\\")) {
|
|
405
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
406
|
+
if (!home) throw makeHttpError(400, "Cannot expand ~ because no home directory is configured");
|
|
407
|
+
return path.join(home, input.slice(2));
|
|
408
|
+
}
|
|
409
|
+
return input;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function resolveCwd(value, baseCwd = options.cwd) {
|
|
413
|
+
const input = expandUserPath(value);
|
|
414
|
+
if (!input) throw makeHttpError(400, "cwd is required");
|
|
415
|
+
const cwd = path.resolve(baseCwd, input);
|
|
416
|
+
let info;
|
|
417
|
+
try {
|
|
418
|
+
info = await stat(cwd);
|
|
419
|
+
} catch {
|
|
420
|
+
throw makeHttpError(400, `cwd does not exist: ${cwd}`);
|
|
421
|
+
}
|
|
422
|
+
if (!info.isDirectory()) throw makeHttpError(400, `cwd is not a directory: ${cwd}`);
|
|
423
|
+
return cwd;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function uniquePathItems(items) {
|
|
427
|
+
const seen = new Set();
|
|
428
|
+
const result = [];
|
|
429
|
+
for (const item of items) {
|
|
430
|
+
if (!item?.cwd || seen.has(item.cwd)) continue;
|
|
431
|
+
seen.add(item.cwd);
|
|
432
|
+
result.push(item);
|
|
433
|
+
}
|
|
434
|
+
return result;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function pathPickerRoots(activeCwd, viewedCwd) {
|
|
438
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
439
|
+
return uniquePathItems([
|
|
440
|
+
{ label: "Tab", cwd: activeCwd, displayCwd: displayPath(activeCwd) },
|
|
441
|
+
{ label: "Default", cwd: options.cwd, displayCwd: displayPath(options.cwd) },
|
|
442
|
+
home ? { label: "Home", cwd: home, displayCwd: displayPath(home) } : undefined,
|
|
443
|
+
{ label: "Root", cwd: path.parse(viewedCwd || activeCwd || options.cwd).root, displayCwd: path.parse(viewedCwd || activeCwd || options.cwd).root },
|
|
444
|
+
]);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function getDirectoryPickerData(viewPath, activeCwd) {
|
|
448
|
+
const cwd = await resolveCwd(viewPath || activeCwd, activeCwd);
|
|
449
|
+
let entries;
|
|
450
|
+
try {
|
|
451
|
+
entries = await readdir(cwd, { withFileTypes: true });
|
|
452
|
+
} catch (error) {
|
|
453
|
+
throw makeHttpError(error?.code === "EACCES" ? 403 : 400, `Cannot read directory ${cwd}: ${sanitizeError(error)}`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const directoryEntries = entries
|
|
457
|
+
.filter((entry) => entry.isDirectory())
|
|
458
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
459
|
+
const directories = directoryEntries.slice(0, 500).map((entry) => {
|
|
460
|
+
const entryPath = path.join(cwd, entry.name);
|
|
461
|
+
return { name: entry.name, cwd: entryPath, displayCwd: displayPath(entryPath), hidden: entry.name.startsWith(".") };
|
|
462
|
+
});
|
|
463
|
+
const parent = path.dirname(cwd);
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
cwd,
|
|
467
|
+
displayCwd: displayPath(cwd),
|
|
468
|
+
parent: parent === cwd ? null : parent,
|
|
469
|
+
roots: pathPickerRoots(activeCwd, cwd),
|
|
470
|
+
directories,
|
|
471
|
+
truncated: directoryEntries.length > directories.length,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
382
475
|
async function getWorkspaceInfo(cwd, startedAt) {
|
|
383
476
|
const info = {
|
|
384
477
|
cwd,
|
|
@@ -517,18 +610,18 @@ function gitWorkflowCommandPayload(result) {
|
|
|
517
610
|
};
|
|
518
611
|
}
|
|
519
612
|
|
|
520
|
-
async function handleGitWorkflowRequest(pathname, body = {}) {
|
|
613
|
+
async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd) {
|
|
521
614
|
try {
|
|
522
615
|
switch (pathname) {
|
|
523
616
|
case "/api/git-workflow/message":
|
|
524
|
-
return { ok: true, data: await readGitWorkflowMessages(
|
|
617
|
+
return { ok: true, data: await readGitWorkflowMessages(cwd) };
|
|
525
618
|
case "/api/git-workflow/add":
|
|
526
|
-
await getGitRoot(
|
|
527
|
-
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "."], { cwd
|
|
619
|
+
await getGitRoot(cwd);
|
|
620
|
+
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "."], { cwd }));
|
|
528
621
|
case "/api/git-workflow/commit": {
|
|
529
622
|
const variant = String(body.variant || "").trim();
|
|
530
623
|
if (!["short", "long"].includes(variant)) throw new Error("variant must be 'short' or 'long'");
|
|
531
|
-
const messages = await readGitWorkflowMessages(
|
|
624
|
+
const messages = await readGitWorkflowMessages(cwd);
|
|
532
625
|
if (variant === "short") {
|
|
533
626
|
const message = messages.short.trim();
|
|
534
627
|
if (!message) throw new Error(`${messages.shortPath} is empty`);
|
|
@@ -538,7 +631,7 @@ async function handleGitWorkflowRequest(pathname, body = {}) {
|
|
|
538
631
|
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["commit", "-F", messages.longPath], { cwd: messages.root, label: "git commit -F dev/COMMIT/staged-commit-long.txt" }));
|
|
539
632
|
}
|
|
540
633
|
case "/api/git-workflow/push": {
|
|
541
|
-
const root = await getGitRoot(
|
|
634
|
+
const root = await getGitRoot(cwd);
|
|
542
635
|
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["push"], { cwd: root, timeoutMs: 15 * 60 * 1000 }));
|
|
543
636
|
}
|
|
544
637
|
case "/api/git-workflow/cancel": {
|
|
@@ -660,12 +753,18 @@ if (options.version) {
|
|
|
660
753
|
process.exit(0);
|
|
661
754
|
}
|
|
662
755
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
if (options.
|
|
666
|
-
piArgs.push(...options.piArgs);
|
|
756
|
+
function buildPiArgsForTab(tabIndex, title) {
|
|
757
|
+
const args = ["--mode", "rpc"];
|
|
758
|
+
if (options.noSession) args.push("--no-session");
|
|
667
759
|
|
|
668
|
-
|
|
760
|
+
const sessionName = tabIndex === 1 ? options.name : title;
|
|
761
|
+
if (sessionName) args.push("--name", sessionName);
|
|
762
|
+
|
|
763
|
+
args.push(...options.piArgs);
|
|
764
|
+
return args;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function resolvePiCommand(piArgs) {
|
|
669
768
|
if (options.piBinExplicit) {
|
|
670
769
|
return { command: options.piBin, args: piArgs, displayCommand: `${options.piBin} ${piArgs.join(" ")}` };
|
|
671
770
|
}
|
|
@@ -683,19 +782,266 @@ async function resolvePiCommand() {
|
|
|
683
782
|
}
|
|
684
783
|
}
|
|
685
784
|
|
|
686
|
-
const
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
}
|
|
692
|
-
|
|
785
|
+
const tabs = new Map();
|
|
786
|
+
let nextTabIndex = 1;
|
|
787
|
+
|
|
788
|
+
function defaultTabTitle(tabIndex) {
|
|
789
|
+
if (options.name) return tabIndex === 1 ? options.name : `${options.name} ${tabIndex}`;
|
|
790
|
+
return `Terminal ${tabIndex}`;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function attachRpcToTab(tab, rpc) {
|
|
794
|
+
tab.rpcUnsubscribe?.();
|
|
795
|
+
tab.rpc = rpc;
|
|
796
|
+
tab.rpcUnsubscribe = rpc.onEvent((event) => {
|
|
797
|
+
const scopedEvent = { ...event, tabId: tab.id, tabTitle: tab.title };
|
|
798
|
+
for (const client of tab.sseClients) sendSse(client, scopedEvent);
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function createTab({ title, cwd } = {}) {
|
|
803
|
+
const tabIndex = nextTabIndex++;
|
|
804
|
+
const tabTitle = String(title || "").trim() || defaultTabTitle(tabIndex);
|
|
805
|
+
const tabCwd = cwd ? await resolveCwd(cwd, options.cwd) : options.cwd;
|
|
806
|
+
const id = randomUUID();
|
|
807
|
+
const piArgs = buildPiArgsForTab(tabIndex, tabTitle);
|
|
808
|
+
const piCommand = await resolvePiCommand(piArgs);
|
|
809
|
+
const rpc = new PiRpcProcess({ ...piCommand, cwd: tabCwd });
|
|
810
|
+
const tab = {
|
|
811
|
+
id,
|
|
812
|
+
index: tabIndex,
|
|
813
|
+
title: tabTitle,
|
|
814
|
+
cwd: tabCwd,
|
|
815
|
+
createdAt: new Date().toISOString(),
|
|
816
|
+
rpc: undefined,
|
|
817
|
+
rpcUnsubscribe: undefined,
|
|
818
|
+
sseClients: new Set(),
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
attachRpcToTab(tab, rpc);
|
|
822
|
+
tabs.set(id, tab);
|
|
823
|
+
rpc.start();
|
|
824
|
+
return tab;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function firstTab() {
|
|
828
|
+
return tabs.values().next().value;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function tabMeta(tab) {
|
|
832
|
+
return {
|
|
833
|
+
id: tab.id,
|
|
834
|
+
index: tab.index,
|
|
835
|
+
title: tab.title,
|
|
836
|
+
cwd: tab.cwd,
|
|
837
|
+
createdAt: tab.createdAt,
|
|
838
|
+
startedAt: tab.rpc.startedAt,
|
|
839
|
+
pid: tab.rpc.child?.pid,
|
|
840
|
+
running: !!tab.rpc.child && tab.rpc.child.exitCode === null,
|
|
841
|
+
command: tab.rpc.displayCommand,
|
|
842
|
+
clientCount: tab.sseClients.size,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function listTabs() {
|
|
847
|
+
return [...tabs.values()].map(tabMeta);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function updateTabCwd(id, cwd) {
|
|
851
|
+
const tab = tabs.get(id);
|
|
852
|
+
if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
|
|
853
|
+
|
|
854
|
+
const nextCwd = await resolveCwd(cwd, tab.cwd);
|
|
855
|
+
if (nextCwd === tab.cwd) return { tab, changed: false };
|
|
856
|
+
|
|
857
|
+
const piArgs = buildPiArgsForTab(tab.index, tab.title);
|
|
858
|
+
const piCommand = await resolvePiCommand(piArgs);
|
|
859
|
+
for (const client of tab.sseClients) {
|
|
860
|
+
sendSse(client, { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd });
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const oldRpc = tab.rpc;
|
|
864
|
+
tab.rpcUnsubscribe?.();
|
|
865
|
+
tab.rpcUnsubscribe = undefined;
|
|
866
|
+
oldRpc.stop();
|
|
867
|
+
|
|
868
|
+
tab.cwd = nextCwd;
|
|
869
|
+
const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
|
|
870
|
+
attachRpcToTab(tab, rpc);
|
|
871
|
+
rpc.start();
|
|
872
|
+
|
|
873
|
+
for (const client of tab.sseClients) {
|
|
874
|
+
sendSse(client, { type: "webui_cwd_changed", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid });
|
|
875
|
+
}
|
|
876
|
+
return { tab, changed: true };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function closeTab(id) {
|
|
880
|
+
const tab = tabs.get(id);
|
|
881
|
+
if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
|
|
882
|
+
if (tabs.size <= 1) throw makeHttpError(400, "Cannot close the last Pi tab");
|
|
883
|
+
|
|
884
|
+
for (const client of tab.sseClients) {
|
|
885
|
+
sendSse(client, { type: "webui_tab_closing", tabId: tab.id, tabTitle: tab.title });
|
|
886
|
+
client.end();
|
|
887
|
+
}
|
|
888
|
+
tab.sseClients.clear();
|
|
889
|
+
tab.rpcUnsubscribe?.();
|
|
890
|
+
tab.rpc.stop();
|
|
891
|
+
tabs.delete(id);
|
|
892
|
+
return tab;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function requestedTabId(req, url, body) {
|
|
896
|
+
const header = req.headers["x-pi-webui-tab"];
|
|
897
|
+
const headerValue = Array.isArray(header) ? header[0] : header;
|
|
898
|
+
return String(url.searchParams.get("tab") || url.searchParams.get("tabId") || body?.tabId || body?.tab || headerValue || "").trim();
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function getRequestedTab(req, url, body = {}) {
|
|
902
|
+
const id = requestedTabId(req, url, body);
|
|
903
|
+
if (!id) {
|
|
904
|
+
const tab = firstTab();
|
|
905
|
+
if (!tab) throw makeHttpError(503, "No Pi tabs are available");
|
|
906
|
+
return tab;
|
|
907
|
+
}
|
|
908
|
+
const tab = tabs.get(id);
|
|
909
|
+
if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
|
|
910
|
+
return tab;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const initialTab = await createTab();
|
|
914
|
+
let currentHost = options.host;
|
|
915
|
+
let networkRebindInProgress = false;
|
|
916
|
+
|
|
917
|
+
function localNetworkAddresses() {
|
|
918
|
+
const addresses = [];
|
|
919
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
920
|
+
for (const entry of entries || []) {
|
|
921
|
+
if (entry.internal || entry.family !== "IPv4") continue;
|
|
922
|
+
addresses.push(entry.address);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return [...new Set(addresses)].sort();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function networkStatus() {
|
|
929
|
+
const open = !isLocalHost(currentHost);
|
|
930
|
+
const networkUrls = open ? localNetworkAddresses().map((address) => `http://${address}:${options.port}/`) : [];
|
|
931
|
+
return {
|
|
932
|
+
open,
|
|
933
|
+
opening: networkRebindInProgress,
|
|
934
|
+
host: currentHost,
|
|
935
|
+
port: options.port,
|
|
936
|
+
localUrl: `http://127.0.0.1:${options.port}/`,
|
|
937
|
+
networkUrls,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function closeSseClientsForRebind(nextHost) {
|
|
942
|
+
for (const tab of tabs.values()) {
|
|
943
|
+
for (const client of tab.sseClients) {
|
|
944
|
+
sendSse(client, { type: "webui_network_rebinding", tabId: tab.id, tabTitle: tab.title, host: nextHost, port: options.port });
|
|
945
|
+
client.end();
|
|
946
|
+
}
|
|
947
|
+
tab.sseClients.clear();
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function closeServerListener() {
|
|
952
|
+
return new Promise((resolve, reject) => {
|
|
953
|
+
if (!server.listening) {
|
|
954
|
+
resolve();
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
server.close((error) => {
|
|
958
|
+
if (error) reject(error);
|
|
959
|
+
else resolve();
|
|
960
|
+
});
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function listenOn(host) {
|
|
965
|
+
return new Promise((resolve, reject) => {
|
|
966
|
+
const cleanup = () => {
|
|
967
|
+
server.off("error", onError);
|
|
968
|
+
server.off("listening", onListening);
|
|
969
|
+
};
|
|
970
|
+
const onError = (error) => {
|
|
971
|
+
cleanup();
|
|
972
|
+
reject(error);
|
|
973
|
+
};
|
|
974
|
+
const onListening = () => {
|
|
975
|
+
cleanup();
|
|
976
|
+
resolve();
|
|
977
|
+
};
|
|
978
|
+
server.once("error", onError);
|
|
979
|
+
server.once("listening", onListening);
|
|
980
|
+
server.listen(options.port, host);
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function openToLocalNetwork() {
|
|
985
|
+
const nextHost = "0.0.0.0";
|
|
986
|
+
if (!isLocalHost(currentHost) || networkRebindInProgress) return networkStatus();
|
|
987
|
+
|
|
988
|
+
networkRebindInProgress = true;
|
|
989
|
+
closeSseClientsForRebind(nextHost);
|
|
990
|
+
const previousHost = currentHost;
|
|
991
|
+
try {
|
|
992
|
+
await closeServerListener();
|
|
993
|
+
await listenOn(nextHost);
|
|
994
|
+
currentHost = nextHost;
|
|
995
|
+
console.warn("WARNING: Web UI is now reachable from the local network and has no authentication.");
|
|
996
|
+
return networkStatus();
|
|
997
|
+
} catch (error) {
|
|
998
|
+
console.error("Failed to open Web UI to local network:", sanitizeError(error));
|
|
999
|
+
if (!server.listening) {
|
|
1000
|
+
try {
|
|
1001
|
+
await listenOn(previousHost);
|
|
1002
|
+
} catch (restoreError) {
|
|
1003
|
+
console.error("Failed to restore Web UI listener:", sanitizeError(restoreError));
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
throw error;
|
|
1007
|
+
} finally {
|
|
1008
|
+
networkRebindInProgress = false;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
693
1011
|
|
|
694
1012
|
const server = createServer(async (req, res) => {
|
|
695
1013
|
try {
|
|
696
1014
|
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
697
1015
|
|
|
1016
|
+
if (url.pathname === "/api/tabs" && req.method === "GET") {
|
|
1017
|
+
sendJson(res, 200, { ok: true, data: { tabs: listTabs() } });
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (url.pathname === "/api/tabs" && req.method === "POST") {
|
|
1022
|
+
const body = await readJsonBody(req);
|
|
1023
|
+
const tab = await createTab({ title: body.title, cwd: body.cwd });
|
|
1024
|
+
sendJson(res, 201, { ok: true, data: { tab: tabMeta(tab), tabs: listTabs() } });
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (url.pathname.startsWith("/api/tabs/") && req.method === "PATCH") {
|
|
1029
|
+
const id = decodeURIComponent(url.pathname.slice("/api/tabs/".length));
|
|
1030
|
+
const body = await readJsonBody(req);
|
|
1031
|
+
const { tab, changed } = await updateTabCwd(id, body.cwd);
|
|
1032
|
+
sendJson(res, 200, { ok: true, data: { tab: tabMeta(tab), tabs: listTabs(), changed } });
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (url.pathname.startsWith("/api/tabs/") && req.method === "DELETE") {
|
|
1037
|
+
const id = decodeURIComponent(url.pathname.slice("/api/tabs/".length));
|
|
1038
|
+
closeTab(id);
|
|
1039
|
+
sendJson(res, 200, { ok: true, data: { tabs: listTabs(), activeTabId: firstTab()?.id || null } });
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
698
1043
|
if (url.pathname === "/api/events" && req.method === "GET") {
|
|
1044
|
+
const tab = getRequestedTab(req, url);
|
|
699
1045
|
res.writeHead(200, {
|
|
700
1046
|
"content-type": "text/event-stream; charset=utf-8",
|
|
701
1047
|
"cache-control": "no-cache, no-transform",
|
|
@@ -703,44 +1049,83 @@ const server = createServer(async (req, res) => {
|
|
|
703
1049
|
"x-content-type-options": "nosniff",
|
|
704
1050
|
});
|
|
705
1051
|
res.write(": connected\n\n");
|
|
706
|
-
sseClients.add(res);
|
|
1052
|
+
tab.sseClients.add(res);
|
|
707
1053
|
sendSse(res, {
|
|
708
1054
|
type: "webui_connected",
|
|
709
1055
|
version: packageJson.version,
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1056
|
+
tabId: tab.id,
|
|
1057
|
+
tabTitle: tab.title,
|
|
1058
|
+
pid: tab.rpc.child?.pid,
|
|
1059
|
+
cwd: tab.cwd,
|
|
1060
|
+
startedAt: tab.rpc.startedAt,
|
|
713
1061
|
});
|
|
714
1062
|
const keepAlive = setInterval(() => res.write(": keepalive\n\n"), 15000);
|
|
715
1063
|
req.on("close", () => {
|
|
716
1064
|
clearInterval(keepAlive);
|
|
717
|
-
sseClients.delete(res);
|
|
1065
|
+
tab.sseClients.delete(res);
|
|
718
1066
|
});
|
|
719
1067
|
return;
|
|
720
1068
|
}
|
|
721
1069
|
|
|
722
1070
|
if (url.pathname === "/api/health" && req.method === "GET") {
|
|
1071
|
+
const tab = firstTab();
|
|
723
1072
|
sendJson(res, 200, {
|
|
724
1073
|
ok: true,
|
|
725
1074
|
webuiVersion: packageJson.version,
|
|
726
|
-
|
|
727
|
-
|
|
1075
|
+
webuiPid: process.pid,
|
|
1076
|
+
piPid: tab?.rpc.child?.pid,
|
|
1077
|
+
piRunning: !!tab?.rpc.child && tab.rpc.child.exitCode === null,
|
|
728
1078
|
cwd: options.cwd,
|
|
1079
|
+
network: networkStatus(),
|
|
1080
|
+
tabs: listTabs(),
|
|
729
1081
|
});
|
|
730
1082
|
return;
|
|
731
1083
|
}
|
|
732
1084
|
|
|
1085
|
+
if (url.pathname === "/api/network" && req.method === "GET") {
|
|
1086
|
+
sendJson(res, 200, { ok: true, data: networkStatus() });
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (url.pathname === "/api/network/open" && req.method === "POST") {
|
|
1091
|
+
if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Opening to the network is only allowed from localhost");
|
|
1092
|
+
const before = networkStatus();
|
|
1093
|
+
sendJson(res, 202, { ok: true, data: { ...before, opening: true } });
|
|
1094
|
+
if (!before.open && !networkRebindInProgress) {
|
|
1095
|
+
setTimeout(() => openToLocalNetwork().catch((error) => console.error("network open failed:", sanitizeError(error))), 20).unref();
|
|
1096
|
+
}
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (url.pathname === "/api/shutdown" && req.method === "POST") {
|
|
1101
|
+
if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Shutdown is only allowed from localhost");
|
|
1102
|
+
sendJson(res, 200, { ok: true, message: "Pi Web UI shutting down", webuiPid: process.pid });
|
|
1103
|
+
setTimeout(() => shutdown("api shutdown"), 20).unref();
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
733
1107
|
if (url.pathname === "/api/workspace" && req.method === "GET") {
|
|
1108
|
+
const tab = getRequestedTab(req, url);
|
|
1109
|
+
sendJson(res, 200, {
|
|
1110
|
+
ok: true,
|
|
1111
|
+
data: await getWorkspaceInfo(tab.cwd, tab.rpc.startedAt),
|
|
1112
|
+
});
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
if (url.pathname === "/api/directories" && req.method === "GET") {
|
|
1117
|
+
const tab = getRequestedTab(req, url);
|
|
734
1118
|
sendJson(res, 200, {
|
|
735
1119
|
ok: true,
|
|
736
|
-
data: await
|
|
1120
|
+
data: await getDirectoryPickerData(url.searchParams.get("path"), tab.cwd),
|
|
737
1121
|
});
|
|
738
1122
|
return;
|
|
739
1123
|
}
|
|
740
1124
|
|
|
741
1125
|
if (url.pathname.startsWith("/api/git-workflow/")) {
|
|
742
1126
|
const body = req.method === "POST" ? await readJsonBody(req) : {};
|
|
743
|
-
const
|
|
1127
|
+
const tab = getRequestedTab(req, url, body);
|
|
1128
|
+
const response = await handleGitWorkflowRequest(url.pathname, body, tab.cwd);
|
|
744
1129
|
if (response) {
|
|
745
1130
|
sendJson(res, 200, response);
|
|
746
1131
|
return;
|
|
@@ -749,16 +1134,19 @@ const server = createServer(async (req, res) => {
|
|
|
749
1134
|
|
|
750
1135
|
const getCommand = req.method === "GET" ? commandFromGet(url.pathname) : undefined;
|
|
751
1136
|
if (getCommand) {
|
|
752
|
-
const
|
|
1137
|
+
const tab = getRequestedTab(req, url);
|
|
1138
|
+
const response = await tab.rpc.send(getCommand);
|
|
753
1139
|
sendJson(res, response.success === false ? 400 : 200, response);
|
|
754
1140
|
return;
|
|
755
1141
|
}
|
|
756
1142
|
|
|
757
1143
|
if (req.method === "POST" && url.pathname === "/api/extension-ui-response") {
|
|
758
1144
|
const body = await readJsonBody(req);
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1145
|
+
const tab = getRequestedTab(req, url, body);
|
|
1146
|
+
const { tabId, tab: _tab, ...payload } = body;
|
|
1147
|
+
if (payload.type !== "extension_ui_response") payload.type = "extension_ui_response";
|
|
1148
|
+
if (!payload.id) throw new Error("id is required");
|
|
1149
|
+
await tab.rpc.writeRaw(payload);
|
|
762
1150
|
sendJson(res, 200, { ok: true });
|
|
763
1151
|
return;
|
|
764
1152
|
}
|
|
@@ -767,7 +1155,8 @@ const server = createServer(async (req, res) => {
|
|
|
767
1155
|
const body = await readJsonBody(req);
|
|
768
1156
|
const command = commandFromPost(url.pathname, body);
|
|
769
1157
|
if (command) {
|
|
770
|
-
const
|
|
1158
|
+
const tab = getRequestedTab(req, url, body);
|
|
1159
|
+
const response = await tab.rpc.send(command);
|
|
771
1160
|
sendJson(res, response.success === false ? 400 : 200, response);
|
|
772
1161
|
return;
|
|
773
1162
|
}
|
|
@@ -777,22 +1166,26 @@ const server = createServer(async (req, res) => {
|
|
|
777
1166
|
|
|
778
1167
|
sendError(res, 404, "Not found");
|
|
779
1168
|
} catch (error) {
|
|
780
|
-
sendError(res, 500, error);
|
|
1169
|
+
sendError(res, error?.statusCode || 500, error);
|
|
781
1170
|
}
|
|
782
1171
|
});
|
|
783
1172
|
|
|
784
1173
|
server.on("error", (error) => {
|
|
1174
|
+
if (networkRebindInProgress) {
|
|
1175
|
+
console.error("Web UI network rebind failed:", sanitizeError(error));
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
785
1178
|
console.error("Web UI server failed:", sanitizeError(error));
|
|
786
|
-
rpc.stop();
|
|
1179
|
+
for (const tab of tabs.values()) tab.rpc.stop();
|
|
787
1180
|
process.exit(1);
|
|
788
1181
|
});
|
|
789
1182
|
|
|
790
|
-
server.listen(options.port,
|
|
791
|
-
const urlHost =
|
|
1183
|
+
server.listen(options.port, currentHost, () => {
|
|
1184
|
+
const urlHost = formatUrlHost(currentHost);
|
|
792
1185
|
console.log(`Pi Web UI: http://${urlHost}:${options.port}/`);
|
|
793
1186
|
console.log(`Working directory: ${options.cwd}`);
|
|
794
|
-
console.log(`Pi RPC: ${
|
|
795
|
-
if (!isLocalHost(
|
|
1187
|
+
console.log(`Pi RPC: ${initialTab.rpc.displayCommand}`);
|
|
1188
|
+
if (!isLocalHost(currentHost)) {
|
|
796
1189
|
console.warn("WARNING: Web UI has no authentication. Only expose it on trusted networks.");
|
|
797
1190
|
}
|
|
798
1191
|
});
|
|
@@ -800,7 +1193,7 @@ server.listen(options.port, options.host, () => {
|
|
|
800
1193
|
function shutdown(signal) {
|
|
801
1194
|
console.log(`\n${signal}: shutting down Pi Web UI...`);
|
|
802
1195
|
server.close(() => process.exit(0));
|
|
803
|
-
rpc.stop();
|
|
1196
|
+
for (const tab of tabs.values()) tab.rpc.stop();
|
|
804
1197
|
setTimeout(() => process.exit(0), 4000).unref();
|
|
805
1198
|
}
|
|
806
1199
|
|