patchwork-os 0.2.0-alpha.33 → 0.2.0-alpha.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/README.md +248 -48
  2. package/deploy/bootstrap-new-vps.sh +12 -12
  3. package/deploy/bootstrap-vps.sh +6 -3
  4. package/deploy/deploy-landing.sh +59 -2
  5. package/dist/bridge.js +35 -1
  6. package/dist/bridge.js.map +1 -1
  7. package/dist/commands/recipe.d.ts +11 -0
  8. package/dist/commands/recipe.js +32 -3
  9. package/dist/commands/recipe.js.map +1 -1
  10. package/dist/commands/recipeInstall.d.ts +79 -1
  11. package/dist/commands/recipeInstall.js +241 -13
  12. package/dist/commands/recipeInstall.js.map +1 -1
  13. package/dist/connectors/asana.d.ts +198 -0
  14. package/dist/connectors/asana.js +680 -0
  15. package/dist/connectors/asana.js.map +1 -0
  16. package/dist/connectors/baseConnector.d.ts +16 -0
  17. package/dist/connectors/baseConnector.js +107 -25
  18. package/dist/connectors/baseConnector.js.map +1 -1
  19. package/dist/connectors/discord.d.ts +150 -0
  20. package/dist/connectors/discord.js +544 -0
  21. package/dist/connectors/discord.js.map +1 -0
  22. package/dist/connectors/github.js +15 -7
  23. package/dist/connectors/github.js.map +1 -1
  24. package/dist/connectors/gitlab.d.ts +180 -0
  25. package/dist/connectors/gitlab.js +582 -0
  26. package/dist/connectors/gitlab.js.map +1 -0
  27. package/dist/connectors/gmail.js +45 -0
  28. package/dist/connectors/gmail.js.map +1 -1
  29. package/dist/connectors/googleDrive.d.ts +34 -0
  30. package/dist/connectors/googleDrive.js +305 -0
  31. package/dist/connectors/googleDrive.js.map +1 -0
  32. package/dist/connectors/htmlEscape.d.ts +5 -0
  33. package/dist/connectors/htmlEscape.js +13 -0
  34. package/dist/connectors/htmlEscape.js.map +1 -0
  35. package/dist/connectors/linear.js +26 -6
  36. package/dist/connectors/linear.js.map +1 -1
  37. package/dist/connectors/mcpOAuth.d.ts +2 -0
  38. package/dist/connectors/mcpOAuth.js +8 -4
  39. package/dist/connectors/mcpOAuth.js.map +1 -1
  40. package/dist/connectors/pagerduty.d.ts +160 -0
  41. package/dist/connectors/pagerduty.js +464 -0
  42. package/dist/connectors/pagerduty.js.map +1 -0
  43. package/dist/connectors/sentry.js +3 -2
  44. package/dist/connectors/sentry.js.map +1 -1
  45. package/dist/connectors/slack.d.ts +1 -1
  46. package/dist/connectors/slack.js +7 -4
  47. package/dist/connectors/slack.js.map +1 -1
  48. package/dist/featureFlags.d.ts +17 -11
  49. package/dist/featureFlags.js +52 -47
  50. package/dist/featureFlags.js.map +1 -1
  51. package/dist/index.js +262 -129
  52. package/dist/index.js.map +1 -1
  53. package/dist/oauth.js +3 -2
  54. package/dist/oauth.js.map +1 -1
  55. package/dist/recipeOrchestration.d.ts +7 -0
  56. package/dist/recipeOrchestration.js +154 -28
  57. package/dist/recipeOrchestration.js.map +1 -1
  58. package/dist/recipes/agentExecutor.d.ts +1 -0
  59. package/dist/recipes/agentExecutor.js +7 -0
  60. package/dist/recipes/agentExecutor.js.map +1 -1
  61. package/dist/recipes/captureForRunlog.d.ts +27 -0
  62. package/dist/recipes/captureForRunlog.js +128 -0
  63. package/dist/recipes/captureForRunlog.js.map +1 -0
  64. package/dist/recipes/chainedRunner.d.ts +39 -3
  65. package/dist/recipes/chainedRunner.js +183 -28
  66. package/dist/recipes/chainedRunner.js.map +1 -1
  67. package/dist/recipes/detectSilentFail.d.ts +34 -0
  68. package/dist/recipes/detectSilentFail.js +105 -0
  69. package/dist/recipes/detectSilentFail.js.map +1 -0
  70. package/dist/recipes/legacyRecipeCompat.d.ts +8 -0
  71. package/dist/recipes/legacyRecipeCompat.js +20 -1
  72. package/dist/recipes/legacyRecipeCompat.js.map +1 -1
  73. package/dist/recipes/manifest.js +21 -6
  74. package/dist/recipes/manifest.js.map +1 -1
  75. package/dist/recipes/migrations/index.d.ts +24 -0
  76. package/dist/recipes/migrations/index.js +55 -0
  77. package/dist/recipes/migrations/index.js.map +1 -0
  78. package/dist/recipes/migrations/types.d.ts +28 -0
  79. package/dist/recipes/migrations/types.js +2 -0
  80. package/dist/recipes/migrations/types.js.map +1 -0
  81. package/dist/recipes/migrations/v1.d.ts +11 -0
  82. package/dist/recipes/migrations/v1.js +18 -0
  83. package/dist/recipes/migrations/v1.js.map +1 -0
  84. package/dist/recipes/replayRun.d.ts +62 -0
  85. package/dist/recipes/replayRun.js +97 -0
  86. package/dist/recipes/replayRun.js.map +1 -0
  87. package/dist/recipes/scheduler.js +102 -11
  88. package/dist/recipes/scheduler.js.map +1 -1
  89. package/dist/recipes/schemaGenerator.js +3 -3
  90. package/dist/recipes/schemaGenerator.js.map +1 -1
  91. package/dist/recipes/templateEngine.js +8 -1
  92. package/dist/recipes/templateEngine.js.map +1 -1
  93. package/dist/recipes/toolRegistry.d.ts +5 -0
  94. package/dist/recipes/toolRegistry.js +9 -0
  95. package/dist/recipes/toolRegistry.js.map +1 -1
  96. package/dist/recipes/tools/asana.d.ts +16 -0
  97. package/dist/recipes/tools/asana.js +524 -0
  98. package/dist/recipes/tools/asana.js.map +1 -0
  99. package/dist/recipes/tools/discord.d.ts +18 -0
  100. package/dist/recipes/tools/discord.js +254 -0
  101. package/dist/recipes/tools/discord.js.map +1 -0
  102. package/dist/recipes/tools/github.js +29 -4
  103. package/dist/recipes/tools/github.js.map +1 -1
  104. package/dist/recipes/tools/gitlab.d.ts +11 -0
  105. package/dist/recipes/tools/gitlab.js +285 -0
  106. package/dist/recipes/tools/gitlab.js.map +1 -0
  107. package/dist/recipes/tools/gmail.d.ts +1 -1
  108. package/dist/recipes/tools/gmail.js +230 -6
  109. package/dist/recipes/tools/gmail.js.map +1 -1
  110. package/dist/recipes/tools/googleDrive.d.ts +1 -0
  111. package/dist/recipes/tools/googleDrive.js +55 -0
  112. package/dist/recipes/tools/googleDrive.js.map +1 -0
  113. package/dist/recipes/tools/index.d.ts +6 -0
  114. package/dist/recipes/tools/index.js +6 -0
  115. package/dist/recipes/tools/index.js.map +1 -1
  116. package/dist/recipes/tools/linear.d.ts +2 -1
  117. package/dist/recipes/tools/linear.js +222 -1
  118. package/dist/recipes/tools/linear.js.map +1 -1
  119. package/dist/recipes/tools/meetingNotes.d.ts +21 -0
  120. package/dist/recipes/tools/meetingNotes.js +701 -0
  121. package/dist/recipes/tools/meetingNotes.js.map +1 -0
  122. package/dist/recipes/tools/pagerduty.d.ts +15 -0
  123. package/dist/recipes/tools/pagerduty.js +451 -0
  124. package/dist/recipes/tools/pagerduty.js.map +1 -0
  125. package/dist/recipes/tools/slack.js +8 -2
  126. package/dist/recipes/tools/slack.js.map +1 -1
  127. package/dist/recipes/validation.js +54 -15
  128. package/dist/recipes/validation.js.map +1 -1
  129. package/dist/recipes/yamlRunner.d.ts +23 -2
  130. package/dist/recipes/yamlRunner.js +265 -60
  131. package/dist/recipes/yamlRunner.js.map +1 -1
  132. package/dist/recipesHttp.d.ts +60 -0
  133. package/dist/recipesHttp.js +418 -3
  134. package/dist/recipesHttp.js.map +1 -1
  135. package/dist/runLog.d.ts +64 -2
  136. package/dist/runLog.js +116 -2
  137. package/dist/runLog.js.map +1 -1
  138. package/dist/server.d.ts +21 -0
  139. package/dist/server.js +387 -8
  140. package/dist/server.js.map +1 -1
  141. package/dist/streamableHttp.d.ts +31 -1
  142. package/dist/streamableHttp.js +20 -2
  143. package/dist/streamableHttp.js.map +1 -1
  144. package/dist/tools/activityLog.d.ts +2 -0
  145. package/dist/tools/addLinearComment.d.ts +1 -0
  146. package/dist/tools/batchLsp.d.ts +3 -0
  147. package/dist/tools/bridgeDoctor.d.ts +1 -0
  148. package/dist/tools/bridgeStatus.d.ts +1 -0
  149. package/dist/tools/cancelClaudeTask.d.ts +1 -0
  150. package/dist/tools/checkDocumentDirty.d.ts +1 -0
  151. package/dist/tools/clipboard.d.ts +2 -0
  152. package/dist/tools/closeTabs.d.ts +2 -0
  153. package/dist/tools/codeLens.d.ts +1 -0
  154. package/dist/tools/contextBundle.d.ts +1 -0
  155. package/dist/tools/createIssueFromAIComment.d.ts +1 -0
  156. package/dist/tools/createLinearIssue.d.ts +1 -0
  157. package/dist/tools/ctxGetTaskContext.d.ts +1 -0
  158. package/dist/tools/ctxQueryTraces.d.ts +1 -0
  159. package/dist/tools/ctxSaveTrace.d.ts +1 -0
  160. package/dist/tools/debug.d.ts +4 -0
  161. package/dist/tools/decorations.d.ts +2 -0
  162. package/dist/tools/documentLinks.d.ts +1 -0
  163. package/dist/tools/editText.d.ts +1 -0
  164. package/dist/tools/enrichCommit.d.ts +1 -0
  165. package/dist/tools/enrichStackTrace.d.ts +1 -0
  166. package/dist/tools/explainDiagnostic.d.ts +1 -0
  167. package/dist/tools/explainSymbol.d.ts +1 -0
  168. package/dist/tools/fetchCalendarEvents.d.ts +1 -0
  169. package/dist/tools/fetchGithubIssue.d.ts +1 -0
  170. package/dist/tools/fetchGithubPR.d.ts +1 -0
  171. package/dist/tools/fetchLinearIssue.d.ts +1 -0
  172. package/dist/tools/fetchSentryIssue.d.ts +1 -0
  173. package/dist/tools/fetchSlackProfile.d.ts +1 -0
  174. package/dist/tools/fileOperations.d.ts +3 -0
  175. package/dist/tools/fileWatcher.d.ts +2 -0
  176. package/dist/tools/findFiles.d.ts +1 -0
  177. package/dist/tools/findRelatedTests.d.ts +1 -0
  178. package/dist/tools/fixAllLintErrors.d.ts +1 -0
  179. package/dist/tools/foldingRanges.d.ts +1 -0
  180. package/dist/tools/formatDocument.d.ts +1 -0
  181. package/dist/tools/generateTests.d.ts +1 -0
  182. package/dist/tools/getAIComments.d.ts +1 -0
  183. package/dist/tools/getAnalyticsReport.d.ts +1 -0
  184. package/dist/tools/getArchitectureContext.d.ts +1 -0
  185. package/dist/tools/getBufferContent.d.ts +1 -0
  186. package/dist/tools/getChangeImpact.d.ts +1 -0
  187. package/dist/tools/getClaudeTaskStatus.d.ts +1 -0
  188. package/dist/tools/getCodeCoverage.d.ts +1 -0
  189. package/dist/tools/getCommitsForIssue.d.ts +1 -0
  190. package/dist/tools/getConnectorStatus.d.ts +1 -0
  191. package/dist/tools/getCurrentSelection.d.ts +2 -0
  192. package/dist/tools/getDebugState.d.ts +1 -0
  193. package/dist/tools/getDependencyTree.d.ts +1 -0
  194. package/dist/tools/getDiagnostics.d.ts +1 -0
  195. package/dist/tools/getDiffFromHandoff.d.ts +1 -0
  196. package/dist/tools/getDocumentSymbols.d.ts +1 -0
  197. package/dist/tools/getFileTree.d.ts +1 -0
  198. package/dist/tools/getGitDiff.d.ts +1 -0
  199. package/dist/tools/getGitHotspots.d.ts +1 -0
  200. package/dist/tools/getGitLog.d.ts +1 -0
  201. package/dist/tools/getGitStatus.d.ts +1 -0
  202. package/dist/tools/getImportTree.d.ts +1 -0
  203. package/dist/tools/getImportedSignatures.d.ts +1 -0
  204. package/dist/tools/getOpenEditors.d.ts +1 -0
  205. package/dist/tools/getPRTemplate.d.ts +1 -0
  206. package/dist/tools/getProjectContext.d.ts +1 -0
  207. package/dist/tools/getProjectInfo.d.ts +1 -0
  208. package/dist/tools/getSecurityAdvisories.d.ts +1 -0
  209. package/dist/tools/getSessionUsage.d.ts +1 -0
  210. package/dist/tools/getSymbolHistory.d.ts +1 -0
  211. package/dist/tools/getToolCapabilities.d.ts +1 -0
  212. package/dist/tools/getTypeSignature.d.ts +1 -0
  213. package/dist/tools/getWorkspaceFolders.d.ts +1 -0
  214. package/dist/tools/getWorkspaceSettings.d.ts +1 -0
  215. package/dist/tools/gitHistory.d.ts +2 -0
  216. package/dist/tools/gitWrite.d.ts +11 -0
  217. package/dist/tools/github/actions.d.ts +2 -0
  218. package/dist/tools/github/composite.d.ts +3 -0
  219. package/dist/tools/github/issues.d.ts +4 -0
  220. package/dist/tools/github/pr.d.ts +7 -0
  221. package/dist/tools/handoffNote.d.ts +2 -0
  222. package/dist/tools/hoverAtCursor.d.ts +1 -0
  223. package/dist/tools/httpClient.d.ts +2 -0
  224. package/dist/tools/inlayHints.d.ts +1 -0
  225. package/dist/tools/launchQuickTask.d.ts +1 -0
  226. package/dist/tools/listClaudeTasks.d.ts +1 -0
  227. package/dist/tools/listTerminals.d.ts +1 -0
  228. package/dist/tools/lsp.d.ts +14 -0
  229. package/dist/tools/navigateToSymbolByName.d.ts +1 -0
  230. package/dist/tools/openDiff.d.ts +1 -0
  231. package/dist/tools/openFile.d.ts +1 -0
  232. package/dist/tools/openInBrowser.d.ts +1 -0
  233. package/dist/tools/organizeImports.d.ts +1 -0
  234. package/dist/tools/performanceReport.d.ts +1 -0
  235. package/dist/tools/planPersistence.d.ts +5 -0
  236. package/dist/tools/previewEdit.d.ts +1 -0
  237. package/dist/tools/refactorAnalyze.d.ts +1 -0
  238. package/dist/tools/refactorPreview.d.ts +1 -0
  239. package/dist/tools/replaceBlock.d.ts +1 -0
  240. package/dist/tools/resumeClaudeTask.d.ts +1 -0
  241. package/dist/tools/runClaudeTask.d.ts +1 -0
  242. package/dist/tools/runCommand.d.ts +1 -0
  243. package/dist/tools/runTests.d.ts +1 -0
  244. package/dist/tools/saveDocument.d.ts +1 -0
  245. package/dist/tools/screenshotAndAnnotate.d.ts +1 -0
  246. package/dist/tools/searchAndReplace.d.ts +1 -0
  247. package/dist/tools/searchTools.d.ts +1 -0
  248. package/dist/tools/searchWorkspace.d.ts +1 -0
  249. package/dist/tools/selectionRanges.d.ts +1 -0
  250. package/dist/tools/semanticTokens.d.ts +1 -0
  251. package/dist/tools/setActiveWorkspaceFolder.d.ts +1 -0
  252. package/dist/tools/signatureHelp.d.ts +1 -0
  253. package/dist/tools/slackListChannels.d.ts +1 -0
  254. package/dist/tools/slackPostMessage.d.ts +1 -0
  255. package/dist/tools/slackPostMessage.js +1 -1
  256. package/dist/tools/slackPostMessage.js.map +1 -1
  257. package/dist/tools/terminal.d.ts +6 -0
  258. package/dist/tools/testTraceToSource.d.ts +1 -0
  259. package/dist/tools/transaction.d.ts +4 -0
  260. package/dist/tools/typeHierarchy.d.ts +1 -0
  261. package/dist/tools/updateLinearIssue.d.ts +1 -0
  262. package/dist/tools/utils.d.ts +2 -0
  263. package/dist/tools/utils.js.map +1 -1
  264. package/dist/tools/vscodeCommands.d.ts +2 -0
  265. package/dist/tools/vscodeTasks.d.ts +2 -0
  266. package/dist/tools/workspaceSettings.d.ts +1 -0
  267. package/package.json +20 -4
  268. package/templates/recipes/project-health-check.yaml +1 -1
  269. package/dist/schemas/dry-run-plan.v1.json +0 -139
  270. package/dist/schemas/recipe.v1.json +0 -684
