@firstpick/pi-package-webui 0.3.8 → 0.4.0
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 -7
- package/bin/pi-webui.mjs +716 -37
- package/package.json +11 -22
- package/public/app.js +1839 -67
- package/public/index.html +41 -3
- package/public/service-worker.js +1 -1
- package/public/styles.css +415 -0
- package/tests/fixtures/fake-pi.mjs +10 -1
- package/tests/http-endpoints-harness.test.mjs +99 -2
- package/tests/mobile-static.test.mjs +80 -23
package/bin/pi-webui.mjs
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { createReadStream } from "node:fs";
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
|
-
import { access, copyFile, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { access, copyFile, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from "node:fs/promises";
|
|
8
8
|
import { homedir, networkInterfaces, tmpdir } from "node:os";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { StringDecoder } from "node:string_decoder";
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
|
-
import {
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import { brotliCompress, constants as zlibConstants, gzip } from "node:zlib";
|
|
14
|
+
import { AuthStorage, SessionManager, SettingsManager, DefaultPackageManager } from "@earendil-works/pi-coding-agent";
|
|
13
15
|
import { authProvidersPayload, createAuthContext, logoutStoredProvider } from "../lib/auth-actions.mjs";
|
|
14
16
|
import {
|
|
15
17
|
collectOpenSessionFiles,
|
|
@@ -66,6 +68,9 @@ const UPDATE_STATUS_CACHE_MS = 10 * 60 * 1000;
|
|
|
66
68
|
const UPDATE_STATUS_TIMEOUT_MS = 10 * 1000;
|
|
67
69
|
const PI_UPDATE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
68
70
|
const PI_UPDATE_OUTPUT_MAX_CHARS = 120_000;
|
|
71
|
+
const UPDATE_PACKAGE_NAMES = [PI_CODING_AGENT_PACKAGE, WEBUI_PACKAGE];
|
|
72
|
+
const PACKAGE_UPDATE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
73
|
+
const PACKAGE_UPDATE_OUTPUT_MAX_CHARS = 120_000;
|
|
69
74
|
const CODEX_USAGE_TIMEOUT_MS = 15 * 1000;
|
|
70
75
|
const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|
71
76
|
const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
|
|
@@ -226,6 +231,8 @@ const OPTIONAL_FEATURE_PACKAGES = new Map([
|
|
|
226
231
|
["statsCommand", "@firstpick/pi-extension-stats"],
|
|
227
232
|
["themeBundle", "@firstpick/pi-themes-bundle"],
|
|
228
233
|
]);
|
|
234
|
+
const WEBUI_CONTROLLED_PACKAGES = new Set([WEBUI_PACKAGE, ...OPTIONAL_FEATURE_PACKAGES.values()]);
|
|
235
|
+
const PACKAGE_NAME_CACHE = new Map();
|
|
229
236
|
|
|
230
237
|
function usage() {
|
|
231
238
|
console.log(`pi-webui ${packageJson.version}
|
|
@@ -2776,10 +2783,23 @@ function gitBranchFromStatus(statusText) {
|
|
|
2776
2783
|
return branchLine.slice(3).trim().replace(/\.\.\..*$/, "") || "detached";
|
|
2777
2784
|
}
|
|
2778
2785
|
|
|
2786
|
+
function gitDivergenceFromBranchStatus(line) {
|
|
2787
|
+
const details = String(line || "").match(/\[(.+)\]\s*$/)?.[1] || "";
|
|
2788
|
+
const ahead = Number.parseInt(details.match(/ahead\s+(\d+)/i)?.[1] || "0", 10) || 0;
|
|
2789
|
+
const behind = Number.parseInt(details.match(/behind\s+(\d+)/i)?.[1] || "0", 10) || 0;
|
|
2790
|
+
return { ahead, behind };
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2779
2793
|
function summarizeGitShortStatus(statusText) {
|
|
2780
|
-
const summary = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0 };
|
|
2794
|
+
const summary = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, ahead: 0, behind: 0 };
|
|
2781
2795
|
for (const line of String(statusText || "").split(/\r?\n/)) {
|
|
2782
|
-
if (!line
|
|
2796
|
+
if (!line) continue;
|
|
2797
|
+
if (line.startsWith("## ")) {
|
|
2798
|
+
const divergence = gitDivergenceFromBranchStatus(line);
|
|
2799
|
+
summary.ahead = divergence.ahead;
|
|
2800
|
+
summary.behind = divergence.behind;
|
|
2801
|
+
continue;
|
|
2802
|
+
}
|
|
2783
2803
|
const x = line[0] || " ";
|
|
2784
2804
|
const y = line[1] || " ";
|
|
2785
2805
|
if (x === "?" && y === "?") {
|
|
@@ -2927,6 +2947,167 @@ function cleanGitBranchName(value) {
|
|
|
2927
2947
|
return branch;
|
|
2928
2948
|
}
|
|
2929
2949
|
|
|
2950
|
+
function cleanGitCommitMessageInput(value) {
|
|
2951
|
+
const message = String(value || "").replace(/\r\n?/g, "\n").trim();
|
|
2952
|
+
if (!message) throw new Error("commit message is required");
|
|
2953
|
+
if (message.includes("\0")) throw new Error("commit message contains a NUL byte");
|
|
2954
|
+
if (message.length > 10000) throw new Error("commit message is too long");
|
|
2955
|
+
return message;
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
function cleanGitHubUsername(value) {
|
|
2959
|
+
const username = String(value || "").trim().replace(/^@+/, "");
|
|
2960
|
+
if (!username) throw new Error("GitHub username is required");
|
|
2961
|
+
if (username.length > 39 || !/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(username) || username.includes("--")) {
|
|
2962
|
+
throw new Error("Invalid GitHub username");
|
|
2963
|
+
}
|
|
2964
|
+
return username;
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
function cleanGitHubRepoName(value) {
|
|
2968
|
+
let repoName = String(value || "").trim();
|
|
2969
|
+
const githubUrlMatch = repoName.match(/github\.com[:/][^/\s]+\/([^/\s]+?)(?:\.git)?\/?$/i);
|
|
2970
|
+
if (githubUrlMatch) repoName = githubUrlMatch[1];
|
|
2971
|
+
if (repoName.includes("/")) repoName = repoName.split("/").filter(Boolean).pop() || "";
|
|
2972
|
+
repoName = repoName.replace(/\.git$/i, "");
|
|
2973
|
+
if (!repoName) throw new Error("GitHub repository name is required");
|
|
2974
|
+
if (repoName.length > 100 || repoName === "." || repoName === ".." || !/^[A-Za-z0-9._-]+$/.test(repoName)) {
|
|
2975
|
+
throw new Error("Invalid GitHub repository name");
|
|
2976
|
+
}
|
|
2977
|
+
return repoName;
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
function gitHubOriginUrl(username, repoName) {
|
|
2981
|
+
return `https://github.com/${cleanGitHubUsername(username)}/${cleanGitHubRepoName(repoName)}.git`;
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
function defaultGitRepoNameFromRoot(root) {
|
|
2985
|
+
try {
|
|
2986
|
+
return cleanGitHubRepoName(path.basename(root));
|
|
2987
|
+
} catch {
|
|
2988
|
+
return "new-repo";
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
async function ensureOutsideGitRepository(cwd) {
|
|
2993
|
+
const result = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 });
|
|
2994
|
+
if (result.exitCode === 0 && result.stdout.trim()) throw new Error(`Already inside a git repository: ${path.resolve(result.stdout.trim())}`);
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
async function regularFileExists(filePath) {
|
|
2998
|
+
try {
|
|
2999
|
+
return (await stat(filePath)).isFile();
|
|
3000
|
+
} catch (error) {
|
|
3001
|
+
if (error?.code === "ENOENT") return false;
|
|
3002
|
+
throw error;
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
async function detectRepositoryStack(root) {
|
|
3007
|
+
let names = new Set();
|
|
3008
|
+
try {
|
|
3009
|
+
names = new Set(await readdir(root));
|
|
3010
|
+
} catch {
|
|
3011
|
+
return "";
|
|
3012
|
+
}
|
|
3013
|
+
const detected = [];
|
|
3014
|
+
if (names.has("package.json")) {
|
|
3015
|
+
try {
|
|
3016
|
+
const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
|
3017
|
+
const deps = { ...(manifest.dependencies || {}), ...(manifest.devDependencies || {}) };
|
|
3018
|
+
if (deps.next) detected.push("Next.js");
|
|
3019
|
+
else if (deps.react || deps.vite) detected.push("React / Vite");
|
|
3020
|
+
else detected.push("Node.js / TypeScript");
|
|
3021
|
+
if (deps.typescript || names.has("tsconfig.json")) detected.push("TypeScript");
|
|
3022
|
+
} catch {
|
|
3023
|
+
detected.push("Node.js");
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
if (names.has("pyproject.toml") || names.has("requirements.txt") || names.has("setup.py")) detected.push("Python");
|
|
3027
|
+
if (names.has("manage.py")) detected.push("Django");
|
|
3028
|
+
if (names.has("Cargo.toml")) detected.push("Rust");
|
|
3029
|
+
if (names.has("go.mod")) detected.push("Go");
|
|
3030
|
+
if (names.has("pom.xml") || names.has("build.gradle") || names.has("build.gradle.kts")) detected.push("Java / Gradle");
|
|
3031
|
+
if (names.has("Dockerfile") || names.has("docker-compose.yml") || names.has("compose.yml")) detected.push("Docker");
|
|
3032
|
+
return [...new Set(detected)].join(", ");
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
async function initialRepositoryFilesStatus(cwd) {
|
|
3036
|
+
const root = await getGitRoot(cwd);
|
|
3037
|
+
const readmePath = path.join(root, "README.md");
|
|
3038
|
+
const gitignorePath = path.join(root, ".gitignore");
|
|
3039
|
+
const [readmeExists, gitignoreExists, detectedStack] = await Promise.all([
|
|
3040
|
+
regularFileExists(readmePath),
|
|
3041
|
+
regularFileExists(gitignorePath),
|
|
3042
|
+
detectRepositoryStack(root),
|
|
3043
|
+
]);
|
|
3044
|
+
return { root, readmePath, gitignorePath, readmeExists, gitignoreExists, detectedStack };
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
function gitignoreLinesForStack(stackInput, detectedStack = "") {
|
|
3048
|
+
const stack = `${stackInput || ""} ${detectedStack || ""}`.toLowerCase();
|
|
3049
|
+
const sections = [
|
|
3050
|
+
["# OS / editors", ".DS_Store", "Thumbs.db", ".idea/", ".vscode/", "*.swp", "*.swo"],
|
|
3051
|
+
["# Local env / secrets", ".env", ".env.*", "!.env.example", "*.local"],
|
|
3052
|
+
["# Logs / temp", "*.log", "logs/", "tmp/", "temp/", ".cache/"],
|
|
3053
|
+
];
|
|
3054
|
+
if (/node|npm|pnpm|yarn|bun|typescript|javascript|react|vite|next/.test(stack)) {
|
|
3055
|
+
sections.push(["# Node / frontend", "node_modules/", "dist/", "build/", ".next/", "out/", "coverage/", ".turbo/", ".vite/", "*.tsbuildinfo"]);
|
|
3056
|
+
}
|
|
3057
|
+
if (/python|django|fastapi|flask/.test(stack)) {
|
|
3058
|
+
sections.push(["# Python", "__pycache__/", "*.py[cod]", ".pytest_cache/", ".ruff_cache/", ".mypy_cache/", ".venv/", "venv/", "htmlcov/", "*.egg-info/"]);
|
|
3059
|
+
}
|
|
3060
|
+
if (/rust|cargo/.test(stack)) sections.push(["# Rust", "target/"]);
|
|
3061
|
+
if (/\bgo\b|golang/.test(stack)) sections.push(["# Go", "bin/", "*.test", "coverage.out"]);
|
|
3062
|
+
if (/java|gradle|maven|kotlin/.test(stack)) sections.push(["# Java / JVM", "target/", "build/", ".gradle/", "*.class"]);
|
|
3063
|
+
if (/docker|container/.test(stack)) sections.push(["# Docker", ".docker/", "docker-compose.override.yml"]);
|
|
3064
|
+
if (sections.length === 3) {
|
|
3065
|
+
sections.push(["# Common dependency / build outputs", "node_modules/", ".venv/", "venv/", "target/", "dist/", "build/", "coverage/", "vendor/", "*.tmp"]);
|
|
3066
|
+
}
|
|
3067
|
+
const seen = new Set();
|
|
3068
|
+
const lines = [];
|
|
3069
|
+
for (const section of sections) {
|
|
3070
|
+
lines.push(section[0]);
|
|
3071
|
+
for (const pattern of section.slice(1)) {
|
|
3072
|
+
if (seen.has(pattern)) continue;
|
|
3073
|
+
seen.add(pattern);
|
|
3074
|
+
lines.push(pattern);
|
|
3075
|
+
}
|
|
3076
|
+
lines.push("");
|
|
3077
|
+
}
|
|
3078
|
+
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
async function prepareInitialRepositoryFiles(cwd, { repoName: repoNameInput, stack = "" } = {}) {
|
|
3082
|
+
const before = await initialRepositoryFilesStatus(cwd);
|
|
3083
|
+
const repoName = repoNameInput ? cleanGitHubRepoName(repoNameInput) : defaultGitRepoNameFromRoot(before.root);
|
|
3084
|
+
if (await regularFileExists(before.readmePath)) {
|
|
3085
|
+
// Explicit pre-add check: keep existing README.md unchanged.
|
|
3086
|
+
} else {
|
|
3087
|
+
await writeFile(before.readmePath, `# ${repoName}\n\nInitialized by Pi Web UI.\n`, "utf8");
|
|
3088
|
+
}
|
|
3089
|
+
let gitignoreCreated = false;
|
|
3090
|
+
let gitignoreSource = before.gitignoreExists ? "existing" : "fallback";
|
|
3091
|
+
if (!(await regularFileExists(before.gitignorePath))) {
|
|
3092
|
+
await writeFile(before.gitignorePath, gitignoreLinesForStack(stack, before.detectedStack), "utf8");
|
|
3093
|
+
gitignoreCreated = true;
|
|
3094
|
+
}
|
|
3095
|
+
const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "--", "README.md", ".gitignore"], { cwd: before.root, label: "git add README.md .gitignore" }));
|
|
3096
|
+
if (payload.data) {
|
|
3097
|
+
payload.data.root = before.root;
|
|
3098
|
+
payload.data.readme = { path: before.readmePath, exists: true, created: !before.readmeExists };
|
|
3099
|
+
payload.data.gitignore = { path: before.gitignorePath, exists: true, created: gitignoreCreated, source: gitignoreSource };
|
|
3100
|
+
payload.data.detectedStack = before.detectedStack;
|
|
3101
|
+
payload.data.stack = stack;
|
|
3102
|
+
payload.data.stdout = [
|
|
3103
|
+
before.readmeExists ? `Using existing ${before.readmePath}` : `Created ${before.readmePath}`,
|
|
3104
|
+
before.gitignoreExists ? `Using existing ${before.gitignorePath}` : `Created ${before.gitignorePath} (${gitignoreSource})`,
|
|
3105
|
+
payload.data.stdout?.trimEnd(),
|
|
3106
|
+
].filter(Boolean).join("\n");
|
|
3107
|
+
}
|
|
3108
|
+
return payload;
|
|
3109
|
+
}
|
|
3110
|
+
|
|
2930
3111
|
async function validateGitBranchName(root, branch) {
|
|
2931
3112
|
const result = await runGitWorkflowCommand(["check-ref-format", "--branch", branch], { cwd: root, timeoutMs: 5000 });
|
|
2932
3113
|
if (result.exitCode !== 0 || result.timedOut || result.cancelled || result.error) {
|
|
@@ -3123,6 +3304,40 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
|
|
|
3123
3304
|
return { ok: true, data: await readGitWorkflowBranchName(cwd) };
|
|
3124
3305
|
case "/api/git-workflow/pr-description":
|
|
3125
3306
|
return { ok: true, data: await readGitWorkflowPrDescription(cwd) };
|
|
3307
|
+
case "/api/git-workflow/init":
|
|
3308
|
+
await ensureOutsideGitRepository(cwd);
|
|
3309
|
+
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["init"], { cwd }));
|
|
3310
|
+
case "/api/git-workflow/init-files-status":
|
|
3311
|
+
return { ok: true, data: await initialRepositoryFilesStatus(cwd) };
|
|
3312
|
+
case "/api/git-workflow/readme":
|
|
3313
|
+
return prepareInitialRepositoryFiles(cwd, { repoName: body.repoName, stack: body.stack });
|
|
3314
|
+
case "/api/git-workflow/initial-commit": {
|
|
3315
|
+
const root = await getGitRoot(cwd);
|
|
3316
|
+
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["commit", "-m", "Initial commit"], { cwd: root, label: "git commit -m \"Initial commit\"" }));
|
|
3317
|
+
}
|
|
3318
|
+
case "/api/git-workflow/main-branch": {
|
|
3319
|
+
const root = await getGitRoot(cwd);
|
|
3320
|
+
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["branch", "-M", "main"], { cwd: root }));
|
|
3321
|
+
}
|
|
3322
|
+
case "/api/git-workflow/remote": {
|
|
3323
|
+
const root = await getGitRoot(cwd);
|
|
3324
|
+
const username = cleanGitHubUsername(body.username);
|
|
3325
|
+
const repoName = cleanGitHubRepoName(body.repoName);
|
|
3326
|
+
const remoteUrl = gitHubOriginUrl(username, repoName);
|
|
3327
|
+
const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["remote", "add", "origin", remoteUrl], { cwd: root }));
|
|
3328
|
+
if (payload.data) {
|
|
3329
|
+
payload.data.root = root;
|
|
3330
|
+
payload.data.remote = "origin";
|
|
3331
|
+
payload.data.remoteUrl = remoteUrl;
|
|
3332
|
+
payload.data.repoName = repoName;
|
|
3333
|
+
payload.data.username = username;
|
|
3334
|
+
}
|
|
3335
|
+
return payload;
|
|
3336
|
+
}
|
|
3337
|
+
case "/api/git-workflow/init-push": {
|
|
3338
|
+
const root = await getGitRoot(cwd);
|
|
3339
|
+
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["push", "-u", "origin", "main"], { cwd: root, timeoutMs: 15 * 60 * 1000 }));
|
|
3340
|
+
}
|
|
3126
3341
|
case "/api/git-workflow/add":
|
|
3127
3342
|
await getGitRoot(cwd);
|
|
3128
3343
|
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "."], { cwd }));
|
|
@@ -3136,7 +3351,12 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
|
|
|
3136
3351
|
}
|
|
3137
3352
|
case "/api/git-workflow/commit": {
|
|
3138
3353
|
const variant = String(body.variant || "").trim();
|
|
3139
|
-
if (!["short", "long"].includes(variant)) throw new Error("variant must be 'short' or '
|
|
3354
|
+
if (!["short", "long", "input"].includes(variant)) throw new Error("variant must be 'short', 'long', or 'input'");
|
|
3355
|
+
if (variant === "input") {
|
|
3356
|
+
const root = await getGitRoot(cwd);
|
|
3357
|
+
const message = cleanGitCommitMessageInput(body.message);
|
|
3358
|
+
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["commit", "-m", message], { cwd: root, label: "git commit -m <input message>" }));
|
|
3359
|
+
}
|
|
3140
3360
|
const messages = await readGitWorkflowMessages(cwd);
|
|
3141
3361
|
if (variant === "short") {
|
|
3142
3362
|
const message = messages.short.trim();
|
|
@@ -3275,6 +3495,43 @@ function normalizeStaticPath(urlPath) {
|
|
|
3275
3495
|
return name;
|
|
3276
3496
|
}
|
|
3277
3497
|
|
|
3498
|
+
const compressWithBrotli = promisify(brotliCompress);
|
|
3499
|
+
const compressWithGzip = promisify(gzip);
|
|
3500
|
+
const STATIC_COMPRESSIBLE_EXTENSIONS = new Set([".html", ".css", ".js", ".mjs", ".svg", ".json", ".webmanifest"]);
|
|
3501
|
+
const STATIC_COMPRESSION_MIN_BYTES = 1024;
|
|
3502
|
+
// filePath -> { mtimeMs, size, etag, raw, br, gz }; invalidated by mtime/size change.
|
|
3503
|
+
const staticAssetCache = new Map();
|
|
3504
|
+
|
|
3505
|
+
async function loadStaticAsset(filePath) {
|
|
3506
|
+
const stats = await stat(filePath);
|
|
3507
|
+
const cached = staticAssetCache.get(filePath);
|
|
3508
|
+
if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) return cached;
|
|
3509
|
+
const raw = await readFile(filePath);
|
|
3510
|
+
const entry = {
|
|
3511
|
+
mtimeMs: stats.mtimeMs,
|
|
3512
|
+
size: stats.size,
|
|
3513
|
+
etag: `"${createHash("sha1").update(raw).digest("base64url")}"`,
|
|
3514
|
+
raw,
|
|
3515
|
+
br: null,
|
|
3516
|
+
gz: null,
|
|
3517
|
+
};
|
|
3518
|
+
staticAssetCache.set(filePath, entry);
|
|
3519
|
+
return entry;
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
function acceptedStaticEncoding(req) {
|
|
3523
|
+
const header = String(req.headers["accept-encoding"] || "");
|
|
3524
|
+
if (/\bbr\b/i.test(header)) return "br";
|
|
3525
|
+
if (/\bgzip\b/i.test(header)) return "gzip";
|
|
3526
|
+
return "";
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
function requestEtagMatches(req, etag) {
|
|
3530
|
+
const header = String(req.headers["if-none-match"] || "");
|
|
3531
|
+
if (!header) return false;
|
|
3532
|
+
return header.split(",").some((candidate) => candidate.trim() === etag);
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3278
3535
|
async function serveStatic(req, res, url) {
|
|
3279
3536
|
if (req.method !== "GET") return false;
|
|
3280
3537
|
const staticName = normalizeStaticPath(url.pathname);
|
|
@@ -3282,13 +3539,42 @@ async function serveStatic(req, res, url) {
|
|
|
3282
3539
|
|
|
3283
3540
|
const filePath = path.join(publicDir, staticName);
|
|
3284
3541
|
const ext = path.extname(filePath);
|
|
3285
|
-
const
|
|
3286
|
-
|
|
3542
|
+
const asset = await loadStaticAsset(filePath);
|
|
3543
|
+
const headers = {
|
|
3287
3544
|
"content-type": MIME_TYPES.get(ext) || "application/octet-stream",
|
|
3288
|
-
|
|
3545
|
+
// no-cache (unlike no-store) allows conditional revalidation via ETag/304
|
|
3546
|
+
// while still guaranteeing fresh content after deploys.
|
|
3547
|
+
"cache-control": "no-cache",
|
|
3548
|
+
etag: asset.etag,
|
|
3549
|
+
vary: "Accept-Encoding",
|
|
3289
3550
|
"x-content-type-options": "nosniff",
|
|
3290
|
-
}
|
|
3291
|
-
|
|
3551
|
+
};
|
|
3552
|
+
if (requestEtagMatches(req, asset.etag)) {
|
|
3553
|
+
res.writeHead(304, headers);
|
|
3554
|
+
res.end();
|
|
3555
|
+
return true;
|
|
3556
|
+
}
|
|
3557
|
+
let body = asset.raw;
|
|
3558
|
+
if (STATIC_COMPRESSIBLE_EXTENSIONS.has(ext) && asset.raw.length >= STATIC_COMPRESSION_MIN_BYTES) {
|
|
3559
|
+
const encoding = acceptedStaticEncoding(req);
|
|
3560
|
+
if (encoding === "br") {
|
|
3561
|
+
asset.br ||= await compressWithBrotli(asset.raw, {
|
|
3562
|
+
params: {
|
|
3563
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: 6,
|
|
3564
|
+
[zlibConstants.BROTLI_PARAM_SIZE_HINT]: asset.raw.length,
|
|
3565
|
+
},
|
|
3566
|
+
});
|
|
3567
|
+
body = asset.br;
|
|
3568
|
+
headers["content-encoding"] = "br";
|
|
3569
|
+
} else if (encoding === "gzip") {
|
|
3570
|
+
asset.gz ||= await compressWithGzip(asset.raw, { level: 7 });
|
|
3571
|
+
body = asset.gz;
|
|
3572
|
+
headers["content-encoding"] = "gzip";
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
headers["content-length"] = body.length;
|
|
3576
|
+
res.writeHead(200, headers);
|
|
3577
|
+
res.end(body);
|
|
3292
3578
|
return true;
|
|
3293
3579
|
}
|
|
3294
3580
|
|
|
@@ -3449,6 +3735,23 @@ function commandFromPost(pathname, body) {
|
|
|
3449
3735
|
}
|
|
3450
3736
|
}
|
|
3451
3737
|
|
|
3738
|
+
/**
|
|
3739
|
+
* Delta transcript support (P1-1): /api/messages?since=N serializes only the
|
|
3740
|
+
* tail starting at message index N plus { totalCount, since } so clients can
|
|
3741
|
+
* merge appended messages instead of re-downloading the whole transcript on
|
|
3742
|
+
* every agent event. Without ?since= the legacy full payload is returned.
|
|
3743
|
+
*/
|
|
3744
|
+
function applyMessagesSinceParam(response, url) {
|
|
3745
|
+
const sinceRaw = url.searchParams.get("since");
|
|
3746
|
+
if (sinceRaw === null) return;
|
|
3747
|
+
const messages = response?.data?.messages;
|
|
3748
|
+
if (!Array.isArray(messages)) return;
|
|
3749
|
+
const parsed = Number.parseInt(sinceRaw, 10);
|
|
3750
|
+
const total = messages.length;
|
|
3751
|
+
const since = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 0), total) : 0;
|
|
3752
|
+
response.data = { ...response.data, messages: messages.slice(since), totalCount: total, since };
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3452
3755
|
function commandFromGet(pathname) {
|
|
3453
3756
|
switch (pathname) {
|
|
3454
3757
|
case "/api/state":
|
|
@@ -3543,10 +3846,161 @@ function readRestoreTabsFromEnv() {
|
|
|
3543
3846
|
}
|
|
3544
3847
|
}
|
|
3545
3848
|
|
|
3546
|
-
function
|
|
3547
|
-
|
|
3849
|
+
async function packageNameForResourcePath(resourcePath) {
|
|
3850
|
+
let current = path.dirname(resourcePath);
|
|
3851
|
+
while (current && current !== path.dirname(current)) {
|
|
3852
|
+
if (PACKAGE_NAME_CACHE.has(current)) return PACKAGE_NAME_CACHE.get(current) || undefined;
|
|
3853
|
+
try {
|
|
3854
|
+
const pkg = JSON.parse(await readFile(path.join(current, "package.json"), "utf8"));
|
|
3855
|
+
const name = typeof pkg.name === "string" ? pkg.name : "";
|
|
3856
|
+
PACKAGE_NAME_CACHE.set(current, name);
|
|
3857
|
+
return name || undefined;
|
|
3858
|
+
} catch {
|
|
3859
|
+
current = path.dirname(current);
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
return undefined;
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
async function packageRootRealpath() {
|
|
3866
|
+
try {
|
|
3867
|
+
return await realpath(packageRoot);
|
|
3868
|
+
} catch {
|
|
3869
|
+
return packageRoot;
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3873
|
+
async function devWorkspaceRoot() {
|
|
3874
|
+
if (!webuiDevServer) return null;
|
|
3875
|
+
const envRoot = process.env.PI_NPM_PACKAGES_ROOT ? path.resolve(expandUserPath(process.env.PI_NPM_PACKAGES_ROOT)) : "";
|
|
3876
|
+
const candidates = [envRoot, path.dirname(packageRoot), path.dirname(await packageRootRealpath())].filter(Boolean);
|
|
3877
|
+
for (const candidate of candidates) {
|
|
3878
|
+
try {
|
|
3879
|
+
const entries = await readdir(candidate, { withFileTypes: true });
|
|
3880
|
+
if (entries.some((entry) => entry.isDirectory() && entry.name === "pi-package-webui")) return candidate;
|
|
3881
|
+
} catch {
|
|
3882
|
+
// Try the next candidate.
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
return null;
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
async function workspacePackageRootForName(packageName) {
|
|
3889
|
+
const root = await devWorkspaceRoot();
|
|
3890
|
+
if (!root) return null;
|
|
3891
|
+
let entries;
|
|
3892
|
+
try {
|
|
3893
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
3894
|
+
} catch {
|
|
3895
|
+
return null;
|
|
3896
|
+
}
|
|
3897
|
+
for (const entry of entries) {
|
|
3898
|
+
if (!entry.isDirectory() || !entry.name.startsWith("pi-")) continue;
|
|
3899
|
+
const candidate = path.join(root, entry.name);
|
|
3900
|
+
if ((await packageNameForResourcePath(path.join(candidate, "index.ts"))) === packageName) return candidate;
|
|
3901
|
+
}
|
|
3902
|
+
return null;
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
function parseNodeModulesPackageRef(manifestEntry) {
|
|
3906
|
+
const normalized = String(manifestEntry || "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
3907
|
+
if (!normalized.startsWith("node_modules/")) return null;
|
|
3908
|
+
const parts = normalized.slice("node_modules/".length).split("/").filter(Boolean);
|
|
3909
|
+
if (!parts.length) return null;
|
|
3910
|
+
const scoped = parts[0].startsWith("@");
|
|
3911
|
+
const packageName = scoped ? `${parts[0]}/${parts[1] || ""}` : parts[0];
|
|
3912
|
+
if (!packageName || packageName.endsWith("/")) return null;
|
|
3913
|
+
const subpath = parts.slice(scoped ? 2 : 1).join("/");
|
|
3914
|
+
return { packageName, subpath };
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
async function resolveStartedWebuiManifestResource(manifestEntry) {
|
|
3918
|
+
const nodeModulesRef = parseNodeModulesPackageRef(manifestEntry);
|
|
3919
|
+
if (nodeModulesRef && WEBUI_CONTROLLED_PACKAGES.has(nodeModulesRef.packageName)) {
|
|
3920
|
+
const workspaceRoot = await workspacePackageRootForName(nodeModulesRef.packageName);
|
|
3921
|
+
if (workspaceRoot) {
|
|
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
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
const candidate = path.resolve(packageRoot, manifestEntry);
|
|
3933
|
+
try {
|
|
3934
|
+
await access(candidate);
|
|
3935
|
+
return candidate;
|
|
3936
|
+
} catch {
|
|
3937
|
+
return null;
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3941
|
+
async function startedWebuiResourcePaths(resourceType) {
|
|
3942
|
+
const entries = Array.isArray(packageJson.pi?.[resourceType]) ? packageJson.pi[resourceType] : [];
|
|
3943
|
+
const resolved = [];
|
|
3944
|
+
for (const entry of entries) {
|
|
3945
|
+
if (typeof entry !== "string") continue;
|
|
3946
|
+
const resourcePath = await resolveStartedWebuiManifestResource(entry);
|
|
3947
|
+
if (resourcePath) resolved.push(resourcePath);
|
|
3948
|
+
}
|
|
3949
|
+
return resolved;
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3952
|
+
function piArgsDisableResourceDiscovery(resourceType) {
|
|
3953
|
+
const flags = {
|
|
3954
|
+
extensions: new Set(["--no-extensions", "-ne"]),
|
|
3955
|
+
skills: new Set(["--no-skills", "-ns"]),
|
|
3956
|
+
prompts: new Set(["--no-prompt-templates", "-np"]),
|
|
3957
|
+
themes: new Set(["--no-themes"]),
|
|
3958
|
+
}[resourceType];
|
|
3959
|
+
return !!flags && options.piArgs.some((arg) => flags.has(arg));
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
async function resolvedNormalPiResourcesForTab(cwd) {
|
|
3963
|
+
try {
|
|
3964
|
+
const settingsManager = SettingsManager.create(cwd, agentDir);
|
|
3965
|
+
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
|
3966
|
+
return await packageManager.resolve(async () => "skip");
|
|
3967
|
+
} catch (error) {
|
|
3968
|
+
console.warn(`failed to resolve configured Pi resources for Web UI tab: ${sanitizeError(error)}`);
|
|
3969
|
+
return { extensions: [], skills: [], prompts: [], themes: [] };
|
|
3970
|
+
}
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
async function normalPiResourcePathsForTab(resolved, resourceType) {
|
|
3974
|
+
if (piArgsDisableResourceDiscovery(resourceType)) return [];
|
|
3975
|
+
const resourcePaths = [];
|
|
3976
|
+
for (const resource of resolved[resourceType] || []) {
|
|
3977
|
+
if (!resource.enabled) continue;
|
|
3978
|
+
const packageName = await packageNameForResourcePath(resource.path);
|
|
3979
|
+
if (packageName && WEBUI_CONTROLLED_PACKAGES.has(packageName)) continue;
|
|
3980
|
+
resourcePaths.push(resource.path);
|
|
3981
|
+
}
|
|
3982
|
+
return resourcePaths;
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
function appendResourceArgs(args, flag, resourcePaths) {
|
|
3986
|
+
for (const resourcePath of resourcePaths) args.push(flag, resourcePath);
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
async function appendCuratedResourceArgs(args, normalResources, resourceType, flag) {
|
|
3990
|
+
appendResourceArgs(args, flag, await normalPiResourcePathsForTab(normalResources, resourceType));
|
|
3991
|
+
appendResourceArgs(args, flag, await startedWebuiResourcePaths(resourceType));
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3994
|
+
async function buildPiArgsForTab(tabIndex, title, tabCwd = options.cwd) {
|
|
3995
|
+
const args = ["--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes"];
|
|
3548
3996
|
if (options.noSession) args.push("--no-session");
|
|
3549
3997
|
|
|
3998
|
+
const normalResources = await resolvedNormalPiResourcesForTab(tabCwd);
|
|
3999
|
+
await appendCuratedResourceArgs(args, normalResources, "extensions", "--extension");
|
|
4000
|
+
await appendCuratedResourceArgs(args, normalResources, "skills", "--skill");
|
|
4001
|
+
await appendCuratedResourceArgs(args, normalResources, "prompts", "--prompt-template");
|
|
4002
|
+
await appendCuratedResourceArgs(args, normalResources, "themes", "--theme");
|
|
4003
|
+
|
|
3550
4004
|
// Load a browser-safe RPC helper into every Web UI tab. It exposes hidden
|
|
3551
4005
|
// extension commands for Web UI-native /tools and /skills selectors without
|
|
3552
4006
|
// depending on TUI-only extension UIs.
|
|
@@ -3559,8 +4013,19 @@ function buildPiArgsForTab(tabIndex, title) {
|
|
|
3559
4013
|
return args;
|
|
3560
4014
|
}
|
|
3561
4015
|
|
|
4016
|
+
function isNodeScriptCommand(command) {
|
|
4017
|
+
return [".cjs", ".js", ".mjs"].includes(path.extname(String(command || "")).toLowerCase());
|
|
4018
|
+
}
|
|
4019
|
+
|
|
3562
4020
|
async function resolvePiCommand(piArgs) {
|
|
3563
4021
|
if (options.piBinExplicit) {
|
|
4022
|
+
if (isNodeScriptCommand(options.piBin)) {
|
|
4023
|
+
return {
|
|
4024
|
+
command: process.execPath,
|
|
4025
|
+
args: [options.piBin, ...piArgs],
|
|
4026
|
+
displayCommand: `${process.execPath} ${options.piBin} ${piArgs.join(" ")}`,
|
|
4027
|
+
};
|
|
4028
|
+
}
|
|
3564
4029
|
return { command: options.piBin, args: piArgs, displayCommand: `${options.piBin} ${piArgs.join(" ")}` };
|
|
3565
4030
|
}
|
|
3566
4031
|
|
|
@@ -4004,7 +4469,7 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
|
|
|
4004
4469
|
const resolvedTitleSource = ["explicit", "auto", "default"].includes(titleSource) ? titleSource : titleIsExplicit ? "explicit" : "default";
|
|
4005
4470
|
const tabCwd = cwd ? await resolveCwd(cwd, options.cwd) : options.cwd;
|
|
4006
4471
|
const id = requestedId && !tabs.has(requestedId) ? requestedId : randomUUID();
|
|
4007
|
-
const piArgs = buildPiArgsForTab(tabIndex, tabTitle);
|
|
4472
|
+
const piArgs = await buildPiArgsForTab(tabIndex, tabTitle, tabCwd);
|
|
4008
4473
|
if (sessionFile && !options.noSession) piArgs.push("--session", sessionFile);
|
|
4009
4474
|
const piCommand = await resolvePiCommand(piArgs);
|
|
4010
4475
|
const rpc = new PiRpcProcess({ ...piCommand, cwd: tabCwd });
|
|
@@ -4247,13 +4712,13 @@ async function getUpdateStatus({ force = false } = {}) {
|
|
|
4247
4712
|
checkedAt: new Date(now).toISOString(),
|
|
4248
4713
|
updateAvailable,
|
|
4249
4714
|
restartRequired: true,
|
|
4250
|
-
command: "pi update",
|
|
4715
|
+
command: "pi update + Web UI/Pi package-manager updates",
|
|
4251
4716
|
webuiDev: webuiDevServer,
|
|
4252
4717
|
pi: piStatus,
|
|
4253
4718
|
webui: webuiStatus,
|
|
4254
4719
|
packages: {
|
|
4255
4720
|
checked: false,
|
|
4256
|
-
note: "pi update
|
|
4721
|
+
note: "Update runs pi update plus all detected local, Pi-agent, npm-global, and Bun-global Web UI/Pi package roots."
|
|
4257
4722
|
},
|
|
4258
4723
|
};
|
|
4259
4724
|
updateStatusCacheAt = now;
|
|
@@ -4262,15 +4727,235 @@ async function getUpdateStatus({ force = false } = {}) {
|
|
|
4262
4727
|
|
|
4263
4728
|
async function resolvePiUpdateCommand() {
|
|
4264
4729
|
if (options.piBinExplicit) {
|
|
4265
|
-
return { command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
|
|
4730
|
+
return { label: "Pi CLI and configured packages", command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
|
|
4266
4731
|
}
|
|
4267
4732
|
|
|
4268
4733
|
const pathPi = await runCommand(options.piBin, ["--version"], { timeoutMs: 3000, maxOutputLength: 4000 });
|
|
4269
4734
|
if (pathPi.exitCode === 0 && !pathPi.timedOut && !pathPi.error) {
|
|
4270
|
-
return { command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
|
|
4735
|
+
return { label: "Pi CLI and configured packages", command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
|
|
4736
|
+
}
|
|
4737
|
+
|
|
4738
|
+
const fallback = await resolvePiCommand(["update"]);
|
|
4739
|
+
return { ...fallback, label: "bundled Pi CLI and configured packages" };
|
|
4740
|
+
}
|
|
4741
|
+
|
|
4742
|
+
function packageNodeModulesPath(nodeModulesRoot, packageName) {
|
|
4743
|
+
return path.join(nodeModulesRoot, ...String(packageName || "").split("/").filter(Boolean));
|
|
4744
|
+
}
|
|
4745
|
+
|
|
4746
|
+
function isWebuiOrPiPackageName(packageName) {
|
|
4747
|
+
const name = String(packageName || "").trim();
|
|
4748
|
+
return UPDATE_PACKAGE_NAMES.includes(name)
|
|
4749
|
+
|| /^@firstpick\/pi(?:-|$)/.test(name)
|
|
4750
|
+
|| /^@earendil-works\/pi(?:-|$)/.test(name)
|
|
4751
|
+
|| /^@firstpick\/.*webui/i.test(name);
|
|
4752
|
+
}
|
|
4753
|
+
|
|
4754
|
+
function declaredWebuiPiPackageNames(manifest) {
|
|
4755
|
+
const names = new Set();
|
|
4756
|
+
for (const section of [manifest?.dependencies, manifest?.optionalDependencies, manifest?.devDependencies]) {
|
|
4757
|
+
for (const packageName of Object.keys(section || {})) {
|
|
4758
|
+
if (isWebuiOrPiPackageName(packageName)) names.add(packageName);
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
return [...names].sort();
|
|
4762
|
+
}
|
|
4763
|
+
|
|
4764
|
+
async function packagesPresentInNodeModulesRoot(nodeModulesRoot, packageNames = UPDATE_PACKAGE_NAMES) {
|
|
4765
|
+
const found = new Set();
|
|
4766
|
+
if (!nodeModulesRoot || !await directoryExists(nodeModulesRoot)) return [];
|
|
4767
|
+
for (const packageName of packageNames) {
|
|
4768
|
+
if (await directoryExists(packageNodeModulesPath(nodeModulesRoot, packageName))) found.add(packageName);
|
|
4769
|
+
}
|
|
4770
|
+
|
|
4771
|
+
let entries = [];
|
|
4772
|
+
try {
|
|
4773
|
+
entries = await readdir(nodeModulesRoot, { withFileTypes: true });
|
|
4774
|
+
} catch {
|
|
4775
|
+
return [...found].sort();
|
|
4776
|
+
}
|
|
4777
|
+
|
|
4778
|
+
for (const entry of entries) {
|
|
4779
|
+
if (!entry.isDirectory()) continue;
|
|
4780
|
+
if (entry.name.startsWith("@")) {
|
|
4781
|
+
let scopedEntries = [];
|
|
4782
|
+
try {
|
|
4783
|
+
scopedEntries = await readdir(path.join(nodeModulesRoot, entry.name), { withFileTypes: true });
|
|
4784
|
+
} catch {
|
|
4785
|
+
continue;
|
|
4786
|
+
}
|
|
4787
|
+
for (const scopedEntry of scopedEntries) {
|
|
4788
|
+
if (!scopedEntry.isDirectory()) continue;
|
|
4789
|
+
const packageName = `${entry.name}/${scopedEntry.name}`;
|
|
4790
|
+
if (isWebuiOrPiPackageName(packageName)) found.add(packageName);
|
|
4791
|
+
}
|
|
4792
|
+
continue;
|
|
4793
|
+
}
|
|
4794
|
+
if (isWebuiOrPiPackageName(entry.name)) found.add(entry.name);
|
|
4271
4795
|
}
|
|
4796
|
+
return [...found].sort();
|
|
4797
|
+
}
|
|
4798
|
+
|
|
4799
|
+
async function packagesPresentInInstallPrefix(installRoot, packageNames = UPDATE_PACKAGE_NAMES) {
|
|
4800
|
+
const found = new Set();
|
|
4801
|
+
if (!installRoot || !await directoryExists(installRoot)) return [];
|
|
4802
|
+
const manifest = await readJsonFileIfExists(path.join(installRoot, "package.json"));
|
|
4803
|
+
for (const packageName of packageNames) {
|
|
4804
|
+
if (declaredDependencySpec(manifest, packageName) !== undefined) found.add(packageName);
|
|
4805
|
+
}
|
|
4806
|
+
for (const packageName of declaredWebuiPiPackageNames(manifest)) found.add(packageName);
|
|
4807
|
+
for (const packageName of await packagesPresentInNodeModulesRoot(path.join(installRoot, "node_modules"), packageNames)) {
|
|
4808
|
+
found.add(packageName);
|
|
4809
|
+
}
|
|
4810
|
+
return [...found].sort();
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4813
|
+
function packageInstallSpecs(packageNames) {
|
|
4814
|
+
return packageNames.map((packageName) => `${packageName}@latest`);
|
|
4815
|
+
}
|
|
4816
|
+
|
|
4817
|
+
function npmCommandName() {
|
|
4818
|
+
return process.env.PI_WEBUI_NPM_BIN || "npm";
|
|
4819
|
+
}
|
|
4820
|
+
|
|
4821
|
+
function npmPrefixUpdateTask(label, installRoot, packageNames) {
|
|
4822
|
+
if (!packageNames.length) return null;
|
|
4823
|
+
const npmCommand = npmCommandName();
|
|
4824
|
+
return {
|
|
4825
|
+
label,
|
|
4826
|
+
command: npmCommand,
|
|
4827
|
+
args: ["install", "--prefix", installRoot, "--ignore-scripts", "--min-release-age=0", ...packageInstallSpecs(packageNames)],
|
|
4828
|
+
cwd: installRoot,
|
|
4829
|
+
};
|
|
4830
|
+
}
|
|
4831
|
+
|
|
4832
|
+
async function currentWebuiPackageUpdateTask() {
|
|
4833
|
+
const sourceCheckout = webuiDevServer || !String(packageRoot).split(path.sep).includes("node_modules");
|
|
4834
|
+
if (sourceCheckout) {
|
|
4835
|
+
const manifest = await readJsonFileIfExists(path.join(packageRoot, "package.json"));
|
|
4836
|
+
const packages = Object.keys(manifest?.dependencies || {}).filter(isWebuiOrPiPackageName).sort();
|
|
4837
|
+
return npmPrefixUpdateTask("current Web UI checkout package dependencies", packageRoot, packages);
|
|
4838
|
+
}
|
|
4839
|
+
|
|
4840
|
+
const installRoot = nodeModulesParentForPackageRoot(packageRoot);
|
|
4841
|
+
const packages = await packagesPresentInInstallPrefix(installRoot);
|
|
4842
|
+
return npmPrefixUpdateTask("current Web UI install root", installRoot, packages);
|
|
4843
|
+
}
|
|
4844
|
+
|
|
4845
|
+
async function agentPackageRootUpdateTask() {
|
|
4846
|
+
const installRoot = configuredAgentNpmRoot();
|
|
4847
|
+
const packages = await packagesPresentInInstallPrefix(installRoot);
|
|
4848
|
+
return npmPrefixUpdateTask("Pi agent npm package root", installRoot, packages);
|
|
4849
|
+
}
|
|
4850
|
+
|
|
4851
|
+
async function npmGlobalNodeModulesRoot() {
|
|
4852
|
+
const npmCommand = npmCommandName();
|
|
4853
|
+
const result = await runCommand(npmCommand, ["root", "-g"], { timeoutMs: 5000, maxOutputLength: 8000 });
|
|
4854
|
+
if (result.exitCode !== 0 || result.timedOut || result.error) return null;
|
|
4855
|
+
return result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1) || null;
|
|
4856
|
+
}
|
|
4857
|
+
|
|
4858
|
+
async function npmGlobalPackageRootUpdateTask() {
|
|
4859
|
+
const nodeModulesRoot = await npmGlobalNodeModulesRoot();
|
|
4860
|
+
const packages = await packagesPresentInNodeModulesRoot(nodeModulesRoot);
|
|
4861
|
+
if (!packages.length) return null;
|
|
4862
|
+
const npmCommand = npmCommandName();
|
|
4863
|
+
return {
|
|
4864
|
+
label: "global npm package root",
|
|
4865
|
+
command: npmCommand,
|
|
4866
|
+
args: ["install", "-g", "--ignore-scripts", "--min-release-age=0", ...packageInstallSpecs(packages)],
|
|
4867
|
+
cwd: nodeModulesRoot ? path.dirname(nodeModulesRoot) : process.cwd(),
|
|
4868
|
+
};
|
|
4869
|
+
}
|
|
4870
|
+
|
|
4871
|
+
async function bunGlobalNodeModulesRoots() {
|
|
4872
|
+
const available = await runCommand("bun", ["--version"], { timeoutMs: 3000, maxOutputLength: 2000 });
|
|
4873
|
+
if (available.exitCode !== 0 || available.timedOut || available.error) return [];
|
|
4874
|
+
|
|
4875
|
+
const roots = new Set([path.join(homedir(), ".bun", "install", "global", "node_modules")]);
|
|
4876
|
+
const binResult = await runCommand("bun", ["pm", "bin", "-g"], { timeoutMs: 3000, maxOutputLength: 8000 });
|
|
4877
|
+
if (binResult.exitCode === 0 && !binResult.timedOut && !binResult.error) {
|
|
4878
|
+
const binDir = binResult.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
|
|
4879
|
+
if (binDir) roots.add(path.join(path.dirname(binDir), "install", "global", "node_modules"));
|
|
4880
|
+
}
|
|
4881
|
+
return [...roots];
|
|
4882
|
+
}
|
|
4883
|
+
|
|
4884
|
+
async function bunGlobalPackageRootUpdateTask() {
|
|
4885
|
+
const packages = new Set();
|
|
4886
|
+
for (const nodeModulesRoot of await bunGlobalNodeModulesRoots()) {
|
|
4887
|
+
for (const packageName of await packagesPresentInNodeModulesRoot(nodeModulesRoot)) packages.add(packageName);
|
|
4888
|
+
}
|
|
4889
|
+
if (!packages.size) return null;
|
|
4890
|
+
return {
|
|
4891
|
+
label: "global Bun package root",
|
|
4892
|
+
command: "bun",
|
|
4893
|
+
args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", ...packageInstallSpecs([...packages])],
|
|
4894
|
+
cwd: homedir(),
|
|
4895
|
+
};
|
|
4896
|
+
}
|
|
4897
|
+
|
|
4898
|
+
function updateTaskDisplay(task) {
|
|
4899
|
+
return task.displayCommand || formatCommandForDisplay(task.command, task.args || []);
|
|
4900
|
+
}
|
|
4901
|
+
|
|
4902
|
+
function uniqueUpdateTasks(tasks) {
|
|
4903
|
+
const unique = [];
|
|
4904
|
+
const seen = new Set();
|
|
4905
|
+
for (const task of tasks.filter(Boolean)) {
|
|
4906
|
+
const key = [task.command, JSON.stringify(task.args || []), task.cwd || ""].join("\u0000");
|
|
4907
|
+
if (seen.has(key)) continue;
|
|
4908
|
+
seen.add(key);
|
|
4909
|
+
unique.push(task);
|
|
4910
|
+
}
|
|
4911
|
+
return unique;
|
|
4912
|
+
}
|
|
4913
|
+
|
|
4914
|
+
async function resolveUpdateTasks() {
|
|
4915
|
+
return uniqueUpdateTasks([
|
|
4916
|
+
await resolvePiUpdateCommand(),
|
|
4917
|
+
await currentWebuiPackageUpdateTask(),
|
|
4918
|
+
await agentPackageRootUpdateTask(),
|
|
4919
|
+
await npmGlobalPackageRootUpdateTask(),
|
|
4920
|
+
await bunGlobalPackageRootUpdateTask(),
|
|
4921
|
+
]);
|
|
4922
|
+
}
|
|
4923
|
+
|
|
4924
|
+
function updateFailureDetails(result) {
|
|
4925
|
+
return [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
|
|
4926
|
+
}
|
|
4272
4927
|
|
|
4273
|
-
|
|
4928
|
+
async function runUpdateTask(task) {
|
|
4929
|
+
const command = updateTaskDisplay(task);
|
|
4930
|
+
recordEvent({ type: "webui_update_step_started", command });
|
|
4931
|
+
const result = await runCommand(task.command, task.args || [], {
|
|
4932
|
+
cwd: task.cwd || process.cwd(),
|
|
4933
|
+
timeoutMs: task.timeoutMs || PACKAGE_UPDATE_TIMEOUT_MS,
|
|
4934
|
+
maxOutputLength: task.maxOutputLength || PACKAGE_UPDATE_OUTPUT_MAX_CHARS,
|
|
4935
|
+
});
|
|
4936
|
+
const ok = result.exitCode === 0 && !result.timedOut && !result.error;
|
|
4937
|
+
if (!ok) {
|
|
4938
|
+
const details = updateFailureDetails(result);
|
|
4939
|
+
recordEvent({ type: "webui_update_step_failed", command, error: truncateStatusText(details || `exit code ${result.exitCode ?? "unknown"}`) });
|
|
4940
|
+
throw makeHttpError(500, truncateLongText(`Update step failed (${task.label || "package update"}): ${command}${details ? `\n${details}` : ""}`));
|
|
4941
|
+
}
|
|
4942
|
+
recordEvent({ type: "webui_update_step_completed", command });
|
|
4943
|
+
return {
|
|
4944
|
+
label: task.label || "package update",
|
|
4945
|
+
command,
|
|
4946
|
+
stdout: result.stdout,
|
|
4947
|
+
stderr: result.stderr,
|
|
4948
|
+
};
|
|
4949
|
+
}
|
|
4950
|
+
|
|
4951
|
+
function combinedUpdateOutput(results, field) {
|
|
4952
|
+
return results
|
|
4953
|
+
.map((result) => {
|
|
4954
|
+
const output = String(result?.[field] || "").trim();
|
|
4955
|
+
return output ? `# ${result.label}\n${output}` : "";
|
|
4956
|
+
})
|
|
4957
|
+
.filter(Boolean)
|
|
4958
|
+
.join("\n\n");
|
|
4274
4959
|
}
|
|
4275
4960
|
|
|
4276
4961
|
async function runPiUpdateAndPrepareRestart() {
|
|
@@ -4279,20 +4964,12 @@ async function runPiUpdateAndPrepareRestart() {
|
|
|
4279
4964
|
let restartPrepared = false;
|
|
4280
4965
|
try {
|
|
4281
4966
|
const restorableTabs = await restorableTabsForRestart();
|
|
4282
|
-
const
|
|
4283
|
-
|
|
4967
|
+
const updateTasks = await resolveUpdateTasks();
|
|
4968
|
+
if (!updateTasks.length) throw makeHttpError(500, "No Pi/Web UI update commands could be resolved.");
|
|
4969
|
+
const command = updateTasks.map(updateTaskDisplay).join(" && ");
|
|
4284
4970
|
recordEvent({ type: "webui_update_started", command, restorableTabCount: restorableTabs.length });
|
|
4285
|
-
const
|
|
4286
|
-
|
|
4287
|
-
timeoutMs: PI_UPDATE_TIMEOUT_MS,
|
|
4288
|
-
maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS,
|
|
4289
|
-
});
|
|
4290
|
-
const ok = result.exitCode === 0 && !result.timedOut && !result.error;
|
|
4291
|
-
if (!ok) {
|
|
4292
|
-
const details = [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
|
|
4293
|
-
recordEvent({ type: "webui_update_failed", command, error: truncateStatusText(details || `exit code ${result.exitCode ?? "unknown"}`) });
|
|
4294
|
-
throw makeHttpError(500, truncateLongText(`Pi update failed: ${command}${details ? `\n${details}` : ""}`));
|
|
4295
|
-
}
|
|
4971
|
+
const results = [];
|
|
4972
|
+
for (const task of updateTasks) results.push(await runUpdateTask(task));
|
|
4296
4973
|
|
|
4297
4974
|
updateStatusCache = null;
|
|
4298
4975
|
updateStatusCacheAt = 0;
|
|
@@ -4300,10 +4977,11 @@ async function runPiUpdateAndPrepareRestart() {
|
|
|
4300
4977
|
restartPrepared = true;
|
|
4301
4978
|
recordEvent({ type: "webui_update_restarting", command, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
|
|
4302
4979
|
return {
|
|
4303
|
-
message: "Pi
|
|
4980
|
+
message: "Pi/Web UI package updates completed. Pi Web UI is restarting.",
|
|
4304
4981
|
command,
|
|
4305
|
-
|
|
4306
|
-
|
|
4982
|
+
commands: results.map((result) => ({ label: result.label, command: result.command })),
|
|
4983
|
+
stdout: combinedUpdateOutput(results, "stdout"),
|
|
4984
|
+
stderr: combinedUpdateOutput(results, "stderr"),
|
|
4307
4985
|
webuiPid: process.pid,
|
|
4308
4986
|
nextWebuiPid: child.pid,
|
|
4309
4987
|
restorableTabCount: restorableTabs.length,
|
|
@@ -4376,7 +5054,7 @@ async function updateTabCwd(id, cwd) {
|
|
|
4376
5054
|
if (tab.rpc?.isRunning()) await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
4377
5055
|
const sessionFile = tabRestorableSessionFile(tab);
|
|
4378
5056
|
|
|
4379
|
-
const piArgs = buildPiArgsForTab(tab.index, tab.title);
|
|
5057
|
+
const piArgs = await buildPiArgsForTab(tab.index, tab.title, nextCwd);
|
|
4380
5058
|
if (sessionFile && !options.noSession) piArgs.push("--session", sessionFile);
|
|
4381
5059
|
const piCommand = await resolvePiCommand(piArgs);
|
|
4382
5060
|
const restartingEvent = { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd, sessionFile };
|
|
@@ -4417,7 +5095,7 @@ async function restartTabRpc(tab, reason = "reload") {
|
|
|
4417
5095
|
if (state.data?.isStreaming) throw makeHttpError(409, "Wait for the current response to finish before reloading.");
|
|
4418
5096
|
if (state.data?.isCompacting) throw makeHttpError(409, "Wait for compaction to finish before reloading.");
|
|
4419
5097
|
|
|
4420
|
-
const piArgs = buildPiArgsForTab(tab.index, tab.title);
|
|
5098
|
+
const piArgs = await buildPiArgsForTab(tab.index, tab.title, tab.cwd);
|
|
4421
5099
|
if (state.data?.sessionFile && !options.noSession) piArgs.push("--session", state.data.sessionFile);
|
|
4422
5100
|
const piCommand = await resolvePiCommand(piArgs);
|
|
4423
5101
|
const reloadingEvent = { type: "webui_tab_reloading", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, reason, sessionFile: state.data?.sessionFile };
|
|
@@ -6441,6 +7119,7 @@ const server = createServer(async (req, res) => {
|
|
|
6441
7119
|
if (getCommand) {
|
|
6442
7120
|
const tab = getRequestedTab(req, url);
|
|
6443
7121
|
const response = await safeRpcResponse(tab, getCommand);
|
|
7122
|
+
if (url.pathname === "/api/messages") applyMessagesSinceParam(response, url);
|
|
6444
7123
|
sendJson(res, response.success === false ? 400 : 200, response);
|
|
6445
7124
|
return;
|
|
6446
7125
|
}
|