@firstpick/pi-package-webui 0.4.5 → 0.4.7
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/bin/pi-webui.mjs +153 -23
- package/package.json +10 -3
- package/public/app.js +469 -45
- package/public/index.html +4 -1
- package/public/styles.css +66 -0
- package/tests/fixtures/fake-pi.mjs +0 -0
- package/tests/http-endpoints-harness.test.mjs +48 -0
- package/tests/mobile-static.test.mjs +14 -3
package/bin/pi-webui.mjs
CHANGED
|
@@ -58,6 +58,7 @@ const webuiDevServer = isTruthyEnv(process.env.PI_WEBUI_DEV) || isSourceCheckout
|
|
|
58
58
|
const DEFAULT_HOST = "127.0.0.1";
|
|
59
59
|
const DEFAULT_PORT = 31415;
|
|
60
60
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
61
|
+
const PROMPT_REQUEST_TIMEOUT_MS = Math.max(REQUEST_TIMEOUT_MS, Number.parseInt(process.env.PI_WEBUI_PROMPT_TIMEOUT_MS || "7200000", 10) || 7200000);
|
|
61
62
|
const WEBUI_HELPER_TIMEOUT_MS = 8 * 1000;
|
|
62
63
|
const WEBUI_HELPER_COMMAND = "webui-helper";
|
|
63
64
|
const WEBUI_HELPER_RESPONSE_PREFIX = "__PI_WEBUI_HELPER_RESPONSE__:";
|
|
@@ -639,7 +640,8 @@ function makeHttpError(statusCode, message) {
|
|
|
639
640
|
}
|
|
640
641
|
|
|
641
642
|
function sendError(res, statusCode, error) {
|
|
642
|
-
|
|
643
|
+
const message = statusCode >= 500 ? sanitizeError(error) : formatCliError(error);
|
|
644
|
+
sendJson(res, statusCode, { ok: false, error: message });
|
|
643
645
|
}
|
|
644
646
|
|
|
645
647
|
function formatBytes(bytes) {
|
|
@@ -1626,22 +1628,85 @@ function normalizeCustomRunnerDefinition(raw, projectRoot, { strict = false } =
|
|
|
1626
1628
|
return { id, label, command, path: filePath, args };
|
|
1627
1629
|
}
|
|
1628
1630
|
|
|
1629
|
-
|
|
1631
|
+
function customAppRunnerDiagnostic(severity, message, runner = {}) {
|
|
1632
|
+
const source = runner && typeof runner === "object" ? runner : {};
|
|
1633
|
+
return {
|
|
1634
|
+
severity,
|
|
1635
|
+
message,
|
|
1636
|
+
runnerId: source.id || "",
|
|
1637
|
+
runnerLabel: source.label || "",
|
|
1638
|
+
path: source.path || source.projectFile || "",
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function directCustomRunnerUnavailableReason(filePath, stats) {
|
|
1643
|
+
if (process.platform !== "win32" && stats && (stats.mode & 0o111) === 0) {
|
|
1644
|
+
return `Path is not executable: ${filePath}. Run chmod +x ${filePath} or set Command to bash, python3, node, etc.`;
|
|
1645
|
+
}
|
|
1646
|
+
return "";
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
async function customAppRunnerUnavailableReason(projectRoot, runner) {
|
|
1650
|
+
const filePath = runner.path;
|
|
1651
|
+
let stats;
|
|
1652
|
+
try {
|
|
1653
|
+
stats = await fileStatsIfExists(resolveProjectRelativePath(projectRoot, filePath));
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
return `Cannot access path ${filePath}: ${formatCliError(error)}`;
|
|
1656
|
+
}
|
|
1657
|
+
if (!stats?.isFile()) return `Path to file does not exist: ${filePath}`;
|
|
1658
|
+
const command = cleanCustomRunnerCommand(runner.command);
|
|
1659
|
+
const directReason = command === "./" ? directCustomRunnerUnavailableReason(filePath, stats) : "";
|
|
1660
|
+
if (directReason) return directReason;
|
|
1661
|
+
const commandParts = customRunnerCommandParts(command);
|
|
1662
|
+
if (command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) return `Command is not available: ${commandParts[0]}`;
|
|
1663
|
+
return "";
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
async function readAppRunnerConfig(projectRoot, { strictRead = false } = {}) {
|
|
1630
1667
|
const configPath = path.join(projectRoot, APP_RUNNER_CONFIG_FILE);
|
|
1631
|
-
|
|
1632
|
-
const
|
|
1668
|
+
let source = {};
|
|
1669
|
+
const diagnostics = [];
|
|
1670
|
+
try {
|
|
1671
|
+
const parsed = JSON.parse(await readFile(configPath, "utf8"));
|
|
1672
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1673
|
+
source = parsed;
|
|
1674
|
+
} else {
|
|
1675
|
+
const message = `${APP_RUNNER_CONFIG_FILE} must contain a JSON object`;
|
|
1676
|
+
if (strictRead) throw makeHttpError(400, message);
|
|
1677
|
+
diagnostics.push(customAppRunnerDiagnostic("error", message));
|
|
1678
|
+
}
|
|
1679
|
+
} catch (error) {
|
|
1680
|
+
if (error?.code !== "ENOENT") {
|
|
1681
|
+
const message = `Cannot read ${APP_RUNNER_CONFIG_FILE}: ${formatCliError(error)}`;
|
|
1682
|
+
if (strictRead) throw makeHttpError(400, message);
|
|
1683
|
+
diagnostics.push(customAppRunnerDiagnostic("error", message));
|
|
1684
|
+
console.warn(`failed to read custom app runner config ${configPath}: ${sanitizeError(error)}`);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
if (source.runners !== undefined && !Array.isArray(source.runners)) {
|
|
1688
|
+
const message = `${APP_RUNNER_CONFIG_FILE} runners must be an array`;
|
|
1689
|
+
if (strictRead) throw makeHttpError(400, message);
|
|
1690
|
+
diagnostics.push(customAppRunnerDiagnostic("error", message));
|
|
1691
|
+
}
|
|
1633
1692
|
const rawRunners = Array.isArray(source.runners) ? source.runners : [];
|
|
1634
1693
|
const runners = [];
|
|
1635
1694
|
for (const raw of rawRunners) {
|
|
1636
1695
|
try {
|
|
1637
1696
|
const runner = normalizeCustomRunnerDefinition(raw, projectRoot);
|
|
1638
|
-
if (
|
|
1697
|
+
if (runners.some((item) => item.id === runner.id)) {
|
|
1698
|
+
diagnostics.push(customAppRunnerDiagnostic("warning", `Duplicate custom runner ignored: ${runner.label || runner.path || runner.id}`, runner));
|
|
1699
|
+
} else {
|
|
1700
|
+
runners.push(runner);
|
|
1701
|
+
}
|
|
1639
1702
|
} catch (error) {
|
|
1703
|
+
const message = `Invalid custom runner ignored: ${formatCliError(error)}`;
|
|
1704
|
+
diagnostics.push(customAppRunnerDiagnostic("error", message, raw));
|
|
1640
1705
|
console.warn(`skipping invalid custom app runner in ${configPath}: ${sanitizeError(error)}`);
|
|
1641
1706
|
}
|
|
1642
1707
|
if (runners.length >= APP_RUNNER_CUSTOM_LIMIT) break;
|
|
1643
1708
|
}
|
|
1644
|
-
return { projectRoot, configPath, runners };
|
|
1709
|
+
return { projectRoot, configPath, runners, diagnostics };
|
|
1645
1710
|
}
|
|
1646
1711
|
|
|
1647
1712
|
async function writeAppRunnerConfig(projectRoot, runners) {
|
|
@@ -1659,15 +1724,12 @@ async function writeAppRunnerConfig(projectRoot, runners) {
|
|
|
1659
1724
|
|
|
1660
1725
|
async function customAppRunnerCandidate(projectRoot, configPath, runner) {
|
|
1661
1726
|
const filePath = runner.path;
|
|
1662
|
-
|
|
1663
|
-
const stats = await fileStatsIfExists(absolutePath);
|
|
1664
|
-
if (!stats?.isFile()) return null;
|
|
1727
|
+
if (await customAppRunnerUnavailableReason(projectRoot, runner)) return null;
|
|
1665
1728
|
const command = cleanCustomRunnerCommand(runner.command);
|
|
1666
1729
|
const args = parseCustomRunnerArgs(runner.args);
|
|
1667
1730
|
const commandParts = customRunnerCommandParts(command);
|
|
1668
1731
|
const effectiveCommand = command === "./" ? `./${filePath}` : commandParts[0];
|
|
1669
1732
|
const effectiveArgs = command === "./" ? args : [...commandParts.slice(1), filePath, ...args];
|
|
1670
|
-
if (command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) return null;
|
|
1671
1733
|
return appRunnerCandidate({
|
|
1672
1734
|
id: appRunnerId("custom", runner.id),
|
|
1673
1735
|
label: runner.label || path.basename(filePath),
|
|
@@ -1695,24 +1757,32 @@ async function addCustomAppRunners(runners, cwd) {
|
|
|
1695
1757
|
async function getCustomAppRunnerConfigData(tab) {
|
|
1696
1758
|
const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
|
|
1697
1759
|
const config = await readAppRunnerConfig(projectRoot);
|
|
1760
|
+
const runners = [];
|
|
1761
|
+
for (const runner of config.runners) {
|
|
1762
|
+
const unavailableReason = await customAppRunnerUnavailableReason(projectRoot, runner);
|
|
1763
|
+
runners.push({
|
|
1764
|
+
...publicCustomRunnerDefinition(runner),
|
|
1765
|
+
available: !unavailableReason,
|
|
1766
|
+
unavailableReason,
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1698
1769
|
return {
|
|
1699
1770
|
projectRoot,
|
|
1700
1771
|
displayProjectRoot: displayPath(projectRoot),
|
|
1701
1772
|
configFile: config.configPath,
|
|
1702
1773
|
displayConfigFile: displayPath(config.configPath),
|
|
1703
1774
|
relativeConfigFile: APP_RUNNER_CONFIG_FILE,
|
|
1704
|
-
runners
|
|
1775
|
+
runners,
|
|
1776
|
+
diagnostics: config.diagnostics,
|
|
1705
1777
|
};
|
|
1706
1778
|
}
|
|
1707
1779
|
|
|
1708
1780
|
async function saveCustomAppRunner(tab, rawRunner) {
|
|
1709
1781
|
const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
|
|
1710
|
-
const config = await readAppRunnerConfig(projectRoot);
|
|
1782
|
+
const config = await readAppRunnerConfig(projectRoot, { strictRead: true });
|
|
1711
1783
|
const normalized = normalizeCustomRunnerDefinition(rawRunner, projectRoot, { strict: true });
|
|
1712
|
-
const
|
|
1713
|
-
if (
|
|
1714
|
-
const commandParts = customRunnerCommandParts(normalized.command);
|
|
1715
|
-
if (normalized.command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) throw makeHttpError(400, `Command is not available: ${commandParts[0]}`);
|
|
1784
|
+
const unavailableReason = await customAppRunnerUnavailableReason(projectRoot, normalized);
|
|
1785
|
+
if (unavailableReason) throw makeHttpError(400, unavailableReason);
|
|
1716
1786
|
const runners = config.runners.filter((runner) => runner.id !== normalized.id);
|
|
1717
1787
|
if (runners.length >= APP_RUNNER_CUSTOM_LIMIT) throw makeHttpError(400, `Custom runner limit reached (${APP_RUNNER_CUSTOM_LIMIT})`);
|
|
1718
1788
|
runners.push(normalized);
|
|
@@ -3065,6 +3135,7 @@ async function getWorkspaceInfo(cwd, startedAt) {
|
|
|
3065
3135
|
let activeGitWorkflowProcess = null;
|
|
3066
3136
|
const GIT_CHANGES_COMMAND_TIMEOUT_MS = 5000;
|
|
3067
3137
|
const GIT_CHANGES_DIFF_MAX_OUTPUT = 500_000;
|
|
3138
|
+
const GIT_PULL_TIMEOUT_MS = 15 * 60 * 1000;
|
|
3068
3139
|
|
|
3069
3140
|
async function getGitRoot(cwd) {
|
|
3070
3141
|
const result = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 });
|
|
@@ -3175,15 +3246,61 @@ async function readGitUntrackedFile(cwd, requestedPath) {
|
|
|
3175
3246
|
return readGitUntrackedEntry(root, normalized);
|
|
3176
3247
|
}
|
|
3177
3248
|
|
|
3249
|
+
async function gitUpstreamRef(root) {
|
|
3250
|
+
try {
|
|
3251
|
+
const upstream = await runGitReadCommand(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], { timeoutMs: 5000, maxOutputLength: 10_000 });
|
|
3252
|
+
return upstream.trim();
|
|
3253
|
+
} catch {
|
|
3254
|
+
return "";
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
async function readGitIncomingChanges(root, summary) {
|
|
3259
|
+
const upstream = await gitUpstreamRef(root);
|
|
3260
|
+
const remote = {
|
|
3261
|
+
upstream,
|
|
3262
|
+
behind: Number(summary?.behind || 0) || 0,
|
|
3263
|
+
canPull: !!upstream && (Number(summary?.behind || 0) || 0) > 0,
|
|
3264
|
+
};
|
|
3265
|
+
if (!remote.canPull) return { remote, section: null };
|
|
3266
|
+
|
|
3267
|
+
const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/", "HEAD..@{upstream}"];
|
|
3268
|
+
try {
|
|
3269
|
+
const diff = await runGitReadCommand(root, diffArgs);
|
|
3270
|
+
return {
|
|
3271
|
+
remote,
|
|
3272
|
+
section: {
|
|
3273
|
+
key: "incoming",
|
|
3274
|
+
label: `Incoming from ${upstream}`,
|
|
3275
|
+
command: `git diff --unified=0 HEAD..${upstream}`,
|
|
3276
|
+
diff: diff.trimEnd(),
|
|
3277
|
+
},
|
|
3278
|
+
};
|
|
3279
|
+
} catch (error) {
|
|
3280
|
+
return { remote: { ...remote, error: sanitizeError(error) }, section: null };
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
async function pullGitChanges(cwd) {
|
|
3285
|
+
const root = await getGitRoot(cwd);
|
|
3286
|
+
const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["pull", "--ff-only"], { cwd: root, timeoutMs: GIT_PULL_TIMEOUT_MS }));
|
|
3287
|
+
if (payload.data) payload.data.root = root;
|
|
3288
|
+
if (payload.ok) payload.data.changes = await readGitChanges(root);
|
|
3289
|
+
else payload.error = (payload.data?.stderr || payload.data?.stdout || payload.error || "git pull --ff-only failed").trim();
|
|
3290
|
+
return payload;
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3178
3293
|
async function readGitChanges(cwd) {
|
|
3179
3294
|
const root = await getGitRoot(cwd);
|
|
3180
|
-
const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"];
|
|
3295
|
+
const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
|
|
3181
3296
|
const [statusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
|
|
3182
3297
|
runGitReadCommand(root, ["status", "--short", "--branch", "--untracked-files=all"], { maxOutputLength: 120_000 }),
|
|
3183
3298
|
runGitReadCommand(root, diffArgs),
|
|
3184
|
-
runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"]),
|
|
3299
|
+
runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"]),
|
|
3185
3300
|
runGitReadCommand(root, ["ls-files", "--others", "--exclude-standard"], { maxOutputLength: 120_000 }),
|
|
3186
3301
|
]);
|
|
3302
|
+
const summary = summarizeGitShortStatus(statusText);
|
|
3303
|
+
const incoming = await readGitIncomingChanges(root, summary);
|
|
3187
3304
|
const untrackedFiles = untrackedText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
3188
3305
|
const untracked = await readGitUntrackedEntries(root, untrackedFiles);
|
|
3189
3306
|
return {
|
|
@@ -3191,12 +3308,14 @@ async function readGitChanges(cwd) {
|
|
|
3191
3308
|
root,
|
|
3192
3309
|
branch: gitBranchFromStatus(statusText),
|
|
3193
3310
|
generatedAt: new Date().toISOString(),
|
|
3194
|
-
summary
|
|
3311
|
+
summary,
|
|
3312
|
+
remote: incoming.remote,
|
|
3195
3313
|
status: statusText.trimEnd(),
|
|
3196
3314
|
sections: [
|
|
3197
|
-
|
|
3198
|
-
{ key: "
|
|
3199
|
-
|
|
3315
|
+
incoming.section,
|
|
3316
|
+
{ key: "staged", label: "Staged", command: "git diff --cached --unified=0", diff: stagedDiff.trimEnd() },
|
|
3317
|
+
{ key: "unstaged", label: "Unstaged", command: "git diff --unified=0", diff: unstagedDiff.trimEnd() },
|
|
3318
|
+
].filter(Boolean),
|
|
3200
3319
|
untracked,
|
|
3201
3320
|
};
|
|
3202
3321
|
}
|
|
@@ -7545,7 +7664,7 @@ const server = createServer(async (req, res) => {
|
|
|
7545
7664
|
maybeNameTabForConversation(tab, command);
|
|
7546
7665
|
markTabWorking(tab);
|
|
7547
7666
|
}
|
|
7548
|
-
const response = await tab.rpc.send(command);
|
|
7667
|
+
const response = await tab.rpc.send(command, PROMPT_REQUEST_TIMEOUT_MS);
|
|
7549
7668
|
if (response.success === false && startsVisibleWork) markTabIdle(tab);
|
|
7550
7669
|
sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
|
|
7551
7670
|
return;
|
|
@@ -7571,6 +7690,17 @@ const server = createServer(async (req, res) => {
|
|
|
7571
7690
|
return;
|
|
7572
7691
|
}
|
|
7573
7692
|
|
|
7693
|
+
if (url.pathname === "/api/git-changes/pull" && req.method === "POST") {
|
|
7694
|
+
const body = await readJsonBody(req);
|
|
7695
|
+
const tab = getRequestedTab(req, url, body);
|
|
7696
|
+
try {
|
|
7697
|
+
sendJson(res, 200, await pullGitChanges(tab.cwd));
|
|
7698
|
+
} catch (error) {
|
|
7699
|
+
sendJson(res, 200, { ok: false, error: sanitizeError(error) });
|
|
7700
|
+
}
|
|
7701
|
+
return;
|
|
7702
|
+
}
|
|
7703
|
+
|
|
7574
7704
|
if (url.pathname === "/api/git-branches" && req.method === "GET") {
|
|
7575
7705
|
const tab = getRequestedTab(req, url);
|
|
7576
7706
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-package-webui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"test": "node tests/run-all.mjs"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@earendil-works/pi-coding-agent": "^0.79.
|
|
56
|
+
"@earendil-works/pi-coding-agent": "^0.79.7"
|
|
57
57
|
},
|
|
58
58
|
"optionalDependencies": {
|
|
59
59
|
"@firstpick/pi-extension-btw": "^0.1.0",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@firstpick/pi-extension-todo-progress": "^0.2.4",
|
|
67
67
|
"@firstpick/pi-extension-tools": "^0.1.6",
|
|
68
68
|
"@firstpick/pi-extension-workflows": "^0.1.0",
|
|
69
|
-
"@firstpick/pi-package-remote-webui": "^0.1.
|
|
69
|
+
"@firstpick/pi-package-remote-webui": "^0.1.2",
|
|
70
70
|
"@firstpick/pi-prompts-git-pr": "^0.1.2",
|
|
71
71
|
"@firstpick/pi-themes-bundle": "^0.1.4"
|
|
72
72
|
},
|
|
@@ -84,5 +84,12 @@
|
|
|
84
84
|
],
|
|
85
85
|
"engines": {
|
|
86
86
|
"node": ">=22.19.0"
|
|
87
|
+
},
|
|
88
|
+
"overrides": {
|
|
89
|
+
"@earendil-works/pi-coding-agent": {
|
|
90
|
+
"protobufjs": "7.6.3",
|
|
91
|
+
"undici": "8.5.0",
|
|
92
|
+
"ws": "8.21.0"
|
|
93
|
+
}
|
|
87
94
|
}
|
|
88
95
|
}
|