@@ -1,3 +1,35 @@
1
+ import { loadConfig } from "./patchworkConfig.js";
2
+ /**
3
+ * Returns true unless `filePath` lives inside an install dir whose
4
+ * `.disabled` marker is present. Top-level legacy recipes (direct children
5
+ * of `recipesDir`) are always considered enabled — there's no install dir
6
+ * to put a marker in. Used by every trigger surface (webhook, manual fire,
7
+ * automation) so the marker means the same thing everywhere.
8
+ */
9
+ export declare function isRecipeFileEnabled(filePath: string, recipesDir: string): boolean;
10
+ /**
11
+ * Unified enable/disable for install-dir AND legacy top-level recipes.
12
+ *
13
+ * Routing:
14
+ * 1. Try to find an install dir whose entrypoint declares this `name`.
15
+ * If found, write/remove the `.disabled` marker on that dir. This
16
+ * matches CLI `recipe enable/disable` and the trigger-side
17
+ * enforcement landed in PRs #43 / #49.
18
+ * 2. Otherwise the recipe is a top-level legacy file — fall back to
19
+ * the legacy `cfg.recipes.disabled` config-file array, which the
20
+ * scheduler already honors as a parallel mechanism (it checks both).
21
+ *
22
+ * Replaces the old dashboard-only `setRecipeEnabledFn` that wrote ONLY to
23
+ * the legacy config — which silently did nothing for install-dir recipes.
24
+ */
25
+ export declare function setRecipeEnabled(name: string, enabled: boolean, options?: {
26
+ recipesDir?: string;
27
+ loadConfigFn?: typeof loadConfig;
28
+ saveConfigFn?: (cfg: unknown) => void;
29
+ }): {
30
+ ok: boolean;
31
+ error?: string;
32
+ };
1
33
  /**
2
34
  * Patchwork recipes HTTP surface — reads installed recipes from disk so the
3
35
  * dashboard Recipes page can list what's available. The bridge does not yet
@@ -39,10 +71,31 @@ export declare function saveRecipeContent(recipesDir: string, name: string, cont
39
71
  path?: string;
40
72
  error?: string;
41
73
  };
74
+ /**
75
+ * Deletes a recipe file (yaml/yml or json) plus any sidecar permissions file.
76
+ * Returns ok=false with a 404-style error when the recipe cannot be located.
77
+ */
78
+ export declare function deleteRecipeContent(recipesDir: string, name: string): {
79
+ ok: boolean;
80
+ path?: string;
81
+ error?: string;
82
+ };
83
+ /**
84
+ * Lints raw YAML/JSON recipe content without writing to disk. Used by the
85
+ * dashboard edit UI to surface validateRecipeDefinition warnings live, in
86
+ * addition to the warnings returned by saveRecipeContent on save.
87
+ */
88
+ export declare function lintRecipeContent(content: string): {
89
+ ok: boolean;
90
+ errors: string[];
91
+ warnings: string[];
92
+ };
42
93
  export interface RecipeSummary {
43
94
  name: string;
44
95
  description?: string;
45
96
  trigger?: string;
97
+ /** For webhook triggers, the configured path (e.g. "/github-pr"). */
98
+ webhookPath?: string;
46
99
  stepCount: number;
47
100
  path: string;
48
101
  installedAt: number;
@@ -55,6 +108,13 @@ export interface RecipeSummary {
55
108
  required?: boolean;
56
109
  default?: string;
57
110
  }>;
111
+ /** Lint summary so the dashboard list can flag invalid recipes without N+1 fetches. */
112
+ lint?: {
113
+ ok: boolean;
114
+ errorCount: number;
115
+ warningCount: number;
116
+ firstError?: string;
117
+ };
58
118
  }
