patchwork-os 0.2.0-beta.2 → 0.2.0-beta.4

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 (261) hide show
  1. package/README.bridge.md +5 -5
  2. package/README.md +244 -30
  3. package/dist/activityLog.d.ts +6 -0
  4. package/dist/activityLog.js +10 -1
  5. package/dist/activityLog.js.map +1 -1
  6. package/dist/analyticsPrefs.d.ts +35 -2
  7. package/dist/analyticsPrefs.js +120 -21
  8. package/dist/analyticsPrefs.js.map +1 -1
  9. package/dist/analyticsSend.js +5 -1
  10. package/dist/analyticsSend.js.map +1 -1
  11. package/dist/approvalHttp.js +25 -8
  12. package/dist/approvalHttp.js.map +1 -1
  13. package/dist/approvalQueue.d.ts +44 -1
  14. package/dist/approvalQueue.js +117 -0
  15. package/dist/approvalQueue.js.map +1 -1
  16. package/dist/automation.d.ts +3 -3
  17. package/dist/automation.js +12 -5
  18. package/dist/automation.js.map +1 -1
  19. package/dist/bridge.d.ts +2 -0
  20. package/dist/bridge.js +140 -8
  21. package/dist/bridge.js.map +1 -1
  22. package/dist/bridgeLockDiscovery.d.ts +27 -1
  23. package/dist/bridgeLockDiscovery.js +38 -11
  24. package/dist/bridgeLockDiscovery.js.map +1 -1
  25. package/dist/claudeOrchestrator.js +27 -10
  26. package/dist/claudeOrchestrator.js.map +1 -1
  27. package/dist/commands/dashboard.js +8 -1
  28. package/dist/commands/dashboard.js.map +1 -1
  29. package/dist/commands/install.js +3 -0
  30. package/dist/commands/install.js.map +1 -1
  31. package/dist/commands/patchworkInit.d.ts +5 -0
  32. package/dist/commands/patchworkInit.js +89 -7
  33. package/dist/commands/patchworkInit.js.map +1 -1
  34. package/dist/commands/recipe.d.ts +51 -0
  35. package/dist/commands/recipe.js +353 -2
  36. package/dist/commands/recipe.js.map +1 -1
  37. package/dist/commands/recipeInstall.js +6 -3
  38. package/dist/commands/recipeInstall.js.map +1 -1
  39. package/dist/commands/task.js +2 -2
  40. package/dist/commands/task.js.map +1 -1
  41. package/dist/commitIssueLinkLog.d.ts +16 -0
  42. package/dist/commitIssueLinkLog.js +87 -4
  43. package/dist/commitIssueLinkLog.js.map +1 -1
  44. package/dist/config.d.ts +29 -3
  45. package/dist/config.js +77 -21
  46. package/dist/config.js.map +1 -1
  47. package/dist/connectorRoutes.js +1 -1
  48. package/dist/connectorRoutes.js.map +1 -1
  49. package/dist/connectors/asana.js +4 -3
  50. package/dist/connectors/asana.js.map +1 -1
  51. package/dist/connectors/confluence.js +35 -0
  52. package/dist/connectors/confluence.js.map +1 -1
  53. package/dist/connectors/datadog.js +33 -4
  54. package/dist/connectors/datadog.js.map +1 -1
  55. package/dist/connectors/discord.js +5 -4
  56. package/dist/connectors/discord.js.map +1 -1
  57. package/dist/connectors/gitlab.js +7 -1
  58. package/dist/connectors/gitlab.js.map +1 -1
  59. package/dist/connectors/mcpOAuth.js +71 -6
  60. package/dist/connectors/mcpOAuth.js.map +1 -1
  61. package/dist/connectors/slack.d.ts +1 -1
  62. package/dist/connectors/slack.js +56 -4
  63. package/dist/connectors/slack.js.map +1 -1
  64. package/dist/connectors/tokenStorage.js +56 -14
  65. package/dist/connectors/tokenStorage.js.map +1 -1
  66. package/dist/decisionTraceLog.d.ts +28 -0
  67. package/dist/decisionTraceLog.js +115 -7
  68. package/dist/decisionTraceLog.js.map +1 -1
  69. package/dist/drivers/claude/subprocess.js +22 -3
  70. package/dist/drivers/claude/subprocess.js.map +1 -1
  71. package/dist/drivers/gemini/index.js +19 -3
  72. package/dist/drivers/gemini/index.js.map +1 -1
  73. package/dist/extensionClient.d.ts +29 -4
  74. package/dist/extensionClient.js +26 -11
  75. package/dist/extensionClient.js.map +1 -1
  76. package/dist/featureFlags.d.ts +76 -0
  77. package/dist/featureFlags.js +153 -3
  78. package/dist/featureFlags.js.map +1 -1
  79. package/dist/fileLockSync.d.ts +67 -0
  80. package/dist/fileLockSync.js +126 -0
  81. package/dist/fileLockSync.js.map +1 -0
  82. package/dist/fp/automationInterpreter.d.ts +6 -0
  83. package/dist/fp/automationInterpreter.js +15 -2
  84. package/dist/fp/automationInterpreter.js.map +1 -1
  85. package/dist/fp/automationState.d.ts +1 -1
  86. package/dist/fp/automationState.js +10 -0
  87. package/dist/fp/automationState.js.map +1 -1
  88. package/dist/fp/commandDescription.js +7 -1
  89. package/dist/fp/commandDescription.js.map +1 -1
  90. package/dist/fsWatchWithFallback.d.ts +36 -0
  91. package/dist/fsWatchWithFallback.js +127 -0
  92. package/dist/fsWatchWithFallback.js.map +1 -0
  93. package/dist/index.js +797 -75
  94. package/dist/index.js.map +1 -1
  95. package/dist/installGuard.js +6 -2
  96. package/dist/installGuard.js.map +1 -1
  97. package/dist/lockfile.js +31 -4
  98. package/dist/lockfile.js.map +1 -1
  99. package/dist/patchworkConfig.js +13 -3
  100. package/dist/patchworkConfig.js.map +1 -1
  101. package/dist/pluginLoader.js +10 -1
  102. package/dist/pluginLoader.js.map +1 -1
  103. package/dist/pluginWatcher.js +6 -13
  104. package/dist/pluginWatcher.js.map +1 -1
  105. package/dist/preToolUseHook.js +3 -2
  106. package/dist/preToolUseHook.js.map +1 -1
  107. package/dist/processTree.d.ts +34 -0
  108. package/dist/processTree.js +105 -0
  109. package/dist/processTree.js.map +1 -0
  110. package/dist/prompts.js +3 -3
  111. package/dist/prompts.js.map +1 -1
  112. package/dist/recipeOrchestration.js +35 -1
  113. package/dist/recipeOrchestration.js.map +1 -1
  114. package/dist/recipeRoutes.d.ts +37 -0
  115. package/dist/recipeRoutes.js +236 -33
  116. package/dist/recipeRoutes.js.map +1 -1
  117. package/dist/recipes/agentExecutor.d.ts +25 -5
  118. package/dist/recipes/agentExecutor.js.map +1 -1
  119. package/dist/recipes/chainedRunner.js +16 -2
  120. package/dist/recipes/chainedRunner.js.map +1 -1
  121. package/dist/recipes/connectorPreflight.d.ts +53 -0
  122. package/dist/recipes/connectorPreflight.js +143 -0
  123. package/dist/recipes/connectorPreflight.js.map +1 -0
  124. package/dist/recipes/githubInstallSource.d.ts +62 -0
  125. package/dist/recipes/githubInstallSource.js +125 -0
  126. package/dist/recipes/githubInstallSource.js.map +1 -0
  127. package/dist/recipes/haltCategory.d.ts +80 -0
  128. package/dist/recipes/haltCategory.js +125 -0
  129. package/dist/recipes/haltCategory.js.map +1 -0
  130. package/dist/recipes/idempotencyKey.d.ts +126 -0
  131. package/dist/recipes/idempotencyKey.js +297 -0
  132. package/dist/recipes/idempotencyKey.js.map +1 -0
  133. package/dist/recipes/installer.js +48 -2
  134. package/dist/recipes/installer.js.map +1 -1
  135. package/dist/recipes/judgeSummary.d.ts +50 -0
  136. package/dist/recipes/judgeSummary.js +47 -0
  137. package/dist/recipes/judgeSummary.js.map +1 -0
  138. package/dist/recipes/judgeVerdict.d.ts +48 -0
  139. package/dist/recipes/judgeVerdict.js +174 -0
  140. package/dist/recipes/judgeVerdict.js.map +1 -0
  141. package/dist/recipes/migrations/index.d.ts +9 -0
  142. package/dist/recipes/migrations/index.js +133 -0
  143. package/dist/recipes/migrations/index.js.map +1 -1
  144. package/dist/recipes/parser.js +82 -4
  145. package/dist/recipes/parser.js.map +1 -1
  146. package/dist/recipes/runBudget.d.ts +70 -0
  147. package/dist/recipes/runBudget.js +109 -0
  148. package/dist/recipes/runBudget.js.map +1 -0
  149. package/dist/recipes/scheduler.d.ts +17 -0
  150. package/dist/recipes/scheduler.js +34 -2
  151. package/dist/recipes/scheduler.js.map +1 -1
  152. package/dist/recipes/schema.d.ts +30 -0
  153. package/dist/recipes/toolRegistry.js +19 -0
  154. package/dist/recipes/toolRegistry.js.map +1 -1
  155. package/dist/recipes/tools/http.d.ts +10 -0
  156. package/dist/recipes/tools/http.js +176 -0
  157. package/dist/recipes/tools/http.js.map +1 -0
  158. package/dist/recipes/tools/index.d.ts +1 -0
  159. package/dist/recipes/tools/index.js +1 -0
  160. package/dist/recipes/tools/index.js.map +1 -1
  161. package/dist/recipes/validation.js +1 -1
  162. package/dist/recipes/validation.js.map +1 -1
  163. package/dist/recipes/yamlRunner.d.ts +75 -8
  164. package/dist/recipes/yamlRunner.js +174 -28
  165. package/dist/recipes/yamlRunner.js.map +1 -1
  166. package/dist/resources.js +21 -13
  167. package/dist/resources.js.map +1 -1
  168. package/dist/runLog.d.ts +28 -0
  169. package/dist/runLog.js +19 -3
  170. package/dist/runLog.js.map +1 -1
  171. package/dist/sanitizeParsedJson.d.ts +39 -0
  172. package/dist/sanitizeParsedJson.js +55 -0
  173. package/dist/sanitizeParsedJson.js.map +1 -0
  174. package/dist/server.d.ts +79 -0
  175. package/dist/server.js +356 -3
  176. package/dist/server.js.map +1 -1
  177. package/dist/sessionCheckpoint.d.ts +8 -0
  178. package/dist/sessionCheckpoint.js +18 -2
  179. package/dist/sessionCheckpoint.js.map +1 -1
  180. package/dist/streamableHttp.js +17 -6
  181. package/dist/streamableHttp.js.map +1 -1
  182. package/dist/tools/bridgeDoctor.js +6 -2
  183. package/dist/tools/bridgeDoctor.js.map +1 -1
  184. package/dist/tools/detectUnusedCode.js +9 -7
  185. package/dist/tools/detectUnusedCode.js.map +1 -1
  186. package/dist/tools/editText.js +2 -1
  187. package/dist/tools/editText.js.map +1 -1
  188. package/dist/tools/fileOperations.js +2 -1
  189. package/dist/tools/fileOperations.js.map +1 -1
  190. package/dist/tools/fileWatcher.js +8 -2
  191. package/dist/tools/fileWatcher.js.map +1 -1
  192. package/dist/tools/fixAllLintErrors.js +10 -5
  193. package/dist/tools/fixAllLintErrors.js.map +1 -1
  194. package/dist/tools/formatDocument.js +10 -5
  195. package/dist/tools/formatDocument.js.map +1 -1
  196. package/dist/tools/getCodeCoverage.js +7 -3
  197. package/dist/tools/getCodeCoverage.js.map +1 -1
  198. package/dist/tools/handoffNote.js +2 -1
  199. package/dist/tools/handoffNote.js.map +1 -1
  200. package/dist/tools/headless/lspClient.js +3 -0
  201. package/dist/tools/headless/lspClient.js.map +1 -1
  202. package/dist/tools/lsp.js +17 -0
  203. package/dist/tools/lsp.js.map +1 -1
  204. package/dist/tools/openDiff.js +4 -1
  205. package/dist/tools/openDiff.js.map +1 -1
  206. package/dist/tools/openFile.js +4 -1
  207. package/dist/tools/openFile.js.map +1 -1
  208. package/dist/tools/organizeImports.js +5 -3
  209. package/dist/tools/organizeImports.js.map +1 -1
  210. package/dist/tools/previewEdit.js +7 -2
  211. package/dist/tools/previewEdit.js.map +1 -1
  212. package/dist/tools/recentTracesDigest.js +56 -11
  213. package/dist/tools/recentTracesDigest.js.map +1 -1
  214. package/dist/tools/refactorExtractFunction.js +4 -1
  215. package/dist/tools/refactorExtractFunction.js.map +1 -1
  216. package/dist/tools/refactorPreview.js +10 -2
  217. package/dist/tools/refactorPreview.js.map +1 -1
  218. package/dist/tools/replaceBlock.js +2 -1
  219. package/dist/tools/replaceBlock.js.map +1 -1
  220. package/dist/tools/searchAndReplace.js +2 -1
  221. package/dist/tools/searchAndReplace.js.map +1 -1
  222. package/dist/tools/spawnWorkspace.js +15 -7
  223. package/dist/tools/spawnWorkspace.js.map +1 -1
  224. package/dist/tools/testRunners/vitestJest.js +3 -1
  225. package/dist/tools/testRunners/vitestJest.js.map +1 -1
  226. package/dist/tools/transaction.js +4 -1
  227. package/dist/tools/transaction.js.map +1 -1
  228. package/dist/tools/utils.js +68 -8
  229. package/dist/tools/utils.js.map +1 -1
  230. package/dist/transport.d.ts +1 -1
  231. package/dist/transport.js +18 -4
  232. package/dist/transport.js.map +1 -1
  233. package/dist/winShim.d.ts +34 -0
  234. package/dist/winShim.js +94 -0
  235. package/dist/winShim.js.map +1 -0
  236. package/dist/writeFileAtomic.d.ts +23 -0
  237. package/dist/writeFileAtomic.js +94 -0
  238. package/dist/writeFileAtomic.js.map +1 -0
  239. package/package.json +17 -6
  240. package/scripts/postinstall.mjs +42 -2
  241. package/scripts/smoke/run-all.mjs +213 -0
  242. package/scripts/start-all.mjs +572 -0
  243. package/scripts/start-all.ps1 +209 -0
  244. package/scripts/start-all.sh +73 -17
  245. package/scripts/start-orchestrator.ps1 +158 -0
  246. package/scripts/start-remote.mjs +122 -0
  247. package/templates/automation-policies/recipe-authoring.json +1 -1
  248. package/templates/automation-policies/security-first.json +1 -1
  249. package/templates/automation-policies/strict-lint.json +1 -1
  250. package/templates/automation-policies/test-driven.json +1 -1
  251. package/templates/automation-policy.example.json +1 -1
  252. package/templates/co.patchwork-os.bridge.plist +1 -1
  253. package/templates/recipes/approval-queue-ui-test.yaml +1 -1
  254. package/templates/recipes/ctx-loop-test.yaml +1 -1
  255. package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
  256. package/dist/commands/marketplace.d.ts +0 -16
  257. package/dist/commands/marketplace.js +0 -32
  258. package/dist/commands/marketplace.js.map +0 -1
  259. package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
  260. package/dist/recipes/legacyRecipeCompat.js +0 -131
  261. package/dist/recipes/legacyRecipeCompat.js.map +0 -1
