@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 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
- sendJson(res, statusCode, { ok: false, error: sanitizeError(error) });
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
- async function readAppRunnerConfig(projectRoot) {
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
- const parsed = await readJsonFileIfExists(configPath);
1632
- const source = parsed && typeof parsed === "object" ? parsed : {};
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 (!runners.some((item) => item.id === runner.id)) runners.push(runner);
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
- const absolutePath = resolveProjectRelativePath(projectRoot, filePath);
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: config.runners.map(publicCustomRunnerDefinition),
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 stats = await fileStatsIfExists(resolveProjectRelativePath(projectRoot, normalized.path));
1713
- if (!stats?.isFile()) throw makeHttpError(400, `Path to file does not exist: ${normalized.path}`);
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: summarizeGitShortStatus(statusText),
3311
+ summary,
3312
+ remote: incoming.remote,
3195
3313
  status: statusText.trimEnd(),
3196
3314
  sections: [
3197
- { key: "staged", label: "Staged", command: "git diff --cached", diff: stagedDiff.trimEnd() },
3198
- { key: "unstaged", label: "Unstaged", command: "git diff", diff: unstagedDiff.trimEnd() },
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.5",
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.3"
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.0",
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
  }