59
119
  export interface ListRecipesResult {
60
120
  recipesDir: string;
@@ -1,8 +1,190 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { parse as parseYaml } from "yaml";
4
5
  import { loadConfig } from "./patchworkConfig.js";
5
6
  import { validateRecipeDefinition } from "./recipes/validation.js";
7
+ /**
8
+ * Per-recipe disabled marker — must match the constant in
9
+ * `src/commands/recipeInstall.ts` and `src/recipes/scheduler.ts` (kept inline
10
+ * here to avoid a circular import via commands → recipesHttp → commands).
11
+ *
12
+ * Absence on a recipe's install dir = enabled (legacy default).
13
+ * Presence = disabled — `runRecipeInstall` writes one on every fresh install.
14
+ */
15
+ const DISABLED_MARKER = ".disabled";
16
+ /**
17
+ * Returns true unless `filePath` lives inside an install dir whose
18
+ * `.disabled` marker is present. Top-level legacy recipes (direct children
19
+ * of `recipesDir`) are always considered enabled — there's no install dir
20
+ * to put a marker in. Used by every trigger surface (webhook, manual fire,
21
+ * automation) so the marker means the same thing everywhere.
22
+ */
23
+ export function isRecipeFileEnabled(filePath, recipesDir) {
24
+ const rel = path.relative(recipesDir, filePath);
25
+ // Top-level file in recipesDir → no install dir → enabled by default.
26
+ if (rel === "" || rel.startsWith("..") || !rel.includes(path.sep)) {
27
+ return true;
28
+ }
29
+ const installDirName = rel.split(path.sep)[0];
30
+ if (!installDirName)
31
+ return true;
32
+ const installDir = path.join(recipesDir, installDirName);
33
+ return !existsSync(path.join(installDir, DISABLED_MARKER));
34
+ }
35
+ /**
36
+ * Iterate one level of subdirectories under `recipesDir` that look like
37
+ * install dirs (directory containing `recipe.json` or at least one `.yaml`).
38
+ * Skips dirs whose `.disabled` marker is present so callers automatically
39
+ * honor the marker without having to remember.
40
+ *
41
+ * Yields `{ installDir, entrypointPath }` pairs where `entrypointPath` is the
42
+ * file the caller should parse:
43
+ * - `recipe.json`'s `recipes.main` if a manifest exists
44
+ * - otherwise the first `*.yaml` / `*.yml` in the dir
45
+ *
46
+ * Used by webhook + manual-fire path resolvers to find recipes installed
47
+ * via `runRecipeInstall`.
48
+ */
49
+ function* iterateInstallDirs(recipesDir, options = {}) {
50
+ const includeDisabled = options.includeDisabled === true;
51
+ let entries;
52
+ try {
53
+ entries = readdirSync(recipesDir);
54
+ }
55
+ catch {
56
+ return;
57
+ }
58
+ for (const f of entries) {
59
+ const fullPath = path.join(recipesDir, f);
60
+ let isDir = false;
61
+ try {
62
+ isDir = statSync(fullPath).isDirectory();
63
+ }
64
+ catch {
65
+ continue;
66
+ }
67
+ if (!isDir)
68
+ continue;
69
+ const enabled = !existsSync(path.join(fullPath, DISABLED_MARKER));
70
+ if (!enabled && !includeDisabled)
71
+ continue;
72
+ let entrypoint = null;
73
+ const manifestPath = path.join(fullPath, "recipe.json");
74
+ if (existsSync(manifestPath)) {
75
+ try {
76
+ const m = JSON.parse(readFileSync(manifestPath, "utf-8"));
77
+ if (m.recipes?.main) {
78
+ const candidate = path.join(fullPath, m.recipes.main);
79
+ if (existsSync(candidate))
80
+ entrypoint = candidate;
81
+ }
82
+ }
83
+ catch {
84
+ // malformed manifest — fall through to first-yaml fallback
85
+ }
86
+ }
87
+ if (!entrypoint) {
88
+ try {
89
+ const yaml = readdirSync(fullPath).find((x) => /\.ya?ml$/i.test(x));
90
+ if (yaml)
91
+ entrypoint = path.join(fullPath, yaml);
92
+ }
93
+ catch {
94
+ // unreadable
95
+ }
96
+ }
97
+ if (entrypoint) {
98
+ yield { installDir: fullPath, entrypointPath: entrypoint, enabled };
99
+ }
100
+ }
101
+ }
102
+ /**
103
+ * Locate an install dir by the *recipe name* declared inside its entrypoint
104
+ * (not the directory name). The dashboard reports recipes by the parsed
105
+ * `name` field, while `runRecipeEnable` looks them up by dir name —
106
+ * the two are usually different (`morning-pkg` vs `morning-brief`). Includes
107
+ * disabled dirs so re-enabling actually finds them.
108
+ */
109
+ function findInstallDirByRecipeName(recipesDir, name) {
110
+ for (const { installDir, entrypointPath } of iterateInstallDirs(recipesDir, {
111
+ includeDisabled: true,
112
+ })) {
113
+ try {
114
+ const ext = path.extname(entrypointPath).toLowerCase();
115
+ const raw = readFileSync(entrypointPath, "utf-8");
116
+ const parsed = (ext === ".json" ? JSON.parse(raw) : parseYaml(raw));
117
+ if (parsed.name === name)
118
+ return installDir;
119
+ }
120
+ catch {
121
+ // skip malformed
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+ /**
127
+ * Unified enable/disable for install-dir AND legacy top-level recipes.
128
+ *
129
+ * Routing:
130
+ * 1. Try to find an install dir whose entrypoint declares this `name`.
131
+ * If found, write/remove the `.disabled` marker on that dir. This
132
+ * matches CLI `recipe enable/disable` and the trigger-side
133
+ * enforcement landed in PRs #43 / #49.
134
+ * 2. Otherwise the recipe is a top-level legacy file — fall back to
135
+ * the legacy `cfg.recipes.disabled` config-file array, which the
136
+ * scheduler already honors as a parallel mechanism (it checks both).
137
+ *
138
+ * Replaces the old dashboard-only `setRecipeEnabledFn` that wrote ONLY to
139
+ * the legacy config — which silently did nothing for install-dir recipes.
140
+ */
141
+ export function setRecipeEnabled(name, enabled, options = {}) {
142
+ const recipesDir = options.recipesDir ?? path.join(os.homedir(), ".patchwork", "recipes");
143
+ try {
144
+ const installDir = findInstallDirByRecipeName(recipesDir, name);
145
+ if (installDir) {
146
+ const markerPath = path.join(installDir, DISABLED_MARKER);
147
+ if (enabled) {
148
+ if (existsSync(markerPath))
149
+ rmSync(markerPath);
150
+ }
151
+ else {
152
+ writeFileSync(markerPath, "");
153
+ }
154
+ return { ok: true };
155
+ }
156
+ // Legacy top-level path — fall back to config-file disabled list
157
+ const cfg = (options.loadConfigFn ?? loadConfig)();
158
+ const disabled = new Set(cfg.recipes?.disabled ?? []);
159
+ if (enabled)
160
+ disabled.delete(name);
161
+ else
162
+ disabled.add(name);
163
+ const next = {
164
+ ...cfg,
165
+ recipes: {
166
+ ...(cfg.recipes ?? {}),
167
+ disabled: [...disabled],
168
+ },
169
+ };
170
+ if (options.saveConfigFn)
171
+ options.saveConfigFn(next);
172
+ else {
173
+ // Dynamic import to avoid coupling at module-load time and to keep
174
+ // tests able to swap the saver via options.saveConfigFn.
175
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic require shape
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) {
@@ -221,9 +419,16 @@ export function saveRecipeContent(recipesDir, name, content) {
221
419
  };
222
420
  }
223
421
  const validation = validateRecipeDefinition(parsed);
422
+ const warnings = validation.issues
423
+ .filter((issue) => issue.level === "warning")
424
+ .map((issue) => issue.message);
224
425
  const validationError = validation.issues.find((issue) => issue.level === "error");
225
426
  if (validationError) {
226
- return { ok: false, error: validationError.message };
427
+ return {
428
+ ok: false,
429
+ error: validationError.message,
430
+ ...(warnings.length > 0 ? { warnings } : {}),
431
+ };
227
432
  }
228
433
  try {
229
434
  mkdirSync(recipesDir, { recursive: true });
@@ -234,7 +439,50 @@ export function saveRecipeContent(recipesDir, name, content) {
234
439
  return { ok: false, error: "Invalid path" };
235
440
  }
236
441
  writeFileSync(candidate, content.endsWith("\n") ? content : `${content}\n`, "utf-8");
237
- return { ok: true, path: candidate };
442
+ return {
443
+ ok: true,
444
+ path: candidate,
445
+ ...(warnings.length > 0 ? { warnings } : {}),
446
+ };
447
+ }
448
+ catch (err) {
449
+ return {
450
+ ok: false,
451
+ error: err instanceof Error ? err.message : String(err),
452
+ };
453
+ }
454
+ }
455
+ /**
456
+ * Deletes a recipe file (yaml/yml or json) plus any sidecar permissions file.
457
+ * Returns ok=false with a 404-style error when the recipe cannot be located.
458
+ */
459
+ export function deleteRecipeContent(recipesDir, name) {
460
+ const safeName = name.toLowerCase();
461
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(safeName)) {
462
+ return { ok: false, error: "Invalid recipe name" };
463
+ }
464
+ const base = path.resolve(recipesDir);
465
+ const target = findYamlRecipePath(recipesDir, safeName) ??
466
+ resolveJsonRecipePathByName(recipesDir, safeName);
467
+ if (!target) {
468
+ return { ok: false, error: "Recipe not found" };
469
+ }
470
+ const resolved = path.resolve(target);
471
+ if (!resolved.startsWith(base + path.sep)) {
472
+ return { ok: false, error: "Invalid path" };
473
+ }
474
+ try {
475
+ rmSync(resolved, { force: true });
476
+ const sidecar = `${resolved}.permissions.json`;
477
+ if (existsSync(sidecar)) {
478
+ try {
479
+ rmSync(sidecar, { force: true });
480
+ }
481
+ catch {
482
+ // sidecar removal best-effort
483
+ }
484
+ }
485
+ return { ok: true, path: resolved };
238
486
  }
239
487
  catch (err) {
240
488
  return {
@@ -243,6 +491,37 @@ export function saveRecipeContent(recipesDir, name, content) {
243
491
  };
244
492
  }
245
493
  }
494
+ /**
495
+ * Lints raw YAML/JSON recipe content without writing to disk. Used by the
496
+ * dashboard edit UI to surface validateRecipeDefinition warnings live, in
497
+ * addition to the warnings returned by saveRecipeContent on save.
498
+ */
499
+ export function lintRecipeContent(content) {
500
+ if (!content.trim()) {
501
+ return { ok: false, errors: ["Recipe content is required"], warnings: [] };
502
+ }
503
+ let parsed;
504
+ try {
505
+ parsed = parseYaml(content);
506
+ }
507
+ catch (err) {
508
+ return {
509
+ ok: false,
510
+ errors: [err instanceof Error ? err.message : String(err)],
511
+ warnings: [],
512
+ };
513
+ }
514
+ const validation = validateRecipeDefinition(parsed);
515
+ const errors = [];
516
+ const warnings = [];
517
+ for (const issue of validation.issues) {
518
+ if (issue.level === "error")
519
+ errors.push(issue.message);
520
+ else
521
+ warnings.push(issue.message);
522
+ }
523
+ return { ok: errors.length === 0, errors, warnings };
524
+ }
246
525
  export function listInstalledRecipes(recipesDir) {
247
526
  let entries;
248
527
  try {
@@ -287,25 +566,118 @@ export function listInstalledRecipes(recipesDir) {
287
566
  }
288
567
  const ext = isYaml ? (f.endsWith(".yml") ? ".yml" : ".yaml") : ".json";
289
568
  const parsedName = parsed.name ?? path.basename(f, ext);
569
+ const lintRes = validateRecipeDefinition(parsed);
570
+ let errCount = 0;
571
+ let warnCount = 0;
572
+ let firstError;
573
+ for (const issue of lintRes.issues) {
574
+ if (issue.level === "error") {
575
+ errCount++;
576
+ if (!firstError)
577
+ firstError = issue.message;
578
+ }
579
+ else {
580
+ warnCount++;
581
+ }
582
+ }
583
+ const webhookPath = parsed.trigger?.type === "webhook" &&
584
+ typeof parsed.trigger?.path === "string"
585
+ ? parsed.trigger.path
586
+ : undefined;
290
587
  recipes.push({
291
588
  name: parsedName,
292
589
  description: parsed.description,
293
590
  trigger: parsed.trigger?.type,
591
+ ...(webhookPath ? { webhookPath } : {}),
294
592
  stepCount: Array.isArray(parsed.steps) ? parsed.steps.length : 0,
295
593
  path: fullPath,
296
594
  installedAt: stat.mtimeMs,
297
595
  hasPermissions,
298
596
  source,
597
+ // Top-level legacy recipes don't have install dirs to put a marker
598
+ // in, so the `enabled` field still comes from the legacy config list.
299
599
  enabled: !disabledSet.has(parsedName),
300
600
  ...(Array.isArray(parsed.vars) && parsed.vars.length > 0
301
601
  ? { vars: parsed.vars }
302
602
  : {}),
603
+ lint: {
604
+ ok: errCount === 0,
605
+ errorCount: errCount,
606
+ warningCount: warnCount,
607
+ ...(firstError ? { firstError } : {}),
608
+ },
303
609
  });
304
610
  }
305
611
  catch {
306
612
  // skip malformed recipe file
307
613
  }
308
614
  }
615
+ // Second pass — recipes installed via `runRecipeInstall` into subdirs.
616
+ // `enabled` reflects the per-install `.disabled` marker; the legacy
617
+ // config disabled list is a top-level concern (we still apply it as a
618
+ // safety belt in case a name collides).
619
+ for (const { installDir, entrypointPath, enabled: installEnabled, } of iterateInstallDirs(recipesDir, { includeDisabled: true })) {
620
+ try {
621
+ const ext = path.extname(entrypointPath).toLowerCase();
622
+ const isYaml = ext === ".yaml" || ext === ".yml";
623
+ const isJson = ext === ".json";
624
+ if (!isYaml && !isJson)
625
+ continue;
626
+ const raw = readFileSync(entrypointPath, "utf-8");
627
+ const parsed = (isYaml ? parseYaml(raw) : JSON.parse(raw));
628
+ const stat = statSync(entrypointPath);
629
+ const parsedName = parsed.name ??
630
+ path.basename(entrypointPath, path.extname(entrypointPath));
631
+ const lintRes = validateRecipeDefinition(parsed);
632
+ let errCount = 0;
633
+ let warnCount = 0;
634
+ let firstError;
635
+ for (const issue of lintRes.issues) {
636
+ if (issue.level === "error") {
637
+ errCount++;
638
+ if (!firstError)
639
+ firstError = issue.message;
640
+ }
641
+ else {
642
+ warnCount++;
643
+ }
644
+ }
645
+ const webhookPath = parsed.trigger?.type === "webhook" &&
646
+ typeof parsed.trigger?.path === "string"
647
+ ? parsed.trigger.path
648
+ : undefined;
649
+ recipes.push({
650
+ name: parsedName,
651
+ description: parsed.description,
652
+ trigger: parsed.trigger?.type,
653
+ ...(webhookPath ? { webhookPath } : {}),
654
+ stepCount: Array.isArray(parsed.steps) ? parsed.steps.length : 0,
655
+ path: entrypointPath,
656
+ installedAt: stat.mtimeMs,
657
+ hasPermissions: false,
658
+ source: "user",
659
+ // Disabled if EITHER the install marker is set OR the legacy config
660
+ // names this recipe — defence-in-depth so a stale config entry can't
661
+ // accidentally re-enable a recipe the user explicitly disabled, and
662
+ // the dashboard can't accidentally enable one disabled by an admin
663
+ // through the legacy file.
664
+ enabled: installEnabled && !disabledSet.has(parsedName),
665
+ ...(Array.isArray(parsed.vars) && parsed.vars.length > 0
666
+ ? { vars: parsed.vars }
667
+ : {}),
668
+ lint: {
669
+ ok: errCount === 0,
670
+ errorCount: errCount,
671
+ warningCount: warnCount,
672
+ ...(firstError ? { firstError } : {}),
673
+ },
674
+ });
675
+ void installDir;
676
+ }
677
+ catch {
678
+ // skip malformed install dir
679
+ }
680
+ }
309
681
  recipes.sort((a, b) => a.name.localeCompare(b.name));
310
682
  return { recipesDir, recipes };
311
683
  }
@@ -348,6 +720,22 @@ export function findYamlRecipePath(recipesDir, name) {
348
720
  // skip malformed candidate
349
721
  }
350
722
  }
723
+ // Also search install dirs from `recipeInstall`. Skips dirs with
724
+ // `.disabled` marker so the manual-fire / orchestrator path can't
725
+ // resolve a recipe the user has explicitly disabled.
726
+ for (const { entrypointPath } of iterateInstallDirs(recipesDir)) {
727
+ if (!/\.ya?ml$/i.test(entrypointPath))
728
+ continue;
729
+ try {
730
+ const parsed = parseYaml(readFileSync(entrypointPath, "utf-8"));
731
+ if (parsed.name?.toLowerCase() === safeName) {
732
+ return entrypointPath;
733
+ }
734
+ }
735
+ catch {
736
+ // skip malformed
737
+ }
738
+ }
351
739
  return null;
352
740
  }
353
741
  /**
@@ -363,6 +751,7 @@ export function findWebhookRecipe(recipesDir, requestPath) {
363
751
  catch {
364
752
  return null;
365
753
  }
754
+ // Pass 1 — top-level files (legacy)
366
755
  for (const f of entries) {
367
756
  const isYaml = f.endsWith(".yaml") || f.endsWith(".yml");
368
757
  const isJson = f.endsWith(".json") && !f.endsWith(".permissions.json");
@@ -387,6 +776,32 @@ export function findWebhookRecipe(recipesDir, requestPath) {
387
776
  // skip malformed
388
777
  }
389
778
  }
779
+ // Pass 2 — install dirs (skips dirs marked .disabled).
780
+ for (const { entrypointPath } of iterateInstallDirs(recipesDir)) {
781
+ const ext = path.extname(entrypointPath).toLowerCase();
782
+ const isYaml = ext === ".yaml" || ext === ".yml";
783
+ const isJson = ext === ".json";
784
+ if (!isYaml && !isJson)
785
+ continue;
786
+ try {
787
+ const raw = readFileSync(entrypointPath, "utf-8");
788
+ const parsed = (isYaml ? parseYaml(raw) : JSON.parse(raw));
789
+ if (parsed.trigger?.type !== "webhook")
790
+ continue;
791
+ if (parsed.trigger.path === requestPath) {
792
+ return {
793
+ name: parsed.name ??
794
+ path.basename(entrypointPath, path.extname(entrypointPath)),
795
+ path: requestPath,
796
+ filePath: entrypointPath,
797
+ format: isYaml ? "yaml" : "json",
798
+ };
799
+ }
800
+ }
801
+ catch {
802
+ // skip malformed
803
+ }
804
+ }
390
805
  return null;
391
806
  }
392
807
  /**