patchwork-os 0.2.0-alpha.34 → 0.2.0-alpha.36
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.md +202 -93
- package/deploy/bootstrap-new-vps.sh +12 -12
- package/deploy/bootstrap-vps.sh +6 -3
- package/deploy/deploy-landing.sh +59 -2
- package/dist/activityLog.d.ts +49 -0
- package/dist/activityLog.js +78 -0
- package/dist/activityLog.js.map +1 -1
- package/dist/approvalHttp.d.ts +25 -0
- package/dist/approvalHttp.js +74 -18
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalInsights.d.ts +49 -0
- package/dist/approvalInsights.js +97 -0
- package/dist/approvalInsights.js.map +1 -0
- package/dist/approvalQueue.d.ts +11 -0
- package/dist/approvalQueue.js +80 -1
- package/dist/approvalQueue.js.map +1 -1
- package/dist/approvalSignals.d.ts +124 -0
- package/dist/approvalSignals.js +512 -0
- package/dist/approvalSignals.js.map +1 -0
- package/dist/automation.d.ts +37 -0
- package/dist/automation.js +105 -61
- package/dist/automation.js.map +1 -1
- package/dist/automationSuggestions.d.ts +79 -0
- package/dist/automationSuggestions.js +150 -0
- package/dist/automationSuggestions.js.map +1 -0
- package/dist/bridge.js +78 -1
- package/dist/bridge.js.map +1 -1
- package/dist/ccPermissions.d.ts +15 -0
- package/dist/ccPermissions.js +15 -0
- package/dist/ccPermissions.js.map +1 -1
- package/dist/claudeDriver.js +74 -16
- package/dist/claudeDriver.js.map +1 -1
- package/dist/commands/patchworkInit.d.ts +8 -0
- package/dist/commands/patchworkInit.js +41 -5
- package/dist/commands/patchworkInit.js.map +1 -1
- package/dist/commands/recipe.d.ts +20 -0
- package/dist/commands/recipe.js +212 -6
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipeInstall.d.ts +79 -1
- package/dist/commands/recipeInstall.js +333 -16
- package/dist/commands/recipeInstall.js.map +1 -1
- package/dist/commands/tracesExport.d.ts +83 -0
- package/dist/commands/tracesExport.js +269 -0
- package/dist/commands/tracesExport.js.map +1 -0
- package/dist/commands/tracesImport.d.ts +56 -0
- package/dist/commands/tracesImport.js +161 -0
- package/dist/commands/tracesImport.js.map +1 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +9 -1
- package/dist/config.js.map +1 -1
- package/dist/connectorRoutes.d.ts +43 -0
- package/dist/connectorRoutes.js +1023 -0
- package/dist/connectorRoutes.js.map +1 -0
- package/dist/connectors/asana.d.ts +198 -0
- package/dist/connectors/asana.js +679 -0
- package/dist/connectors/asana.js.map +1 -0
- package/dist/connectors/baseConnector.d.ts +36 -0
- package/dist/connectors/baseConnector.js +151 -28
- package/dist/connectors/baseConnector.js.map +1 -1
- package/dist/connectors/discord.d.ts +150 -0
- package/dist/connectors/discord.js +543 -0
- package/dist/connectors/discord.js.map +1 -0
- package/dist/connectors/github.js +11 -4
- package/dist/connectors/github.js.map +1 -1
- package/dist/connectors/gitlab.d.ts +180 -0
- package/dist/connectors/gitlab.js +582 -0
- package/dist/connectors/gitlab.js.map +1 -0
- package/dist/connectors/gmail.js +50 -10
- package/dist/connectors/gmail.js.map +1 -1
- package/dist/connectors/googleCalendar.js +36 -10
- package/dist/connectors/googleCalendar.js.map +1 -1
- package/dist/connectors/googleDrive.d.ts +34 -0
- package/dist/connectors/googleDrive.js +321 -0
- package/dist/connectors/googleDrive.js.map +1 -0
- package/dist/connectors/linear.js +23 -4
- package/dist/connectors/linear.js.map +1 -1
- package/dist/connectors/mcpOAuth.js +26 -2
- package/dist/connectors/mcpOAuth.js.map +1 -1
- package/dist/connectors/oauthStateStore.d.ts +31 -0
- package/dist/connectors/oauthStateStore.js +52 -0
- package/dist/connectors/oauthStateStore.js.map +1 -0
- package/dist/connectors/pagerduty.d.ts +160 -0
- package/dist/connectors/pagerduty.js +464 -0
- package/dist/connectors/pagerduty.js.map +1 -0
- package/dist/connectors/slack.d.ts +16 -1
- package/dist/connectors/slack.js +57 -5
- package/dist/connectors/slack.js.map +1 -1
- package/dist/connectors/tokenStorage.js +27 -2
- package/dist/connectors/tokenStorage.js.map +1 -1
- package/dist/connectors/zendesk.js +19 -1
- package/dist/connectors/zendesk.js.map +1 -1
- package/dist/cors.d.ts +10 -0
- package/dist/cors.js +29 -0
- package/dist/cors.js.map +1 -0
- package/dist/decisionReplay.d.ts +72 -0
- package/dist/decisionReplay.js +92 -0
- package/dist/decisionReplay.js.map +1 -0
- package/dist/decisionTraceLog.d.ts +6 -0
- package/dist/decisionTraceLog.js +54 -2
- package/dist/decisionTraceLog.js.map +1 -1
- package/dist/featureFlags.d.ts +17 -11
- package/dist/featureFlags.js +52 -47
- package/dist/featureFlags.js.map +1 -1
- package/dist/fp/automationInterpreter.js +25 -21
- package/dist/fp/automationInterpreter.js.map +1 -1
- package/dist/fp/automationState.js +4 -1
- package/dist/fp/automationState.js.map +1 -1
- package/dist/fp/policyParser.js +4 -1
- package/dist/fp/policyParser.js.map +1 -1
- package/dist/inboxRoutes.d.ts +22 -0
- package/dist/inboxRoutes.js +114 -0
- package/dist/inboxRoutes.js.map +1 -0
- package/dist/index.js +734 -144
- package/dist/index.js.map +1 -1
- package/dist/mcpRoutes.d.ts +37 -0
- package/dist/mcpRoutes.js +76 -0
- package/dist/mcpRoutes.js.map +1 -0
- package/dist/oauth.d.ts +3 -0
- package/dist/oauth.js +151 -26
- package/dist/oauth.js.map +1 -1
- package/dist/oauthRoutes.d.ts +32 -0
- package/dist/oauthRoutes.js +124 -0
- package/dist/oauthRoutes.js.map +1 -0
- package/dist/orchestrator/orchestratorBridge.js +2 -2
- package/dist/orchestrator/orchestratorBridge.js.map +1 -1
- package/dist/patchworkConfig.d.ts +7 -0
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/pluginLoader.d.ts +12 -0
- package/dist/pluginLoader.js +43 -4
- package/dist/pluginLoader.js.map +1 -1
- package/dist/pluginWatcher.js +8 -3
- package/dist/pluginWatcher.js.map +1 -1
- package/dist/preToolUseHook.d.ts +12 -0
- package/dist/preToolUseHook.js +23 -0
- package/dist/preToolUseHook.js.map +1 -1
- package/dist/recipeOrchestration.d.ts +8 -0
- package/dist/recipeOrchestration.js +320 -39
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +154 -0
- package/dist/recipeRoutes.js +1098 -0
- package/dist/recipeRoutes.js.map +1 -0
- package/dist/recipes/captureForRunlog.d.ts +27 -0
- package/dist/recipes/captureForRunlog.js +128 -0
- package/dist/recipes/captureForRunlog.js.map +1 -0
- package/dist/recipes/chainedRunner.d.ts +54 -3
- package/dist/recipes/chainedRunner.js +256 -36
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/compiler.js +3 -3
- package/dist/recipes/compiler.js.map +1 -1
- package/dist/recipes/detectSilentFail.d.ts +34 -0
- package/dist/recipes/detectSilentFail.js +105 -0
- package/dist/recipes/detectSilentFail.js.map +1 -0
- package/dist/recipes/installer.js +3 -3
- package/dist/recipes/installer.js.map +1 -1
- package/dist/recipes/manifest.js +21 -6
- package/dist/recipes/manifest.js.map +1 -1
- package/dist/recipes/migrationWarnings.d.ts +12 -0
- package/dist/recipes/migrationWarnings.js +44 -0
- package/dist/recipes/migrationWarnings.js.map +1 -0
- package/dist/recipes/replayRun.d.ts +62 -0
- package/dist/recipes/replayRun.js +97 -0
- package/dist/recipes/replayRun.js.map +1 -0
- package/dist/recipes/resolveRecipePath.d.ts +69 -0
- package/dist/recipes/resolveRecipePath.js +202 -0
- package/dist/recipes/resolveRecipePath.js.map +1 -0
- package/dist/recipes/scheduler.js +102 -11
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schemaGenerator.js +3 -3
- package/dist/recipes/schemaGenerator.js.map +1 -1
- package/dist/recipes/toolRegistry.d.ts +5 -0
- package/dist/recipes/toolRegistry.js +9 -0
- package/dist/recipes/toolRegistry.js.map +1 -1
- package/dist/recipes/tools/asana.d.ts +16 -0
- package/dist/recipes/tools/asana.js +524 -0
- package/dist/recipes/tools/asana.js.map +1 -0
- package/dist/recipes/tools/discord.d.ts +18 -0
- package/dist/recipes/tools/discord.js +254 -0
- package/dist/recipes/tools/discord.js.map +1 -0
- package/dist/recipes/tools/file.d.ts +6 -0
- package/dist/recipes/tools/file.js +12 -8
- package/dist/recipes/tools/file.js.map +1 -1
- package/dist/recipes/tools/github.js +29 -4
- package/dist/recipes/tools/github.js.map +1 -1
- package/dist/recipes/tools/gitlab.d.ts +11 -0
- package/dist/recipes/tools/gitlab.js +285 -0
- package/dist/recipes/tools/gitlab.js.map +1 -0
- package/dist/recipes/tools/gmail.d.ts +1 -1
- package/dist/recipes/tools/gmail.js +230 -6
- package/dist/recipes/tools/gmail.js.map +1 -1
- package/dist/recipes/tools/googleDrive.d.ts +1 -0
- package/dist/recipes/tools/googleDrive.js +55 -0
- package/dist/recipes/tools/googleDrive.js.map +1 -0
- package/dist/recipes/tools/index.d.ts +8 -0
- package/dist/recipes/tools/index.js +8 -0
- package/dist/recipes/tools/index.js.map +1 -1
- package/dist/recipes/tools/jira.d.ts +14 -0
- package/dist/recipes/tools/jira.js +369 -0
- package/dist/recipes/tools/jira.js.map +1 -0
- package/dist/recipes/tools/linear.d.ts +2 -1
- package/dist/recipes/tools/linear.js +227 -3
- package/dist/recipes/tools/linear.js.map +1 -1
- package/dist/recipes/tools/meetingNotes.d.ts +21 -0
- package/dist/recipes/tools/meetingNotes.js +701 -0
- package/dist/recipes/tools/meetingNotes.js.map +1 -0
- package/dist/recipes/tools/pagerduty.d.ts +15 -0
- package/dist/recipes/tools/pagerduty.js +451 -0
- package/dist/recipes/tools/pagerduty.js.map +1 -0
- package/dist/recipes/tools/sentry.d.ts +12 -0
- package/dist/recipes/tools/sentry.js +73 -0
- package/dist/recipes/tools/sentry.js.map +1 -0
- package/dist/recipes/tools/slack.js +15 -5
- package/dist/recipes/tools/slack.js.map +1 -1
- package/dist/recipes/validation.js +83 -14
- package/dist/recipes/validation.js.map +1 -1
- package/dist/recipes/yamlRunner.d.ts +30 -2
- package/dist/recipes/yamlRunner.js +369 -70
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +76 -1
- package/dist/recipesHttp.js +474 -12
- package/dist/recipesHttp.js.map +1 -1
- package/dist/runLog.d.ts +78 -2
- package/dist/runLog.js +204 -6
- package/dist/runLog.js.map +1 -1
- package/dist/schemas/dry-run-plan.v1.json +139 -0
- package/dist/schemas/recipe.v1.json +684 -0
- package/dist/server.d.ts +79 -10
- package/dist/server.js +366 -1384
- package/dist/server.js.map +1 -1
- package/dist/ssrfGuard.d.ts +54 -0
- package/dist/ssrfGuard.js +122 -0
- package/dist/ssrfGuard.js.map +1 -0
- package/dist/streamableHttp.d.ts +39 -1
- package/dist/streamableHttp.js +126 -17
- package/dist/streamableHttp.js.map +1 -1
- package/dist/tools/getDocumentSymbols.d.ts +24 -0
- package/dist/tools/getDocumentSymbols.js +74 -8
- package/dist/tools/getDocumentSymbols.js.map +1 -1
- package/dist/tools/getSecurityAdvisories.js +10 -1
- package/dist/tools/getSecurityAdvisories.js.map +1 -1
- package/dist/tools/getSessionUsage.d.ts +3 -0
- package/dist/tools/getSessionUsage.js +3 -0
- package/dist/tools/getSessionUsage.js.map +1 -1
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.js +32 -2
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/slackPostMessage.js +1 -1
- package/dist/tools/slackPostMessage.js.map +1 -1
- package/dist/tools/transaction.d.ts +19 -0
- package/dist/tools/transaction.js +29 -0
- package/dist/tools/transaction.js.map +1 -1
- package/dist/traceEncryption.d.ts +46 -0
- package/dist/traceEncryption.js +124 -0
- package/dist/traceEncryption.js.map +1 -0
- package/dist/transport.d.ts +39 -0
- package/dist/transport.js +88 -8
- package/dist/transport.js.map +1 -1
- package/package.json +22 -5
- package/templates/policies/README.md +72 -0
- package/templates/policies/conservative.json +14 -0
- package/templates/policies/developer.json +14 -0
- package/templates/policies/headless-ci.json +24 -0
- package/templates/policies/personal-assistant.json +15 -0
- package/templates/policies/regulated-industry.json +18 -0
- package/templates/recipes/project-health-check.yaml +1 -1
- package/templates/recipes/webhook/README.md +70 -0
- package/templates/recipes/webhook/capture-thought.yaml +26 -0
- package/templates/recipes/webhook/customer-escalation.yaml +49 -0
- package/templates/recipes/webhook/incident-intake.yaml +46 -0
- package/templates/recipes/webhook/meeting-prep.yaml +48 -0
- package/templates/recipes/webhook/morning-brief.yaml +57 -0
package/dist/recipesHttp.js
CHANGED
|
@@ -1,8 +1,190 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
-
import
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
3
5
|
import { parse as parseYaml } from "yaml";
|
|
4
6
|
import { loadConfig } from "./patchworkConfig.js";
|
|
5
7
|
import { validateRecipeDefinition } from "./recipes/validation.js";
|
|
8
|
+
/**
|
|
9
|
+
* Per-recipe disabled marker — must match the constant in
|
|
10
|
+
* `src/commands/recipeInstall.ts` and `src/recipes/scheduler.ts` (kept inline
|
|
11
|
+
* here to avoid a circular import via commands → recipesHttp → commands).
|
|
12
|
+
*
|
|
13
|
+
* Absence on a recipe's install dir = enabled (legacy default).
|
|
14
|
+
* Presence = disabled — `runRecipeInstall` writes one on every fresh install.
|
|
15
|
+
*/
|
|
16
|
+
const DISABLED_MARKER = ".disabled";
|
|
17
|
+
/**
|
|
18
|
+
* Returns true unless `filePath` lives inside an install dir whose
|
|
19
|
+
* `.disabled` marker is present. Top-level legacy recipes (direct children
|
|
20
|
+
* of `recipesDir`) are always considered enabled — there's no install dir
|
|
21
|
+
* to put a marker in. Used by every trigger surface (webhook, manual fire,
|
|
22
|
+
* automation) so the marker means the same thing everywhere.
|
|
23
|
+
*/
|
|
24
|
+
export function isRecipeFileEnabled(filePath, recipesDir) {
|
|
25
|
+
const rel = path.relative(recipesDir, filePath);
|
|
26
|
+
// Top-level file in recipesDir → no install dir → enabled by default.
|
|
27
|
+
if (rel === "" || rel.startsWith("..") || !rel.includes(path.sep)) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
const installDirName = rel.split(path.sep)[0];
|
|
31
|
+
if (!installDirName)
|
|
32
|
+
return true;
|
|
33
|
+
const installDir = path.join(recipesDir, installDirName);
|
|
34
|
+
return !existsSync(path.join(installDir, DISABLED_MARKER));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Iterate one level of subdirectories under `recipesDir` that look like
|
|
38
|
+
* install dirs (directory containing `recipe.json` or at least one `.yaml`).
|
|
39
|
+
* Skips dirs whose `.disabled` marker is present so callers automatically
|
|
40
|
+
* honor the marker without having to remember.
|
|
41
|
+
*
|
|
42
|
+
* Yields `{ installDir, entrypointPath }` pairs where `entrypointPath` is the
|
|
43
|
+
* file the caller should parse:
|
|
44
|
+
* - `recipe.json`'s `recipes.main` if a manifest exists
|
|
45
|
+
* - otherwise the first `*.yaml` / `*.yml` in the dir
|
|
46
|
+
*
|
|
47
|
+
* Used by webhook + manual-fire path resolvers to find recipes installed
|
|
48
|
+
* via `runRecipeInstall`.
|
|
49
|
+
*/
|
|
50
|
+
function* iterateInstallDirs(recipesDir, options = {}) {
|
|
51
|
+
const includeDisabled = options.includeDisabled === true;
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = readdirSync(recipesDir);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
for (const f of entries) {
|
|
60
|
+
const fullPath = path.join(recipesDir, f);
|
|
61
|
+
let isDir = false;
|
|
62
|
+
try {
|
|
63
|
+
isDir = statSync(fullPath).isDirectory();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!isDir)
|
|
69
|
+
continue;
|
|
70
|
+
const enabled = !existsSync(path.join(fullPath, DISABLED_MARKER));
|
|
71
|
+
if (!enabled && !includeDisabled)
|
|
72
|
+
continue;
|
|
73
|
+
let entrypoint = null;
|
|
74
|
+
const manifestPath = path.join(fullPath, "recipe.json");
|
|
75
|
+
if (existsSync(manifestPath)) {
|
|
76
|
+
try {
|
|
77
|
+
const m = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
78
|
+
if (m.recipes?.main) {
|
|
79
|
+
const candidate = path.join(fullPath, m.recipes.main);
|
|
80
|
+
if (existsSync(candidate))
|
|
81
|
+
entrypoint = candidate;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// malformed manifest — fall through to first-yaml fallback
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!entrypoint) {
|
|
89
|
+
try {
|
|
90
|
+
const yaml = readdirSync(fullPath).find((x) => /\.ya?ml$/i.test(x));
|
|
91
|
+
if (yaml)
|
|
92
|
+
entrypoint = path.join(fullPath, yaml);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// unreadable
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (entrypoint) {
|
|
99
|
+
yield { installDir: fullPath, entrypointPath: entrypoint, enabled };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Locate an install dir by the *recipe name* declared inside its entrypoint
|
|
105
|
+
* (not the directory name). The dashboard reports recipes by the parsed
|
|
106
|
+
* `name` field, while `runRecipeEnable` looks them up by dir name —
|
|
107
|
+
* the two are usually different (`morning-pkg` vs `morning-brief`). Includes
|
|
108
|
+
* disabled dirs so re-enabling actually finds them.
|
|
109
|
+
*/
|
|
110
|
+
function findInstallDirByRecipeName(recipesDir, name) {
|
|
111
|
+
for (const { installDir, entrypointPath } of iterateInstallDirs(recipesDir, {
|
|
112
|
+
includeDisabled: true,
|
|
113
|
+
})) {
|
|
114
|
+
try {
|
|
115
|
+
const ext = path.extname(entrypointPath).toLowerCase();
|
|
116
|
+
const raw = readFileSync(entrypointPath, "utf-8");
|
|
117
|
+
const parsed = (ext === ".json" ? JSON.parse(raw) : parseYaml(raw));
|
|
118
|
+
if (parsed.name === name)
|
|
119
|
+
return installDir;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// skip malformed
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Unified enable/disable for install-dir AND legacy top-level recipes.
|
|
129
|
+
*
|
|
130
|
+
* Routing:
|
|
131
|
+
* 1. Try to find an install dir whose entrypoint declares this `name`.
|
|
132
|
+
* If found, write/remove the `.disabled` marker on that dir. This
|
|
133
|
+
* matches CLI `recipe enable/disable` and the trigger-side
|
|
134
|
+
* enforcement landed in PRs #43 / #49.
|
|
135
|
+
* 2. Otherwise the recipe is a top-level legacy file — fall back to
|
|
136
|
+
* the legacy `cfg.recipes.disabled` config-file array, which the
|
|
137
|
+
* scheduler already honors as a parallel mechanism (it checks both).
|
|
138
|
+
*
|
|
139
|
+
* Replaces the old dashboard-only `setRecipeEnabledFn` that wrote ONLY to
|
|
140
|
+
* the legacy config — which silently did nothing for install-dir recipes.
|
|
141
|
+
*/
|
|
142
|
+
export function setRecipeEnabled(name, enabled, options = {}) {
|
|
143
|
+
const recipesDir = options.recipesDir ?? path.join(homedir(), ".patchwork", "recipes");
|
|
144
|
+
try {
|
|
145
|
+
const installDir = findInstallDirByRecipeName(recipesDir, name);
|
|
146
|
+
if (installDir) {
|
|
147
|
+
const markerPath = path.join(installDir, DISABLED_MARKER);
|
|
148
|
+
if (enabled) {
|
|
149
|
+
if (existsSync(markerPath))
|
|
150
|
+
rmSync(markerPath);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
writeFileSync(markerPath, "");
|
|
154
|
+
}
|
|
155
|
+
return { ok: true };
|
|
156
|
+
}
|
|
157
|
+
// Legacy top-level path — fall back to config-file disabled list
|
|
158
|
+
const cfg = (options.loadConfigFn ?? loadConfig)();
|
|
159
|
+
const disabled = new Set(cfg.recipes?.disabled ?? []);
|
|
160
|
+
if (enabled)
|
|
161
|
+
disabled.delete(name);
|
|
162
|
+
else
|
|
163
|
+
disabled.add(name);
|
|
164
|
+
const next = {
|
|
165
|
+
...cfg,
|
|
166
|
+
recipes: {
|
|
167
|
+
...(cfg.recipes ?? {}),
|
|
168
|
+
disabled: [...disabled],
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
if (options.saveConfigFn)
|
|
172
|
+
options.saveConfigFn(next);
|
|
173
|
+
else {
|
|
174
|
+
// Dynamic import to avoid coupling at module-load time and to keep
|
|
175
|
+
// tests able to swap the saver via options.saveConfigFn.
|
|
176
|
+
const mod = require("./patchworkConfig.js");
|
|
177
|
+
mod.savePatchworkConfig(next);
|
|
178
|
+
}
|
|
179
|
+
return { ok: true };
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
error: err instanceof Error ? err.message : String(err),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
6
188
|
function normalizeRecipeDraftTrigger(trigger) {
|
|
7
189
|
if (trigger.type === "schedule" || trigger.type === "cron") {
|
|
8
190
|
const schedule = typeof trigger.schedule === "string" && trigger.schedule.trim()
|
|
@@ -170,6 +352,22 @@ function resolveJsonRecipePathByName(recipesDir, safeName) {
|
|
|
170
352
|
catch {
|
|
171
353
|
return null;
|
|
172
354
|
}
|
|
355
|
+
// Also search install dirs from `recipeInstall`. Skips dirs with
|
|
356
|
+
// `.disabled` marker so the manual-fire / orchestrator path can't
|
|
357
|
+
// resolve a recipe the user has explicitly disabled.
|
|
358
|
+
for (const { entrypointPath } of iterateInstallDirs(recipesDir)) {
|
|
359
|
+
if (!entrypointPath.endsWith(".json"))
|
|
360
|
+
continue;
|
|
361
|
+
try {
|
|
362
|
+
const parsed = JSON.parse(readFileSync(entrypointPath, "utf-8"));
|
|
363
|
+
if (parsed.name?.toLowerCase() === safeName) {
|
|
364
|
+
return entrypointPath;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
// skip malformed
|
|
369
|
+
}
|
|
370
|
+
}
|
|
173
371
|
return null;
|
|
174
372
|
}
|
|
175
373
|
export function loadRecipeContent(recipesDir, name) {
|
|
@@ -264,7 +462,7 @@ export function deleteRecipeContent(recipesDir, name) {
|
|
|
264
462
|
return { ok: false, error: "Invalid recipe name" };
|
|
265
463
|
}
|
|
266
464
|
const base = path.resolve(recipesDir);
|
|
267
|
-
|
|
465
|
+
const target = findYamlRecipePath(recipesDir, safeName) ??
|
|
268
466
|
resolveJsonRecipePathByName(recipesDir, safeName);
|
|
269
467
|
if (!target) {
|
|
270
468
|
return { ok: false, error: "Recipe not found" };
|
|
@@ -293,6 +491,116 @@ export function deleteRecipeContent(recipesDir, name) {
|
|
|
293
491
|
};
|
|
294
492
|
}
|
|
295
493
|
}
|
|
494
|
+
/**
|
|
495
|
+
* Duplicate a recipe as a variant. Copies the source YAML, rewrites the
|
|
496
|
+
* `name:` field to `<original>-v<N>` (first available suffix), and writes
|
|
497
|
+
* the copy to disk. Returns the new variant name and path on success.
|
|
498
|
+
*
|
|
499
|
+
* The variant name follows the same validation rules as recipe names.
|
|
500
|
+
* Suffixes v2..v9 are tried before returning an error.
|
|
501
|
+
*/
|
|
502
|
+
export function duplicateRecipe(recipesDir, sourceName) {
|
|
503
|
+
const safeName = sourceName.toLowerCase();
|
|
504
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(safeName)) {
|
|
505
|
+
return { ok: false, error: "Invalid recipe name" };
|
|
506
|
+
}
|
|
507
|
+
const source = loadRecipeContent(recipesDir, safeName);
|
|
508
|
+
if (!source) {
|
|
509
|
+
return { ok: false, error: "Recipe not found" };
|
|
510
|
+
}
|
|
511
|
+
// Determine next available variant name: strip any existing -vN suffix,
|
|
512
|
+
// then try -v2 through -v9.
|
|
513
|
+
const base = safeName.replace(/-v\d+$/, "");
|
|
514
|
+
let variantName = null;
|
|
515
|
+
for (let n = 2; n <= 9; n++) {
|
|
516
|
+
const candidate = `${base}-v${n}`;
|
|
517
|
+
if (!findYamlRecipePath(recipesDir, candidate)) {
|
|
518
|
+
variantName = candidate;
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (!variantName) {
|
|
523
|
+
return {
|
|
524
|
+
ok: false,
|
|
525
|
+
error: "Too many variants already exist (v2–v9 taken)",
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
// Rewrite the name: field in the YAML. Simple line-by-line replacement
|
|
529
|
+
// is safe here: the name field is always a scalar on its own line.
|
|
530
|
+
const newContent = source.content.replace(/^name:\s*.+$/m, `name: ${variantName}`);
|
|
531
|
+
const saveResult = saveRecipeContent(recipesDir, variantName, newContent);
|
|
532
|
+
if (!saveResult.ok) {
|
|
533
|
+
return { ok: false, error: saveResult.error };
|
|
534
|
+
}
|
|
535
|
+
return { ok: true, variantName, path: saveResult.path };
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Promote a variant recipe to become the canonical name.
|
|
539
|
+
*
|
|
540
|
+
* Steps:
|
|
541
|
+
* 1. Load the variant's YAML.
|
|
542
|
+
* 2. Rewrite its `name:` field to `targetName`.
|
|
543
|
+
* 3. Save under `targetName` (overwrites any existing file at that name).
|
|
544
|
+
* 4. Delete the variant file so only one copy exists.
|
|
545
|
+
*
|
|
546
|
+
* The caller supplies `variantName` (e.g. "morning-brief-v2") and
|
|
547
|
+
* `targetName` (e.g. "morning-brief"). Both must pass the recipe name
|
|
548
|
+
* validation regex. Returns `{ ok, path }` on success.
|
|
549
|
+
*/
|
|
550
|
+
export async function promoteRecipeVariant(recipesDir, variantName, targetName, options) {
|
|
551
|
+
const safeVariant = variantName.toLowerCase();
|
|
552
|
+
const safeTarget = targetName.toLowerCase();
|
|
553
|
+
const nameRe = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
554
|
+
if (!nameRe.test(safeVariant) || !nameRe.test(safeTarget)) {
|
|
555
|
+
return { ok: false, error: "Invalid recipe name" };
|
|
556
|
+
}
|
|
557
|
+
if (safeVariant === safeTarget) {
|
|
558
|
+
return { ok: false, error: "Variant and target names must differ" };
|
|
559
|
+
}
|
|
560
|
+
const source = loadRecipeContent(recipesDir, safeVariant);
|
|
561
|
+
if (!source) {
|
|
562
|
+
return { ok: false, error: "Variant recipe not found" };
|
|
563
|
+
}
|
|
564
|
+
// Guard against silent overwrites: if the target already exists the caller
|
|
565
|
+
// must pass force:true. We also capture the prior content hash for audit.
|
|
566
|
+
const existing = loadRecipeContent(recipesDir, safeTarget);
|
|
567
|
+
if (existing && !options?.force) {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
targetExists: true,
|
|
571
|
+
error: `Recipe "${safeTarget}" already exists. Pass force:true to overwrite.`,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
// Write audit log entry before the overwrite so the replaced content is
|
|
575
|
+
// traceable even if the variant file is deleted in the next step.
|
|
576
|
+
if (existing) {
|
|
577
|
+
try {
|
|
578
|
+
const priorHash = createHash("sha256")
|
|
579
|
+
.update(existing.content)
|
|
580
|
+
.digest("hex");
|
|
581
|
+
const auditPath = existing.path.replace(/\.ya?ml$/, ".promote-audit.json");
|
|
582
|
+
writeFileSync(auditPath, JSON.stringify({
|
|
583
|
+
ts: new Date().toISOString(),
|
|
584
|
+
action: "promote_overwrite",
|
|
585
|
+
variantName: safeVariant,
|
|
586
|
+
targetName: safeTarget,
|
|
587
|
+
priorContentHash: priorHash,
|
|
588
|
+
priorContentPath: existing.path,
|
|
589
|
+
}, null, 2), "utf-8");
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
// Audit log failure must not block the promote — log and continue.
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const newContent = source.content.replace(/^name:\s*.+$/m, `name: ${safeTarget}`);
|
|
596
|
+
const saveResult = saveRecipeContent(recipesDir, safeTarget, newContent);
|
|
597
|
+
if (!saveResult.ok) {
|
|
598
|
+
return { ok: false, error: saveResult.error };
|
|
599
|
+
}
|
|
600
|
+
// Delete the variant file — best-effort; don't fail the promote if cleanup fails.
|
|
601
|
+
deleteRecipeContent(recipesDir, safeVariant);
|
|
602
|
+
return { ok: true, path: saveResult.path };
|
|
603
|
+
}
|
|
296
604
|
/**
|
|
297
605
|
* Lints raw YAML/JSON recipe content without writing to disk. Used by the
|
|
298
606
|
* dashboard edit UI to surface validateRecipeDefinition warnings live, in
|
|
@@ -324,6 +632,57 @@ export function lintRecipeContent(content) {
|
|
|
324
632
|
}
|
|
325
633
|
return { ok: errors.length === 0, errors, warnings };
|
|
326
634
|
}
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
// Recipe trust levels
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
export const TRUST_LEVELS = [
|
|
639
|
+
"draft",
|
|
640
|
+
"manual_run",
|
|
641
|
+
"ask_every_time",
|
|
642
|
+
"ask_novel",
|
|
643
|
+
"mostly_trusted",
|
|
644
|
+
"fully_trusted",
|
|
645
|
+
];
|
|
646
|
+
const TRUST_LEVELS_FILE = "trust_levels.json";
|
|
647
|
+
function trustLevelsPath(recipesDir) {
|
|
648
|
+
return path.join(recipesDir, TRUST_LEVELS_FILE);
|
|
649
|
+
}
|
|
650
|
+
function loadTrustLevels(recipesDir) {
|
|
651
|
+
const p = trustLevelsPath(recipesDir);
|
|
652
|
+
try {
|
|
653
|
+
const raw = readFileSync(p, "utf-8");
|
|
654
|
+
return JSON.parse(raw);
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
return {};
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
function saveTrustLevels(recipesDir, levels) {
|
|
661
|
+
const p = trustLevelsPath(recipesDir);
|
|
662
|
+
mkdirSync(recipesDir, { recursive: true });
|
|
663
|
+
writeFileSync(p, JSON.stringify(levels, null, 2), "utf-8");
|
|
664
|
+
}
|
|
665
|
+
export function getTrustLevel(recipesDir, name) {
|
|
666
|
+
const levels = loadTrustLevels(recipesDir);
|
|
667
|
+
return levels[name] ?? "draft";
|
|
668
|
+
}
|
|
669
|
+
export function setTrustLevel(recipesDir, name, level) {
|
|
670
|
+
if (!TRUST_LEVELS.includes(level)) {
|
|
671
|
+
return { ok: false, error: `Invalid trust level: ${level}` };
|
|
672
|
+
}
|
|
673
|
+
try {
|
|
674
|
+
const levels = loadTrustLevels(recipesDir);
|
|
675
|
+
levels[name] = level;
|
|
676
|
+
saveTrustLevels(recipesDir, levels);
|
|
677
|
+
return { ok: true };
|
|
678
|
+
}
|
|
679
|
+
catch (err) {
|
|
680
|
+
return {
|
|
681
|
+
ok: false,
|
|
682
|
+
error: err instanceof Error ? err.message : String(err),
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
}
|
|
327
686
|
export function listInstalledRecipes(recipesDir) {
|
|
328
687
|
let entries;
|
|
329
688
|
try {
|
|
@@ -334,6 +693,7 @@ export function listInstalledRecipes(recipesDir) {
|
|
|
334
693
|
}
|
|
335
694
|
const cfg = loadConfig();
|
|
336
695
|
const disabledSet = new Set(cfg.recipes?.disabled ?? []);
|
|
696
|
+
const trustLevels = loadTrustLevels(recipesDir);
|
|
337
697
|
const recipes = [];
|
|
338
698
|
for (const f of entries) {
|
|
339
699
|
const isYaml = f.endsWith(".yaml") || f.endsWith(".yml");
|
|
@@ -345,15 +705,6 @@ export function listInstalledRecipes(recipesDir) {
|
|
|
345
705
|
const raw = readFileSync(fullPath, "utf-8");
|
|
346
706
|
const parsed = (isYaml ? parseYaml(raw) : JSON.parse(raw));
|
|
347
707
|
const stat = statSync(fullPath);
|
|
348
|
-
const permsPath = `${fullPath}.permissions.json`;
|
|
349
|
-
let hasPermissions = false;
|
|
350
|
-
try {
|
|
351
|
-
statSync(permsPath);
|
|
352
|
-
hasPermissions = true;
|
|
353
|
-
}
|
|
354
|
-
catch {
|
|
355
|
-
// no permissions sidecar
|
|
356
|
-
}
|
|
357
708
|
const resolvedRecipesDir = path.resolve(recipesDir);
|
|
358
709
|
let source;
|
|
359
710
|
if (fullPath.startsWith(resolvedRecipesDir + path.sep) ||
|
|
@@ -394,9 +745,11 @@ export function listInstalledRecipes(recipesDir) {
|
|
|
394
745
|
stepCount: Array.isArray(parsed.steps) ? parsed.steps.length : 0,
|
|
395
746
|
path: fullPath,
|
|
396
747
|
installedAt: stat.mtimeMs,
|
|
397
|
-
hasPermissions,
|
|
398
748
|
source,
|
|
749
|
+
// Top-level legacy recipes don't have install dirs to put a marker
|
|
750
|
+
// in, so the `enabled` field still comes from the legacy config list.
|
|
399
751
|
enabled: !disabledSet.has(parsedName),
|
|
752
|
+
trustLevel: (trustLevels[parsedName] ?? "draft"),
|
|
400
753
|
...(Array.isArray(parsed.vars) && parsed.vars.length > 0
|
|
401
754
|
? { vars: parsed.vars }
|
|
402
755
|
: {}),
|
|
@@ -412,6 +765,72 @@ export function listInstalledRecipes(recipesDir) {
|
|
|
412
765
|
// skip malformed recipe file
|
|
413
766
|
}
|
|
414
767
|
}
|
|
768
|
+
// Second pass — recipes installed via `runRecipeInstall` into subdirs.
|
|
769
|
+
// `enabled` reflects the per-install `.disabled` marker; the legacy
|
|
770
|
+
// config disabled list is a top-level concern (we still apply it as a
|
|
771
|
+
// safety belt in case a name collides).
|
|
772
|
+
for (const { installDir, entrypointPath, enabled: installEnabled, } of iterateInstallDirs(recipesDir, { includeDisabled: true })) {
|
|
773
|
+
try {
|
|
774
|
+
const ext = path.extname(entrypointPath).toLowerCase();
|
|
775
|
+
const isYaml = ext === ".yaml" || ext === ".yml";
|
|
776
|
+
const isJson = ext === ".json";
|
|
777
|
+
if (!isYaml && !isJson)
|
|
778
|
+
continue;
|
|
779
|
+
const raw = readFileSync(entrypointPath, "utf-8");
|
|
780
|
+
const parsed = (isYaml ? parseYaml(raw) : JSON.parse(raw));
|
|
781
|
+
const stat = statSync(entrypointPath);
|
|
782
|
+
const parsedName = parsed.name ??
|
|
783
|
+
path.basename(entrypointPath, path.extname(entrypointPath));
|
|
784
|
+
const lintRes = validateRecipeDefinition(parsed);
|
|
785
|
+
let errCount = 0;
|
|
786
|
+
let warnCount = 0;
|
|
787
|
+
let firstError;
|
|
788
|
+
for (const issue of lintRes.issues) {
|
|
789
|
+
if (issue.level === "error") {
|
|
790
|
+
errCount++;
|
|
791
|
+
if (!firstError)
|
|
792
|
+
firstError = issue.message;
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
warnCount++;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
const webhookPath = parsed.trigger?.type === "webhook" &&
|
|
799
|
+
typeof parsed.trigger?.path === "string"
|
|
800
|
+
? parsed.trigger.path
|
|
801
|
+
: undefined;
|
|
802
|
+
recipes.push({
|
|
803
|
+
name: parsedName,
|
|
804
|
+
description: parsed.description,
|
|
805
|
+
trigger: parsed.trigger?.type,
|
|
806
|
+
...(webhookPath ? { webhookPath } : {}),
|
|
807
|
+
stepCount: Array.isArray(parsed.steps) ? parsed.steps.length : 0,
|
|
808
|
+
path: entrypointPath,
|
|
809
|
+
installedAt: stat.mtimeMs,
|
|
810
|
+
source: "user",
|
|
811
|
+
// Disabled if EITHER the install marker is set OR the legacy config
|
|
812
|
+
// names this recipe — defence-in-depth so a stale config entry can't
|
|
813
|
+
// accidentally re-enable a recipe the user explicitly disabled, and
|
|
814
|
+
// the dashboard can't accidentally enable one disabled by an admin
|
|
815
|
+
// through the legacy file.
|
|
816
|
+
enabled: installEnabled && !disabledSet.has(parsedName),
|
|
817
|
+
trustLevel: (trustLevels[parsedName] ?? "draft"),
|
|
818
|
+
...(Array.isArray(parsed.vars) && parsed.vars.length > 0
|
|
819
|
+
? { vars: parsed.vars }
|
|
820
|
+
: {}),
|
|
821
|
+
lint: {
|
|
822
|
+
ok: errCount === 0,
|
|
823
|
+
errorCount: errCount,
|
|
824
|
+
warningCount: warnCount,
|
|
825
|
+
...(firstError ? { firstError } : {}),
|
|
826
|
+
},
|
|
827
|
+
});
|
|
828
|
+
void installDir;
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
// skip malformed install dir
|
|
832
|
+
}
|
|
833
|
+
}
|
|
415
834
|
recipes.sort((a, b) => a.name.localeCompare(b.name));
|
|
416
835
|
return { recipesDir, recipes };
|
|
417
836
|
}
|
|
@@ -454,6 +873,22 @@ export function findYamlRecipePath(recipesDir, name) {
|
|
|
454
873
|
// skip malformed candidate
|
|
455
874
|
}
|
|
456
875
|
}
|
|
876
|
+
// Also search install dirs from `recipeInstall`. Skips dirs with
|
|
877
|
+
// `.disabled` marker so the manual-fire / orchestrator path can't
|
|
878
|
+
// resolve a recipe the user has explicitly disabled.
|
|
879
|
+
for (const { entrypointPath } of iterateInstallDirs(recipesDir)) {
|
|
880
|
+
if (!/\.ya?ml$/i.test(entrypointPath))
|
|
881
|
+
continue;
|
|
882
|
+
try {
|
|
883
|
+
const parsed = parseYaml(readFileSync(entrypointPath, "utf-8"));
|
|
884
|
+
if (parsed.name?.toLowerCase() === safeName) {
|
|
885
|
+
return entrypointPath;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
// skip malformed
|
|
890
|
+
}
|
|
891
|
+
}
|
|
457
892
|
return null;
|
|
458
893
|
}
|
|
459
894
|
/**
|
|
@@ -469,6 +904,7 @@ export function findWebhookRecipe(recipesDir, requestPath) {
|
|
|
469
904
|
catch {
|
|
470
905
|
return null;
|
|
471
906
|
}
|
|
907
|
+
// Pass 1 — top-level files (legacy)
|
|
472
908
|
for (const f of entries) {
|
|
473
909
|
const isYaml = f.endsWith(".yaml") || f.endsWith(".yml");
|
|
474
910
|
const isJson = f.endsWith(".json") && !f.endsWith(".permissions.json");
|
|
@@ -493,6 +929,32 @@ export function findWebhookRecipe(recipesDir, requestPath) {
|
|
|
493
929
|
// skip malformed
|
|
494
930
|
}
|
|
495
931
|
}
|
|
932
|
+
// Pass 2 — install dirs (skips dirs marked .disabled).
|
|
933
|
+
for (const { entrypointPath } of iterateInstallDirs(recipesDir)) {
|
|
934
|
+
const ext = path.extname(entrypointPath).toLowerCase();
|
|
935
|
+
const isYaml = ext === ".yaml" || ext === ".yml";
|
|
936
|
+
const isJson = ext === ".json";
|
|
937
|
+
if (!isYaml && !isJson)
|
|
938
|
+
continue;
|
|
939
|
+
try {
|
|
940
|
+
const raw = readFileSync(entrypointPath, "utf-8");
|
|
941
|
+
const parsed = (isYaml ? parseYaml(raw) : JSON.parse(raw));
|
|
942
|
+
if (parsed.trigger?.type !== "webhook")
|
|
943
|
+
continue;
|
|
944
|
+
if (parsed.trigger.path === requestPath) {
|
|
945
|
+
return {
|
|
946
|
+
name: parsed.name ??
|
|
947
|
+
path.basename(entrypointPath, path.extname(entrypointPath)),
|
|
948
|
+
path: requestPath,
|
|
949
|
+
filePath: entrypointPath,
|
|
950
|
+
format: isYaml ? "yaml" : "json",
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
// skip malformed
|
|
956
|
+
}
|
|
957
|
+
}
|
|
496
958
|
return null;
|
|
497
959
|
}
|
|
498
960
|
/**
|