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

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 (135) hide show
  1. package/README.bridge.md +5 -5
  2. package/README.md +156 -12
  3. package/dist/activityLog.d.ts +6 -0
  4. package/dist/activityLog.js +8 -0
  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/bridge.d.ts +2 -0
  12. package/dist/bridge.js +111 -7
  13. package/dist/bridge.js.map +1 -1
  14. package/dist/bridgeLockDiscovery.d.ts +27 -1
  15. package/dist/bridgeLockDiscovery.js +37 -11
  16. package/dist/bridgeLockDiscovery.js.map +1 -1
  17. package/dist/commands/patchworkInit.d.ts +5 -0
  18. package/dist/commands/patchworkInit.js +86 -7
  19. package/dist/commands/patchworkInit.js.map +1 -1
  20. package/dist/commands/recipe.d.ts +51 -0
  21. package/dist/commands/recipe.js +353 -2
  22. package/dist/commands/recipe.js.map +1 -1
  23. package/dist/commands/recipeInstall.js +6 -3
  24. package/dist/commands/recipeInstall.js.map +1 -1
  25. package/dist/commands/task.js +2 -2
  26. package/dist/commands/task.js.map +1 -1
  27. package/dist/config.d.ts +9 -2
  28. package/dist/config.js +35 -17
  29. package/dist/config.js.map +1 -1
  30. package/dist/connectors/tokenStorage.js +46 -10
  31. package/dist/connectors/tokenStorage.js.map +1 -1
  32. package/dist/featureFlags.d.ts +76 -0
  33. package/dist/featureFlags.js +166 -2
  34. package/dist/featureFlags.js.map +1 -1
  35. package/dist/index.js +765 -69
  36. package/dist/index.js.map +1 -1
  37. package/dist/lockfile.js +4 -1
  38. package/dist/lockfile.js.map +1 -1
  39. package/dist/patchworkConfig.js +5 -0
  40. package/dist/patchworkConfig.js.map +1 -1
  41. package/dist/recipeOrchestration.js +35 -1
  42. package/dist/recipeOrchestration.js.map +1 -1
  43. package/dist/recipeRoutes.d.ts +36 -0
  44. package/dist/recipeRoutes.js +231 -32
  45. package/dist/recipeRoutes.js.map +1 -1
  46. package/dist/recipes/agentExecutor.d.ts +25 -5
  47. package/dist/recipes/agentExecutor.js.map +1 -1
  48. package/dist/recipes/chainedRunner.js +16 -2
  49. package/dist/recipes/chainedRunner.js.map +1 -1
  50. package/dist/recipes/connectorPreflight.d.ts +53 -0
  51. package/dist/recipes/connectorPreflight.js +79 -0
  52. package/dist/recipes/connectorPreflight.js.map +1 -0
  53. package/dist/recipes/githubInstallSource.d.ts +62 -0
  54. package/dist/recipes/githubInstallSource.js +125 -0
  55. package/dist/recipes/githubInstallSource.js.map +1 -0
  56. package/dist/recipes/haltCategory.d.ts +80 -0
  57. package/dist/recipes/haltCategory.js +125 -0
  58. package/dist/recipes/haltCategory.js.map +1 -0
  59. package/dist/recipes/idempotencyKey.d.ts +126 -0
  60. package/dist/recipes/idempotencyKey.js +298 -0
  61. package/dist/recipes/idempotencyKey.js.map +1 -0
  62. package/dist/recipes/judgeSummary.d.ts +50 -0
  63. package/dist/recipes/judgeSummary.js +47 -0
  64. package/dist/recipes/judgeSummary.js.map +1 -0
  65. package/dist/recipes/judgeVerdict.d.ts +48 -0
  66. package/dist/recipes/judgeVerdict.js +174 -0
  67. package/dist/recipes/judgeVerdict.js.map +1 -0
  68. package/dist/recipes/migrations/index.d.ts +9 -0
  69. package/dist/recipes/migrations/index.js +133 -0
  70. package/dist/recipes/migrations/index.js.map +1 -1
  71. package/dist/recipes/runBudget.d.ts +70 -0
  72. package/dist/recipes/runBudget.js +109 -0
  73. package/dist/recipes/runBudget.js.map +1 -0
  74. package/dist/recipes/scheduler.js +1 -1
  75. package/dist/recipes/scheduler.js.map +1 -1
  76. package/dist/recipes/schema.d.ts +30 -0
  77. package/dist/recipes/toolRegistry.js +19 -0
  78. package/dist/recipes/toolRegistry.js.map +1 -1
  79. package/dist/recipes/tools/http.d.ts +10 -0
  80. package/dist/recipes/tools/http.js +176 -0
  81. package/dist/recipes/tools/http.js.map +1 -0
  82. package/dist/recipes/tools/index.d.ts +1 -0
  83. package/dist/recipes/tools/index.js +1 -0
  84. package/dist/recipes/tools/index.js.map +1 -1
  85. package/dist/recipes/validation.js +1 -1
  86. package/dist/recipes/validation.js.map +1 -1
  87. package/dist/recipes/yamlRunner.d.ts +71 -7
  88. package/dist/recipes/yamlRunner.js +156 -22
  89. package/dist/recipes/yamlRunner.js.map +1 -1
  90. package/dist/runLog.d.ts +28 -0
  91. package/dist/runLog.js +5 -0
  92. package/dist/runLog.js.map +1 -1
  93. package/dist/server.d.ts +65 -0
  94. package/dist/server.js +302 -3
  95. package/dist/server.js.map +1 -1
  96. package/dist/streamableHttp.js +17 -6
  97. package/dist/streamableHttp.js.map +1 -1
  98. package/dist/tools/bridgeDoctor.js +6 -2
  99. package/dist/tools/bridgeDoctor.js.map +1 -1
  100. package/dist/tools/ccRoutines.d.ts +221 -0
  101. package/dist/tools/ccRoutines.js +264 -0
  102. package/dist/tools/ccRoutines.js.map +1 -0
  103. package/dist/tools/getCodeCoverage.js +7 -3
  104. package/dist/tools/getCodeCoverage.js.map +1 -1
  105. package/dist/tools/index.js +6 -0
  106. package/dist/tools/index.js.map +1 -1
  107. package/dist/tools/recentTracesDigest.js +56 -11
  108. package/dist/tools/recentTracesDigest.js.map +1 -1
  109. package/dist/tools/testRunners/vitestJest.js +3 -1
  110. package/dist/tools/testRunners/vitestJest.js.map +1 -1
  111. package/dist/tools/utils.js +6 -3
  112. package/dist/tools/utils.js.map +1 -1
  113. package/package.json +17 -6
  114. package/scripts/postinstall.mjs +27 -0
  115. package/scripts/smoke/run-all.mjs +162 -0
  116. package/scripts/start-all.mjs +513 -0
  117. package/scripts/start-all.ps1 +209 -0
  118. package/scripts/start-all.sh +73 -17
  119. package/scripts/start-orchestrator.ps1 +158 -0
  120. package/scripts/start-remote.mjs +122 -0
  121. package/templates/automation-policies/recipe-authoring.json +1 -1
  122. package/templates/automation-policies/security-first.json +1 -1
  123. package/templates/automation-policies/strict-lint.json +1 -1
  124. package/templates/automation-policies/test-driven.json +1 -1
  125. package/templates/automation-policy.example.json +1 -1
  126. package/templates/co.patchwork-os.bridge.plist +1 -1
  127. package/templates/recipes/approval-queue-ui-test.yaml +1 -1
  128. package/templates/recipes/ctx-loop-test.yaml +1 -1
  129. package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
  130. package/dist/commands/marketplace.d.ts +0 -16
  131. package/dist/commands/marketplace.js +0 -32
  132. package/dist/commands/marketplace.js.map +0 -1
  133. package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
  134. package/dist/recipes/legacyRecipeCompat.js +0 -131
  135. package/dist/recipes/legacyRecipeCompat.js.map +0 -1