@@ -188,7 +188,11 @@ export function readBodyWithCap(req, max) {
188
188
  const onEnd = () => {
189
189
  if (aborted)
190
190
  return;
191
- resolve({ ok: true, body: Buffer.concat(chunks).toString("utf-8") });
191
+ const bytes = Buffer.concat(chunks);
192
+ // `bytes` is the raw on-the-wire body; `body` is the utf-8 decode used
193
+ // by JSON parsers. HMAC consumers must use `bytes` to avoid the
194
+ // utf-8 round-trip changing the signed payload.
195
+ resolve({ ok: true, body: bytes.toString("utf-8"), bytes });
192
196
  };
193
197
  const onError = () => {
194
198
  if (aborted)
@@ -254,6 +258,24 @@ function respondInvalidJson(res) {
254
258
  res.writeHead(400, { "Content-Type": "application/json" });
255
259
  res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
256
260
  }
261
+ /**
262
+ * Best-effort fire of the recipe-changed notification. Wraps the
263
+ * callback in try/catch + console.error so a misbehaving notifier
264
+ * (most likely scheduler.start() throwing) cannot turn a successful
265
+ * disk-write into a failed-looking HTTP response. Used by install /
266
+ * save / delete / archive / duplicate / setEnabled / saveContent
267
+ * routes after their respective success paths.
268
+ */
269
+ function fireOnRecipesChanged(deps) {
270
+ if (!deps.onRecipesChangedFn)
271
+ return;
272
+ try {
273
+ deps.onRecipesChangedFn();
274
+ }
275
+ catch (err) {
276
+ console.error(`[recipeRoutes] onRecipesChangedFn threw:`, err);
277
+ }
278
+ }
257
279
  /**
258
280
  * Try to handle a recipe / run-audit / template route. Returns true if
259
281
  * the route was dispatched (caller should `return` from the request
@@ -295,7 +317,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
295
317
  res.writeHead(503, { "Content-Type": "application/json" });
296
318
  res.end(JSON.stringify({
297
319
  ok: false,
298
- error: "Recipe execution unavailable — requires --claude-driver subprocess",
320
+ error: "Recipe execution unavailable — requires --driver subprocess",
299
321
  }));
300
322
  return;
301
323
  }
@@ -347,7 +369,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
347
369
  res.writeHead(503, { "Content-Type": "application/json" });
348
370
  res.end(JSON.stringify({
349
371
  ok: false,
350
- error: "Recipe execution unavailable — requires --claude-driver subprocess",
372
+ error: "Recipe execution unavailable — requires --driver subprocess",
351
373
  }));
352
374
  return;
353
375
  }
@@ -383,6 +405,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
383
405
  const trigger = sp.get("trigger");
384
406
  const status = sp.get("status");
385
407
  const recipe = sp.get("recipe");
408
+ const manualRunId = sp.get("manualRunId");
386
409
  const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
387
410
  const after = afterRaw ? Number.parseInt(afterRaw, 10) : Number.NaN;
388
411
  const runs = deps.runsFn?.({
@@ -390,6 +413,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
390
413
  ...(trigger && { trigger }),
391
414
  ...(status && { status }),
392
415
  ...(recipe && { recipe }),
416
+ ...(manualRunId && { manualRunId }),
393
417
  ...(Number.isFinite(after) && { after }),
394
418
  }) ?? [];
395
419
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -400,6 +424,53 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
400
424
  }
401
425
  return true;
402
426
  }
427
+ // GET /runs/halt-summary — aggregated halt categories over recent runs.
428
+ // Drives the dashboard /runs page header widget that answers "is the
429
+ // haltReason work surfacing real signal, or is everything 'unknown'?".
430
+ if (parsedUrl.pathname === "/runs/halt-summary" && req.method === "GET") {
431
+ try {
432
+ const sp = parsedUrl.searchParams;
433
+ const sinceMsRaw = sp.get("sinceMs");
434
+ const limitRaw = sp.get("limit");
435
+ const recipe = sp.get("recipe");
436
+ const sinceMs = sinceMsRaw ? Number.parseInt(sinceMsRaw, 10) : Number.NaN;
437
+ const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
438
+ const summary = deps.haltSummaryFn?.({
439
+ ...(Number.isFinite(sinceMs) && { sinceMs }),
440
+ ...(Number.isFinite(limit) && { limit }),
441
+ ...(recipe && { recipe }),
442
+ }) ?? { total: 0, byCategory: {}, recent: [] };
443
+ res.writeHead(200, { "Content-Type": "application/json" });
444
+ res.end(JSON.stringify(summary));
445
+ }
446
+ catch (err) {
447
+ respond500(res, err);
448
+ }
449
+ return true;
450
+ }
451
+ // GET /runs/judge-summary — PR3b sibling of /runs/halt-summary.
452
+ // Same query shape (sinceMs, limit, recipe), returns JudgeSummary.
453
+ if (parsedUrl.pathname === "/runs/judge-summary" && req.method === "GET") {
454
+ try {
455
+ const sp = parsedUrl.searchParams;
456
+ const sinceMsRaw = sp.get("sinceMs");
457
+ const limitRaw = sp.get("limit");
458
+ const recipe = sp.get("recipe");
459
+ const sinceMs = sinceMsRaw ? Number.parseInt(sinceMsRaw, 10) : Number.NaN;
460
+ const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
461
+ const summary = deps.judgeSummaryFn?.({
462
+ ...(Number.isFinite(sinceMs) && { sinceMs }),
463
+ ...(Number.isFinite(limit) && { limit }),
464
+ ...(recipe && { recipe }),
465
+ }) ?? { total: 0, byVerdict: {}, recent: [] };
466
+ res.writeHead(200, { "Content-Type": "application/json" });
467
+ res.end(JSON.stringify(summary));
468
+ }
469
+ catch (err) {
470
+ respond500(res, err);
471
+ }
472
+ return true;
473
+ }
403
474
  // GET /runs/:seq — single run detail (includes stepResults if present)
404
475
  const runDetailMatch = req.method === "GET" ? /^\/runs\/(\d+)$/.exec(parsedUrl.pathname) : null;
405
476
  if (runDetailMatch?.[1]) {
@@ -540,7 +611,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
540
611
  res.writeHead(503, { "Content-Type": "application/json" });
541
612
  res.end(JSON.stringify({
542
613
  ok: false,
543
- error: "Recipe generation unavailable — requires --claude-driver subprocess",
614
+ error: "Recipe generation unavailable — requires --driver subprocess",
544
615
  unavailable: true,
545
616
  }));
546
617
  return;
@@ -591,6 +662,8 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
591
662
  return;
592
663
  }
593
664
  const result = deps.saveRecipeFn(draft);
665
+ if (result.ok)
666
+ fireOnRecipesChanged(deps);
594
667
  res.writeHead(result.ok ? 201 : 400, {
595
668
  "Content-Type": "application/json",
596
669
  });
@@ -670,6 +743,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
670
743
  return;
671
744
  }
672
745
  const result = deps.setRecipeEnabledFn(name, body.enabled);
746
+ // Enable/disable changes which cron triggers should fire — the
747
+ // RecipeScheduler honours the disabled set on every start(), so
748
+ // re-priming after a toggle picks up the change without a restart.
749
+ if (result.ok)
750
+ fireOnRecipesChanged(deps);
673
751
  res.writeHead(result.ok ? 200 : 400, {
674
752
  "Content-Type": "application/json",
675
753
  });
@@ -806,6 +884,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
806
884
  return;
807
885
  }
808
886
  const result = deps.saveRecipeContentFn(name, body.content);
887
+ // Editing recipe YAML can change cron schedule, webhook path,
888
+ // or trigger type entirely — re-prime the scheduler so the new
889
+ // shape takes effect without a bridge restart.
890
+ if (result.ok)
891
+ fireOnRecipesChanged(deps);
809
892
  res.writeHead(result.ok ? 200 : 400, {
810
893
  "Content-Type": "application/json",
811
894
  });
@@ -828,6 +911,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
828
911
  return true;
829
912
  }
830
913
  const result = deps.deleteRecipeContentFn(name);
914
+ // Deleting a cron recipe leaves an orphaned interval in the scheduler
915
+ // until the next start() — re-prime so it goes away.
916
+ if (result.ok)
917
+ fireOnRecipesChanged(deps);
831
918
  const status = result.ok
832
919
  ? 200
833
920
  : result.error === "Recipe not found"
@@ -847,6 +934,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
847
934
  return true;
848
935
  }
849
936
  const result = deps.archiveRecipeFn(name);
937
+ // Archiving moves the recipe under .archive/ where the scheduler
938
+ // ignores it — same orphan-interval cleanup needed as for delete.
939
+ if (result.ok)
940
+ fireOnRecipesChanged(deps);
850
941
  const status = result.ok
851
942
  ? 200
852
943
  : result.error === "Recipe not found"
@@ -866,6 +957,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
866
957
  return true;
867
958
  }
868
959
  const result = deps.duplicateRecipeFn(name);
960
+ // Duplication adds a new recipe file to the dir — re-prime so any
961
+ // cron trigger inside the duplicate starts firing immediately.
962
+ if (result.ok)
963
+ fireOnRecipesChanged(deps);
869
964
  const status = result.ok
870
965
  ? 201
871
966
  : result.error === "Recipe not found"
@@ -901,6 +996,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
901
996
  const result = await deps.promoteRecipeVariantFn(variantName, targetName, {
902
997
  force: force === true,
903
998
  });
999
+ // Promotion overwrites the canonical file with the variant's
1000
+ // contents — same scheduler refresh story as save/edit.
1001
+ if (result.ok)
1002
+ fireOnRecipesChanged(deps);
904
1003
  const httpStatus = result.ok
905
1004
  ? 200
906
1005
  : result.targetExists
@@ -993,27 +1092,30 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
993
1092
  // -----------------------------------------------------------------
994
1093
  // BUNDLE INSTALL DISPATCH (#130 PR A).
995
1094
  //
996
- // `github:patchworkos/recipes/bundles/<name>` installs every recipe
1095
+ // `github:<owner>/<repo>/bundles/<name>` installs every recipe
997
1096
  // listed in the bundle's `patchwork-bundle.json`. Plugin (`plugin`)
998
1097
  // and policy template (`policy_template`) declared in the manifest
999
1098
  // are surfaced as advisory-only — wiring those needs separate
1000
1099
  // decisions (npm-install surface, policy application UX) tracked
1001
- // outside this PR. See the #130 scoping comment.
1100
+ // outside this PR.
1101
+ //
1102
+ // Org allowlist (#audit-thread): historically the path was hard-
1103
+ // coded to `patchworkos/recipes`. Now any allowlisted org/repo
1104
+ // can host a bundle; parse + validate via the shared helper so
1105
+ // single-recipe and bundle install share one source-of-truth.
1002
1106
  // -----------------------------------------------------------------
1003
- const bundlePrefix = "github:patchworkos/recipes/bundles/";
1004
- if (source.startsWith(bundlePrefix)) {
1005
- const bundleName = source.slice(bundlePrefix.length);
1006
- const { isSafeBasename } = await import("./commands/recipeInstall.js");
1007
- if (!isSafeBasename(bundleName)) {
1008
- res.writeHead(400, { "Content-Type": "application/json" });
1009
- res.end(JSON.stringify({
1010
- ok: false,
1011
- error: "Invalid bundle name in source",
1012
- code: "invalid_bundle_name",
1013
- }));
1014
- return;
1015
- }
1016
- const manifestUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/bundles/${bundleName}/patchwork-bundle.json`;
1107
+ const bundleParse = source.startsWith("github:")
1108
+ ? await (async () => {
1109
+ const { parseGithubInstallSource } = await import("./recipes/githubInstallSource.js");
1110
+ return parseGithubInstallSource(source);
1111
+ })()
1112
+ : null;
1113
+ if (bundleParse?.ok && bundleParse.parsed.kind === "bundle") {
1114
+ const { buildGithubRawUrl } = await import("./recipes/githubInstallSource.js");
1115
+ const bundleName = bundleParse.parsed.name;
1116
+ const bundleOwner = bundleParse.parsed.owner;
1117
+ const bundleRepo = bundleParse.parsed.repo;
1118
+ const manifestUrl = buildGithubRawUrl(bundleParse.parsed);
1017
1119
  const ctl = new AbortController();
1018
1120
  const timeout = setTimeout(() => ctl.abort(), 30_000);
1019
1121
  let manifestRes;
@@ -1072,6 +1174,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1072
1174
  }));
1073
1175
  return;
1074
1176
  }
1177
+ // Validate each declared recipe basename to block traversal +
1178
+ // junk segments. `isSafeBasename` lives in the legacy recipe-
1179
+ // install command but the predicate is the right shape here.
1180
+ const { isSafeBasename } = await import("./commands/recipeInstall.js");
1075
1181
  if (!Array.isArray(manifest.recipes) ||
1076
1182
  manifest.recipes.length === 0 ||
1077
1183
  !manifest.recipes.every((r) => typeof r === "string" && isSafeBasename(r))) {
@@ -1093,7 +1199,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1093
1199
  const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
1094
1200
  mkdirSync(recipesDir, { recursive: true });
1095
1201
  for (const r of manifest.recipes) {
1096
- const recipeUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/recipes/${r}/${r}.yaml`;
1202
+ // Bundle's manifest is allowed to declare recipes that
1203
+ // live in the same repo as the bundle. Build the URL with
1204
+ // the parsed owner/repo, not the hard-coded original.
1205
+ const recipeUrl = `https://raw.githubusercontent.com/${bundleOwner}/${bundleRepo}/main/recipes/${r}/${r}.yaml`;
1097
1206
  const recipeCtl = new AbortController();
1098
1207
  const recipeTimeout = setTimeout(() => recipeCtl.abort(), 30_000);
1099
1208
  try {
@@ -1155,6 +1264,14 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1155
1264
  // 200 if any recipe installed; 502 otherwise. Always include both
1156
1265
  // arrays so callers (CLI + dashboard) can render partial-success.
1157
1266
  const status = installed.length > 0 ? 200 : 502;
1267
+ // Notify the scheduler so cron-trigger recipes in the bundle
1268
+ // start firing without a bridge restart. Fired once per bundle
1269
+ // (not per recipe inside) since scheduler.start() reads the
1270
+ // whole recipes dir anyway. Guarded by the partial-success
1271
+ // check — no point waking up the scheduler for a 0-installed
1272
+ // failure.
1273
+ if (installed.length > 0)
1274
+ fireOnRecipesChanged(deps);
1158
1275
  res.writeHead(status, { "Content-Type": "application/json" });
1159
1276
  res.end(JSON.stringify({
1160
1277
  ok: installed.length > 0,
@@ -1166,24 +1283,40 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1166
1283
  }));
1167
1284
  return;
1168
1285
  }
1169
- const githubPrefix = "github:patchworkos/recipes/recipes/";
1170
1286
  let fetchUrl;
1171
1287
  let recipeName;
1172
- if (source.startsWith(githubPrefix)) {
1173
- recipeName = source.slice(githubPrefix.length);
1174
- // The constructed URL is internal — recipeName must be a safe
1175
- // single-segment so we don't end up encoding `../etc/passwd` into
1176
- // the path. Reuse the strict basename predicate from `recipeInstall`.
1177
- const { isSafeBasename } = await import("./commands/recipeInstall.js");
1178
- if (!isSafeBasename(recipeName)) {
1288
+ if (source.startsWith("github:")) {
1289
+ // Parse the new generalised shape (any allowlisted org/repo)
1290
+ // instead of only `github:patchworkos/recipes/recipes/<name>`.
1291
+ // Distinguishes bad shape (400) from not-on-allowlist (403)
1292
+ // so operators can spot a config error vs. a typo.
1293
+ const { parseGithubInstallSource, buildGithubRawUrl } = await import("./recipes/githubInstallSource.js");
1294
+ const parsed = parseGithubInstallSource(source);
1295
+ if (!parsed.ok) {
1296
+ const status = parsed.code === "not_allowlisted" ? 403 : 400;
1297
+ res.writeHead(status, { "Content-Type": "application/json" });
1298
+ res.end(JSON.stringify({
1299
+ ok: false,
1300
+ error: parsed.error,
1301
+ code: parsed.code,
1302
+ }));
1303
+ return;
1304
+ }
1305
+ // Bundle shape on /recipes/install (single-recipe path) is a
1306
+ // mistake — surface it explicitly rather than silently fetching
1307
+ // an unrelated URL. Bundle installs have their own code path
1308
+ // above this block.
1309
+ if (parsed.parsed.kind === "bundle") {
1179
1310
  res.writeHead(400, { "Content-Type": "application/json" });
1180
1311
  res.end(JSON.stringify({
1181
1312
  ok: false,
1182
- error: "Invalid recipe name in source",
1313
+ error: "Bundle source on single-recipe install. Use the bundle install path.",
1314
+ code: "bad_shape",
1183
1315
  }));
1184
1316
  return;
1185
1317
  }
1186
- fetchUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/recipes/${recipeName}/${recipeName}.yaml`;
1318
+ recipeName = parsed.parsed.name;
1319
+ fetchUrl = buildGithubRawUrl(parsed.parsed);
1187
1320
  }
1188
1321
  else if (source.startsWith("https://")) {
1189
1322
  // Non-github source: must clear the env-var allowlist AND the SSRF
@@ -1337,7 +1470,46 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1337
1470
  const installResult = installRecipeFromFile(tmpFile, {
1338
1471
  recipesDir,
1339
1472
  });
1340
- result = { action: installResult.action, name: recipeName };
1473
+ // Soft preflight: detect which connectors the recipe uses
1474
+ // and surface the unconfigured ones as a warning. The recipe
1475
+ // is already on disk — this is a hint for the dashboard to
1476
+ // prompt "you'll need to connect Slack + Gmail to run this",
1477
+ // not a gate on the install itself. Defensive: any failure
1478
+ // here MUST NOT roll the install back, so the whole block is
1479
+ // wrapped in try/catch.
1480
+ let missingConnectors;
1481
+ try {
1482
+ const { readFileSync } = await import("node:fs");
1483
+ const installedJson = readFileSync(installResult.installedPath, "utf-8");
1484
+ const recipe = JSON.parse(installedJson);
1485
+ if (Array.isArray(recipe.steps)) {
1486
+ const { detectRequiredConnectors, findMissingConnectors } = await import("./recipes/connectorPreflight.js");
1487
+ const required = detectRequiredConnectors(recipe);
1488
+ if (required.length > 0) {
1489
+ const { handleConnectionsList } = await import("./connectors/gmail.js");
1490
+ const connsResult = await handleConnectionsList();
1491
+ let connections = [];
1492
+ try {
1493
+ const body = JSON.parse(connsResult.body);
1494
+ connections = body.connectors ?? [];
1495
+ }
1496
+ catch {
1497
+ /* malformed body — treat as no connections */
1498
+ }
1499
+ const missing = findMissingConnectors(required, connections);
1500
+ if (missing.length > 0)
1501
+ missingConnectors = missing;
1502
+ }
1503
+ }
1504
+ }
1505
+ catch (preflightErr) {
1506
+ console.warn(`[recipes/install] connector preflight failed (non-blocking):`, preflightErr);
1507
+ }
1508
+ result = {
1509
+ action: installResult.action,
1510
+ name: recipeName,
1511
+ ...(missingConnectors ? { missingConnectors } : {}),
1512
+ };
1341
1513
  }
