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.
- package/README.bridge.md +5 -5
- package/README.md +244 -30
- package/dist/activityLog.d.ts +6 -0
- package/dist/activityLog.js +10 -1
- 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/approvalHttp.js +25 -8
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalQueue.d.ts +44 -1
- package/dist/approvalQueue.js +117 -0
- package/dist/approvalQueue.js.map +1 -1
- package/dist/automation.d.ts +3 -3
- package/dist/automation.js +12 -5
- package/dist/automation.js.map +1 -1
- package/dist/bridge.d.ts +2 -0
- package/dist/bridge.js +140 -8
- package/dist/bridge.js.map +1 -1
- package/dist/bridgeLockDiscovery.d.ts +27 -1
- package/dist/bridgeLockDiscovery.js +38 -11
- package/dist/bridgeLockDiscovery.js.map +1 -1
- package/dist/claudeOrchestrator.js +27 -10
- package/dist/claudeOrchestrator.js.map +1 -1
- package/dist/commands/dashboard.js +8 -1
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/commands/install.js +3 -0
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/patchworkInit.d.ts +5 -0
- package/dist/commands/patchworkInit.js +89 -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/commitIssueLinkLog.d.ts +16 -0
- package/dist/commitIssueLinkLog.js +87 -4
- package/dist/commitIssueLinkLog.js.map +1 -1
- package/dist/config.d.ts +29 -3
- package/dist/config.js +77 -21
- package/dist/config.js.map +1 -1
- package/dist/connectorRoutes.js +1 -1
- package/dist/connectorRoutes.js.map +1 -1
- package/dist/connectors/asana.js +4 -3
- package/dist/connectors/asana.js.map +1 -1
- package/dist/connectors/confluence.js +35 -0
- package/dist/connectors/confluence.js.map +1 -1
- package/dist/connectors/datadog.js +33 -4
- package/dist/connectors/datadog.js.map +1 -1
- package/dist/connectors/discord.js +5 -4
- package/dist/connectors/discord.js.map +1 -1
- package/dist/connectors/gitlab.js +7 -1
- package/dist/connectors/gitlab.js.map +1 -1
- package/dist/connectors/mcpOAuth.js +71 -6
- package/dist/connectors/mcpOAuth.js.map +1 -1
- package/dist/connectors/slack.d.ts +1 -1
- package/dist/connectors/slack.js +56 -4
- package/dist/connectors/slack.js.map +1 -1
- package/dist/connectors/tokenStorage.js +56 -14
- package/dist/connectors/tokenStorage.js.map +1 -1
- package/dist/decisionTraceLog.d.ts +28 -0
- package/dist/decisionTraceLog.js +115 -7
- package/dist/decisionTraceLog.js.map +1 -1
- package/dist/drivers/claude/subprocess.js +22 -3
- package/dist/drivers/claude/subprocess.js.map +1 -1
- package/dist/drivers/gemini/index.js +19 -3
- package/dist/drivers/gemini/index.js.map +1 -1
- package/dist/extensionClient.d.ts +29 -4
- package/dist/extensionClient.js +26 -11
- package/dist/extensionClient.js.map +1 -1
- package/dist/featureFlags.d.ts +76 -0
- package/dist/featureFlags.js +153 -3
- package/dist/featureFlags.js.map +1 -1
- package/dist/fileLockSync.d.ts +67 -0
- package/dist/fileLockSync.js +126 -0
- package/dist/fileLockSync.js.map +1 -0
- package/dist/fp/automationInterpreter.d.ts +6 -0
- package/dist/fp/automationInterpreter.js +15 -2
- package/dist/fp/automationInterpreter.js.map +1 -1
- package/dist/fp/automationState.d.ts +1 -1
- package/dist/fp/automationState.js +10 -0
- package/dist/fp/automationState.js.map +1 -1
- package/dist/fp/commandDescription.js +7 -1
- package/dist/fp/commandDescription.js.map +1 -1
- package/dist/fsWatchWithFallback.d.ts +36 -0
- package/dist/fsWatchWithFallback.js +127 -0
- package/dist/fsWatchWithFallback.js.map +1 -0
- package/dist/index.js +797 -75
- package/dist/index.js.map +1 -1
- package/dist/installGuard.js +6 -2
- package/dist/installGuard.js.map +1 -1
- package/dist/lockfile.js +31 -4
- package/dist/lockfile.js.map +1 -1
- package/dist/patchworkConfig.js +13 -3
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/pluginLoader.js +10 -1
- package/dist/pluginLoader.js.map +1 -1
- package/dist/pluginWatcher.js +6 -13
- package/dist/pluginWatcher.js.map +1 -1
- package/dist/preToolUseHook.js +3 -2
- package/dist/preToolUseHook.js.map +1 -1
- package/dist/processTree.d.ts +34 -0
- package/dist/processTree.js +105 -0
- package/dist/processTree.js.map +1 -0
- package/dist/prompts.js +3 -3
- package/dist/prompts.js.map +1 -1
- package/dist/recipeOrchestration.js +35 -1
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +37 -0
- package/dist/recipeRoutes.js +236 -33
- 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 +143 -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 +297 -0
- package/dist/recipes/idempotencyKey.js.map +1 -0
- package/dist/recipes/installer.js +48 -2
- package/dist/recipes/installer.js.map +1 -1
- 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/parser.js +82 -4
- package/dist/recipes/parser.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.d.ts +17 -0
- package/dist/recipes/scheduler.js +34 -2
- 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 +75 -8
- package/dist/recipes/yamlRunner.js +174 -28
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/resources.js +21 -13
- package/dist/resources.js.map +1 -1
- package/dist/runLog.d.ts +28 -0
- package/dist/runLog.js +19 -3
- package/dist/runLog.js.map +1 -1
- package/dist/sanitizeParsedJson.d.ts +39 -0
- package/dist/sanitizeParsedJson.js +55 -0
- package/dist/sanitizeParsedJson.js.map +1 -0
- package/dist/server.d.ts +79 -0
- package/dist/server.js +356 -3
- package/dist/server.js.map +1 -1
- package/dist/sessionCheckpoint.d.ts +8 -0
- package/dist/sessionCheckpoint.js +18 -2
- package/dist/sessionCheckpoint.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/detectUnusedCode.js +9 -7
- package/dist/tools/detectUnusedCode.js.map +1 -1
- package/dist/tools/editText.js +2 -1
- package/dist/tools/editText.js.map +1 -1
- package/dist/tools/fileOperations.js +2 -1
- package/dist/tools/fileOperations.js.map +1 -1
- package/dist/tools/fileWatcher.js +8 -2
- package/dist/tools/fileWatcher.js.map +1 -1
- package/dist/tools/fixAllLintErrors.js +10 -5
- package/dist/tools/fixAllLintErrors.js.map +1 -1
- package/dist/tools/formatDocument.js +10 -5
- package/dist/tools/formatDocument.js.map +1 -1
- package/dist/tools/getCodeCoverage.js +7 -3
- package/dist/tools/getCodeCoverage.js.map +1 -1
- package/dist/tools/handoffNote.js +2 -1
- package/dist/tools/handoffNote.js.map +1 -1
- package/dist/tools/headless/lspClient.js +3 -0
- package/dist/tools/headless/lspClient.js.map +1 -1
- package/dist/tools/lsp.js +17 -0
- package/dist/tools/lsp.js.map +1 -1
- package/dist/tools/openDiff.js +4 -1
- package/dist/tools/openDiff.js.map +1 -1
- package/dist/tools/openFile.js +4 -1
- package/dist/tools/openFile.js.map +1 -1
- package/dist/tools/organizeImports.js +5 -3
- package/dist/tools/organizeImports.js.map +1 -1
- package/dist/tools/previewEdit.js +7 -2
- package/dist/tools/previewEdit.js.map +1 -1
- package/dist/tools/recentTracesDigest.js +56 -11
- package/dist/tools/recentTracesDigest.js.map +1 -1
- package/dist/tools/refactorExtractFunction.js +4 -1
- package/dist/tools/refactorExtractFunction.js.map +1 -1
- package/dist/tools/refactorPreview.js +10 -2
- package/dist/tools/refactorPreview.js.map +1 -1
- package/dist/tools/replaceBlock.js +2 -1
- package/dist/tools/replaceBlock.js.map +1 -1
- package/dist/tools/searchAndReplace.js +2 -1
- package/dist/tools/searchAndReplace.js.map +1 -1
- package/dist/tools/spawnWorkspace.js +15 -7
- package/dist/tools/spawnWorkspace.js.map +1 -1
- package/dist/tools/testRunners/vitestJest.js +3 -1
- package/dist/tools/testRunners/vitestJest.js.map +1 -1
- package/dist/tools/transaction.js +4 -1
- package/dist/tools/transaction.js.map +1 -1
- package/dist/tools/utils.js +68 -8
- package/dist/tools/utils.js.map +1 -1
- package/dist/transport.d.ts +1 -1
- package/dist/transport.js +18 -4
- package/dist/transport.js.map +1 -1
- package/dist/winShim.d.ts +34 -0
- package/dist/winShim.js +94 -0
- package/dist/winShim.js.map +1 -0
- package/dist/writeFileAtomic.d.ts +23 -0
- package/dist/writeFileAtomic.js +94 -0
- package/dist/writeFileAtomic.js.map +1 -0
- package/package.json +17 -6
- package/scripts/postinstall.mjs +42 -2
- package/scripts/smoke/run-all.mjs +213 -0
- package/scripts/start-all.mjs +572 -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
|
@@ -188,7 +188,11 @@ export function readBodyWithCap(req, max) {
|
|
|
188
188
|
const onEnd = () => {
|
|
189
189
|
if (aborted)
|
|
190
190
|
return;
|
|
191
|
-
|
|
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 --
|
|
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 --
|
|
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 --
|
|
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
|
|
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.
|
|
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
|
|
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`;
|
|
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
|
-
|
|
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(
|
|
1173
|
-
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
const {
|
|
1178
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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({
|