@loicngr/kobo 1.11.9 → 1.11.10

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.
Files changed (173) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +5 -0
  3. package/README.md +2 -2
  4. package/dist/server/db/migrations.js +155 -19
  5. package/dist/server/db/schema.js +93 -0
  6. package/dist/server/index.js +35 -26
  7. package/dist/server/routes/dev-server.js +8 -7
  8. package/dist/server/routes/documents.js +33 -3
  9. package/dist/server/routes/health.js +16 -15
  10. package/dist/server/routes/images.js +17 -2
  11. package/dist/server/routes/workspaces.js +63 -48
  12. package/dist/server/services/agent/orchestrator.js +8 -1
  13. package/dist/server/services/auto-loop-service.js +1 -1
  14. package/dist/server/services/dev-server-service.js +48 -41
  15. package/dist/server/services/forge/bitbucket/provider.js +6 -11
  16. package/dist/server/services/forge/github/provider.js +10 -9
  17. package/dist/server/services/forge/gitlab/provider.js +11 -13
  18. package/dist/server/services/git-stats-service.js +8 -6
  19. package/dist/server/services/image-service.js +34 -5
  20. package/dist/server/services/pr-watcher-service.js +20 -9
  21. package/dist/server/services/search-service.js +53 -40
  22. package/dist/server/services/websocket-service.js +8 -3
  23. package/dist/server/services/workspace-service.js +81 -10
  24. package/dist/server/services/worktree-purge-service.js +1 -1
  25. package/dist/server/utils/git-ops.js +89 -22
  26. package/dist/server/utils/safe-path.js +14 -0
  27. package/package.json +22 -22
  28. package/src/client/dist/pwa/assets/{ActivityFeed-Dv06XbgS.css → ActivityFeed-BrjeLlhi.css} +1 -1
  29. package/src/client/dist/pwa/assets/ActivityFeed-UEAexGjc.js +8 -0
  30. package/src/client/dist/pwa/assets/ChangelogPage-XSYwu9XA.js +1 -0
  31. package/src/client/dist/pwa/assets/{ClosePopup-BZ1O0Jxe.js → ClosePopup-dpcrW9DC.js} +1 -1
  32. package/src/client/dist/pwa/assets/CreatePage-BK__rjvR.js +2 -0
  33. package/src/client/dist/pwa/assets/CreatePage-QwtfYunr.css +1 -0
  34. package/src/client/dist/pwa/assets/{DiffViewer-D9FN4RS8.css → DiffViewer-CS3P9Ngi.css} +1 -1
  35. package/src/client/dist/pwa/assets/DiffViewer-DnFN6Sc_.js +8 -0
  36. package/src/client/dist/pwa/assets/HealthPage-DVrZfMI6.js +1 -0
  37. package/src/client/dist/pwa/assets/MainLayout-D_3nFaCG.js +19 -0
  38. package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +1 -0
  39. package/src/client/dist/pwa/assets/QBadge-sMJZ_eZm.js +1 -0
  40. package/src/client/dist/pwa/assets/QBanner-DedhGMcj.js +1 -0
  41. package/src/client/dist/pwa/assets/QBtnToggle-BgfN19l8.js +1 -0
  42. package/src/client/dist/pwa/assets/QChip-EQGYShN0.js +1 -0
  43. package/src/client/dist/pwa/assets/QIcon--SvgE-nS.js +1 -0
  44. package/src/client/dist/pwa/assets/QList-Be_FbTA3.js +1 -0
  45. package/src/client/dist/pwa/assets/QPage-DuIsRmYu.js +1 -0
  46. package/src/client/dist/pwa/assets/QSelect-CtxxliRU.js +36 -0
  47. package/src/client/dist/pwa/assets/QSeparator-BdyRE-AM.js +1 -0
  48. package/src/client/dist/pwa/assets/QSpace-D9UEvM99.js +1 -0
  49. package/src/client/dist/pwa/assets/QSpinnerDots-B4RmFJUj.js +1 -0
  50. package/src/client/dist/pwa/assets/QTooltip-HEIFh5w9.js +1 -0
  51. package/src/client/dist/pwa/assets/SearchPage-1B5HOf90.js +1 -0
  52. package/src/client/dist/pwa/assets/SettingsPage-v8f4rrCt.js +16 -0
  53. package/src/client/dist/pwa/assets/TouchPan-lynWXY69.js +1 -0
  54. package/src/client/dist/pwa/assets/WorkspacePage-ChnyJ3xI.css +1 -0
  55. package/src/client/dist/pwa/assets/WorkspacePage-Vjzi8YnW.js +4 -0
  56. package/src/client/dist/pwa/assets/codicon-Brq4_Ui5.ttf +0 -0
  57. package/src/client/dist/pwa/assets/{css.worker-CBlhdqTa.js → css.worker-JKZQyld4.js} +38 -34
  58. package/src/client/dist/pwa/assets/cssMode-cg8iVI-i.js +1 -0
  59. package/src/client/dist/pwa/assets/documents-Dlx8yiSO.js +1 -0
  60. package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +1 -0
  61. package/src/client/dist/pwa/assets/editor.api-CfaJRkrX.js +847 -0
  62. package/src/client/dist/pwa/assets/editor.worker-D-m8IBMa.js +30 -0
  63. package/src/client/dist/pwa/assets/expand-template-D3LZldj1.js +1 -0
  64. package/src/client/dist/pwa/assets/formatters-D0dU6VcA.js +1 -0
  65. package/src/client/dist/pwa/assets/{freemarker2-Dgrv0PyH.js → freemarker2-B_8OIClK.js} +1 -1
  66. package/src/client/dist/pwa/assets/handlebars-CaYi_uxU.js +1 -0
  67. package/src/client/dist/pwa/assets/html-BnCUDUl1.js +1 -0
  68. package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +506 -0
  69. package/src/client/dist/pwa/assets/htmlMode-BZjKGnQF.js +1 -0
  70. package/src/client/dist/pwa/assets/i18n-B6SDs7Lm.js +1 -0
  71. package/src/client/dist/pwa/assets/index-CK1nk7qr.css +1 -0
  72. package/src/client/dist/pwa/assets/index-CoOsPyyG.js +82 -0
  73. package/src/client/dist/pwa/assets/{javascript-IK9qSsfx.js → javascript-DkSfoeHU.js} +1 -1
  74. package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +62 -0
  75. package/src/client/dist/pwa/assets/{jsonMode-D_Vdxb-B.js → jsonMode-yKXxfx1V.js} +3 -3
  76. package/src/client/dist/pwa/assets/{kobo-commands-zljfAram.js → kobo-commands-vn1IQxLn.js} +1 -1
  77. package/src/client/dist/pwa/assets/layout-CiQpugQB.js +1 -0
  78. package/src/client/dist/pwa/assets/liquid-Dlhysv3U.js +1 -0
  79. package/src/client/dist/pwa/assets/lspLanguageFeatures-CeYSQFUz.js +4 -0
  80. package/src/client/dist/pwa/assets/mdx-B8iME5NA.js +1 -0
  81. package/src/client/dist/pwa/assets/network-auth-j2Drut6q.js +1 -0
  82. package/src/client/dist/pwa/assets/pending-diff-open-B4RYz7G_.js +1 -0
  83. package/src/client/dist/pwa/assets/permissionModes-6jhegAm-.js +1 -0
  84. package/src/client/dist/pwa/assets/{project-color-6xNNY9u6.js → project-color-e3OdFi9X.js} +1 -1
  85. package/src/client/dist/pwa/assets/python-CbFbC0ZR.js +1 -0
  86. package/src/client/dist/pwa/assets/razor-7c2NCdFI.js +1 -0
  87. package/src/client/dist/pwa/assets/reactivity.esm-bundler-DtSZKRQB.js +1 -0
  88. package/src/client/dist/pwa/assets/render-chat-markdown-Fer8kY38.js +67 -0
  89. package/src/client/dist/pwa/assets/runtime-dom.esm-bundler-DgevF6KV.js +4 -0
  90. package/src/client/dist/pwa/assets/{ts.worker-PmaSgaZk.js → ts.worker-C-oGa8JT.js} +199 -195
  91. package/src/client/dist/pwa/assets/{tsMode-DiiMWOVl.js → tsMode-BmShKjbx.js} +1 -1
  92. package/src/client/dist/pwa/assets/typescript-Dhu7Upbs.js +1 -0
  93. package/src/client/dist/pwa/assets/use-checkbox-SrkgiJho.js +1 -0
  94. package/src/client/dist/pwa/assets/use-onboarding-CTZuBZOi.css +1 -0
  95. package/src/client/dist/pwa/assets/use-onboarding-fe4sPCNf.js +2 -0
  96. package/src/client/dist/pwa/assets/use-quasar-CeHlTrGg.js +1 -0
  97. package/src/client/dist/pwa/assets/vs-B-xsceNQ.js +56 -0
  98. package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +1 -0
  99. package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +3 -0
  100. package/src/client/dist/pwa/assets/workers-zVMTXVoC.js +1 -0
  101. package/src/client/dist/pwa/assets/xml-D64wpCtR.js +1 -0
  102. package/src/client/dist/pwa/assets/yaml-CLSRKwfx.js +1 -0
  103. package/src/client/dist/pwa/index.html +8 -8
  104. package/src/client/dist/pwa/manifest.json +1 -1
  105. package/src/client/dist/pwa/sw.js +1 -1
  106. package/src/client/dist/pwa/assets/ActivityFeed-Bs1uccfa.js +0 -8
  107. package/src/client/dist/pwa/assets/ChangelogPage-nW4vA3qx.js +0 -1
  108. package/src/client/dist/pwa/assets/CreatePage-BrdHh0Th.js +0 -2
  109. package/src/client/dist/pwa/assets/CreatePage-CRIBi2Vx.css +0 -1
  110. package/src/client/dist/pwa/assets/DiffViewer-DhtJQzG2.js +0 -8
  111. package/src/client/dist/pwa/assets/HealthPage-CExFCl_7.js +0 -1
  112. package/src/client/dist/pwa/assets/MainLayout-Coqyj1FP.css +0 -1
  113. package/src/client/dist/pwa/assets/MainLayout-WYZAjpD2.js +0 -37
  114. package/src/client/dist/pwa/assets/QBadge-DV5kn4mL.js +0 -1
  115. package/src/client/dist/pwa/assets/QBanner-CmfaeizG.js +0 -1
  116. package/src/client/dist/pwa/assets/QBtnGroup-C442-yBb.js +0 -1
  117. package/src/client/dist/pwa/assets/QBtnToggle-CqyoLg5U.js +0 -1
  118. package/src/client/dist/pwa/assets/QChip-DMYWc1jc.js +0 -1
  119. package/src/client/dist/pwa/assets/QIcon-C6E-oEjI.js +0 -1
  120. package/src/client/dist/pwa/assets/QInput-BF8x4RTv.js +0 -1
  121. package/src/client/dist/pwa/assets/QList-D5EKdFPn.js +0 -1
  122. package/src/client/dist/pwa/assets/QPage-D3rjwA1l.js +0 -1
  123. package/src/client/dist/pwa/assets/QSelect-CAa3syxF.js +0 -36
  124. package/src/client/dist/pwa/assets/QSeparator-DWTCQxsa.js +0 -1
  125. package/src/client/dist/pwa/assets/QSpace-ezNrZkuj.js +0 -1
  126. package/src/client/dist/pwa/assets/QSpinnerDots-DIV68-cV.js +0 -1
  127. package/src/client/dist/pwa/assets/QTooltip-6IQJP1_Y.js +0 -1
  128. package/src/client/dist/pwa/assets/SearchPage-CQtm05UQ.js +0 -1
  129. package/src/client/dist/pwa/assets/SettingsPage-B38IzmSs.js +0 -16
  130. package/src/client/dist/pwa/assets/ThinkingItem-BqWpfNZP.js +0 -1
  131. package/src/client/dist/pwa/assets/ThinkingItem-Ckk4K1O-.css +0 -1
  132. package/src/client/dist/pwa/assets/TouchPan-C939psBh.js +0 -1
  133. package/src/client/dist/pwa/assets/WorkspacePage-CFF5Ghug.css +0 -1
  134. package/src/client/dist/pwa/assets/WorkspacePage-dS-Nc6QU.js +0 -4
  135. package/src/client/dist/pwa/assets/build-path-tree-C5rzY2hF.js +0 -1
  136. package/src/client/dist/pwa/assets/chunk-QTnfLwEv.js +0 -1
  137. package/src/client/dist/pwa/assets/codicon-ngg6Pgfi.ttf +0 -0
  138. package/src/client/dist/pwa/assets/cssMode-p60S_iK6.js +0 -1
  139. package/src/client/dist/pwa/assets/documents-CzlF-PFm.js +0 -1
  140. package/src/client/dist/pwa/assets/editor-BFebQ78e.css +0 -1
  141. package/src/client/dist/pwa/assets/editor.api2-DfOm2dM5.js +0 -872
  142. package/src/client/dist/pwa/assets/editor.main-6eriWmTe.js +0 -6
  143. package/src/client/dist/pwa/assets/editor.worker-Cu3tR8iJ.js +0 -26
  144. package/src/client/dist/pwa/assets/expand-template-B_RAt6BV.js +0 -1
  145. package/src/client/dist/pwa/assets/formatters-BoLOujHM.js +0 -1
  146. package/src/client/dist/pwa/assets/handlebars-D0XTaj97.js +0 -1
  147. package/src/client/dist/pwa/assets/html-DkYxFVm6.js +0 -1
  148. package/src/client/dist/pwa/assets/html.worker-BKBrNna1.js +0 -502
  149. package/src/client/dist/pwa/assets/htmlMode-Dj3mtvXI.js +0 -1
  150. package/src/client/dist/pwa/assets/i18n-Dzzn4YfF.js +0 -1
  151. package/src/client/dist/pwa/assets/index-C06a940j.js +0 -84
  152. package/src/client/dist/pwa/assets/index-CwYR3iHt.css +0 -1
  153. package/src/client/dist/pwa/assets/json.worker-BaLYt9Ss.js +0 -58
  154. package/src/client/dist/pwa/assets/layout-BAnzeEPY.js +0 -1
  155. package/src/client/dist/pwa/assets/liquid-DURTrym0.js +0 -1
  156. package/src/client/dist/pwa/assets/lspLanguageFeatures-Bki_90YN.js +0 -4
  157. package/src/client/dist/pwa/assets/mdx-z7FKEEaF.js +0 -1
  158. package/src/client/dist/pwa/assets/monaco.contribution-D52lvpFV.js +0 -2
  159. package/src/client/dist/pwa/assets/network-auth-DrGWXKl2.js +0 -1
  160. package/src/client/dist/pwa/assets/pending-diff-open-DX42h-5U.js +0 -1
  161. package/src/client/dist/pwa/assets/permissionModes-DMHgg-69.js +0 -1
  162. package/src/client/dist/pwa/assets/python-DHQ3U-kG.js +0 -1
  163. package/src/client/dist/pwa/assets/razor-DMlreu1b.js +0 -1
  164. package/src/client/dist/pwa/assets/render-chat-markdown-0Vj9B94m.js +0 -66
  165. package/src/client/dist/pwa/assets/typescript-0wxHr6dC.js +0 -1
  166. package/src/client/dist/pwa/assets/use-checkbox-E9SV7iH5.js +0 -1
  167. package/src/client/dist/pwa/assets/use-onboarding--YqDGUQR.js +0 -2
  168. package/src/client/dist/pwa/assets/use-onboarding-B3l7mx48.css +0 -1
  169. package/src/client/dist/pwa/assets/use-quasar-oX1TyrPM.js +0 -1
  170. package/src/client/dist/pwa/assets/vue.runtime.esm-bundler-JZnIeD9D.js +0 -3
  171. package/src/client/dist/pwa/assets/workers-D0OEUSgt.js +0 -1
  172. package/src/client/dist/pwa/assets/xml-C7PczWV5.js +0 -1
  173. package/src/client/dist/pwa/assets/yaml-Dj6QPnyC.js +0 -1
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { Hono } from 'hono';
4
4
  import * as imageService from '../services/image-service.js';