1342
1514
  finally {
1343
1515
  try {
@@ -1347,11 +1519,42 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1347
1519
  // best-effort cleanup
1348
1520
  }
1349
1521
  }
1522
+ // Notify the scheduler so the new recipe's cron/webhook trigger
1523
+ // starts firing without a bridge restart. The recipe file is
1524
+ // already on disk (`writeFileSync` above), so the next
1525
+ // `scheduler.start()` will pick it up via its directory scan.
1526
+ // Errors here are logged but never surface to the caller — the
1527
+ // install itself succeeded; a scheduler restart bug must not
1528
+ // make the response look failed.
1529
+ fireOnRecipesChanged(deps);
1350
1530
  res.writeHead(200, { "Content-Type": "application/json" });
1351
1531
  res.end(JSON.stringify({ ok: true, ...result }));
1352
1532
  }
1353
1533
  catch (err) {
1354
- // Truly unexpected installer crash, manifest validation throw, etc.
1534
+ // Distinguish "the recipe YAML is malformed" (user-actionable, 400)
1535
+ // from "the installer itself crashed" (server bug, 500). Before this
1536
+ // every parser error came back as the same opaque 500 — dashboards
1537
+ // surfaced "Internal server error" with no way to know what was wrong.
1538
+ const errName = err instanceof Error ? err.name : "";
1539
+ const errMsg = err instanceof Error ? err.message : String(err);
1540
+ const isParseError = errName === "RecipeParseError" ||
1541
+ // js-yaml / the `yaml` package both throw YAMLException / YAMLParseError.
1542
+ errName === "YAMLException" ||
1543
+ errName === "YAMLParseError" ||
1544
+ /yaml/i.test(errName);
1545
+ if (isParseError) {
1546
+ // Return only the first line of the parser message — strips any
1547
+ // embedded file path or stack frame that downstream parsers
1548
+ // sometimes include (CodeQL: js/stack-trace-exposure).
1549
+ const safeMsg = errMsg.split("\n", 1)[0]?.slice(0, 500) ?? "invalid recipe";
1550
+ res.writeHead(400, { "Content-Type": "application/json" });
1551
+ res.end(JSON.stringify({
1552
+ ok: false,
1553
+ error: safeMsg,
1554
+ code: "invalid_recipe",
1555
+ }));
1556
+ return;
1557
+ }
1355
1558
  console.error(`[recipes/install] internal install error:`, err);
1356
1559
  res.writeHead(500, { "Content-Type": "application/json" });
1357
1560
  res.end(JSON.stringify({