@@ -254,6 +254,24 @@ function respondInvalidJson(res) {
254
254
  res.writeHead(400, { "Content-Type": "application/json" });
255
255
  res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
256
256
  }
257
+ /**
258
+ * Best-effort fire of the recipe-changed notification. Wraps the
259
+ * callback in try/catch + console.error so a misbehaving notifier
260
+ * (most likely scheduler.start() throwing) cannot turn a successful
261
+ * disk-write into a failed-looking HTTP response. Used by install /
262
+ * save / delete / archive / duplicate / setEnabled / saveContent
263
+ * routes after their respective success paths.
264
+ */
265
+ function fireOnRecipesChanged(deps) {
266
+ if (!deps.onRecipesChangedFn)
267
+ return;
268
+ try {
269
+ deps.onRecipesChangedFn();
270
+ }
271
+ catch (err) {
272
+ console.error(`[recipeRoutes] onRecipesChangedFn threw:`, err);
273
+ }
274
+ }
257
275
  /**
258
276
  * Try to handle a recipe / run-audit / template route. Returns true if
259
277
  * the route was dispatched (caller should `return` from the request
@@ -295,7 +313,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
295
313
  res.writeHead(503, { "Content-Type": "application/json" });
296
314
  res.end(JSON.stringify({
297
315
  ok: false,
298
- error: "Recipe execution unavailable — requires --claude-driver subprocess",
316
+ error: "Recipe execution unavailable — requires --driver subprocess",
299
317
  }));
300
318
  return;
301
319
  }
@@ -347,7 +365,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
347
365
  res.writeHead(503, { "Content-Type": "application/json" });
348
366
  res.end(JSON.stringify({
349
367
  ok: false,
350
- error: "Recipe execution unavailable — requires --claude-driver subprocess",
368
+ error: "Recipe execution unavailable — requires --driver subprocess",
351
369
  }));
352
370
  return;
353
371
  }
@@ -383,6 +401,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
383
401
  const trigger = sp.get("trigger");
384
402
  const status = sp.get("status");
385
403
  const recipe = sp.get("recipe");
404
+ const manualRunId = sp.get("manualRunId");
386
405
  const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
387
406
  const after = afterRaw ? Number.parseInt(afterRaw, 10) : Number.NaN;
388
407
  const runs = deps.runsFn?.({
@@ -390,6 +409,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
390
409
  ...(trigger && { trigger }),
391
410
  ...(status && { status }),
392
411
  ...(recipe && { recipe }),
412
+ ...(manualRunId && { manualRunId }),
393
413
  ...(Number.isFinite(after) && { after }),
394
414
  }) ?? [];
395
415
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -400,6 +420,53 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
400
420
  }
401
421
  return true;
402
422
  }
423
+ // GET /runs/halt-summary — aggregated halt categories over recent runs.
424
+ // Drives the dashboard /runs page header widget that answers "is the
425
+ // haltReason work surfacing real signal, or is everything 'unknown'?".
426
+ if (parsedUrl.pathname === "/runs/halt-summary" && req.method === "GET") {
427
+ try {
428
+ const sp = parsedUrl.searchParams;
429
+ const sinceMsRaw = sp.get("sinceMs");
430
+ const limitRaw = sp.get("limit");
431
+ const recipe = sp.get("recipe");
432
+ const sinceMs = sinceMsRaw ? Number.parseInt(sinceMsRaw, 10) : Number.NaN;
433
+ const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
434
+ const summary = deps.haltSummaryFn?.({
435
+ ...(Number.isFinite(sinceMs) && { sinceMs }),
436
+ ...(Number.isFinite(limit) && { limit }),
437
+ ...(recipe && { recipe }),
438
+ }) ?? { total: 0, byCategory: {}, recent: [] };
439
+ res.writeHead(200, { "Content-Type": "application/json" });
440
+ res.end(JSON.stringify(summary));
441
+ }
442
+ catch (err) {
443
+ respond500(res, err);
444
+ }
445
+ return true;
446
+ }
447
+ // GET /runs/judge-summary — PR3b sibling of /runs/halt-summary.
448
+ // Same query shape (sinceMs, limit, recipe), returns JudgeSummary.
449
+ if (parsedUrl.pathname === "/runs/judge-summary" && req.method === "GET") {
450
+ try {
451
+ const sp = parsedUrl.searchParams;
452
+ const sinceMsRaw = sp.get("sinceMs");
453
+ const limitRaw = sp.get("limit");
454
+ const recipe = sp.get("recipe");
455
+ const sinceMs = sinceMsRaw ? Number.parseInt(sinceMsRaw, 10) : Number.NaN;
456
+ const limit = limitRaw ? Number.parseInt(limitRaw, 10) : Number.NaN;
457
+ const summary = deps.judgeSummaryFn?.({
458
+ ...(Number.isFinite(sinceMs) && { sinceMs }),
459
+ ...(Number.isFinite(limit) && { limit }),
460
+ ...(recipe && { recipe }),
461
+ }) ?? { total: 0, byVerdict: {}, recent: [] };
462
+ res.writeHead(200, { "Content-Type": "application/json" });
463
+ res.end(JSON.stringify(summary));
464
+ }
465
+ catch (err) {
466
+ respond500(res, err);
467
+ }
468
+ return true;
469
+ }
403
470
  // GET /runs/:seq — single run detail (includes stepResults if present)
404
471
  const runDetailMatch = req.method === "GET" ? /^\/runs\/(\d+)$/.exec(parsedUrl.pathname) : null;
405
472
  if (runDetailMatch?.[1]) {
@@ -540,7 +607,7 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
540
607
  res.writeHead(503, { "Content-Type": "application/json" });
541
608
  res.end(JSON.stringify({
542
609
  ok: false,
543
- error: "Recipe generation unavailable — requires --claude-driver subprocess",
610
+ error: "Recipe generation unavailable — requires --driver subprocess",
544
611
  unavailable: true,
545
612
  }));
546
613
  return;
@@ -591,6 +658,8 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
591
658
  return;
592
659
  }
593
660
  const result = deps.saveRecipeFn(draft);
661
+ if (result.ok)
662
+ fireOnRecipesChanged(deps);
594
663
  res.writeHead(result.ok ? 201 : 400, {
595
664
  "Content-Type": "application/json",
596
665
  });
@@ -670,6 +739,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
670
739
  return;
671
740
  }
672
741
  const result = deps.setRecipeEnabledFn(name, body.enabled);
742
+ // Enable/disable changes which cron triggers should fire — the
743
+ // RecipeScheduler honours the disabled set on every start(), so
744
+ // re-priming after a toggle picks up the change without a restart.
745
+ if (result.ok)
746
+ fireOnRecipesChanged(deps);
673
747
  res.writeHead(result.ok ? 200 : 400, {
674
748
  "Content-Type": "application/json",
675
749
  });
@@ -806,6 +880,11 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
806
880
  return;
807
881
  }
808
882
  const result = deps.saveRecipeContentFn(name, body.content);
883
+ // Editing recipe YAML can change cron schedule, webhook path,
884
+ // or trigger type entirely — re-prime the scheduler so the new
885
+ // shape takes effect without a bridge restart.
886
+ if (result.ok)
887
+ fireOnRecipesChanged(deps);
809
888
  res.writeHead(result.ok ? 200 : 400, {
810
889
  "Content-Type": "application/json",
811
890
  });
@@ -828,6 +907,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
828
907
  return true;
829
908
  }
830
909
  const result = deps.deleteRecipeContentFn(name);
910
+ // Deleting a cron recipe leaves an orphaned interval in the scheduler
911
+ // until the next start() — re-prime so it goes away.
912
+ if (result.ok)
913
+ fireOnRecipesChanged(deps);
831
914
  const status = result.ok
832
915
  ? 200
833
916
  : result.error === "Recipe not found"
@@ -847,6 +930,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
847
930
  return true;
848
931
  }
849
932
  const result = deps.archiveRecipeFn(name);
933
+ // Archiving moves the recipe under .archive/ where the scheduler
934
+ // ignores it — same orphan-interval cleanup needed as for delete.
935
+ if (result.ok)
936
+ fireOnRecipesChanged(deps);
850
937
  const status = result.ok
851
938
  ? 200
852
939
  : result.error === "Recipe not found"
@@ -866,6 +953,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
866
953
  return true;
867
954
  }
868
955
  const result = deps.duplicateRecipeFn(name);
956
+ // Duplication adds a new recipe file to the dir — re-prime so any
957
+ // cron trigger inside the duplicate starts firing immediately.
958
+ if (result.ok)
959
+ fireOnRecipesChanged(deps);
869
960
  const status = result.ok
870
961
  ? 201
871
962
  : result.error === "Recipe not found"
@@ -901,6 +992,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
901
992
  const result = await deps.promoteRecipeVariantFn(variantName, targetName, {
902
993
  force: force === true,
903
994
  });
995
+ // Promotion overwrites the canonical file with the variant's
996
+ // contents — same scheduler refresh story as save/edit.
997
+ if (result.ok)
998
+ fireOnRecipesChanged(deps);
904
999
  const httpStatus = result.ok
905
1000
  ? 200
906
1001
  : result.targetExists
@@ -993,27 +1088,30 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
993
1088
  // -----------------------------------------------------------------
994
1089
  // BUNDLE INSTALL DISPATCH (#130 PR A).
995
1090
  //
996
- // `github:patchworkos/recipes/bundles/<name>` installs every recipe
1091
+ // `github:<owner>/<repo>/bundles/<name>` installs every recipe
997
1092
  // listed in the bundle's `patchwork-bundle.json`. Plugin (`plugin`)
998
1093
  // and policy template (`policy_template`) declared in the manifest
999
1094
  // are surfaced as advisory-only — wiring those needs separate
1000
1095
  // decisions (npm-install surface, policy application UX) tracked
1001
- // outside this PR. See the #130 scoping comment.
1096
+ // outside this PR.
1097
+ //
1098
+ // Org allowlist (#audit-thread): historically the path was hard-
1099
+ // coded to `patchworkos/recipes`. Now any allowlisted org/repo
1100
+ // can host a bundle; parse + validate via the shared helper so
1101
+ // single-recipe and bundle install share one source-of-truth.
1002
1102
  // -----------------------------------------------------------------
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`;
1103
+ const bundleParse = source.startsWith("github:")
1104
+ ? await (async () => {
1105
+ const { parseGithubInstallSource } = await import("./recipes/githubInstallSource.js");
1106
+ return parseGithubInstallSource(source);
1107
+ })()
1108
+ : null;
1109
+ if (bundleParse?.ok && bundleParse.parsed.kind === "bundle") {
1110
+ const { buildGithubRawUrl } = await import("./recipes/githubInstallSource.js");
1111
+ const bundleName = bundleParse.parsed.name;
1112
+ const bundleOwner = bundleParse.parsed.owner;
1113
+ const bundleRepo = bundleParse.parsed.repo;
1114
+ const manifestUrl = buildGithubRawUrl(bundleParse.parsed);
1017
1115
  const ctl = new AbortController();
1018
1116
  const timeout = setTimeout(() => ctl.abort(), 30_000);
1019
1117
  let manifestRes;
@@ -1072,6 +1170,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1072
1170
  }));
1073
1171
  return;
1074
1172
  }
1173
+ // Validate each declared recipe basename to block traversal +
1174
+ // junk segments. `isSafeBasename` lives in the legacy recipe-
1175
+ // install command but the predicate is the right shape here.
1176
+ const { isSafeBasename } = await import("./commands/recipeInstall.js");
1075
1177
  if (!Array.isArray(manifest.recipes) ||
1076
1178
  manifest.recipes.length === 0 ||
1077
1179
  !manifest.recipes.every((r) => typeof r === "string" && isSafeBasename(r))) {
@@ -1093,7 +1195,10 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1093
1195
  const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
1094
1196
  mkdirSync(recipesDir, { recursive: true });
1095
1197
  for (const r of manifest.recipes) {
1096
- const recipeUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/recipes/${r}/${r}.yaml`;
1198
+ // Bundle's manifest is allowed to declare recipes that
1199
+ // live in the same repo as the bundle. Build the URL with
1200
+ // the parsed owner/repo, not the hard-coded original.
1201
+ const recipeUrl = `https://raw.githubusercontent.com/${bundleOwner}/${bundleRepo}/main/recipes/${r}/${r}.yaml`;
1097
1202
  const recipeCtl = new AbortController();
1098
1203
  const recipeTimeout = setTimeout(() => recipeCtl.abort(), 30_000);
1099
1204
  try {
@@ -1155,6 +1260,14 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1155
1260
  // 200 if any recipe installed; 502 otherwise. Always include both
1156
1261
  // arrays so callers (CLI + dashboard) can render partial-success.
1157
1262
  const status = installed.length > 0 ? 200 : 502;
1263
+ // Notify the scheduler so cron-trigger recipes in the bundle
1264
+ // start firing without a bridge restart. Fired once per bundle
1265
+ // (not per recipe inside) since scheduler.start() reads the
1266
+ // whole recipes dir anyway. Guarded by the partial-success
1267
+ // check — no point waking up the scheduler for a 0-installed
1268
+ // failure.
1269
+ if (installed.length > 0)
1270
+ fireOnRecipesChanged(deps);
1158
1271
  res.writeHead(status, { "Content-Type": "application/json" });
1159
1272
  res.end(JSON.stringify({
1160
1273
  ok: installed.length > 0,
@@ -1166,24 +1279,40 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1166
1279
  }));
1167
1280
  return;
1168
1281
  }
1169
- const githubPrefix = "github:patchworkos/recipes/recipes/";
1170
1282
  let fetchUrl;
1171
1283
  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)) {
1284
+ if (source.startsWith("github:")) {
1285
+ // Parse the new generalised shape (any allowlisted org/repo)
1286
+ // instead of only `github:patchworkos/recipes/recipes/<name>`.
1287
+ // Distinguishes bad shape (400) from not-on-allowlist (403)
1288
+ // so operators can spot a config error vs. a typo.
1289
+ const { parseGithubInstallSource, buildGithubRawUrl } = await import("./recipes/githubInstallSource.js");
1290
+ const parsed = parseGithubInstallSource(source);
1291
+ if (!parsed.ok) {
1292
+ const status = parsed.code === "not_allowlisted" ? 403 : 400;
1293
+ res.writeHead(status, { "Content-Type": "application/json" });
1294
+ res.end(JSON.stringify({
1295
+ ok: false,
1296
+ error: parsed.error,
1297
+ code: parsed.code,
1298
+ }));
1299
+ return;
1300
+ }
1301
+ // Bundle shape on /recipes/install (single-recipe path) is a
1302
+ // mistake — surface it explicitly rather than silently fetching
1303
+ // an unrelated URL. Bundle installs have their own code path
1304
+ // above this block.
1305
+ if (parsed.parsed.kind === "bundle") {
1179
1306
  res.writeHead(400, { "Content-Type": "application/json" });
1180
1307
  res.end(JSON.stringify({
1181
1308
  ok: false,
1182
- error: "Invalid recipe name in source",
1309
+ error: "Bundle source on single-recipe install. Use the bundle install path.",
1310
+ code: "bad_shape",
1183
1311
  }));
1184
1312
  return;
1185
1313
  }
1186
- fetchUrl = `https://raw.githubusercontent.com/patchworkos/recipes/main/recipes/${recipeName}/${recipeName}.yaml`;
1314
+ recipeName = parsed.parsed.name;
1315
+ fetchUrl = buildGithubRawUrl(parsed.parsed);
1187
1316
  }
1188
1317
  else if (source.startsWith("https://")) {
1189
1318
  // Non-github source: must clear the env-var allowlist AND the SSRF
@@ -1337,7 +1466,46 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1337
1466
  const installResult = installRecipeFromFile(tmpFile, {
1338
1467
  recipesDir,
1339
1468
  });
1340
- result = { action: installResult.action, name: recipeName };
1469
+ // Soft preflight: detect which connectors the recipe uses
1470
+ // and surface the unconfigured ones as a warning. The recipe
1471
+ // is already on disk — this is a hint for the dashboard to
1472
+ // prompt "you'll need to connect Slack + Gmail to run this",
1473
+ // not a gate on the install itself. Defensive: any failure
1474
+ // here MUST NOT roll the install back, so the whole block is
1475
+ // wrapped in try/catch.
1476
+ let missingConnectors;
1477
+ try {
1478
+ const { readFileSync } = await import("node:fs");
1479
+ const installedJson = readFileSync(installResult.installedPath, "utf-8");
1480
+ const recipe = JSON.parse(installedJson);
1481
+ if (Array.isArray(recipe.steps)) {
1482
+ const { detectRequiredConnectors, findMissingConnectors } = await import("./recipes/connectorPreflight.js");
1483
+ const required = detectRequiredConnectors(recipe);
1484
+ if (required.length > 0) {
1485
+ const { handleConnectionsList } = await import("./connectors/gmail.js");
1486
+ const connsResult = await handleConnectionsList();
1487
+ let connections = [];
1488
+ try {
1489
+ const body = JSON.parse(connsResult.body);
1490
+ connections = body.connectors ?? [];
1491
+ }
1492
+ catch {
1493
+ /* malformed body — treat as no connections */
1494
+ }
1495
+ const missing = findMissingConnectors(required, connections);
1496
+ if (missing.length > 0)
1497
+ missingConnectors = missing;
1498
+ }
1499
+ }
1500
+ }
1501
+ catch (preflightErr) {
1502
+ console.warn(`[recipes/install] connector preflight failed (non-blocking):`, preflightErr);
1503
+ }
1504
+ result = {
1505
+ action: installResult.action,
1506
+ name: recipeName,
1507
+ ...(missingConnectors ? { missingConnectors } : {}),
1508
+ };
1341
1509
  }