5
5
  import * as workspaceService from '../services/workspace-service.js';
6
+ import { isPathInside } from '../utils/safe-path.js';
6
7
  /** Maximum allowed upload size for a single image (10 MB). */
7
8
  const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
8
9
  /** MIME types accepted for image uploads. */
@@ -67,7 +68,7 @@ app.get('/:id/images/file', async (c) => {
67
68
  }
68
69
  // Allowlist: only the upload storage layout is served. No symlink escape
69
70
  // either — we resolve and check containment.
70
- if (!/^(\.ai\/images\/|images\/)[^/]/.test(requested) || requested.includes('..')) {
71
+ if (!/^\.ai\/images\/[^/]+$/.test(requested) || requested.includes('..')) {
71
72
  return c.json({ error: 'Invalid or disallowed image path' }, 400);
72
73
  }
73
74
  const worktreePath = workspace.worktreePath;
@@ -78,9 +79,23 @@ app.get('/:id/images/file', async (c) => {
78
79
  if (fullPath !== imagesRoot && !fullPath.startsWith(imagesRoot + path.sep)) {
79
80
  return c.json({ error: 'Path escapes images root' }, 400);
80
81
  }
82
+ if (!fs.existsSync(imagesRoot) || fs.lstatSync(imagesRoot).isSymbolicLink()) {
83
+ return c.json({ error: 'Invalid images root' }, 400);
84
+ }
85
+ if (!fs.existsSync(fullPath))
86
+ return c.json({ error: 'Image not found' }, 404);
87
+ const fileStat = fs.lstatSync(fullPath);
88
+ if (fileStat.isSymbolicLink())
89
+ return c.json({ error: 'Symbolic links are not allowed' }, 400);
90
+ const realWorktree = await fs.promises.realpath(worktreePath);
91
+ const realImagesRoot = await fs.promises.realpath(imagesRoot);
92
+ const realFile = await fs.promises.realpath(fullPath);
93
+ if (!isPathInside(realWorktree, realImagesRoot) || !isPathInside(realImagesRoot, realFile)) {
94
+ return c.json({ error: 'Path escapes images root' }, 400);
95
+ }
81
96
  let buffer;
82
97
  try {
83
- buffer = await fs.promises.readFile(fullPath);
98
+ buffer = await fs.promises.readFile(realFile);
84
99
  }
85
100
  catch {
86
101
  return c.json({ error: 'Image not found' }, 404);
@@ -48,9 +48,23 @@ const app = new Hono();
48
48
  /** Tracks workspaces currently running a setup script to prevent concurrent executions. */
49
49
  const setupScriptRunning = new Set();
50
50
  const VALID_AGENT_PERMISSION_MODES = ['plan', 'bypass', 'strict', 'interactive'];
51
+ const VALID_WORKSPACE_STATUSES = [
52
+ 'created',
53
+ 'extracting',
54
+ 'brainstorming',
55
+ 'executing',
56
+ 'awaiting-user',
57
+ 'completed',
58
+ 'idle',
59
+ 'error',
60
+ 'quota',
61
+ ];
51
62
  function isAgentPermissionMode(value) {
52
63
  return typeof value === 'string' && VALID_AGENT_PERMISSION_MODES.includes(value);
53
64
  }
65
+ function isWorkspaceStatus(value) {
66
+ return typeof value === 'string' && VALID_WORKSPACE_STATUSES.includes(value);
67
+ }
54
68
  async function deliverAgentPrompt(workspace, workingDir, prompt) {
55
69
  const delivery = await agentManager.sendMessageForFallback(workspace.id, prompt);
56
70
  if (delivery.status === 'sent')
@@ -1650,29 +1664,19 @@ app.get('/:id/session-metrics', (c) => {
1650
1664
  session.id,
1651
1665
  { sessionId: session.id, toolCalls: 0, errors: 0, inputTokens: 0, outputTokens: 0 },
1652
1666
  ]));
1653
- const events = db
1654
- .prepare("SELECT session_id, payload FROM ws_events WHERE workspace_id = ? AND type = 'agent:event' AND session_id IS NOT NULL")
1667
+ const rows = db
1668
+ .prepare(`SELECT session_id, tool_calls, errors, input_tokens, output_tokens
1669
+ FROM session_event_metrics
1670
+ WHERE workspace_id = ?`)
1655
1671
  .all(id);
1656
- for (const row of events) {
1672
+ for (const row of rows) {
1657
1673
  const metric = metrics.get(row.session_id);
1658
1674
  if (!metric)
1659
1675
  continue;
1660
- try {
1661
- const event = JSON.parse(row.payload);
1662
- if (event.kind === 'tool:call')
1663
- metric.toolCalls++;
1664
- if (event.kind === 'error' || (event.kind === 'tool:result' && event.isError === true))
1665
- metric.errors++;
1666
- if (event.kind === 'usage') {
1667
- if (typeof event.inputTokens === 'number')
1668
- metric.inputTokens = Math.max(metric.inputTokens, event.inputTokens);
1669
- if (typeof event.outputTokens === 'number')
1670
- metric.outputTokens = Math.max(metric.outputTokens, event.outputTokens);
1671
- }
1672
- }
1673
- catch {
1674
- // Corrupt historical events are intentionally ignored in diagnostics.
1675
- }
1676
+ metric.toolCalls = row.tool_calls;
1677
+ metric.errors = row.errors;
1678
+ metric.inputTokens = row.input_tokens;
1679
+ metric.outputTokens = row.output_tokens;
1676
1680
  }
1677
1681
  return c.json({ sessions, metrics: [...metrics.values()] });
1678
1682
  }
@@ -1719,7 +1723,8 @@ app.get('/:id/events', (c) => {
1719
1723
  // optional: scope to a session view. Session views also include
1720
1724
  // workspace-level rows where session_id IS NULL (legacy/pre-session items).
1721
1725
  const session = c.req.query('session');
1722
- const limit = Math.min(parseInt(c.req.query('limit') ?? '100', 10) || 100, 500);
1726
+ const parsedLimit = parseInt(c.req.query('limit') ?? '100', 10);
1727
+ const limit = Math.max(1, Math.min(Number.isFinite(parsedLimit) ? parsedLimit : 100, 500));
1723
1728
  const db = getDb();
1724
1729
  let rows;
1725
1730
  if (around) {
@@ -1758,12 +1763,12 @@ app.get('/:id/events', (c) => {
1758
1763
  .prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) ORDER BY rowid DESC LIMIT ?')
1759
1764
  .all(id, session, limit)
1760
1765
  : db
1761
- .prepare('SELECT * FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC LIMIT ?')
1766
+ .prepare('SELECT * FROM ws_events WHERE workspace_id = ? ORDER BY rowid DESC LIMIT ?')
1762
1767
  .all(id, limit);
1763
1768
  }
1764
1769
  // Reverse to chronological order (we queried DESC for "before" pagination,
1765
1770
  // or for the "session + no cursor" case where we fetched the newest first).
1766
- if (before || (session && !around))
1771
+ if (!around)
1767
1772
  rows.reverse();
1768
1773
  const events = rows.map((row) => {
1769
1774
  let parsedPayload;
@@ -1798,10 +1803,12 @@ app.get('/:id/events', (c) => {
1798
1803
  hasMore = older.c > 0;
1799
1804
  }
1800
1805
  }
1801
- else if (session) {
1802
- const total = db
1803
- .prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL)')
1804
- .get(id, session);
1806
+ else {
1807
+ const total = session
1808
+ ? db
1809
+ .prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL)')
1810
+ .get(id, session)
1811
+ : db.prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ?').get(id);
1805
1812
  hasMore = total.c > rows.length;
1806
1813
  }
1807
1814
  }
@@ -2017,7 +2024,11 @@ app.put('/:id/tags', async (c) => {
2017
2024
  app.patch('/:id', migrationGuard, async (c) => {
2018
2025
  try {
2019
2026
  const id = c.req.param('id');
2020
- const body = await c.req.json();
2027
+ const parsed = await c.req.json();
2028
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
2029
+ return c.json({ error: 'Request body must be a JSON object' }, 400);
2030
+ }
2031
+ const body = parsed;
2021
2032
  const workspace = workspaceService.getWorkspace(id);
2022
2033
  if (!workspace) {
2023
2034
  return c.json({ error: `Workspace '${id}' not found` }, 404);
@@ -2026,12 +2037,12 @@ app.patch('/:id', migrationGuard, async (c) => {
2026
2037
  if ('agent_description' in body) {
2027
2038
  return c.json({ error: 'agent_description must be set via the agent MCP tool, not via the API' }, 400);
2028
2039
  }
2029
- let updated = workspace;
2030
- if (body.model !== undefined) {
2031
- updated = workspaceService.updateWorkspaceModel(id, body.model);
2040
+ if (body.model !== undefined && (typeof body.model !== 'string' || !body.model.trim())) {
2041
+ return c.json({ error: 'model must be a non-empty string' }, 400);
2032
2042
  }
2033
- if (body.reasoningEffort !== undefined) {
2034
- updated = workspaceService.updateWorkspaceReasoningEffort(id, body.reasoningEffort);
2043
+ if (body.reasoningEffort !== undefined &&
2044
+ (typeof body.reasoningEffort !== 'string' || !body.reasoningEffort.trim())) {
2045
+ return c.json({ error: 'reasoningEffort must be a non-empty string' }, 400);
2035
2046
  }
2036
2047
  if (body.agentPermissionMode !== undefined) {
2037
2048
  if (!isAgentPermissionMode(body.agentPermissionMode)) {
@@ -2048,25 +2059,25 @@ app.patch('/:id', migrationGuard, async (c) => {
2048
2059
  error: `Engine '${engineId}' does not support agentPermissionMode '${body.agentPermissionMode}'. Supported: ${supported.join(', ')}`,
2049
2060
  }, 400);
2050
2061
  }
2051
- updated = workspaceService.updateAgentPermissionMode(id, body.agentPermissionMode);
2052
2062
  }
2053
- if (body.status) {
2063
+ if (body.status !== undefined) {
2064
+ if (!isWorkspaceStatus(body.status)) {
2065
+ return c.json({ error: `Invalid status. Must be one of: ${VALID_WORKSPACE_STATUSES.join(', ')}` }, 400);
2066
+ }
2054
2067
  if (body.status === 'idle' && agentManager.hasController(id)) {
2055
2068
  return c.json({ error: 'Cannot mark an active agent workspace idle; end or stop the agent session first' }, 409);
2056
2069
  }
2057
- updated = workspaceService.updateWorkspaceStatus(id, body.status);
2058
2070
  }
2059
- if (body.name !== undefined) {
2060
- updated = workspaceService.updateWorkspaceName(id, body.name);
2071
+ if (body.name !== undefined && typeof body.name !== 'string') {
2072
+ return c.json({ error: 'name must be a string' }, 400);
2061
2073
  }
2062
2074
  if ('description' in body) {
2063
2075
  const desc = body.description;
2064
2076
  if (desc !== null && typeof desc !== 'string') {
2065
2077
  return c.json({ error: 'description must be a string or null' }, 400);
2066
2078
  }
2067
- updated = workspaceService.updateWorkspaceDescription(id, desc);
2068
2079
  }
2069
- if (!body.status &&
2080
+ if (body.status === undefined &&
2070
2081
  body.model === undefined &&
2071
2082
  body.reasoningEffort === undefined &&
2072
2083
  body.agentPermissionMode === undefined &&
@@ -2074,7 +2085,7 @@ app.patch('/:id', migrationGuard, async (c) => {
2074
2085
  !('description' in body)) {
2075
2086
  return c.json({ error: 'Missing field: status, model, reasoningEffort, agentPermissionMode, name, or description' }, 400);
2076
2087
  }
2077
- return c.json(updated);
2088
+ return c.json(workspaceService.updateWorkspaceFields(id, body));
2078
2089
  }
2079
2090
  catch (err) {
2080
2091
  const message = err instanceof Error ? err.message : String(err);
@@ -2270,7 +2281,7 @@ app.post('/:id/run-setup-script', async (c) => {
2270
2281
  }
2271
2282
  });
2272
2283
  // POST /api/workspaces/:id/archive — mark workspace as archived (soft-delete)
2273
- app.post('/:id/archive', migrationGuard, (c) => {
2284
+ app.post('/:id/archive', migrationGuard, async (c) => {
2274
2285
  try {
2275
2286
  const id = c.req.param('id');
2276
2287
  const workspace = workspaceService.getWorkspace(id);
@@ -2287,7 +2298,7 @@ app.post('/:id/archive', migrationGuard, (c) => {
2287
2298
  // Agent may not be running — ignore
2288
2299
  }
2289
2300
  try {
2290
- devServerService.stopDevServer(id);
2301
+ await devServerService.stopDevServer(id);
2291
2302
  }
2292
2303
  catch (err) {
2293
2304
  const message = err instanceof Error ? err.message : String(err);
@@ -2362,7 +2373,7 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
2362
2373
  // Every side-effect is best-effort — failures are collected as warnings
2363
2374
  // rather than thrown, so a bulk delete never aborts mid-batch. Returns the
2364
2375
  // list of user-facing warning messages (empty when everything was clean).
2365
- function deleteWorkspaceWithSideEffects(workspace, opts) {
2376
+ async function deleteWorkspaceWithSideEffects(workspace, opts) {
2366
2377
  // Stop agent if running (best-effort)
2367
2378
  try {
2368
2379
  agentManager.stopAgent(workspace.id);
@@ -2373,7 +2384,7 @@ function deleteWorkspaceWithSideEffects(workspace, opts) {
2373
2384
  // Stop dev server if it was running. The processSpawn would otherwise
2374
2385
  // outlive the workspace (and keep its port + docker containers alive).
2375
2386
  try {
2376
- devServerService.stopDevServer(workspace.id);
2387
+ await devServerService.stopDevServer(workspace.id);
2377
2388
  }
2378
2389
  catch (err) {
2379
2390
  console.error(`[workspaces] stopDevServer during delete failed for '${workspace.name}':`, err);
@@ -2457,7 +2468,7 @@ app.delete('/archived', migrationGuard, async (c) => {
2457
2468
  let deleted = 0;
2458
2469
  for (const workspace of archived) {
2459
2470
  try {
2460
- warnings.push(...deleteWorkspaceWithSideEffects(workspace, body));
2471
+ warnings.push(...(await deleteWorkspaceWithSideEffects(workspace, body)));
2461
2472
  deleted++;
2462
2473
  }
2463
2474
  catch (err) {
@@ -2485,7 +2496,7 @@ app.delete('/:id', migrationGuard, async (c) => {
2485
2496
  const body = await c.req
2486
2497
  .json()
2487
2498
  .catch(() => ({}));
2488
- const warnings = deleteWorkspaceWithSideEffects(workspace, body);
2499
+ const warnings = await deleteWorkspaceWithSideEffects(workspace, body);
2489
2500
  // When everything worked cleanly we keep the legacy 204 response so
2490
2501
  // existing clients aren't surprised by a JSON body. Warnings promote the
2491
2502
  // response to 200 so the body is readable.
@@ -2507,6 +2518,10 @@ app.post('/:id/start', migrationGuard, async (c) => {
2507
2518
  if (!workspace) {
2508
2519
  return c.json({ error: `Workspace '${id}' not found` }, 404);
2509
2520
  }
2521
+ if (workspace.archivedAt)
2522
+ return c.json({ error: `Workspace '${id}' is archived` }, 409);
2523
+ if (workspace.worktreePurgedAt)
2524
+ return c.json({ error: `Workspace '${id}' worktree is purged` }, 409);
2510
2525
  // If the workspace declares an engine, ensure it is still registered.
2511
2526
  // Otherwise startAgent() would throw from deep inside resolveEngine and
2512
2527
  // surface as an opaque 500 — better to fail fast with a clear 400.
@@ -3300,7 +3315,7 @@ app.post('/:id/merge-pr', async (c) => {
3300
3315
  }, 409);
3301
3316
  }
3302
3317
  const snapshot = await provider.getPrStatus(workspace.worktreePath, workspace.workingBranch);
3303
- if (!snapshot || snapshot.state !== 'OPEN') {
3318
+ if (snapshot?.state !== 'OPEN') {
3304
3319
  return c.json({ error: 'No open PR found for this workspace', code: 'pr_not_open' }, 409);
3305
3320
  }
3306
3321
  if (!snapshot.readyToMerge) {
@@ -3654,7 +3669,7 @@ app.post('/:id/start-ci-fix', migrationGuard, async (c) => {
3654
3669
  if (!snapshot) {
3655
3670
  snapshot = getAllPrSnapshots()[id] ?? null;
3656
3671
  }
3657
- if (!snapshot || snapshot.state !== 'OPEN' || snapshot.ci.rollup !== 'FAILURE') {
3672
+ if (snapshot?.state !== 'OPEN' || snapshot.ci.rollup !== 'FAILURE') {
3658
3673
  return c.json({ error: 'No failing CI detected on this workspace' }, 400);
3659
3674
  }
3660
3675
  const effective = settingsService.getEffectiveSettings(workspace.projectPath);
@@ -847,6 +847,13 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
847
847
  * query the controller via `_getControllers()` for tests.
848
848
  */
849
849
  export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort) {
850
+ const workspace = getWs(workspaceId);
851
+ if (!workspace)
852
+ throw new Error(`Workspace '${workspaceId}' not found`);
853
+ if (workspace.archivedAt)
854
+ throw new Error(`Workspace '${workspaceId}' is archived`);
855
+ if (workspace.worktreePurgedAt)
856
+ throw new Error(`Workspace '${workspaceId}' worktree is purged`);
850
857
  // "For this turn" never survives a new agent session.
851
858
  permissionPolicyService.clearTurnPermissions(workspaceId);
852
859
  // Zombie detection: an SDK iterator hung on a never-resolved canUseTool
@@ -1317,7 +1324,7 @@ export function _getPendingDeferred() {
1317
1324
  const out = new Map();
1318
1325
  for (const [wid, arr] of pendingQueue) {
1319
1326
  const head = arr[0];
1320
- if (!head || head.kind !== 'question')
1327
+ if (head?.kind !== 'question')
1321
1328
  continue;
1322
1329
  out.set(wid, {
1323
1330
  toolCallId: head.toolCallId,
@@ -78,7 +78,7 @@ export function enable(workspaceId) {
78
78
  */
79
79
  export function disable(workspaceId, reason) {
80
80
  const row = getRow(workspaceId);
81
- if (!row || row.auto_loop !== 1)
81
+ if (row?.auto_loop !== 1)
82
82
  return;
83
83
  const db = getDb();
84
84
  db.prepare('UPDATE workspaces SET auto_loop = 0 WHERE id = ?').run(workspaceId);
@@ -1,4 +1,4 @@
1
- import { execSync, spawn } from 'node:child_process';
1
+ import { execFile, spawn } from 'node:child_process';
2
2
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { getProjectSettings } from './settings-service.js';
@@ -10,6 +10,16 @@ function cleanEnv() {
10
10
  const { PORT, SERVER_PORT, ...rest } = process.env;
11
11
  return rest;
12
12
  }
13
+ function runCommand(command, args, options = {}) {
14
+ return new Promise((resolve, reject) => {
15
+ execFile(command, args, { ...options, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
16
+ if (err)
17
+ reject(err);
18
+ else
19
+ resolve(stdout);
20
+ });
21
+ });
22
+ }
13
23
  // ── State ──────────────────────────────────────────────────────────────────────
14
24
  /** workspaceId -> spawned dev-server process */
15
25
  const trackedProcesses = new Map();
@@ -61,28 +71,32 @@ export function resolveInstance(projectPath, workingBranch) {
61
71
  for (const file of files) {
62
72
  const content = readFileSync(path.join(instancesDir, file), 'utf-8');
63
73
  const parsed = parseEnvFile(content);
64
- if (parsed.INSTANCE_NAME && parsed.INSTANCE_NAME.toLowerCase() === sanitized) {
74
+ if (parsed.INSTANCE_NAME &&
75
+ parsed.INSTANCE_NAME.toLowerCase() === sanitized &&
76
+ parsed.PROJECT_NAME &&
77
+ /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(parsed.PROJECT_NAME)) {
65
78
  return {
66
79
  instanceName: parsed.INSTANCE_NAME,
67
- projectName: parsed.PROJECT_NAME ?? '',
80
+ projectName: parsed.PROJECT_NAME,
68
81
  httpPort: parsed.HTTP_PORT ?? '',
69
82
  };
70
83
  }
71
84
  }
72
85
  return null;
73
86
  }
87
+ function containerBelongsToProject(containerName, projectName) {
88
+ const name = containerName.toLowerCase();
89
+ const project = projectName.toLowerCase();
90
+ return name === project || name.startsWith(`${project}-`) || name.startsWith(`${project}_`);
91
+ }
74
92
  // ── Docker helpers ─────────────────────────────────────────────────────────────
75
93
  /**
76
94
  * List all running Docker container names.
77
- * Note: uses execSync with shell because docker ps --format requires
78
- * Go template syntax with `{{}}`. Input is a static string, no injection risk.
95
+ * Uses execFile so Docker inspection cannot block the Node event loop.
79
96
  */
80
- export function listRunningContainers() {
97
+ export async function listRunningContainers() {
81
98
  try {
82
- const output = execSync('docker ps --format "{{.Names}}"', {
83
- encoding: 'utf-8',
84
- timeout: 10000,
85
- });
99
+ const output = await runCommand('docker', ['ps', '--format', '{{.Names}}'], { timeout: 10_000 });
86
100
  return output
87
101
  .split('\n')
88
102
  .map((s) => s.trim())
@@ -102,7 +116,7 @@ export function listRunningContainers() {
102
116
  * in `docker ps` yet. This prevents the UI from flashing to `'stopped'` during
103
117
  * long build phases.
104
118
  */
105
- export function getStatus(projectPath, workingBranch, workspaceId) {
119
+ export async function getStatus(projectPath, workingBranch, workspaceId) {
106
120
  const config = resolveInstance(projectPath, workingBranch);
107
121
  if (!config) {
108
122
  return {
@@ -114,8 +128,8 @@ export function getStatus(projectPath, workingBranch, workspaceId) {
114
128
  containers: [],
115
129
  };
116
130
  }
117
- const running = listRunningContainers();
118
- const matching = running.filter((name) => name.toLowerCase().includes(config.projectName.toLowerCase()));
131
+ const running = await listRunningContainers();
132
+ const matching = running.filter((name) => containerBelongsToProject(name, config.projectName));
119
133
  if (matching.length > 0) {
120
134
  return {
121
135
  status: 'running',
@@ -192,9 +206,14 @@ export function startDevServer(workspaceId) {
192
206
  });
193
207
  proc.on('exit', (code) => {
194
208
  trackedProcesses.delete(workspaceId);
195
- const currentStatus = getStatus(workspace.projectPath, workspace.workingBranch);
196
- updateDevServerStatus(workspaceId, currentStatus.status);
197
- emitEphemeral(workspaceId, 'devserver:status', currentStatus);
209
+ void getStatus(workspace.projectPath, workspace.workingBranch)
210
+ .then((currentStatus) => {
211
+ updateDevServerStatus(workspaceId, currentStatus.status);
212
+ emitEphemeral(workspaceId, 'devserver:status', currentStatus);
213
+ })
214
+ .catch((err) => {
215
+ console.error(`[dev-server] Failed to refresh status for workspace ${workspaceId}:`, err);
216
+ });
198
217
  if (code !== 0) {
199
218
  console.error(`[dev-server] Process exited with code ${code} for workspace ${workspaceId}`);
200
219
  }
@@ -229,7 +248,7 @@ export function startDevServer(workspaceId) {
229
248
  /**
230
249
  * Stop the dev-server for a workspace.
231
250
  */
232
- export function stopDevServer(workspaceId) {
251
+ export async function stopDevServer(workspaceId) {
233
252
  const workspace = getWorkspace(workspaceId);
234
253
  if (!workspace) {
235
254
  throw new Error(`Workspace '${workspaceId}' not found`);
@@ -256,18 +275,15 @@ export function stopDevServer(workspaceId) {
256
275
  }
257
276
  const settings = getProjectSettings(workspace.projectPath);
258
277
  if (settings?.devServer.stopCommand) {
259
- // Custom stop script — run synchronously with instance context in env
260
278
  try {
261
- execSync(settings.devServer.stopCommand, {
279
+ await runCommand('bash', ['-c', settings.devServer.stopCommand], {
262
280
  cwd,
263
281
  env: {
264
282
  ...cleanEnv(),
265
283
  INSTANCE: instanceName,
266
284
  PROJECT_NAME: config?.projectName ?? '',
267
285
  },
268
- encoding: 'utf-8',
269
- timeout: 30000,
270
- shell: 'bash',
286
+ timeout: 30_000,
271
287
  });
272
288
  }
273
289
  catch (err) {
@@ -278,11 +294,7 @@ export function stopDevServer(workspaceId) {
278
294
  // (handles cases where custom stop command doesn't use -p flag)
279
295
  if (config?.projectName) {
280
296
  try {
281
- execSync(`docker compose -p "${config.projectName}" down`, {
282
- cwd: cwd,
283
- encoding: 'utf-8',
284
- timeout: 30000,
285
- });
297
+ await runCommand('docker', ['compose', '-p', config.projectName, 'down'], { cwd, timeout: 30_000 });
286
298
  }
287
299
  catch {
288
300
  // May already be stopped by the custom command — ignore
@@ -303,10 +315,9 @@ export function stopDevServer(workspaceId) {
303
315
  // ── Logs ───────────────────────────────────────────────────────────────────────
304
316
  /**
305
317
  * Get logs from running dev-server containers for a workspace.
306
- * Note: uses execSync for `docker logs` — container names come from
307
- * `docker ps` output (not user input), so no injection risk.
318
+ * Docker log reads run concurrently without blocking the Node event loop.
308
319
  */
309
- export function getDevServerLogs(workspaceId, tail = 200) {
320
+ export async function getDevServerLogs(workspaceId, tail = 200) {
310
321
  const workspace = getWorkspace(workspaceId);
311
322
  if (!workspace) {
312
323
  return 'Workspace not found';
@@ -315,24 +326,20 @@ export function getDevServerLogs(workspaceId, tail = 200) {
315
326
  if (!config) {
316
327
  return 'No dev-server instance found';
317
328
  }
318
- const running = listRunningContainers();
319
- const matching = running.filter((name) => name.toLowerCase().includes(config.projectName.toLowerCase()));
329
+ const running = await listRunningContainers();
330
+ const matching = running.filter((name) => containerBelongsToProject(name, config.projectName));
320
331
  if (matching.length === 0) {
321
332
  return 'No running containers found';
322
333
  }
323
- const outputs = [];
324
- for (const container of matching) {
334
+ const outputs = await Promise.all(matching.map(async (container) => {
325
335
  try {
326
- const logs = execSync(`docker logs --tail ${tail} ${container}`, {
327
- encoding: 'utf-8',
328
- timeout: 10000,
329
- });
330
- outputs.push(`=== ${container} ===\n${logs}`);
336
+ const logs = await runCommand('docker', ['logs', '--tail', String(tail), container], { timeout: 10_000 });
337
+ return `=== ${container} ===\n${logs}`;
331
338
  }
332
339
  catch (err) {
333
340
  const message = err instanceof Error ? err.message : String(err);
334
- outputs.push(`=== ${container} ===\n[Error fetching logs: ${message}]`);
341
+ return `=== ${container} ===\n[Error fetching logs: ${message}]`;
335
342
  }
336
- }
343
+ }));
337
344
  return outputs.join('\n\n');
338
345
  }
@@ -144,12 +144,13 @@ function bktEnv() {
144
144
  return process.env;
145
145
  }
146
146
  function bktOptions(repoPath) {
147
- return { cwd: repoPath, encoding: 'utf-8', env: bktEnv() };
147
+ return { cwd: repoPath, encoding: 'utf-8', env: bktEnv(), timeout: 30_000 };
148
148
  }
149
149
  async function resolveTarget(repoPath) {
150
150
  const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], {
151
151
  cwd: repoPath,
152
152
  encoding: 'utf-8',
153
+ timeout: 30_000,
153
154
  });
154
155
  const target = parseBitbucketRemote(stdout);
155
156
  if (!target)
@@ -158,27 +159,21 @@ async function resolveTarget(repoPath) {
158
159
  // keyring and context access, which cannot prompt from the Kobo server.
159
160
  if (bktEnv().BKT_TOKEN)
160
161
  return { ...target, context: null };
161
- const { stdout: contextOutput } = await execFileAsync('bkt', ['context', 'list', '--json'], {
162
- cwd: repoPath,
163
- encoding: 'utf-8',
164
- });
162
+ const { stdout: contextOutput } = await execFileAsync('bkt', ['context', 'list', '--json'], bktOptions(repoPath));
165
163
  const contexts = record(JSON.parse(contextOutput)).contexts;
166
164
  const existing = (Array.isArray(contexts) ? contexts : [])
167
165
  .map(record)
168
166
  .find((context) => string(context.workspace) === target.workspace && string(context.default_repo) === target.repo);
169
167
  if (existing && string(existing.name))
170
168
  return { ...target, context: string(existing.name) };
171
- const { stdout: authOutput } = await execFileAsync('bkt', ['auth', 'status', '--json'], {
172
- cwd: repoPath,
173
- encoding: 'utf-8',
174
- });
169
+ const { stdout: authOutput } = await execFileAsync('bkt', ['auth', 'status', '--json'], bktOptions(repoPath));
175
170
  const hosts = record(JSON.parse(authOutput)).hosts;
176
171
  const host = (Array.isArray(hosts) ? hosts : []).map(record).find((item) => string(item.kind) === 'cloud');
177
172
  const hostKey = string(host?.key);
178
173
  if (!hostKey)
179
174
  throw new Error('No authenticated Bitbucket Cloud host found; run bkt auth login first');
180
175
  const context = contextName(target.workspace, target.repo);
181
- await execFileAsync('bkt', ['context', 'create', context, '--host', hostKey, '--workspace', target.workspace, '--repo', target.repo], { cwd: repoPath, encoding: 'utf-8' });
176
+ await execFileAsync('bkt', ['context', 'create', context, '--host', hostKey, '--workspace', target.workspace, '--repo', target.repo], bktOptions(repoPath));
182
177
  return { ...target, context };
183
178
  }
184
179
  function targetArgs(target) {
@@ -286,6 +281,6 @@ export const bitbucketProvider = {
286
281
  await execFileAsync('bkt', bktArgs(target, ['pr', 'merge', String(number), '--close-source=false', ...targetArgs(target)]), bktOptions(repoPath));
287
282
  },
288
283
  async deleteRemoteBranch(repoPath, branchName) {
289
- await execFileAsync('git', ['push', 'origin', '--delete', branchName], { cwd: repoPath, encoding: 'utf-8' });
284
+ await execFileAsync('git', ['push', 'origin', '--delete', branchName], bktOptions(repoPath));
290
285
  },
291
286
  };
@@ -3,6 +3,10 @@ import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import { deriveReadyToMerge } from '../types.js';
5
5
  const execFileAsync = promisify(execFile);
6
+ const CLI_TIMEOUT_MS = 30_000;
7
+ function cliOptions(repoPath) {
8
+ return { cwd: repoPath, encoding: 'utf-8', timeout: CLI_TIMEOUT_MS };
9
+ }
6
10
  // NOTE: `reviewThreads` is intentionally NOT in this list.
7
11
  // `gh pr view --json reviewThreads` is rejected with `Unknown JSON field`
8
12
  // — there is no stable `gh` version that exposes it. Until upstream adds it,
@@ -101,7 +105,7 @@ export const githubProvider = {
101
105
  },
102
106
  async isAvailable(repoPath) {
103
107
  try {
104
- await execFileAsync('gh', ['auth', 'status'], { cwd: repoPath, encoding: 'utf-8' });
108
+ await execFileAsync('gh', ['auth', 'status'], cliOptions(repoPath));
105
109
  return { available: true };
106
110
  }
107
111
  catch (err) {
@@ -110,10 +114,7 @@ export const githubProvider = {
110
114
  },
111
115
  async getPrStatus(repoPath, branch) {
112
116
  try {
113
- const { stdout } = await execFileAsync('gh', ['pr', 'view', branch, '--json', GH_PR_FIELDS], {
114
- cwd: repoPath,
115
- encoding: 'utf-8',
116
- });
117
+ const { stdout } = await execFileAsync('gh', ['pr', 'view', branch, '--json', GH_PR_FIELDS], cliOptions(repoPath));
117
118
  const raw = stdout.trim();
118
119
  if (!raw)
119
120
  return null;
@@ -124,19 +125,19 @@ export const githubProvider = {
124
125
  }
125
126
  },
126
127
  async createPr(repoPath, opts) {
127
- const { stdout } = await execFileAsync('gh', ['pr', 'create', '--base', opts.base, '--head', opts.head, '--title', opts.title, '--body', opts.body], { cwd: repoPath, encoding: 'utf-8' });
128
+ const { stdout } = await execFileAsync('gh', ['pr', 'create', '--base', opts.base, '--head', opts.head, '--title', opts.title, '--body', opts.body], cliOptions(repoPath));
128
129
  const match = stdout.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
129
130
  if (!match)
130
131
  throw new Error('Could not parse PR URL from gh output');
131
132
  return { url: match[0], number: Number.parseInt(match[1], 10) };
132
133
  },
133
134
  async changePrBase(repoPath, base) {
134
- await execFileAsync('gh', ['pr', 'edit', '--base', base], { cwd: repoPath, encoding: 'utf-8' });
135
+ await execFileAsync('gh', ['pr', 'edit', '--base', base], cliOptions(repoPath));
135
136
  },
136
137
  async mergeRequest(repoPath, number) {
137
- await execFileAsync('gh', ['pr', 'merge', String(number), '--merge'], { cwd: repoPath, encoding: 'utf-8' });
138
+ await execFileAsync('gh', ['pr', 'merge', String(number), '--merge'], cliOptions(repoPath));
138
139
  },
139
140
  async deleteRemoteBranch(repoPath, branch) {
140
- await execFileAsync('git', ['push', 'origin', '--delete', branch], { cwd: repoPath, encoding: 'utf-8' });
141
+ await execFileAsync('git', ['push', 'origin', '--delete', branch], cliOptions(repoPath));
141
142
  },
142
143
  };