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.
- package/README.bridge.md +5 -5
- package/README.md +156 -12
- package/dist/activityLog.d.ts +6 -0
- package/dist/activityLog.js +8 -0
- package/dist/activityLog.js.map +1 -1
- package/dist/analyticsPrefs.d.ts +35 -2
- package/dist/analyticsPrefs.js +120 -21
- package/dist/analyticsPrefs.js.map +1 -1
- package/dist/analyticsSend.js +5 -1
- package/dist/analyticsSend.js.map +1 -1
- package/dist/bridge.d.ts +2 -0
- package/dist/bridge.js +111 -7
- package/dist/bridge.js.map +1 -1
- package/dist/bridgeLockDiscovery.d.ts +27 -1
- package/dist/bridgeLockDiscovery.js +37 -11
- package/dist/bridgeLockDiscovery.js.map +1 -1
- package/dist/commands/patchworkInit.d.ts +5 -0
- package/dist/commands/patchworkInit.js +86 -7
- package/dist/commands/patchworkInit.js.map +1 -1
- package/dist/commands/recipe.d.ts +51 -0
- package/dist/commands/recipe.js +353 -2
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipeInstall.js +6 -3
- package/dist/commands/recipeInstall.js.map +1 -1
- package/dist/commands/task.js +2 -2
- package/dist/commands/task.js.map +1 -1
- package/dist/config.d.ts +9 -2
- package/dist/config.js +35 -17
- package/dist/config.js.map +1 -1
- package/dist/connectors/tokenStorage.js +46 -10
- package/dist/connectors/tokenStorage.js.map +1 -1
- package/dist/featureFlags.d.ts +76 -0
- package/dist/featureFlags.js +166 -2
- package/dist/featureFlags.js.map +1 -1
- package/dist/index.js +765 -69
- package/dist/index.js.map +1 -1
- package/dist/lockfile.js +4 -1
- package/dist/lockfile.js.map +1 -1
- package/dist/patchworkConfig.js +5 -0
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/recipeOrchestration.js +35 -1
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +36 -0
- package/dist/recipeRoutes.js +231 -32
- package/dist/recipeRoutes.js.map +1 -1
- package/dist/recipes/agentExecutor.d.ts +25 -5
- package/dist/recipes/agentExecutor.js.map +1 -1
- package/dist/recipes/chainedRunner.js +16 -2
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/connectorPreflight.d.ts +53 -0
- package/dist/recipes/connectorPreflight.js +79 -0
- package/dist/recipes/connectorPreflight.js.map +1 -0
- package/dist/recipes/githubInstallSource.d.ts +62 -0
- package/dist/recipes/githubInstallSource.js +125 -0
- package/dist/recipes/githubInstallSource.js.map +1 -0
- package/dist/recipes/haltCategory.d.ts +80 -0
- package/dist/recipes/haltCategory.js +125 -0
- package/dist/recipes/haltCategory.js.map +1 -0
- package/dist/recipes/idempotencyKey.d.ts +126 -0
- package/dist/recipes/idempotencyKey.js +298 -0
- package/dist/recipes/idempotencyKey.js.map +1 -0
- package/dist/recipes/judgeSummary.d.ts +50 -0
- package/dist/recipes/judgeSummary.js +47 -0
- package/dist/recipes/judgeSummary.js.map +1 -0
- package/dist/recipes/judgeVerdict.d.ts +48 -0
- package/dist/recipes/judgeVerdict.js +174 -0
- package/dist/recipes/judgeVerdict.js.map +1 -0
- package/dist/recipes/migrations/index.d.ts +9 -0
- package/dist/recipes/migrations/index.js +133 -0
- package/dist/recipes/migrations/index.js.map +1 -1
- package/dist/recipes/runBudget.d.ts +70 -0
- package/dist/recipes/runBudget.js +109 -0
- package/dist/recipes/runBudget.js.map +1 -0
- package/dist/recipes/scheduler.js +1 -1
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schema.d.ts +30 -0
- package/dist/recipes/toolRegistry.js +19 -0
- package/dist/recipes/toolRegistry.js.map +1 -1
- package/dist/recipes/tools/http.d.ts +10 -0
- package/dist/recipes/tools/http.js +176 -0
- package/dist/recipes/tools/http.js.map +1 -0
- package/dist/recipes/tools/index.d.ts +1 -0
- package/dist/recipes/tools/index.js +1 -0
- package/dist/recipes/tools/index.js.map +1 -1
- package/dist/recipes/validation.js +1 -1
- package/dist/recipes/validation.js.map +1 -1
- package/dist/recipes/yamlRunner.d.ts +71 -7
- package/dist/recipes/yamlRunner.js +156 -22
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/runLog.d.ts +28 -0
- package/dist/runLog.js +5 -0
- package/dist/runLog.js.map +1 -1
- package/dist/server.d.ts +65 -0
- package/dist/server.js +302 -3
- package/dist/server.js.map +1 -1
- package/dist/streamableHttp.js +17 -6
- package/dist/streamableHttp.js.map +1 -1
- package/dist/tools/bridgeDoctor.js +6 -2
- package/dist/tools/bridgeDoctor.js.map +1 -1
- package/dist/tools/ccRoutines.d.ts +221 -0
- package/dist/tools/ccRoutines.js +264 -0
- package/dist/tools/ccRoutines.js.map +1 -0
- package/dist/tools/getCodeCoverage.js +7 -3
- package/dist/tools/getCodeCoverage.js.map +1 -1
- package/dist/tools/index.js +6 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/recentTracesDigest.js +56 -11
- package/dist/tools/recentTracesDigest.js.map +1 -1
- package/dist/tools/testRunners/vitestJest.js +3 -1
- package/dist/tools/testRunners/vitestJest.js.map +1 -1
- package/dist/tools/utils.js +6 -3
- package/dist/tools/utils.js.map +1 -1
- package/package.json +17 -6
- package/scripts/postinstall.mjs +27 -0
- package/scripts/smoke/run-all.mjs +162 -0
- package/scripts/start-all.mjs +513 -0
- package/scripts/start-all.ps1 +209 -0
- package/scripts/start-all.sh +73 -17
- package/scripts/start-orchestrator.ps1 +158 -0
- package/scripts/start-remote.mjs +122 -0
- package/templates/automation-policies/recipe-authoring.json +1 -1
- package/templates/automation-policies/security-first.json +1 -1
- package/templates/automation-policies/strict-lint.json +1 -1
- package/templates/automation-policies/test-driven.json +1 -1
- package/templates/automation-policy.example.json +1 -1
- package/templates/co.patchwork-os.bridge.plist +1 -1
- package/templates/recipes/approval-queue-ui-test.yaml +1 -1
- package/templates/recipes/ctx-loop-test.yaml +1 -1
- package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
- package/dist/commands/marketplace.d.ts +0 -16
- package/dist/commands/marketplace.js +0 -32
- package/dist/commands/marketplace.js.map +0 -1
- package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
- package/dist/recipes/legacyRecipeCompat.js +0 -131
- package/dist/recipes/legacyRecipeCompat.js.map +0 -1
package/dist/recipeRoutes.js
CHANGED
|
@@ -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 --
|
|
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 --
|
|
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 --
|
|
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
|
|
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.
|
|
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
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|
-
|
|
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(
|
|
1173
|
-
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
const {
|
|
1178
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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({
|