1342
1510
  finally {
1343
1511
  try {
@@ -1347,11 +1515,42 @@ export function tryHandleRecipeRoute(req, res, parsedUrl, deps) {
1347
1515
  // best-effort cleanup
1348
1516
  }
1349
1517
  }
1518
+ // Notify the scheduler so the new recipe's cron/webhook trigger
1519
+ // starts firing without a bridge restart. The recipe file is
1520
+ // already on disk (`writeFileSync` above), so the next
1521
+ // `scheduler.start()` will pick it up via its directory scan.
1522
+ // Errors here are logged but never surface to the caller — the
1523
+ // install itself succeeded; a scheduler restart bug must not
1524
+ // make the response look failed.
1525
+ fireOnRecipesChanged(deps);
1350
1526
  res.writeHead(200, { "Content-Type": "application/json" });
1351
1527
  res.end(JSON.stringify({ ok: true, ...result }));
1352
1528
  }
1353
1529
  catch (err) {
1354
- // Truly unexpected installer crash, manifest validation throw, etc.
1530
+ // Distinguish "the recipe YAML is malformed" (user-actionable, 400)
1531
+ // from "the installer itself crashed" (server bug, 500). Before this
1532
+ // every parser error came back as the same opaque 500 — dashboards
1533
+ // surfaced "Internal server error" with no way to know what was wrong.
1534
+ const errName = err instanceof Error ? err.name : "";
1535
+ const errMsg = err instanceof Error ? err.message : String(err);
1536
+ const isParseError = errName === "RecipeParseError" ||
1537
+ // js-yaml / the `yaml` package both throw YAMLException / YAMLParseError.
1538
+ errName === "YAMLException" ||
1539
+ errName === "YAMLParseError" ||
1540
+ /yaml/i.test(errName);
1541
+ if (isParseError) {
1542
+ // Return only the first line of the parser message — strips any
1543
+ // embedded file path or stack frame that downstream parsers
1544
+ // sometimes include (CodeQL: js/stack-trace-exposure).
1545
+ const safeMsg = errMsg.split("\n", 1)[0]?.slice(0, 500) ?? "invalid recipe";
1546
+ res.writeHead(400, { "Content-Type": "application/json" });
1547
+ res.end(JSON.stringify({
1548
+ ok: false,
1549
+ error: safeMsg,
1550
+ code: "invalid_recipe",
1551
+ }));
1552
+ return;
1553
+ }
1355
1554
  console.error(`[recipes/install] internal install error:`, err);
1356
1555
  res.writeHead(500, { "Content-Type": "application/json" });
1357
1556
  res.end(JSON.stringify({